feat(ui) - path analysis (#1514)

* fix(ui) - insight item name

* feat(ui) - path analysis - wip

* feat(ui) - path analysis - wip

* feat(ui) - path analysis

* feat(ui) - user retention

* feat(ui): path analysis - filters and graph

* change(ui): plugins text and icon

* feat(ui): path analysis - filters and graph

* feat(ui): path analysis - filters and graph

* feat(ui): path analysis

* feat(ui): path analysis - start point filters

* feat(ui): path analysis
This commit is contained in:
Shekar Siri 2023-10-12 17:04:19 +02:00 committed by GitHub
parent 429e55b746
commit 89704b033f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 2493 additions and 864 deletions

View file

@ -18,8 +18,8 @@ export interface Module {
export const modules = [
{
label: 'Assist',
description: 'Record and replay user sessions to see a video of what users did on your website.',
label: 'Cobrowse',
description: 'Enable live session playing, interaction, screen sharing, and annotations over video call.',
key: MODULES.ASSIST,
icon: 'broadcast'
},

View file

@ -0,0 +1,37 @@
.cohortTableContainer {
display: flex;
overflow-x: hidden;
width: 100%;
}
.fixedTableWrapper {
flex-shrink: 0;
}
.scrollableTableWrapper {
overflow-x: auto;
width: 100%;
}
.cohortTable {
border-collapse: separate;
width: max-content;
border-spacing: 6px;
}
.cell {
border: transparent;
border-radius: 3px;
padding: 4px 8px;
text-align: center;
min-width: 80px;
cursor: pointer;
}
.header {
height: 29px;
}
.header .bg {
background-color: #f2f2f2;
}

View file

@ -0,0 +1,152 @@
import React from 'react';
import styles from './CohortCard.module.css';
interface Props {
data: any
}
function CohortCard(props: Props) {
// const { data } = props;
const data = [
{
cohort: '2022-01-01',
users: 100,
data: [100, 95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5],
},
{
cohort: '2022-01-08',
users: 100,
data: [100, 95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15],
},
{
cohort: '2022-01-08',
users: 100,
data: [100, 95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15],
},
{
cohort: '2022-01-08',
users: 100,
data: [100, 95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40, 35, 30],
},
{
cohort: '2022-01-08',
users: 100,
data: [90, 70, 50, 30],
},
{
cohort: '2022-01-08',
users: 100,
data: [90, 70, 50, 30],
},
{
cohort: '2022-01-08',
users: 100,
data: [90, 70, 50, 30],
},
{
cohort: '2022-01-08',
users: 100,
data: [90, 70, 50, 30],
},
{
cohort: '2022-01-08',
users: 100,
data: [90, 70, 50, 30],
},
{
cohort: '2022-01-08',
users: 100,
data: [90, 70, 50, 30],
},
{
cohort: '2022-01-08',
users: 100,
data: [90, 70, 50, 30],
},
{
cohort: '2022-01-08',
users: 100,
data: [90, 70, 50, 30],
},
{
cohort: '2022-01-08',
users: 100,
data: [90, 70, 50, 30],
},
{
cohort: '2022-01-08',
users: 100,
data: [90, 70, 50, 30],
},
{
cohort: '2022-01-08',
users: 100,
data: [90, 70, 50, 30],
},
{
cohort: '2022-01-08',
users: 100,
data: [90, 70, 50, 30],
},
// ... more rows
];
const getCellColor = (value: number) => {
const maxValue = 100; // Adjust this based on the maximum value in your data
const maxOpacity = 0.5;
const opacity = (value / maxValue) * maxOpacity;
return `rgba(62, 170, 175, ${opacity})`;
};
return (
<div className={styles.cohortTableContainer}>
<div className={styles.fixedTableWrapper}>
<table className={styles.cohortTable}>
<thead>
<tr>
<th className={`${styles.cell} text-left`}>Date</th>
<th className={`${styles.cell} text-left`}>Users</th>
</tr>
<tr>
<th className={`${styles.cell} ${styles.header}`}></th>
<th className={`${styles.cell} ${styles.header}`}></th>
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={`row-fixed-${rowIndex}`}>
<td className={styles.cell}>{row.cohort}</td>
<td className={styles.cell}>{row.users}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.scrollableTableWrapper}>
<table className={styles.cohortTable}>
<thead>
<tr>
<th className={`${styles.cell}`} style={{ textAlign: 'left'}} colSpan={10}>Weeks later users retained</th>
</tr>
<tr>
{data[0].data.map((_, index) => (
<th key={`header-${index}`} className={`${styles.cell} ${styles.header}`}>{`${index + 1}`}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={`row-scrollable-${rowIndex}`}>
{row.data.map((cell, cellIndex) => (
<td key={`cell-${rowIndex}-${cellIndex} text-center`} className={styles.cell} style={{ backgroundColor: getCellColor(cell) }}>{cell}%</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default CohortCard;

View file

@ -0,0 +1 @@
export { default } from './CohortCard';

View file

@ -0,0 +1,27 @@
import React from 'react';
import { Icon } from 'UI';
import Issue from 'App/mstore/types/issue';
interface Props {
issue: Issue;
}
function CardIssueItem(props: Props) {
const { issue } = props;
return (
<div className='flex items-center py-2 hover:bg-active-blue cursor-pointer'>
<div className='mr-auto flex items-center'>
<div className='flex items-center justify-center flex-shrink-0 mr-3 relative'>
<Icon name={issue.icon} size='24' className='z-10 inset-0' />
</div>
<div className='flex-1 overflow-hidden'>
{issue.name}
<span className='color-gray-medium mx-2'>{issue.source}</span>
</div>
</div>
<div>{issue.sessionCount}</div>
</div>
);
}
export default CardIssueItem;

View file

@ -0,0 +1,107 @@
import React, { useEffect, useState } from 'react';
import { useStore } from 'App/mstore';
import { observer, useObserver } from 'mobx-react-lite';
import { Loader, Pagination, Button, NoContent } from 'UI';
import { debounce } from 'App/utils';
import useIsMounted from 'App/hooks/useIsMounted';
import CardIssueItem from './CardIssueItem';
import SessionsModal from '../SessionsModal';
import { useModal } from 'App/components/Modal';
import Issue from 'App/mstore/types/issue';
function CardIssues() {
const { metricStore, dashboardStore } = useStore();
const [data, setData] = useState<{
issues: Issue[];
total: number;
}>({ issues: [], total: 0 });
const [loading, setLoading] = useState(false);
const widget: any = useObserver(() => metricStore.instance);
const isMounted = useIsMounted();
const { showModal } = useModal();
const fetchIssues = (filter: any) => {
if (!isMounted()) return;
setLoading(true);
const newFilter = {
...filter,
series: filter.series.map((item: any) => {
return {
...item,
filter: {
...item.filter,
filters: item.filter.filters.filter((filter: any, index: any) => {
const stage = widget.data.funnel.stages[index];
return stage && stage.isActive;
}).map((f: any) => f.toJson())
}
};
})
};
widget.fetchIssues(newFilter).then((res: any) => {
setData(res);
}).finally(() => {
setLoading(false);
});
};
const handleClick = (issue: any) => {
showModal(<SessionsModal issue={issue} list={[]} />, { right: true, width: 900 });
};
const filter = useObserver(() => dashboardStore.drillDownFilter);
const drillDownPeriod = useObserver(() => dashboardStore.drillDownPeriod);
const debounceRequest: any = React.useCallback(debounce(fetchIssues, 1000), []);
const depsString = JSON.stringify(widget.series);
useEffect(() => {
const newPayload = {
...widget,
page: metricStore.sessionsPage,
limit: metricStore.sessionsPageSize,
filters: filter.filters
};
console.log('drillDownPeriod', newPayload);
debounceRequest(newPayload);
}, [drillDownPeriod, filter.filters, depsString, metricStore.sessionsPage]);
return useObserver(() => (
<div className='my-8 bg-white rounded p-4 border'>
<div className='flex justify-between'>
<h1 className='font-medium text-2xl'>Issues</h1>
{/*<div>*/}
{/* <Button variant='text-primary'>All Sessions</Button>*/}
{/*</div>*/}
</div>
<Loader loading={loading}>
<NoContent show={data.issues.length == 0} title="No data!">
{data.issues.map((item: any, index: any) => (
<div onClick={() => handleClick(item)} key={index}>
<CardIssueItem issue={item} />
</div>
))}
</NoContent>
</Loader>
<div className='w-full flex items-center justify-between pt-4'>
<div className='text-disabled-text'>
Showing <span
className='font-semibold'>{Math.min(data.issues.length, metricStore.sessionsPageSize)}</span> out of{' '}
<span className='font-semibold'>{data.total}</span> Issues
</div>
<Pagination
page={metricStore.sessionsPage}
totalPages={Math.ceil(data.issues.length / metricStore.sessionsPageSize)}
onPageChange={(page: any) => metricStore.updateKey('sessionsPage', page)}
limit={metricStore.sessionsPageSize}
debounceRequest={500}
/>
</div>
</div>
));
}
export default observer(CardIssues);

View file

@ -0,0 +1 @@
export { default } from './CardIssues'

View file

@ -0,0 +1,29 @@
import React from 'react';
import { Icon } from 'UI';
interface Props {
user: any
}
function CardUserItem(props: Props) {
const { user } = props;
return (
<div className="flex items-center py-2 hover:bg-active-blue cursor-pointer">
<div className="mr-auto flex items-center">
<div className="flex items-center justify-center flex-shrink-0 mr-2 relative">
<div className="w-8 h-8 rounded-full flex items-center bg-tealx-light justify-center">
<Icon name="person-fill" size="15" className="z-10 inset-0" color="tealx" />
</div>
</div>
<div className="flex-1 overflow-hidden">
{user.name}
{/* <span className="color-gray-medium mx-2">some-button</span> */}
</div>
</div>
<div className="flex items-center">
<div className="mr-2 link">{user.sessions}</div>
<div><Icon name="chevron-right" size="16" /></div>
</div>
</div>
);
}
export default CardUserItem;

View file

@ -0,0 +1,77 @@
import { useModal } from 'App/components/Modal';
import { observer } from 'mobx-react-lite';
import React, { useEffect, useState } from 'react';
import { RouteComponentProps, withRouter } from 'react-router';
import { Loader, Pagination, Button } from 'UI';
import SessionsModal from './SessionsModal';
import CardUserItem from './CardUserItem';
import { useStore } from 'App/mstore';
interface Props {
history: any;
location: any;
}
function CardUserList(props: RouteComponentProps<Props>) {
const [loading, setLoading] = useState(false);
const { showModal } = useModal();
const userId = new URLSearchParams(props.location.search).get("userId");
const { metricStore, dashboardStore } = useStore();
const [data, setData] = useState<any>([
{ name: 'user@domain.com', sessions: 29 },
{ name: 'user@domain.com', sessions: 29 },
{ name: 'user@domain.com', sessions: 29 },
{ name: 'user@domain.com', sessions: 29 },
]);
const pageSize = data.length;
const handleClick = (issue: any) => {
props.history.replace({search: (new URLSearchParams({userId : '123'})).toString()});
// showModal(<SessionsModal list={[]} />, { right: true, width: 450 })
}
useEffect(() => {
if (!userId) return;
showModal(<SessionsModal userId={userId} name="test" hash="test" />, { right: true, width: 600, onClose: () => {
if (props.history.location.pathname.includes("/metric")) {
props.history.replace({search: ""});
}
}});
}, [userId]);
return (
<div className="my-8 bg-white rounded p-4 border">
<div className="flex justify-between">
<h1 className="font-medium text-2xl">Returning users between</h1>
<div>
<Button variant="text-primary">All Sessions</Button>
</div>
</div>
<Loader loading={loading}>
{data.map((item: any, index: any) => (
<div key={index} onClick={() => handleClick(item)}>
<CardUserItem user={item} />
</div>
))}
</Loader>
<div className="w-full flex items-center justify-between pt-4">
<div className="text-disabled-text">
Showing <span className="font-semibold">{Math.min(data.length, pageSize)}</span> out of{' '}
<span className="font-semibold">{data.length}</span> Issues
</div>
<Pagination
page={metricStore.sessionsPage}
totalPages={Math.ceil(data.length / metricStore.sessionsPageSize)}
onPageChange={(page: any) => metricStore.updateKey('sessionsPage', page)}
limit={metricStore.sessionsPageSize}
debounceRequest={500}
/>
</div>
</div>
);
}
export default withRouter(observer(CardUserList));

View file

@ -0,0 +1,99 @@
import React, { useEffect } from 'react';
import { useStore } from 'App/mstore';
import { FilterKey } from 'App/types/filter/filterType';
import { NoContent, Pagination, Loader, Avatar } from 'UI';
import SessionItem from 'Shared/SessionItem';
import SelectDateRange from 'Shared/SelectDateRange';
import { useObserver, observer } from 'mobx-react-lite';
import { useModal } from 'App/components/Modal';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
const PER_PAGE = 10;
interface Props {
userId: string;
hash: string;
name: string;
}
function SessionsModal(props: Props) {
const { userId, hash, name } = props;
const { sessionStore } = useStore();
const { hideModal } = useModal();
const [loading, setLoading] = React.useState(false);
const [data, setData] = React.useState<any>({ sessions: [], total: 0 });
const filter = useObserver(() => sessionStore.userFilter);
const onDateChange = (period: any) => {
filter.update('period', period);
};
const fetchData = () => {
setLoading(true);
sessionStore
.getSessions(filter)
.then(setData)
.catch(() => {
console.log('error');
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
const userFilter = { key: FilterKey.USERID, value: [userId], operator: 'is', isEvent: false };
filter.update('filters', [userFilter]);
}, []);
useEffect(fetchData, [filter.page, filter.startDate, filter.endDate]);
return (
<div className="h-screen overflow-y-auto bg-white">
<div className="flex items-center justify-between w-full px-5 py-3">
<div className="text-lg flex items-center">
<Avatar isActive={false} seed={hash} isAssist={false} />
<div className="ml-3">
{name}'s <span className="color-gray-dark">Sessions</span>
</div>
</div>
<div>
<SelectDateRange period={filter.period} onChange={onDateChange} right={true} />
</div>
</div>
<NoContent show={data.sessions.length === 0} title={
<div>
<AnimatedSVG name={ICONS.NO_SESSIONS} size={170} />
<div className="mt-2" />
<div className="text-center text-gray-600">No recordings found.</div>
</div>
}>
<div className="border rounded m-5">
<Loader loading={loading}>
{data.sessions.map((session: any) => (
<div className="border-b last:border-none" key={session.sessionId}>
<SessionItem key={session.sessionId} session={session} compact={true} onClick={hideModal} />
</div>
))}
</Loader>
<div className="flex items-center justify-between p-5">
<div>
{/* showing x to x of total sessions */}
Showing <span className="font-medium">{(filter.page - 1) * PER_PAGE + 1}</span> to{' '}
<span className="font-medium">{(filter.page - 1) * PER_PAGE + data.sessions.length}</span> of{' '}
<span className="font-medium">{data.total}</span> sessions.
</div>
<Pagination
page={filter.page}
totalPages={Math.ceil(data.total / PER_PAGE)}
onPageChange={(page) => filter.update('page', page)}
limit={PER_PAGE}
debounceRequest={1000}
/>
</div>
</div>
</NoContent>
</div>
);
}
export default observer(SessionsModal);

View file

@ -0,0 +1 @@
export { default } from './SessionsModal';

View file

@ -0,0 +1,58 @@
import filters from 'App/duck/filters';
import Filter from 'App/mstore/types/filter';
import { FilterKey } from 'App/types/filter/filterType';
import { observer } from 'mobx-react-lite';
import React from 'react';
import FilterItem from 'Shared/Filters/FilterItem';
import cn from 'classnames';
import { Button } from 'UI';
interface Props {
filter: Filter;
}
function ExcludeFilters(props: Props) {
const { filter } = props;
const hasExcludes = filter.excludes.length > 0;
const addPageFilter = () => {
const filterItem = filter.createFilterBykey(FilterKey.LOCATION);
filter.addExcludeFilter(filterItem);
};
const onUpdateFilter = (filterIndex: any, filterItem: any) => {
filter.updateExcludeFilter(filterIndex, filterItem);
};
const onRemoveFilter = (filterIndex: any) => {
filter.removeExcludeFilter(filterIndex);
};
return (
<div className={cn("flex items-center border-b", { 'p-5' : hasExcludes, 'px-2': !hasExcludes })}>
{filter.excludes.length > 0 ? (
<div className="flex items-center mb-2 flex-col">
<div className="text-sm color-gray-medium mr-auto mb-2">EXCLUDES</div>
{filter.excludes.map((f: any, index: number) => (
<FilterItem
hideIndex={true}
filterIndex={index}
filter={f}
onUpdate={(f) => onUpdateFilter(index, f)}
onRemoveFilter={() => onRemoveFilter(index)}
// saveRequestPayloads={saveRequestPayloads}
disableDelete={false}
// excludeFilterKeys={excludeFilterKeys}
/>
))}
</div>
) : (
<Button variant="text-primary" onClick={addPageFilter}>
Add Exclustion
</Button>
)}
</div>
);
}
export default observer(ExcludeFilters);

View file

@ -5,6 +5,7 @@ import FilterSelection from 'Shared/Filters/FilterSelection';
import SeriesName from './SeriesName';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
import ExcludeFilters from './ExcludeFilters';
interface Props {
seriesIndex: number;
@ -15,52 +16,57 @@ interface Props {
hideHeader?: boolean;
emptyMessage?: any;
observeChanges?: () => void;
excludeFilterKeys?: Array<string>
excludeFilterKeys?: Array<string>;
canExclude?: boolean;
}
function FilterSeries(props: Props) {
const {
observeChanges = () => {
},
observeChanges = () => {},
canDelete,
hideHeader = false,
emptyMessage = 'Add user event or filter to define the series by clicking Add Step.',
supportsEmpty = true,
excludeFilterKeys = []
excludeFilterKeys = [],
canExclude = false,
} = props;
const [expanded, setExpanded] = useState(true)
const [expanded, setExpanded] = useState(true);
const { series, seriesIndex } = props;
const onAddFilter = (filter: any) => {
series.filter.addFilter(filter)
observeChanges()
}
series.filter.addFilter(filter);
observeChanges();
};
const onUpdateFilter = (filterIndex: any, filter: any) => {
series.filter.updateFilter(filterIndex, filter)
observeChanges()
}
series.filter.updateFilter(filterIndex, filter);
observeChanges();
};
const onChangeEventsOrder = (_: any, { name, value }: any) => {
series.filter.updateKey(name, value)
observeChanges()
}
series.filter.updateKey(name, value);
observeChanges();
};
const onRemoveFilter = (filterIndex: any) => {
series.filter.removeFilter(filterIndex)
observeChanges()
}
series.filter.removeFilter(filterIndex);
observeChanges();
};
console.log(series.filter)
return (
<div className="border rounded bg-white">
<div className={cn("border-b px-5 h-12 flex items-center relative", { 'hidden': hideHeader })}>
{canExclude && <ExcludeFilters filter={series.filter} />}
<div className={cn('border-b px-5 h-12 flex items-center relative', { hidden: hideHeader })}>
<div className="mr-auto">
<SeriesName seriesIndex={seriesIndex} name={series.name} onUpdate={(name) => series.update('name', name)} />
<SeriesName
seriesIndex={seriesIndex}
name={series.name}
onUpdate={(name) => series.update('name', name)}
/>
</div>
<div className="flex items-center cursor-pointer">
<div onClick={props.onRemoveSeries} className={cn("ml-3", { 'disabled': !canDelete })}>
<div onClick={props.onRemoveSeries} className={cn('ml-3', { disabled: !canDelete })}>
<Icon name="trash" size="16" />
</div>
@ -92,7 +98,9 @@ function FilterSeries(props: Props) {
onFilterClick={onAddFilter}
excludeFilterKeys={excludeFilterKeys}
>
<Button variant="text-primary" icon="plus">ADD STEP</Button>
<Button variant="text-primary" icon="plus">
ADD STEP
</Button>
</FilterSelection>
</div>
</div>
@ -102,4 +110,4 @@ function FilterSeries(props: Props) {
);
}
export default observer(FilterSeries);
export default observer(FilterSeries);

View file

@ -0,0 +1,82 @@
import React, { useEffect } from 'react';
import { useStore } from 'App/mstore';
import { dashboardService, metricService } from 'App/services';
import { Loader, Modal, NoContent, Pagination } from 'UI';
import SessionItem from 'Shared/SessionItem';
import Session from 'App/mstore/types/session';
import { useModal } from 'Components/Modal';
interface Props {
list: any,
issue: any
}
function SessionsModal(props: Props) {
const { issue } = props;
const { metricStore, dashboardStore } = useStore();
const [loading, setLoading] = React.useState(false);
const [page, setPage] = React.useState(1);
const [total, setTotal] = React.useState(0);
const [list, setList] = React.useState<any>([]);
const { hideModal } = useModal();
const length = list.length;
const fetchSessions = async (filter: any) => {
setLoading(true);
filter.filters = [
{
type: 'issue',
operator: 'is',
value: [issue.type]
}
];
const res = await metricService.fetchSessions(null, filter);
console.log('res', res);
setList(res[0].sessions.map((item: any) => new Session().fromJson(item)));
setTotal(res[0].total);
setLoading(false);
};
useEffect(() => {
fetchSessions({ ...dashboardStore.drillDownFilter, ...metricStore.instance.toJson(), limit: 10, page: page });
}, [page]);
useEffect(() => {
fetchSessions({ ...dashboardStore.drillDownFilter, ...metricStore.instance.toJson(), limit: 10, page: 1 });
}, [props.issue]);
return (
<div className='bg-white h-screen'>
<Modal.Header title='Sessions'>
Sessions with selected issue
</Modal.Header>
<Loader loading={loading}>
<NoContent show={length == 0} title='No data!'>
{list.map((item: any) => (
<SessionItem session={item} onClick={hideModal} />
))}
</NoContent>
</Loader>
<div className='w-full flex items-center justify-between p-4 absolute bottom-0 bg-white'>
<div className='text-disabled-text'>
Showing <span
className='font-semibold'>{Math.min(length, 10)}</span> out of{' '}
<span className='font-semibold'>{total}</span> Issues
</div>
<Pagination
page={page}
totalPages={Math.ceil(total / 10)}
onPageChange={(page: any) => setPage(page)}
limit={10}
debounceRequest={500}
/>
</div>
</div>
);
}
export default SessionsModal;

View file

@ -0,0 +1 @@
export { default } from './SessionsModal';

View file

@ -11,203 +11,245 @@ import WidgetPredefinedChart from '../WidgetPredefinedChart';
import CustomMetricOverviewChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricOverviewChart';
import { getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper';
import { debounce } from 'App/utils';
import useIsMounted from 'App/hooks/useIsMounted'
import useIsMounted from 'App/hooks/useIsMounted';
import { FilterKey } from 'Types/filter/filterType';
import { TIMESERIES, TABLE, CLICKMAP, FUNNEL, ERRORS, PERFORMANCE, RESOURCE_MONITORING, WEB_VITALS, INSIGHTS } from 'App/constants/card';
import {
TIMESERIES,
TABLE,
CLICKMAP,
FUNNEL,
ERRORS,
PERFORMANCE,
RESOURCE_MONITORING,
WEB_VITALS,
INSIGHTS,
USER_PATH,
RETENTION
} from 'App/constants/card';
import FunnelWidget from 'App/components/Funnels/FunnelWidget';
import SessionWidget from '../Sessions/SessionWidget';
import CustomMetricTableSessions from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions';
import CustomMetricTableErrors from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors';
import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard'
import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard';
import InsightsCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/InsightsCard';
import SankeyChart from 'Shared/Insights/SankeyChart';
import CohortCard from '../../Widgets/CustomMetricsWidgets/CohortCard';
interface Props {
metric: any;
isWidget?: boolean;
isTemplate?: boolean;
isPreview?: boolean;
metric: any;
isWidget?: boolean;
isTemplate?: boolean;
isPreview?: boolean;
}
function WidgetChart(props: Props) {
const { isWidget = false, metric, isTemplate } = props;
const { dashboardStore, metricStore, sessionStore } = useStore();
const _metric: any = metricStore.instance;
const period = dashboardStore.period;
const drillDownPeriod = dashboardStore.drillDownPeriod;
const drillDownFilter = dashboardStore.drillDownFilter;
const colors = Styles.customMetricColors;
const [loading, setLoading] = useState(true)
const isOverviewWidget = metric.metricType === WEB_VITALS;
const params = { density: isOverviewWidget ? 7 : 70 }
const metricParams = { ...params }
const prevMetricRef = useRef<any>();
const isMounted = useIsMounted();
const [data, setData] = useState<any>(metric.data);
const { isWidget = false, metric, isTemplate } = props;
const { dashboardStore, metricStore, sessionStore } = useStore();
const _metric: any = metricStore.instance;
const period = dashboardStore.period;
const drillDownPeriod = dashboardStore.drillDownPeriod;
const drillDownFilter = dashboardStore.drillDownFilter;
const colors = Styles.customMetricColors;
const [loading, setLoading] = useState(true);
const isOverviewWidget = metric.metricType === WEB_VITALS;
const params = { density: isOverviewWidget ? 7 : 70 };
const metricParams = { ...params };
const prevMetricRef = useRef<any>();
const isMounted = useIsMounted();
const [data, setData] = useState<any>(metric.data);
const isTableWidget = metric.metricType === 'table' && metric.viewType === 'table';
const isPieChart = metric.metricType === 'table' && metric.viewType === 'pieChart';
const isTableWidget = metric.metricType === 'table' && metric.viewType === 'table';
const isPieChart = metric.metricType === 'table' && metric.viewType === 'pieChart';
useEffect(() => {
return () => {
dashboardStore.resetDrillDownFilter();
}
}, [])
useEffect(() => {
return () => {
dashboardStore.resetDrillDownFilter();
};
}, []);
const onChartClick = (event: any) => {
if (event) {
if (isTableWidget || isPieChart) { // get the filter of clicked row
const periodTimestamps = drillDownPeriod.toTimestamps()
drillDownFilter.merge({
filters: event,
startTimestamp: periodTimestamps.startTimestamp,
endTimestamp: periodTimestamps.endTimestamp,
});
} else { // get the filter of clicked chart point
const payload = event.activePayload[0].payload;
const timestamp = payload.timestamp;
const periodTimestamps = getStartAndEndTimestampsByDensity(timestamp, drillDownPeriod.start, drillDownPeriod.end, params.density);
drillDownFilter.merge({
startTimestamp: periodTimestamps.startTimestamp,
endTimestamp: periodTimestamps.endTimestamp,
});
}
}
}
const depsString = JSON.stringify(_metric.series);
const fetchMetricChartData = (metric: any, payload: any, isWidget: any, period: any) => {
if (!isMounted()) return;
setLoading(true)
dashboardStore.fetchMetricChartData(metric, payload, isWidget, period).then((res: any) => {
if (isMounted()) setData(res);
}).finally(() => {
setLoading(false);
const onChartClick = (event: any) => {
if (event) {
if (isTableWidget || isPieChart) { // get the filter of clicked row
const periodTimestamps = drillDownPeriod.toTimestamps();
drillDownFilter.merge({
filters: event,
startTimestamp: periodTimestamps.startTimestamp,
endTimestamp: periodTimestamps.endTimestamp
});
} else { // get the filter of clicked chart point
const payload = event.activePayload[0].payload;
const timestamp = payload.timestamp;
const periodTimestamps = getStartAndEndTimestampsByDensity(timestamp, drillDownPeriod.start, drillDownPeriod.end, params.density);
drillDownFilter.merge({
startTimestamp: periodTimestamps.startTimestamp,
endTimestamp: periodTimestamps.endTimestamp
});
}
}
};
const depsString = JSON.stringify({
..._metric.series, ..._metric.excludes, ..._metric.startPoint,
hideExcess: _metric.hideExcess
});
const fetchMetricChartData = (metric: any, payload: any, isWidget: any, period: any) => {
if (!isMounted()) return;
setLoading(true);
dashboardStore.fetchMetricChartData(metric, payload, isWidget, period).then((res: any) => {
if (isMounted()) setData(res);
}).finally(() => {
setLoading(false);
});
};
const debounceRequest: any = React.useCallback(debounce(fetchMetricChartData, 500), []);
const loadPage = () => {
if (prevMetricRef.current && prevMetricRef.current.name !== metric.name) {
prevMetricRef.current = metric;
return;
}
prevMetricRef.current = metric;
const timestmaps = drillDownPeriod.toTimestamps();
const payload = isWidget ? { ...params } : { ...metricParams, ...timestmaps, ...metric.toJson() };
debounceRequest(metric, payload, isWidget, !isWidget ? drillDownPeriod : period);
};
useEffect(() => {
_metric.updateKey('page', 1);
loadPage();
}, [drillDownPeriod, period, depsString, metric.metricType, metric.metricOf, metric.viewType, metric.metricValue, metric.startType]);
useEffect(loadPage, [_metric.page]);
const renderChart = () => {
const { metricType, viewType, metricOf } = metric;
const metricWithData = { ...metric, data };
if (metricType === FUNNEL) {
return <FunnelWidget metric={metric} data={data} isWidget={isWidget || isTemplate} />;
}
const debounceRequest: any = React.useCallback(debounce(fetchMetricChartData, 500), []);
const loadPage = () => {
if (prevMetricRef.current && prevMetricRef.current.name !== metric.name) {
prevMetricRef.current = metric;
return
}
prevMetricRef.current = metric;
const timestmaps = drillDownPeriod.toTimestamps();
const payload = isWidget ? { ...params } : { ...metricParams, ...timestmaps, ...metric.toJson() };
debounceRequest(metric, payload, isWidget, !isWidget ? drillDownPeriod : period);
if (metricType === 'predefined' || metricType === ERRORS || metricType === PERFORMANCE || metricType === RESOURCE_MONITORING || metricType === WEB_VITALS) {
const defaultMetric = metric.data.chart && metric.data.chart.length === 0 ? metricWithData : metric;
if (isOverviewWidget) {
return <CustomMetricOverviewChart data={data} />;
}
return <WidgetPredefinedChart isTemplate={isTemplate} metric={defaultMetric} data={data}
predefinedKey={metric.metricOf} />;
}
useEffect(() => {
_metric.updateKey('page', 1)
loadPage();
}, [drillDownPeriod, period, depsString, metric.metricType, metric.metricOf, metric.viewType, metric.metricValue]);
useEffect(loadPage, [_metric.page]);
const renderChart = () => {
const { metricType, viewType, metricOf } = metric;
const metricWithData = { ...metric, data };
if (metricType === FUNNEL) {
return <FunnelWidget metric={metric} data={data} isWidget={isWidget || isTemplate} />
}
if (metricType === 'predefined' || metricType === ERRORS || metricType === PERFORMANCE || metricType === RESOURCE_MONITORING || metricType === WEB_VITALS) {
const defaultMetric = metric.data.chart && metric.data.chart.length === 0 ? metricWithData : metric
if (isOverviewWidget) {
return <CustomMetricOverviewChart data={data} />
}
return <WidgetPredefinedChart isTemplate={isTemplate} metric={defaultMetric} data={data} predefinedKey={metric.metricOf} />
}
// TODO add USER_PATH, RETENTION, FEATUER_ADOPTION
if (metricType === TIMESERIES) {
if (viewType === 'lineChart') {
return (
<CustomMetriLineChart
data={data}
colors={colors}
params={params}
onClick={onChartClick}
/>
)
} else if (viewType === 'progress') {
return (
<CustomMetricPercentage
data={data[0]}
colors={colors}
params={params}
/>
)
}
}
if (metricType === TABLE) {
if (metricOf === FilterKey.SESSIONS) {
return (
<CustomMetricTableSessions
metric={metric}
data={data}
isTemplate={isTemplate}
isEdit={!isWidget && !isTemplate}
/>
)
}
if (metricOf === FilterKey.ERRORS) {
return (
<CustomMetricTableErrors
metric={metric}
data={data}
// isTemplate={isTemplate}
isEdit={!isWidget && !isTemplate}
/>
)
}
if (viewType === TABLE) {
return (
<CustomMetricTable
metric={metric} data={data[0]}
onClick={onChartClick}
isTemplate={isTemplate}
/>
)
} else if (viewType === 'pieChart') {
return (
<CustomMetricPieChart
metric={metric}
data={data[0]}
colors={colors}
// params={params}
onClick={onChartClick}
/>
)
}
}
if (metricType === CLICKMAP) {
if (!props.isPreview) {
return (
<div style={{ height: '229px', overflow:'hidden', marginBottom: '10px'}}>
<img src={metric.thumbnail} alt="clickmap thumbnail" />
</div>
)
}
return (
<ClickMapCard />
)
}
if (metricType === INSIGHTS) {
return <InsightsCard data={data} />
}
return <div>Unknown metric type</div>;
if (metricType === TIMESERIES) {
if (viewType === 'lineChart') {
return (
<CustomMetriLineChart
data={data}
colors={colors}
params={params}
onClick={onChartClick}
/>
);
} else if (viewType === 'progress') {
return (
<CustomMetricPercentage
data={data[0]}
colors={colors}
params={params}
/>
);
}
}
return (
<Loader loading={loading} style={{ height: `${isOverviewWidget ? 100 : 240}px` }}>
<div style={{ minHeight: isOverviewWidget ? 100 : 240 }}>{renderChart()}</div>
</Loader>
);
if (metricType === TABLE) {
if (metricOf === FilterKey.SESSIONS) {
return (
<CustomMetricTableSessions
metric={metric}
data={data}
isTemplate={isTemplate}
isEdit={!isWidget && !isTemplate}
/>
);
}
if (metricOf === FilterKey.ERRORS) {
return (
<CustomMetricTableErrors
metric={metric}
data={data}
// isTemplate={isTemplate}
isEdit={!isWidget && !isTemplate}
/>
);
}
if (viewType === TABLE) {
return (
<CustomMetricTable
metric={metric} data={data[0]}
onClick={onChartClick}
isTemplate={isTemplate}
/>
);
} else if (viewType === 'pieChart') {
return (
<CustomMetricPieChart
metric={metric}
data={data[0]}
colors={colors}
// params={params}
onClick={onChartClick}
/>
);
}
}
if (metricType === CLICKMAP) {
if (!props.isPreview) {
return (
<div style={{ height: '229px', overflow: 'hidden', marginBottom: '10px' }}>
<img src={metric.thumbnail} alt='clickmap thumbnail' />
</div>
);
}
return (
<ClickMapCard />
);
}
if (metricType === INSIGHTS) {
return <InsightsCard data={data} />;
}
if (metricType === USER_PATH && data && data.links) {
return <SankeyChart
height={props.isPreview ? 500 : 240}
data={data}
onChartClick={(filters: any) => {
dashboardStore.drillDownFilter.merge({ filters });
}} />;
}
if (metricType === RETENTION) {
if (viewType === 'trend') {
return (
<CustomMetriLineChart
data={data}
colors={colors}
params={params}
onClick={onChartClick}
/>
);
} else if (viewType === 'cohort') {
return (
<CohortCard data={data[0]} />
);
}
}
return <div>Unknown metric type</div>;
};
return (
<Loader loading={loading} style={{ height: `${isOverviewWidget ? 100 : 240}px` }}>
<div style={{ minHeight: isOverviewWidget ? 100 : 240 }}>{renderChart()}</div>
</Loader>
);
}
export default observer(WidgetChart);

View file

@ -19,10 +19,14 @@ import {
PERFORMANCE,
WEB_VITALS,
INSIGHTS,
USER_PATH,
RETENTION
} from 'App/constants/card';
import { eventKeys } from 'App/types/filter/newFilter';
import { eventKeys, filtersMap } from 'App/types/filter/newFilter';
import { renderClickmapThumbnail } from './renderMap';
import Widget from 'App/mstore/types/widget';
import FilterItem from 'Shared/Filters/FilterItem';
interface Props {
history: any;
match: any;
@ -33,8 +37,8 @@ function WidgetForm(props: Props) {
const {
history,
match: {
params: { siteId, dashboardId },
},
params: { siteId, dashboardId }
}
} = props;
const { metricStore, dashboardStore } = useStore();
const isSaving = metricStore.isSaving;
@ -47,6 +51,8 @@ function WidgetForm(props: Props) {
const isClickmap = metric.metricType === CLICKMAP;
const isFunnel = metric.metricType === FUNNEL;
const isInsights = metric.metricType === INSIGHTS;
const isPathAnalysis = metric.metricType === USER_PATH;
const isRetention = metric.metricType === RETENTION;
const canAddSeries = metric.series.length < 3;
const eventsLength = metric.series[0].filter.filters.filter((i: any) => i.isEvent).length;
const cannotSaveFunnel = isFunnel && (!metric.series[0] || eventsLength <= 1);
@ -55,7 +61,7 @@ function WidgetForm(props: Props) {
metric.metricType
);
const excludeFilterKeys = isClickmap ? eventKeys : [];
const excludeFilterKeys = isClickmap || isPathAnalysis ? eventKeys : [];
useEffect(() => {
if (!!metric && !initialInstance) {
@ -91,7 +97,7 @@ function WidgetForm(props: Props) {
}
}
const savedMetric = await metricStore.save(metric);
setInitialInstance(metric.toJson())
setInitialInstance(metric.toJson());
if (wasCreating) {
if (parseInt(dashboardId, 10) > 0) {
history.replace(
@ -112,50 +118,83 @@ function WidgetForm(props: Props) {
await confirm({
header: 'Confirm',
confirmButton: 'Yes, delete',
confirmation: `Are you sure you want to permanently delete this card?`,
confirmation: `Are you sure you want to permanently delete this card?`
})
) {
metricStore.delete(metric).then(props.onDelete);
}
};
const undoChnages = () => {
const undoChanges = () => {
const w = new Widget();
metricStore.merge(w.fromJson(initialInstance), false);
};
return (
<div className="p-6">
<div className="form-group">
<div className="flex items-center">
<span className="mr-2">Card showing</span>
<div className='p-6'>
<div className='form-group'>
<div className='flex items-center'>
<span className='mr-2'>Card showing</span>
<MetricTypeDropdown onSelect={writeOption} />
<MetricSubtypeDropdown onSelect={writeOption} />
{isPathAnalysis && (
<>
<span className='mx-3'></span>
<Select
name='startType'
options={[
{ value: 'start', label: 'With Start Point' },
{ value: 'end', label: 'With End Point' }
]}
defaultValue={metric.startType}
// value={metric.metricOf}
onChange={writeOption}
placeholder='All Issues'
/>
<span className='mx-3'>showing</span>
<Select
name='metricValue'
options={[
{ value: 'location', label: 'Pages' },
{ value: 'click', label: 'Clicks' },
{ value: 'input', label: 'Input' },
{ value: 'custom', label: 'Custom' },
]}
defaultValue={metric.metricValue}
isMulti={true}
// value={metric.metricValue}
onChange={writeOption}
placeholder='All Issues'
/>
</>
)}
{metric.metricOf === FilterKey.ISSUE && metric.metricType === TABLE && (
<>
<span className="mx-3">issue type</span>
<span className='mx-3'>issue type</span>
<Select
name="metricValue"
name='metricValue'
options={issueOptions}
value={metric.metricValue}
onChange={writeOption}
isMulti={true}
placeholder="All Issues"
placeholder='All Issues'
/>
</>
)}
{metric.metricType === INSIGHTS && (
<>
<span className="mx-3">of</span>
<span className='mx-3'>of</span>
<Select
name="metricValue"
name='metricValue'
options={issueCategories}
value={metric.metricValue}
onChange={writeOption}
isMulti={true}
placeholder="All Categories"
placeholder='All Categories'
/>
</>
)}
@ -163,9 +202,9 @@ function WidgetForm(props: Props) {
{metric.metricType === 'table' &&
!(metric.metricOf === FilterKey.ERRORS || metric.metricOf === FilterKey.SESSIONS) && (
<>
<span className="mx-3">showing</span>
<span className='mx-3'>showing</span>
<Select
name="metricFormat"
name='metricFormat'
options={[{ value: 'sessionCount', label: 'Session Count' }]}
defaultValue={metric.metricFormat}
onChange={writeOption}
@ -175,23 +214,38 @@ function WidgetForm(props: Props) {
</div>
</div>
{isPathAnalysis && (
<div className='form-group flex flex-col'>
{metric.startType === 'start' ? 'Start Point' : 'End Point'}
<FilterItem
hideDelete={true}
filter={metric.startPoint}
allowedFilterKeys={[FilterKey.LOCATION, FilterKey.CLICK, FilterKey.INPUT, FilterKey.CUSTOM]}
onUpdate={(val) => {
metric.updateStartPoint(val);
}} onRemoveFilter={() => {
}} />
</div>
)}
{isPredefined && (
<div className="flex items-center my-6 justify-center">
<Icon name="info-circle" size="18" color="gray-medium" />
<div className="ml-2">
<div className='flex items-center my-6 justify-center'>
<Icon name='info-circle' size='18' color='gray-medium' />
<div className='ml-2'>
Filtering and drill-downs will be supported soon for this card type.
</div>
</div>
)}
{!isPredefined && (
<div className="form-group">
<div className="flex items-center font-medium py-2">
{`${isTable || isFunnel || isClickmap || isInsights ? 'Filter by' : 'Chart Series'}`}
{!isTable && !isFunnel && !isClickmap && !isInsights && (
<div className='form-group'>
<div className='flex items-center font-medium py-2'>
{`${isTable || isFunnel || isClickmap || isInsights || isPathAnalysis || isRetention ? 'Filter by' : 'Chart Series'}`}
{!isTable && !isFunnel && !isClickmap && !isInsights && !isPathAnalysis && !isRetention && (
<Button
className="ml-2"
variant="text-primary"
className='ml-2'
variant='text-primary'
onClick={() => metric.addSeries()}
disabled={!canAddSeries}
>
@ -202,14 +256,15 @@ function WidgetForm(props: Props) {
{metric.series.length > 0 &&
metric.series
.slice(0, isTable || isFunnel || isClickmap || isInsights ? 1 : metric.series.length)
.slice(0, isTable || isFunnel || isClickmap || isInsights || isRetention ? 1 : metric.series.length)
.map((series: any, index: number) => (
<div className="mb-2" key={series.name}>
<div className='mb-2' key={series.name}>
<FilterSeries
supportsEmpty={!isClickmap}
canExclude={isPathAnalysis}
supportsEmpty={!isClickmap && !isPathAnalysis}
excludeFilterKeys={excludeFilterKeys}
observeChanges={() => metric.updateKey('hasChanged', true)}
hideHeader={isTable || isClickmap || isInsights}
hideHeader={isTable || isClickmap || isInsights || isPathAnalysis || isFunnel}
seriesIndex={index}
series={series}
onRemoveSeries={() => metric.removeSeries(index)}
@ -225,30 +280,30 @@ function WidgetForm(props: Props) {
</div>
)}
<div className="form-groups flex items-center justify-between">
<div className='form-groups flex items-center justify-between'>
<Tooltip
title="Cannot save funnel metric without at least 2 events"
title='Cannot save funnel metric without at least 2 events'
disabled={!cannotSaveFunnel}
>
<div className="flex items-center">
<Button variant="primary" onClick={onSave} disabled={isSaving || cannotSaveFunnel}>
<div className='flex items-center'>
<Button variant='primary' onClick={onSave} disabled={isSaving || cannotSaveFunnel}>
{metric.exists()
? 'Update'
: parseInt(dashboardId) > 0
? 'Create & Add to Dashboard'
: 'Create'}
? 'Create & Add to Dashboard'
: 'Create'}
</Button>
{metric.exists() && metric.hasChanged && (
<Button onClick={undoChnages} variant="text" icon="arrow-counterclockwise" className="ml-2">
<Button onClick={undoChanges} variant='text' icon='arrow-counterclockwise' className='ml-2'>
Undo
</Button>
)}
</div>
</Tooltip>
<div className="flex items-center">
<div className='flex items-center'>
{metric.exists() && (
<Button variant="text-primary" onClick={onDelete}>
<Icon name="trash" size="14" className="mr-2" color="teal" />
<Button variant='text-primary' onClick={onDelete}>
<Icon name='trash' size='14' className='mr-2' color='teal' />
Delete
</Button>
)}

View file

@ -1,5 +1,5 @@
import React from 'react';
import { DROPDOWN_OPTIONS, INSIGHTS, Option } from 'App/constants/card';
import { DROPDOWN_OPTIONS, INSIGHTS, Option, USER_PATH } from 'App/constants/card';
import Select from 'Shared/Select';
import { components } from 'react-select';
import CustomDropdownOption from 'Shared/CustomDropdownOption';
@ -37,6 +37,7 @@ function MetricTypeDropdown(props: Props) {
}
setTimeout(() => onChange(type.value), 0);
}
setTimeout(() => onChange(USER_PATH), 0);
}, []);
const onChange = (type: string) => {

View file

@ -8,7 +8,8 @@ import { FilterKey } from 'Types/filter/filterType';
import WidgetDateRange from '../WidgetDateRange/WidgetDateRange';
import ClickMapRagePicker from "Components/Dashboard/components/ClickMapRagePicker";
import DashboardSelectionModal from '../DashboardSelectionModal/DashboardSelectionModal';
import { CLICKMAP, TABLE, TIMESERIES } from "App/constants/card";
import { CLICKMAP, TABLE, TIMESERIES, RETENTION, USER_PATH } from 'App/constants/card';
import { Space, Switch } from 'antd';
interface Props {
className?: string;
@ -23,6 +24,7 @@ function WidgetPreview(props: Props) {
const metric: any = metricStore.instance;
const isTimeSeries = metric.metricType === TIMESERIES;
const isTable = metric.metricType === TABLE;
const isRetention = metric.metricType === RETENTION;
const disableVisualization = metric.metricOf === FilterKey.SESSIONS || metric.metricOf === FilterKey.ERRORS;
const changeViewType = (_, { name, value }: any) => {
@ -39,6 +41,23 @@ function WidgetPreview(props: Props) {
{props.name}
</h2>
<div className="flex items-center">
{metric.metricType === USER_PATH && (
<a
href="#"
onClick={(e) => {
e.preventDefault();
metric.update({ hideExcess: !metric.hideExcess });
}}
>
<Space>
<Switch
checked={metric.hideExcess}
size="small"
/>
<span className="mr-4 color-gray-medium">Hide Minor Paths</span>
</Space>
</a>
)}
{isTimeSeries && (
<>
<span className="mr-4 color-gray-medium">Visualization</span>
@ -75,6 +94,25 @@ function WidgetPreview(props: Props) {
/>
</>
)}
{isRetention && (
<>
<span className="mr-4 color-gray-medium">Visualization</span>
<SegmentSelection
name="viewType"
className="my-3"
primary={true}
size="small"
onSelect={ changeViewType }
value={{ value: metric.viewType }}
list={[
{ value: 'trend', name: 'Trend', icon: 'graph-up-arrow' },
{ value: 'cohort', name: 'Cohort', icon: 'dice-3' },
]}
disabledMessage="Chart view is not supported"
/>
</>
)}
<div className="mx-4" />
{metric.metricType === CLICKMAP ? (
<ClickMapRagePicker />

View file

@ -19,7 +19,11 @@ import {
CLICKMAP,
FUNNEL,
INSIGHTS,
USER_PATH,
RETENTION,
} from 'App/constants/card';
import CardIssues from '../CardIssues';
import CardUserList from '../CardUserList/CardUserList';
interface Props {
history: any;
@ -124,6 +128,9 @@ function WidgetView(props: Props) {
{widget.metricType === FUNNEL && <FunnelIssues />}
</>
)}
{widget.metricType === USER_PATH && <CardIssues />}
{widget.metricType === RETENTION && <CardUserList />}
</NoContent>
</div>
</Loader>

View file

@ -12,6 +12,7 @@ import AlertButton from './AlertButton';
import stl from './widgetWrapper.module.css';
import { FilterKey } from 'App/types/filter/filterType';
import LazyLoad from 'react-lazyload';
import { TIMESERIES } from "App/constants/card";
interface Props {
className?: string;
@ -44,7 +45,7 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
isGridView = false,
} = props;
const widget: any = props.widget;
const isTimeSeries = widget.metricType === 'timeseries';
const isTimeSeries = widget.metricType === TIMESERIES;
const isPredefined = widget.metricType === 'predefined';
const dashboard = dashboardStore.selectedDashboard;

View file

@ -2,14 +2,14 @@ import React from 'react';
import FilterOperator from '../FilterOperator';
import FilterSelection from '../FilterSelection';
import FilterValue from '../FilterValue';
import {Button} from 'UI';
import { Button } from 'UI';
import FilterSource from '../FilterSource';
import {FilterKey, FilterType} from 'App/types/filter/filterType';
import { FilterKey, FilterType } from 'App/types/filter/filterType';
import SubFilterItem from '../SubFilterItem';
import {toJS} from "mobx";
interface Props {
filterIndex: number;
filterIndex?: number;
filter: any; // event/filter
onUpdate: (filter: any) => void;
onRemoveFilter: () => void;
@ -17,7 +17,10 @@ interface Props {
saveRequestPayloads?: boolean;
disableDelete?: boolean;
excludeFilterKeys?: Array<string>;
allowedFilterKeys?: Array<string>;
readonly?: boolean;
hideIndex?: boolean;
hideDelete?: boolean;
}
function FilterItem(props: Props) {
@ -27,8 +30,10 @@ function FilterItem(props: Props) {
filter,
saveRequestPayloads,
disableDelete = false,
hideDelete = false,
allowedFilterKeys = [],
excludeFilterKeys = []
} = props;
, hideIndex = false } = props;
const canShowValues = !(filter.operator === 'isAny' || filter.operator === 'onAny' || filter.operator === 'isUndefined');
const isSubFilter = filter.type === FilterType.SUB_FILTERS;
const replaceFilter = (filter: any) => {
@ -62,7 +67,7 @@ function FilterItem(props: Props) {
return (
<div className="flex items-center hover:bg-active-blue -mx-5 px-5 py-2">
<div className="flex items-start w-full">
{!isFilter && (
{!isFilter && !hideIndex && !!filterIndex && (
<div
className="mt-1 flex-shrink-0 border w-6 h-6 text-xs flex items-center justify-center rounded-full bg-gray-light-shade mr-2">
<span>{filterIndex + 1}</span>
@ -71,6 +76,7 @@ function FilterItem(props: Props) {
<FilterSelection
filter={filter}
onFilterClick={replaceFilter}
allowedFilterKeys={allowedFilterKeys}
excludeFilterKeys={excludeFilterKeys}
disabled={disableDelete || props.readonly}
/>
@ -140,7 +146,7 @@ function FilterItem(props: Props) {
</div>
)}
</div>
{props.readonly ? null :
{(props.readonly || props.hideDelete) ? null :
<div className="flex flex-shrink-0 self-start ml-auto">
<Button
disabled={disableDelete}

View file

@ -45,7 +45,7 @@ function FilterList(props: Props) {
{hasEvents && (
<>
<div className="flex items-center mb-2">
<div className="text-sm color-gray-medium mr-auto">EVENTS</div>
<div className="text-sm color-gray-medium mr-auto">{filter.eventsHeader}</div>
{!hideEventsOrder && (
<div className="flex items-center">
<div

View file

@ -6,19 +6,23 @@ import stl from './FilterModal.module.css';
import { filtersMap } from 'Types/filter/newFilter';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
function filterJson(jsonObj: Record<string, any>, excludeKeys: string[] = []): Record<string, any> {
let filtered: Record<string, any> = {};
for (const key in jsonObj) {
const arr = jsonObj[key].filter((i: any) => !excludeKeys.includes(i.key));
if (arr.length) {
filtered[key] = arr;
}
}
return filtered;
function filterJson(
jsonObj: Record<string, any>,
excludeKeys: string[] = [],
allowedFilterKeys: string[] = []
): Record<string, any> {
return Object.fromEntries(
Object.entries(jsonObj).map(([key, value]) => {
const arr = value.filter((i: { key: string }) => {
if (excludeKeys.includes(i.key)) return false;
return !(allowedFilterKeys.length > 0 && !allowedFilterKeys.includes(i.key));
});
return [key, arr];
}).filter(([_, arr]) => arr.length > 0)
);
}
export const getMatchingEntries = (searchQuery: string, filters: Record<string, any>) => {
const matchingCategories: string[] = [];
const matchingFilters: Record<string, any> = {};
@ -27,7 +31,7 @@ export const getMatchingEntries = (searchQuery: string, filters: Record<string,
if (lowerCaseQuery.length === 0)
return {
matchingCategories: Object.keys(filters),
matchingFilters: filters,
matchingFilters: filters
};
Object.keys(filters).forEach((name) => {
@ -56,7 +60,9 @@ interface Props {
fetchingFilterSearchList: boolean;
searchQuery?: string;
excludeFilterKeys?: Array<string>;
allowedFilterKeys?: Array<string>;
}
function FilterModal(props: Props) {
const {
filters,
@ -66,18 +72,19 @@ function FilterModal(props: Props) {
fetchingFilterSearchList,
searchQuery = '',
excludeFilterKeys = [],
allowedFilterKeys = []
} = props;
const showSearchList = isMainSearch && searchQuery.length > 0;
const onFilterSearchClick = (filter: any) => {
const _filter = {...filtersMap[filter.type]};
const _filter = { ...filtersMap[filter.type] };
_filter.value = [filter.value];
onFilterClick(_filter);
};
const { matchingCategories, matchingFilters } = getMatchingEntries(
searchQuery,
filterJson(filters, excludeFilterKeys)
filterJson(filters, excludeFilterKeys, allowedFilterKeys)
);
const isResultEmpty =
@ -93,8 +100,8 @@ function FilterModal(props: Props) {
>
{matchingCategories.map((key) => {
return (
<div className="mb-6 flex flex-col gap-2 break-inside-avoid" key={key}>
<div className="uppercase font-medium mb-1 color-gray-medium tracking-widest text-sm">
<div className='mb-6 flex flex-col gap-2 break-inside-avoid' key={key}>
<div className='uppercase font-medium mb-1 color-gray-medium tracking-widest text-sm'>
{key}
</div>
<div>
@ -108,8 +115,8 @@ function FilterModal(props: Props) {
)}
onClick={() => onFilterClick({ ...filter, value: [''] })}
>
<Icon name={filter.icon} size="16" />
<span className="ml-2">{filter.label}</span>
<Icon name={filter.icon} size='16' />
<span className='ml-2'>{filter.label}</span>
</div>
))}
</div>
@ -118,12 +125,12 @@ function FilterModal(props: Props) {
})}
</div>
{showSearchList && (
<Loader size="small" loading={fetchingFilterSearchList}>
<div className="-mx-6 px-6">
<Loader size='small' loading={fetchingFilterSearchList}>
<div className='-mx-6 px-6'>
{isResultEmpty && !fetchingFilterSearchList ? (
<div className="flex items-center flex-col">
<div className='flex items-center flex-col'>
<AnimatedSVG name={ICONS.NO_SEARCH_RESULTS} size={180} />
<div className="color-gray-medium font-medium px-3"> No Suggestions Found </div>
<div className='color-gray-medium font-medium px-3'> No Suggestions Found</div>
</div>
) : (
Object.keys(filterSearchList).map((key, index) => {
@ -131,7 +138,7 @@ function FilterModal(props: Props) {
const option = filtersMap[key];
return option ? (
<div key={index} className={cn('mb-3')}>
<div className="font-medium uppercase color-gray-medium mb-2">
<div className='font-medium uppercase color-gray-medium mb-2'>
{option.label}
</div>
<div>
@ -144,8 +151,8 @@ function FilterModal(props: Props) {
)}
onClick={() => onFilterSearchClick({ type: key, value: f.value })}
>
<Icon className="mr-2" name={option.icon} size="16" />
<div className="whitespace-nowrap text-ellipsis overflow-hidden">
<Icon className='mr-2' name={option.icon} size='16' />
<div className='whitespace-nowrap text-ellipsis overflow-hidden'>
{f.value}
</div>
</div>
@ -174,6 +181,6 @@ export default connect((state: any, props: any) => {
: state.getIn(['search', 'filterSearchList']),
fetchingFilterSearchList: props.isLive
? state.getIn(['liveSearch', 'fetchFilterSearch', 'loading'])
: state.getIn(['search', 'fetchFilterSearch', 'loading']),
: state.getIn(['search', 'fetchFilterSearch', 'loading'])
};
})(FilterModal);

View file

@ -15,19 +15,21 @@ interface Props {
onFilterClick: (filter: any) => void;
children?: any;
isLive?: boolean;
excludeFilterKeys?: Array<string>
disabled?: boolean
excludeFilterKeys?: Array<string>;
allowedFilterKeys?: Array<string>;
disabled?: boolean;
}
function FilterSelection(props: Props) {
const { filter, onFilterClick, children, excludeFilterKeys = [], disabled = false } = props;
const { filter, onFilterClick, children, excludeFilterKeys = [], allowedFilterKeys = [], disabled = false } = props;
const [showModal, setShowModal] = useState(false);
return (
<div className="relative flex-shrink-0">
<div className='relative flex-shrink-0'>
<OutsideClickDetectingDiv
className="relative"
className='relative'
onClickOutside={() =>
setTimeout(function () {
setTimeout(function() {
setShowModal(false);
}, 200)
}
@ -43,26 +45,27 @@ function FilterSelection(props: Props) {
})
) : (
<div
className={cn("rounded py-1 px-3 flex items-center cursor-pointer bg-gray-lightest text-ellipsis hover:bg-gray-light-shade", { 'opacity-50 pointer-events-none' : disabled })}
className={cn('rounded py-1 px-3 flex items-center cursor-pointer bg-gray-lightest text-ellipsis hover:bg-gray-light-shade', { 'opacity-50 pointer-events-none': disabled })}
style={{ width: '150px', height: '26px', border: 'solid thin #e9e9e9' }}
onClick={() => setShowModal(true)}
>
<div
className="overflow-hidden whitespace-nowrap text-ellipsis mr-auto truncate"
className='overflow-hidden whitespace-nowrap text-ellipsis mr-auto truncate'
style={{ textOverflow: 'ellipsis' }}
>
{filter.label}
</div>
<Icon name="chevron-down" size="14" />
<Icon name='chevron-down' size='14' />
</div>
)}
</OutsideClickDetectingDiv>
{showModal && (
<div className="absolute left-0 border shadow rounded bg-white z-50">
<div className='absolute left-0 border shadow rounded bg-white z-50'>
<FilterModal
isLive={isRoute(ASSIST_ROUTE, window.location.pathname)}
onFilterClick={onFilterClick}
excludeFilterKeys={excludeFilterKeys}
allowedFilterKeys={allowedFilterKeys}
/>
</div>
)}
@ -74,7 +77,7 @@ export default connect(
(state: any) => ({
filterList: state.getIn(['search', 'filterList']),
filterListLive: state.getIn(['search', 'filterListLive']),
isLive: state.getIn(['sessions', 'activeTab']).type === 'live',
isLive: state.getIn(['sessions', 'activeTab']).type === 'live'
}),
{}
)(FilterSelection);

View file

@ -0,0 +1,45 @@
import React from 'react';
import { Layer } from 'recharts';
function CustomLink(props: any) {
const [fill, setFill] = React.useState('url(#linkGradient)');
const { payload, sourceX, targetX, sourceY, targetY, sourceControlX, targetControlX, linkWidth, index, activeLink } =
props;
const activeSource = activeLink?.payload.source;
const activeTarget = activeLink?.payload.target;
const isActive = activeSource?.name === payload.source.name && activeTarget?.name === payload.target.name;
const onClick = () => {
if (props.onClick) {
props.onClick(props);
}
};
return (
<Layer key={`CustomLink${index}`} onClick={onClick}>
<path
d={`
M${sourceX},${sourceY + linkWidth / 2}
C${sourceControlX},${sourceY + linkWidth / 2}
${targetControlX},${targetY + linkWidth / 2}
${targetX},${targetY + linkWidth / 2}
L${targetX},${targetY - linkWidth / 2}
C${targetControlX},${targetY - linkWidth / 2}
${sourceControlX},${sourceY - linkWidth / 2}
${sourceX},${sourceY - linkWidth / 2}
Z
`}
fill={isActive ? 'rgba(57, 78, 255, 0.5)' : fill}
strokeWidth='0'
onMouseEnter={() => {
setFill('rgba(57, 78, 255, 0.5)');
}}
onMouseLeave={() => {
setFill('url(#linkGradient)');
}}
/>
</Layer>
);
}
export default CustomLink;

View file

@ -0,0 +1,35 @@
import React from 'react';
import { Layer, Rectangle } from 'recharts';
import NodeButton from './NodeButton';
import NodeDropdown from './NodeDropdown';
function CustomNode(props: any) {
const { x, y, width, height, index, payload, containerWidth } = props;
const isOut = x + width + 6 > containerWidth;
return (
<Layer key={`CustomNode${index}`} style={{ cursor: 'pointer' }}>
<Rectangle x={x} y={y} width={width} height={height} fill='#394EFF' fillOpacity='1' />
{/*<foreignObject*/}
{/* x={isOut ? x - 6 : x + width + 5}*/}
{/* y={0}*/}
{/* height={48}*/}
{/* style={{ width: '150px', padding: '2px' }}*/}
{/*>*/}
{/* <NodeDropdown payload={payload} />*/}
{/*</foreignObject>*/}
<foreignObject
x={isOut ? x - 6 : x + width + 5}
y={y + 5}
height='200'
style={{ width: '150px' }}
>
<NodeButton payload={payload} />
</foreignObject>
</Layer>
);
}
export default CustomNode;

View file

@ -0,0 +1,62 @@
import React from 'react';
import { Icon } from 'UI';
import { Popover } from 'antd';
interface Props {
payload: any;
}
function NodeButton(props: Props) {
const { payload } = props;
const [show, setShow] = React.useState(false);
console.log('payload', payload, props)
const toggleMenu = (e: React.MouseEvent) => {
setShow(!show);
};
return (
<div className='relative'>
<Popover content={
<div className='bg-white rounded w-fit mt-1 text-xs'>
<div className='border-b py-1 px-2 flex items-center'>
<div className='w-6 shrink-0'>
<Icon name='link-45deg' size={18} />
</div>
<div className='ml-1'>{payload.name}</div>
</div>
<div className='border-b py-1 px-2 flex items-center'>
<div className='w-6 shrink-0'>
<Icon name='arrow-right-short' size={18} color='green' />
</div>
<div className='ml-1 font-medium'>Continuing {payload.value}</div>
</div>
<div className='border-b py-1 px-2 flex items-center'>
<div className='w-6 shrink-0'>
<Icon name='clock-history' size={16} />
</div>
<div className='ml-1 font-medium'>
Average time from previous step <span>{payload.avgTimeFromPrevious}</span>
</div>
</div>
</div>
} title={<div className='text-sm'>Title</div>}>
<div
className='copy-popover select-none rounded shadow'
style={{
backgroundColor: 'white',
padding: '3px 6px',
width: 'fit-content',
fontSize: '12px'
}}
onClick={toggleMenu}
>
{payload.name} <span style={{ fontWeight: 'bold' }}>{payload.value + '%'}</span>
{/*{' '} <span style={{}}>{payload.avgTimeFromPrevious}</span>*/}
</div>
</Popover>
</div>
);
}
export default NodeButton;

View file

@ -0,0 +1,31 @@
import React from 'react';
// import Select from 'Shared/Select';
import { Dropdown, MenuProps, Select, Space } from 'antd';
import { DownOutlined, SmileOutlined } from '@ant-design/icons';
interface Props {
payload: any;
}
function NodeDropdown(props: Props) {
const items: MenuProps['items'] = [
{
key: '1',
label: (
<a target='_blank' rel='noopener noreferrer' href='https://www.antgroup.com'>
1st menu item
</a>
)
}
];
return (
<Select style={{ width: 120 }} placeholder='Slect Event' dropdownStyle={{
border: 'none'
}}>
<Select.Option value='jack'>Jack</Select.Option>
<Select.Option value='lucy'>Lucy</Select.Option>
</Select>
);
}
export default NodeDropdown;

View file

@ -1,134 +1,100 @@
import React from 'react';
import { Sankey, Tooltip, Rectangle, Layer, ResponsiveContainer } from 'recharts';
import React, { useEffect } from 'react';
import { Sankey, ResponsiveContainer } from 'recharts';
import CustomLink from './CustomLink';
import CustomNode from './CustomNode';
import { NoContent } from 'UI';
type Node = {
interface Node {
name: string;
eventType: string;
avgTimeFromPrevious: number | null;
}
type Link = {
interface Link {
eventType: string;
value: number;
source: number;
target: number;
value: number;
}
export interface SankeyChartData {
links: Link[];
interface Data {
nodes: Node[];
links: Link[];
}
interface Props {
data: SankeyChartData;
data: Data;
nodePadding?: number;
nodeWidth?: number;
onChartClick?: (data: any) => void;
height?: number;
}
function SankeyChart(props: Props) {
const { data, nodePadding = 50, nodeWidth = 10 } = props;
const { data, nodeWidth = 10, height = 240 } = props;
const [activeLink, setActiveLink] = React.useState<any>(null);
data.nodes = data.nodes.map((node: any) => {
return {
...node,
avgTimeFromPrevious: 200
};
});
useEffect(() => {
if (!activeLink) return;
const { source, target } = activeLink.payload;
const filters = [];
if (source) {
filters.push({
operator: 'is',
type: source.eventType,
value: [source.name],
isEvent: true
});
}
if (target) {
filters.push({
operator: 'is',
type: target.eventType,
value: [target.name],
isEvent: true
});
}
props.onChartClick?.(filters);
}, [activeLink]);
return (
<div className="rounded border shadow">
<div className="text-lg p-3 border-b bg-gray-lightest">Sankey Chart</div>
<div className="">
<ResponsiveContainer height={500} width="100%">
<Sankey
width={960}
height={500}
data={data}
// node={{ stroke: '#77c878', strokeWidth: 0 }}
node={<CustomNodeComponent />}
nodePadding={nodePadding}
nodeWidth={nodeWidth}
margin={{
left: 10,
right: 100,
top: 10,
bottom: 10,
}}
link={<CustomLinkComponent />}
>
<defs>
<linearGradient id={'linkGradient'}>
<stop offset="0%" stopColor="rgba(0, 136, 254, 0.5)" />
<stop offset="100%" stopColor="rgba(0, 197, 159, 0.3)" />
</linearGradient>
</defs>
<Tooltip content={<CustomTooltip />} />
</Sankey>
</ResponsiveContainer>
</div>
</div>
<NoContent show={!(data && data.nodes && data.nodes.length && data.links)}>
<ResponsiveContainer height={height} width='100%'>
<Sankey
data={data}
node={<CustomNode />}
nodeWidth={nodeWidth}
sort={false}
// linkCurvature={0.5}
// iterations={128}
margin={{
left: 0,
right: 200,
top: 0,
bottom: 10
}}
link={<CustomLink onClick={(props: any) => setActiveLink(props)} activeLink={activeLink} />}
>
<defs>
<linearGradient id={'linkGradient'}>
<stop offset='0%' stopColor='rgba(57, 78, 255, 0.2)' />
<stop offset='100%' stopColor='rgba(57, 78, 255, 0.2)' />
</linearGradient>
</defs>
</Sankey>
</ResponsiveContainer>
</NoContent>
);
}
export default SankeyChart;
const CustomTooltip = (props: any) => {
return <div className="rounded bg-white border p-0 px-1 text-sm">test</div>;
// if (active && payload && payload.length) {
// return (
// <div className="custom-tooltip">
// <p className="label">{`${label} : ${payload[0].value}`}</p>
// <p className="intro">{getIntroOfPage(label)}</p>
// <p className="desc">Anything you want can be displayed here.</p>
// </div>
// );
// }
return null;
};
function CustomNodeComponent({ x, y, width, height, index, payload, containerWidth }: any) {
const isOut = x + width + 6 > containerWidth;
return (
<Layer key={`CustomNode${index}`}>
<Rectangle x={x} y={y} width={width} height={height} fill="#5192ca" fillOpacity="1" />
<text
textAnchor={isOut ? 'end' : 'start'}
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2}
fontSize="8"
// stroke="#333"
>
{payload.name}
</text>
<text
textAnchor={isOut ? 'end' : 'start'}
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2 + 13}
fontSize="12"
// stroke="#333"
// strokeOpacity="0.5"
>
{payload.value + 'k'}
</text>
</Layer>
);
}
const CustomLinkComponent = (props: any) => {
const [fill, setFill] = React.useState('url(#linkGradient)');
const { sourceX, targetX, sourceY, targetY, sourceControlX, targetControlX, linkWidth, index } =
props;
return (
<Layer key={`CustomLink${index}`}>
<path
d={`
M${sourceX},${sourceY + linkWidth / 2}
C${sourceControlX},${sourceY + linkWidth / 2}
${targetControlX},${targetY + linkWidth / 2}
${targetX},${targetY + linkWidth / 2}
L${targetX},${targetY - linkWidth / 2}
C${targetControlX},${targetY - linkWidth / 2}
${sourceControlX},${sourceY - linkWidth / 2}
${sourceX},${sourceY - linkWidth / 2}
Z
`}
fill={fill}
strokeWidth="0"
onMouseEnter={() => {
setFill('rgba(0, 136, 254, 0.5)');
}}
onMouseLeave={() => {
setFill('url(#linkGradient)');
}}
/>
</Layer>
);
};

View file

@ -88,7 +88,7 @@ function LiveSessionList(props: Props) {
<div className="flex mb-6 justify-between items-center">
<div className="flex items-center">
<h3 className="text-2xl capitalize mr-2">
<span>Live Sessions</span>
<span>Cobrowse</span>
{/* <span className="ml-2 font-normal color-gray-medium">{numberWithCommas(total)}</span> */}
</h3>
@ -126,7 +126,7 @@ function LiveSessionList(props: Props) {
subtext={
<div className="text-center flex justify-center items-center flex-col">
<span>
Support users with live sessions, co-browsing, and video calls.
Support users with live sessions, cobrowsing, and video calls.
<a
target="_blank"
className="link ml-1"

View file

@ -2,177 +2,199 @@ import React from 'react';
import Select, { components, DropdownIndicatorProps } from 'react-select';
import { Icon } from 'UI';
import colors from 'App/theme/colors';
const { ValueContainer } = components;
type ValueObject = {
value: string | number,
label: React.ReactNode,
value: string | number,
label: React.ReactNode,
}
interface Props<Value extends ValueObject> {
options: Value[];
isSearchable?: boolean;
defaultValue?: string | number;
plain?: boolean;
components?: any;
styles?: Record<string, any>;
controlStyle?: Record<string, any>;
onChange: (newValue: { name: string, value: Value }) => void;
name?: string;
placeholder?: string;
[x:string]: any;
options: Value[];
isSearchable?: boolean;
defaultValue?: string | number;
plain?: boolean;
components?: any;
styles?: Record<string, any>;
controlStyle?: Record<string, any>;
onChange: (newValue: { name: string, value: Value }) => void;
name?: string;
placeholder?: string;
[x: string]: any;
}
export default function<Value extends ValueObject>({ placeholder='Select', name = '', onChange, right = false, plain = false, options, isSearchable = false, components = {}, styles = {}, defaultValue = '', controlStyle = {}, ...rest }: Props<Value>) {
const defaultSelected = defaultValue ? (options.find(o => o.value === defaultValue) || options[0]): null;
const customStyles = {
option: (provided: any, state: any) => ({
...provided,
whiteSpace: 'nowrap',
transition: 'all 0.3s',
backgroundColor: state.isFocused ? colors['active-blue'] : 'transparent',
color: state.isFocused ? colors.teal : 'black',
fontSize: '14px',
'&:hover': {
transition: 'all 0.2s',
backgroundColor: colors['active-blue'],
},
'&:focus': {
transition: 'all 0.2s',
backgroundColor: colors['active-blue'],
}
}),
menu: (provided: any, state: any) => ({
...provided,
top: 31,
borderRadius: '3px',
right: right ? 0 : undefined,
border: `1px solid ${colors['gray-light']}`,
// borderRadius: '3px',
backgroundColor: '#fff',
boxShadow: '1px 1px 1px rgba(0, 0, 0, 0.1)',
position: 'absolute',
minWidth: 'fit-content',
// zIndex: 99,
overflow: 'hidden',
zIndex: 100,
...(right && { right: 0 })
}),
menuList: (provided: any, state: any) => ({
...provided,
padding: 0,
}),
control: (provided: any) => {
const obj = {
...provided,
border: 'solid thin #ddd',
cursor: 'pointer',
minHeight: '36px',
transition: 'all 0.5s',
['&:hover']: {
backgroundColor: colors['gray-lightest'],
transition: 'all 0.2s ease-in-out'
},
...controlStyle,
}
if (plain) {
obj['backgroundColor'] = 'transparent';
obj['border'] = '1px solid transparent'
obj['backgroundColor'] = 'transparent'
obj['&:hover'] = {
borderColor: 'transparent',
backgroundColor: colors['gray-light'],
transition: 'all 0.2s ease-in-out'
}
obj['&:focus'] = {
borderColor: 'transparent'
}
obj['&:active'] = {
borderColor: 'transparent'
}
}
return obj;
export default function <Value extends ValueObject>({
placeholder = 'Select',
name = '',
onChange,
right = false,
plain = false,
options,
isSearchable = false,
components = {},
styles = {},
defaultValue = '',
controlStyle = {},
...rest
}: Props<Value>) {
const defaultSelected = Array.isArray(defaultValue) ?
defaultValue.map((value) => options.find((option) => option.value === value)) :
options.find((option) => option.value === defaultValue
) || null;
if (Array.isArray(defaultSelected) && defaultSelected.length === 0) {
console.log('defaultSelected', defaultSelected);
}
const customStyles = {
option: (provided: any, state: any) => ({
...provided,
whiteSpace: 'nowrap',
transition: 'all 0.3s',
backgroundColor: state.isFocused ? colors['active-blue'] : 'transparent',
color: state.isFocused ? colors.teal : 'black',
fontSize: '14px',
'&:hover': {
transition: 'all 0.2s',
backgroundColor: colors['active-blue']
},
'&:focus': {
transition: 'all 0.2s',
backgroundColor: colors['active-blue']
}
}),
menu: (provided: any, state: any) => ({
...provided,
top: 31,
borderRadius: '3px',
right: right ? 0 : undefined,
border: `1px solid ${colors['gray-light']}`,
// borderRadius: '3px',
backgroundColor: '#fff',
boxShadow: '1px 1px 1px rgba(0, 0, 0, 0.1)',
position: 'absolute',
minWidth: 'fit-content',
// zIndex: 99,
overflow: 'hidden',
zIndex: 100,
...(right && { right: 0 })
}),
menuList: (provided: any, state: any) => ({
...provided,
padding: 0
}),
control: (provided: any) => {
const obj = {
...provided,
border: 'solid thin #ddd',
cursor: 'pointer',
minHeight: '36px',
transition: 'all 0.5s',
['&:hover']: {
backgroundColor: colors['gray-lightest'],
transition: 'all 0.2s ease-in-out'
},
indicatorsContainer: (provided: any) => ({
...provided,
maxHeight: '34px',
padding: 0,
}),
valueContainer: (provided: any) => ({
...provided,
paddingRight: '0px',
}),
singleValue: (provided: any, state: { isDisabled: any; }) => {
const opacity = state.isDisabled ? 0.5 : 1;
const transition = 'opacity 300ms';
...controlStyle
};
if (plain) {
obj['backgroundColor'] = 'transparent';
obj['border'] = '1px solid transparent';
obj['backgroundColor'] = 'transparent';
obj['&:hover'] = {
borderColor: 'transparent',
backgroundColor: colors['gray-light'],
transition: 'all 0.2s ease-in-out'
};
obj['&:focus'] = {
borderColor: 'transparent'
};
obj['&:active'] = {
borderColor: 'transparent'
};
}
return obj;
},
indicatorsContainer: (provided: any) => ({
...provided,
maxHeight: '34px',
padding: 0
}),
valueContainer: (provided: any) => ({
...provided,
paddingRight: '0px'
}),
singleValue: (provided: any, state: { isDisabled: any; }) => {
const opacity = state.isDisabled ? 0.5 : 1;
const transition = 'opacity 300ms';
return { ...provided, opacity, transition, fontWeight: '500' };
},
input: (provided: any) => ({
...provided,
'& input:focus': {
border: 'none !important',
}
}),
noOptionsMessage: (provided: any) => ({
...provided,
whiteSpace: 'nowrap !important',
// minWidth: 'fit-content',
}),
}
return { ...provided, opacity, transition, fontWeight: '500' };
},
input: (provided: any) => ({
...provided,
'& input:focus': {
border: 'none !important'
}
}),
noOptionsMessage: (provided: any) => ({
...provided,
whiteSpace: 'nowrap !important'
// minWidth: 'fit-content',
})
};
return (
<Select
options={options}
isSearchable={isSearchable}
defaultValue={defaultSelected}
components={{
IndicatorSeparator: () => null,
DropdownIndicator,
ValueContainer: CustomValueContainer,
...components,
}}
onChange={(value) => onChange({ name, value: value })}
styles={{ ...customStyles, ...styles }}
theme={(theme) => ({
...theme,
colors: {
...theme.colors,
primary: '#394EFF',
}
})}
blurInputOnSelect={true}
placeholder={placeholder}
{...rest}
/>
);
return (
<Select
options={options}
isSearchable={isSearchable}
defaultValue={defaultSelected}
components={{
IndicatorSeparator: () => null,
DropdownIndicator,
ValueContainer: CustomValueContainer,
...components
}}
onChange={(value) => onChange({ name, value: value })}
styles={{ ...customStyles, ...styles }}
theme={(theme) => ({
...theme,
colors: {
...theme.colors,
primary: '#394EFF'
}
})}
blurInputOnSelect={true}
placeholder={placeholder}
{...rest}
/>
);
}
const DropdownIndicator = (
props: DropdownIndicatorProps<true>
) => {
return (
<components.DropdownIndicator {...props}>
<Icon name="chevron-down" size="16" />
</components.DropdownIndicator>
);
};
props: DropdownIndicatorProps<true>
) => {
return (
<components.DropdownIndicator {...props}>
<Icon name='chevron-down' size='16' />
</components.DropdownIndicator>
);
};
const CustomValueContainer = ({ children, ...rest }: any) => {
const selectedCount = rest.getValue().length
const conditional = (selectedCount < 3)
const selectedCount = rest.getValue().length;
const conditional = (selectedCount < 3);
let firstChild: any = []
let firstChild: any = [];
if (!conditional) {
firstChild = [children[0].shift(), children[1]]
}
return (
<ValueContainer {...rest}>
{conditional ? children : firstChild}
{!conditional && ` and ${selectedCount - 1} others`}
</ValueContainer>
)
if (!conditional) {
firstChild = [children[0].shift(), children[1]];
}
return (
<ValueContainer {...rest}>
{conditional ? children : firstChild}
{!conditional && ` and ${selectedCount - 1} others`}
</ValueContainer>
);
};

File diff suppressed because one or more lines are too long

View file

@ -20,7 +20,7 @@ export const ERRORS = 'errors';
export const PERFORMANCE = 'performance';
export const RESOURCE_MONITORING = 'resources';
export const WEB_VITALS = 'webVitals';
export const USER_PATH = 'userPath';
export const USER_PATH = 'pathAnalysis';
export const RETENTION = 'retention';
export const FEATURE_ADOPTION = 'featureAdoption';
export const INSIGHTS = 'insights';
@ -185,18 +185,19 @@ export const TYPES: CardType[] = [
{ title: 'Captured Sessions', slug: FilterKey.COUNT_SESSIONS, description: '' },
],
},
// {
// title: 'Path Analysis',
// icon: 'signpost-split',
// description: 'See where users are flowing and explore their journeys.',
// slug: USER_PATH,
// },
// {
// title: 'Retention',
// icon: 'arrow-repeat',
// description: 'Get an understanding of how many users are returning.',
// slug: RETENTION,
// },
{
title: 'Path Analysis',
icon: 'signpost-split',
description: 'See where users are flowing and explore their journeys.',
slug: USER_PATH,
},
{
title: 'Retention',
icon: 'arrow-repeat',
description: 'Get an understanding of how many users are returning.',
slug: RETENTION,
disabled: true,
},
// {
// title: 'Feature Adoption',
// icon: 'card-checklist',

View file

@ -21,7 +21,6 @@ const customTheme: ThemeConfig = {
bodyBg: colors['gray-lightest'],
headerBg: colors['gray-lightest'],
siderBg: colors['gray-lightest'],
},
Menu: {
colorPrimary: colors.teal,

View file

@ -132,7 +132,7 @@ function SideMenu(props: Props) {
<>
<Menu
defaultSelectedKeys={['1']} mode='inline' onClick={handleClick}
style={{ border: 'none', marginTop: '8px' }}
style={{ marginTop: '8px', border: 'none' }}
inlineCollapsed={isCollapsed}
>
{isPreferencesActive && (
@ -151,7 +151,7 @@ function SideMenu(props: Props) {
{index > 0 && <Divider style={{ margin: '6px 0' }} />}
<Menu.ItemGroup
key={category.key}
title={category.title}
title={<div style={{ paddingLeft: isCollapsed ? '' : '6px' }} className={cn({ 'text-center' : isCollapsed })}>{category.title}</div>}
>
{category.items.filter((item: any) => !item.hidden).map((item: any) => {
const isActive = isMenuItemActive(item.key);
@ -160,6 +160,7 @@ function SideMenu(props: Props) {
key={item.key}
title={<Text className={cn('ml-5 !rounded')}>{item.label}</Text>}
icon={<SVG name={item.icon} size={16} />}>
{/*style={{ paddingLeft: '30px' }}*/}
{item.children.map((child: any) => <Menu.Item
className={cn('ml-8', { 'ant-menu-item-selected !bg-active-dark-blue': isMenuItemActive(child.key) })}
key={child.key}>{child.label}</Menu.Item>)}
@ -168,7 +169,7 @@ function SideMenu(props: Props) {
<Menu.Item
key={item.key}
icon={<Icon name={item.icon} size={16} color={isActive ? 'teal' : ''} />}
// style={{ color: '#333', height: '32px' }}
style={{ paddingLeft: '20px' }}
className={cn('!rounded')}
itemIcon={item.leading ?
<Icon name={item.leading} size={16} color={isActive ? 'teal' : ''} /> : null}>

View file

@ -18,7 +18,7 @@ function TopHeader() {
position: 'sticky',
top: 0,
zIndex: 1,
padding: '0 15px',
padding: '0 20px',
display: 'flex',
alignItems: 'center',
height: '60px'
@ -30,6 +30,7 @@ function TopHeader() {
onClick={() => {
settingsStore.updateMenuCollapsed(!settingsStore.menuCollapsed);
}}
style={{ paddingTop: '4px' }}
className='cursor-pointer'
>
<Icon name={settingsStore.menuCollapsed ? 'side_menu_closed' : 'side_menu_open'} size={20} />

View file

@ -70,7 +70,7 @@ export const categories: Category[] = [
title: 'Assist',
key: 'assist',
items: [
{ label: 'Live Sessions', key: MENU.LIVE_SESSIONS, icon: 'broadcast' },
{ label: 'Cobrowse', key: MENU.LIVE_SESSIONS, icon: 'broadcast' },
{ label: 'Recordings', key: MENU.RECORDINGS, icon: 'record-btn', isEnterprise: true }
]
},
@ -112,7 +112,7 @@ export const preferences: Category[] = [
{ label: 'Integrations', key: PREFERENCES_MENU.INTEGRATIONS, icon: 'plug' },
{ label: 'Metadata', key: PREFERENCES_MENU.METADATA, icon: 'tags' },
{ label: 'Webhooks', key: PREFERENCES_MENU.WEBHOOKS, icon: 'link-45deg' },
{ label: 'Modules', key: PREFERENCES_MENU.MODULES, icon: 'people' },
{ label: 'Modules', key: PREFERENCES_MENU.MODULES, icon: 'puzzle' },
{ label: 'Projects', key: PREFERENCES_MENU.PROJECTS, icon: 'folder2' },
{
label: 'Roles & Access',

View file

@ -423,21 +423,19 @@ export default class DashboardStore {
params['limit'] = metric.limit;
}
return new Promise((resolve, reject) => {
return new Promise(async (resolve, reject) => {
this.pendingRequests += 1;
return metricService
.getMetricChartData(metric, params, isWidget)
.then((data: any) => {
resolve(metric.setData(data, period));
})
.catch((err: any) => {
reject(err);
})
.finally(() => {
setTimeout(() => {
this.pendingRequests = this.pendingRequests - 1;
}, 100);
});
try {
const data = await metricService.getMetricChartData(metric, params, isWidget);
resolve(metric.setData(data, period));
} catch (error) {
reject(error);
} finally {
setTimeout(() => {
this.pendingRequests -= 1;
}, 100);
}
});
}
}

View file

@ -12,7 +12,9 @@ import {
PERFORMANCE,
WEB_VITALS,
INSIGHTS,
CLICKMAP
CLICKMAP,
USER_PATH,
RETENTION
} from 'App/constants/card';
import { clickmapFilter } from 'App/types/filter/newFilter';
import { getRE } from 'App/utils';
@ -109,8 +111,14 @@ export default class MetricStore {
this.changeType(type);
}
if (obj.hasOwnProperty('metricOf') && obj.metricOf !== this.instance.metricOf && (obj.metricOf === 'sessions' || obj.metricOf === 'jsErrors')) {
obj.viewType = 'table'
if (obj.hasOwnProperty('metricOf') && obj.metricOf !== this.instance.metricOf) {
if (obj.metricOf === 'sessions' || obj.metricOf === 'jsErrors') {
obj.viewType = 'table'
}
if (this.instance.metricType === USER_PATH) {
this.instance.series[0].filter.eventsHeader = obj.metricOf === 'start-point' ? 'START POINT' : 'END POINT';
}
}
// handle metricValue change
@ -140,6 +148,9 @@ export default class MetricStore {
if (value === TIMESERIES) {
obj['viewType'] = 'lineChart';
}
if (value === RETENTION) {
obj['viewType'] = 'cohort';
}
if (
value === ERRORS ||
value === RESOURCE_MONITORING ||
@ -156,11 +167,21 @@ export default class MetricStore {
obj.series[0].filter.eventsOrderSupport = ['then']
}
if (value === USER_PATH) {
obj.series[0].filter.eventsHeader = 'START POINT';
} else {
obj.series[0].filter.eventsHeader = 'EVENTS'
}
if (value === INSIGHTS) {
obj['metricOf'] = 'issueCategories';
obj['viewType'] = 'list';
}
if (value === USER_PATH) {
// obj['startType'] = 'start';
}
if (value === CLICKMAP) {
obj.series = obj.series.slice(0, 1);
if (this.instance.metricType !== CLICKMAP) {
@ -174,6 +195,8 @@ export default class MetricStore {
});
}
}
console.log('obj', obj);
this.instance.update(obj);
}

View file

@ -1,15 +1,18 @@
import { makeAutoObservable, runInAction, observable, action } from "mobx"
import FilterItem from "./filterItem"
import { filtersMap } from 'Types/filter/newFilter';
export default class Filter {
public static get ID_KEY():string { return "filterId" }
filterId: string = ''
name: string = ''
filters: FilterItem[] = []
excludes: FilterItem[] = []
eventsOrder: string = 'then'
eventsOrderSupport: string[] = ['then', 'or', 'and']
startTimestamp: number = 0
endTimestamp: number = 0
eventsHeader: string = "EVENTS"
constructor() {
makeAutoObservable(this, {
@ -22,6 +25,7 @@ export default class Filter {
removeFilter: action,
updateKey: action,
merge: action,
addExcludeFilter: action,
})
}
@ -73,6 +77,10 @@ export default class Filter {
return json
}
createFilterBykey(key: string) {
return filtersMap[key] ? new FilterItem(filtersMap[key]) : new FilterItem()
}
toJson() {
const json = {
name: this.name,
@ -81,4 +89,16 @@ export default class Filter {
}
return json
}
addExcludeFilter(filter: FilterItem) {
this.excludes.push(filter)
}
updateExcludeFilter(index: number, filter: FilterItem) {
this.excludes[index] = new FilterItem(filter)
}
removeExcludeFilter(index: number) {
this.excludes.splice(index, 1)
}
}

View file

@ -0,0 +1,41 @@
const ISSUE_MAP: any = {
dead_click: { name: 'Dead Click', icon: 'funnel/emoji-dizzy-fill', color: '#9C001F' },
rage_click: { name: 'Rage Click', icon: 'funnel/emoji-angry-fill', color: '#CC0000' },
click_rage: { name: 'Click Rage', icon: 'funnel/emoji-angry-fill', color: '#CC0000' },
excessive_scrolling: { name: 'Excessive Scrolling', icon: 'funnel/mouse', color: '#D3545F' },
bad_request: { name: 'Bad Request', icon: 'funnel/patch-exclamation-fill', color: '#D70072' },
missing_resource: { name: 'Missing Resource', icon: 'funnel/image-fill', color: '#B89C50' },
memory: { name: 'Memory', icon: 'funnel/cpu-fill', color: '#8A5A83' },
cpu: { name: 'CPU', icon: 'funnel/hdd-fill', color: '#8A5A83' },
slow_resource: { name: 'Slow Resource', icon: 'funnel/hourglass-top', color: '#8B006D' },
slow_page_load: { name: 'Slow Page Load', icon: 'funnel/hourglass-top', color: '#8B006D' },
custom_event_error: { name: 'Custom Event Error', icon: 'funnel/exclamation-circle-fill', color: '#BF6C00' },
custom: { name: 'Custom', icon: 'funnel/exclamation-circle-fill', color: '#BF6C00' },
crash: { name: 'Crash', icon: 'funnel/file-x', color: '#BF2D00' },
js_exception: { name: 'JS Exception', icon: 'funnel/exclamation-circle', color: '#BF2D00' }
};
export default class Issue {
type: string = '';
name: string = '';
sessionCount: number = 0;
icon: string = '';
source: string = '';
constructor() {
this.type = '';
this.name = '';
this.sessionCount = 0;
this.icon = '';
this.source = '';
}
fromJSON(json: any) {
this.type = json.name;
this.name = ISSUE_MAP[json.name].name || '';
this.sessionCount = json.sessionCount;
this.icon = ISSUE_MAP[json.name].icon || '';
this.source = json.source;
return this;
}
}

View file

@ -6,13 +6,16 @@ import Funnelissue from 'App/mstore/types/funnelIssue';
import { issueOptions, issueCategories, issueCategoriesMap } from 'App/constants/filterOptions';
import { FilterKey } from 'Types/filter/filterType';
import Period, { LAST_24_HOURS } from 'Types/app/period';
import Funnel from "../types/funnel";
import Funnel from '../types/funnel';
import { metricService } from 'App/services';
import { FUNNEL, INSIGHTS, TABLE, WEB_VITALS } from 'App/constants/card';
import { FUNNEL, INSIGHTS, TABLE, USER_PATH, WEB_VITALS } from 'App/constants/card';
import Error from '../types/error';
import { getChartFormatter } from 'Types/dashboard/helper';
import FilterItem from './filterItem';
import { filtersMap } from 'Types/filter/newFilter';
import Issue from '../types/issue';
export class InishtIssue {
export class InsightIssue {
icon: string;
iconColor: string;
change: number;
@ -46,10 +49,20 @@ export class InishtIssue {
}
}
function cleanFilter(filter: any) {
delete filter['operatorOptions'];
delete filter['placeholder'];
delete filter['category'];
delete filter['label'];
delete filter['icon'];
delete filter['key'];
}
export default class Widget {
public static get ID_KEY(): string {
return 'metricId';
}
metricId: any = undefined;
widgetId: any = undefined;
category?: string = undefined;
@ -63,7 +76,7 @@ export default class Widget {
sessions: [] = [];
isPublic: boolean = true;
owner: string = '';
lastModified: number = new Date().getTime();
lastModified: DateTime | null = new Date().getTime();
dashboards: any[] = [];
dashboardIds: any[] = [];
config: any = {};
@ -71,6 +84,11 @@ export default class Widget {
limit: number = 5;
thumbnail?: string;
params: any = { density: 70 };
startType: string = 'start';
// startPoint: FilterItem = filtersMap[FilterKey.LOCATION];
startPoint: FilterItem = new FilterItem(filtersMap[FilterKey.LOCATION]);
excludes: FilterItem[] = [];
hideExcess?: boolean = false;
period: Record<string, any> = Period({ rangeName: LAST_24_HOURS }); // temp value in detail view
hasChanged: boolean = false;
@ -83,7 +101,7 @@ export default class Widget {
chart: [],
namesMap: {},
avg: 0,
percentiles: [],
percentiles: []
};
isLoading: boolean = false;
isValid: boolean = false;
@ -156,13 +174,13 @@ export default class Widget {
config: {
position: this.position,
col: this.config.col,
row: this.config.row,
},
row: this.config.row
}
};
}
toJson() {
return {
const data: any = {
metricId: this.metricId,
widgetId: this.widgetId,
metricOf: this.metricOf,
@ -184,10 +202,25 @@ export default class Widget {
this.metricOf === FilterKey.PAGES_RESPONSE_TIME_DISTRIBUTION
? 4
: this.metricType === WEB_VITALS
? 1
: 2,
},
? 1
: 2
}
};
if (this.metricType === USER_PATH) {
data.hideExcess = this.hideExcess;
data.startType = this.startType;
data.startPoint = [this.startPoint.toJson()];
console.log('excludes', this.excludes);
data.excludes = this.series[0].filter.excludes.map((i: any) => i.toJson());
}
return data;
}
updateStartPoint(startPoint: any) {
runInAction(() => {
this.startPoint = new FilterItem(startPoint);
});
}
validate() {
@ -214,10 +247,10 @@ export default class Widget {
.filter((i: any) => i.change > 0 || i.change < 0)
.map(
(i: any) =>
new InishtIssue(i.category, i.name, i.ratio, i.oldValue, i.value, i.change, i.isNew)
new InsightIssue(i.category, i.name, i.ratio, i.oldValue, i.value, i.change, i.isNew)
);
} else if (this.metricType === FUNNEL) {
_data.funnel = new Funnel().fromJSON(_data);
_data.funnel = new Funnel().fromJSON(_data);
} else {
if (data.hasOwnProperty('chart')) {
_data['value'] = data.value;
@ -237,20 +270,20 @@ export default class Widget {
_data['chart'] = getChartFormatter(period)(Array.isArray(data) ? data : []);
_data['namesMap'] = Array.isArray(data)
? data
.map((i) => Object.keys(i))
.flat()
.filter((i) => i !== 'time' && i !== 'timestamp')
.reduce((unique: any, item: any) => {
if (!unique.includes(item)) {
unique.push(item);
}
return unique;
}, [])
.map((i) => Object.keys(i))
.flat()
.filter((i) => i !== 'time' && i !== 'timestamp')
.reduce((unique: any, item: any) => {
if (!unique.includes(item)) {
unique.push(item);
}
return unique;
}, [])
: [];
}
}
Object.assign(this.data, _data)
Object.assign(this.data, _data);
return _data;
}
@ -261,7 +294,7 @@ export default class Widget {
response.map((cat: { sessions: any[] }) => {
return {
...cat,
sessions: cat.sessions.map((s: any) => new Session().fromJson(s)),
sessions: cat.sessions.map((s: any) => new Session().fromJson(s))
};
})
);
@ -269,17 +302,30 @@ export default class Widget {
});
}
fetchIssues(filter: any): Promise<any> {
fetchIssues(card: any): Promise<any> {
return new Promise((resolve) => {
metricService.fetchIssues(filter).then((response: any) => {
const significantIssues = response.issues.significant
? response.issues.significant.map((issue: any) => new Funnelissue().fromJSON(issue))
: [];
const insignificantIssues = response.issues.insignificant
? response.issues.insignificant.map((issue: any) => new Funnelissue().fromJSON(issue))
: [];
metricService.fetchIssues(card)
.then((response: any) => {
if (card.metricType === USER_PATH) {
resolve({
total: response.count,
issues: response.values.map((issue: any) => new Issue().fromJSON(issue))
});
} else {
const significantIssues = response.issues.significant
? response.issues.significant.map((issue: any) => new Funnelissue().fromJSON(issue))
: [];
const insignificantIssues = response.issues.insignificant
? response.issues.insignificant.map((issue: any) => new Funnelissue().fromJSON(issue))
: [];
resolve({
issues: significantIssues.length > 0 ? significantIssues : insignificantIssues
});
}
}).finally(() => {
resolve({
issues: significantIssues.length > 0 ? significantIssues : insignificantIssues,
issues: []
});
});
});
@ -292,7 +338,7 @@ export default class Widget {
.then((response: any) => {
resolve({
issue: new Funnelissue().fromJSON(response.issue),
sessions: response.sessions.sessions.map((s: any) => new Session().fromJson(s)),
sessions: response.sessions.sessions.map((s: any) => new Session().fromJson(s))
});
})
.catch((error: any) => {

View file

@ -1,113 +1,127 @@
import Widget from "App/mstore/types/widget";
import Widget from 'App/mstore/types/widget';
import APIClient from 'App/api_client';
import { CLICKMAP } from "App/constants/card";
import { CLICKMAP, USER_PATH } from 'App/constants/card';
export default class MetricService {
private client: APIClient;
private client: APIClient;
constructor(client?: APIClient) {
this.client = client ? client : new APIClient();
constructor(client?: APIClient) {
this.client = client ? client : new APIClient();
}
initClient(client?: APIClient) {
this.client = client || new APIClient();
}
/**
* Get all metrics.
* @returns {Promise<any>}
*/
getMetrics(): Promise<any> {
return this.client.get('/cards')
.then((response: { json: () => any; }) => response.json())
.then((response: { data: any; }) => response.data || []);
}
/**
* Get a metric by metricId.
* @param metricId
* @returns {Promise<any>}
*/
getMetric(metricId: string): Promise<any> {
return this.client.get('/cards/' + metricId)
.then(r => r.json())
.then((response: { data: any; }) => response.data || {})
.catch(e => Promise.reject(e));
}
/**
* Save a metric.
* @param metric
* @returns
*/
saveMetric(metric: Widget): Promise<any> {
const data = metric.toJson();
const isCreating = !data[Widget.ID_KEY];
const url = isCreating ? '/cards' : '/cards/' + data[Widget.ID_KEY];
return this.client.post(url, data)
.then(r => r.json())
.then((response: { data: any; }) => response.data || {})
.catch(e => Promise.reject(e));
}
/**
* Delete a metric.
* @param metricId
* @returns {Promise<any>}
*/
deleteMetric(metricId: string): Promise<any> {
return this.client.delete('/cards/' + metricId)
.then((response: { json: () => any; }) => response.json())
.then((response: { data: any; }) => response.data);
}
/**
* Get all templates.
* @returns {Promise<any>}
*/
getTemplates(): Promise<any> {
return this.client.get('/cards/templates')
.then((response: { json: () => any; }) => response.json())
.then((response: { data: any; }) => response.data || []);
}
async getMetricChartData(metric: Widget, data: any, isWidget: boolean = false): Promise<any> {
if (
metric.metricType === CLICKMAP
&& document.location.pathname.split('/').pop() === 'metrics'
&& (document.location.pathname.indexOf('dashboard') !== -1 && document.location.pathname.indexOf('metric') === -1)
) {
return Promise.resolve({});
}
const path = isWidget ? `/cards/${metric.metricId}/chart` : `/cards/try`;
if (metric.metricType === USER_PATH) {
data.density = 4;
data.metricOf = 'sessionCount';
}
try {
const r = await this.client.post(path, data);
const response = await r.json();
return response.data || {};
} catch (e) {
return await Promise.reject(e);
}
}
/**
* Fetch sessions from the server.
* @param metricId {String}
* @param filter
* @returns
*/
fetchSessions(metricId: string | null, filter: any): Promise<any> {
return this.client.post(metricId ? `/cards/${metricId}/sessions` : '/cards/try/sessions', filter)
.then((response: { json: () => any; }) => response.json())
.then((response: { data: any; }) => response.data || []);
}
async fetchIssues(filter: any): Promise<any> {
if (filter.metricType === USER_PATH) {
const widget = new Widget().fromJson(filter);
const drillDownFilter = filter.filters;
filter = widget.toJson();
filter.filters = drillDownFilter;
}
initClient(client?: APIClient) {
this.client = client || new APIClient();
}
let resp: Response = await this.client.post(`/cards/try/issues`, filter);
const json: any = await resp.json();
return await json.data || {};
}
/**
* Get all metrics.
* @returns {Promise<any>}
*/
getMetrics(): Promise<any> {
return this.client.get('/cards')
.then((response: { json: () => any; }) => response.json())
.then((response: { data: any; }) => response.data || []);
}
/**
* Get a metric by metricId.
* @param metricId
* @returns {Promise<any>}
*/
getMetric(metricId: string): Promise<any> {
return this.client.get('/cards/' + metricId)
.then(r => r.json())
.then((response: { data: any; }) => response.data || {})
.catch(e => Promise.reject(e))
}
/**
* Save a metric.
* @param metric
* @returns
*/
saveMetric(metric: Widget): Promise<any> {
const data = metric.toJson()
const isCreating = !data[Widget.ID_KEY];
const url = isCreating ? '/cards' : '/cards/' + data[Widget.ID_KEY];
return this.client.post(url, data)
.then(r => r.json())
.then((response: { data: any; }) => response.data || {})
.catch(e => Promise.reject(e))
}
/**
* Delete a metric.
* @param metricId
* @returns {Promise<any>}
*/
deleteMetric(metricId: string): Promise<any> {
return this.client.delete('/cards/' + metricId)
.then((response: { json: () => any; }) => response.json())
.then((response: { data: any; }) => response.data);
}
/**
* Get all templates.
* @returns {Promise<any>}
*/
getTemplates(): Promise<any> {
return this.client.get('/cards/templates')
.then((response: { json: () => any; }) => response.json())
.then((response: { data: any; }) => response.data || []);
}
getMetricChartData(metric: Widget, data: any, isWidget: boolean = false): Promise<any> {
if (
metric.metricType === CLICKMAP
&& document.location.pathname.split('/').pop() === 'metrics'
&& (document.location.pathname.indexOf('dashboard') !== -1 && document.location.pathname.indexOf('metric') === -1)
) {
return Promise.resolve({})
}
const path = isWidget ? `/cards/${metric.metricId}/chart` : `/cards/try`;
return this.client.post(path, data)
.then(r => r.json())
.then((response: { data: any; }) => response.data || {})
.catch(e => Promise.reject(e))
}
/**
* Fetch sessions from the server.
* @param metricId {String}
* @param filter
* @returns
*/
fetchSessions(metricId: string, filter: any): Promise<any> {
return this.client.post(metricId ? `/cards/${metricId}/sessions` : '/cards/try/sessions', filter)
.then((response: { json: () => any; }) => response.json())
.then((response: { data: any; }) => response.data || []);
}
fetchIssues(filter: string): Promise<any> {
return this.client.post(`/cards/try/issues`, filter)
.then((response: { json: () => any; }) => response.json())
.then((response: { data: any; }) => response.data || {});
}
fetchIssue(metricId: string, issueId: string, params: any): Promise<any> {
return this.client.post(`/cards/${metricId}/issues/${issueId}/sessions`, params)
.then((response: { json: () => any; }) => response.json())
.then((response: { data: any; }) => response.data || {});
}
fetchIssue(metricId: string, issueId: string, params: any): Promise<any> {
return this.client.post(`/cards/${metricId}/issues/${issueId}/sessions`, params)
.then((response: { json: () => any; }) => response.json())
.then((response: { data: any; }) => response.data || {});
}
}

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-clock-history" viewBox="0 0 16 16">
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022l-.074.997zm2.004.45a7.003 7.003 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342l-.36.933zm1.37.71a7.01 7.01 0 0 0-.439-.27l.493-.87a8.025 8.025 0 0 1 .979.654l-.615.789a6.996 6.996 0 0 0-.418-.302zm1.834 1.79a6.99 6.99 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7.08 7.08 0 0 0-.214-.468l.893-.45a7.976 7.976 0 0 1 .45 1.088l-.95.313a7.023 7.023 0 0 0-.179-.483zm.53 2.507a6.991 6.991 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.131 1.538c.033-.17.06-.339.081-.51l.993.123a7.957 7.957 0 0 1-.23 1.155l-.964-.267c.046-.165.086-.332.12-.501zm-.952 2.379c.184-.29.346-.594.486-.908l.914.405c-.16.36-.345.706-.555 1.038l-.845-.535zm-.964 1.205c.122-.122.239-.248.35-.378l.758.653a8.073 8.073 0 0 1-.401.432l-.707-.707z"/>
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0v1z"/>
<path d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-dice-3" viewBox="0 0 16 16">
<path d="M13 1a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h10zM3 0a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V3a3 3 0 0 0-3-3H3z"/>
<path d="M5.5 4a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm8 8a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm-4-4a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 387 B

View file

@ -1,75 +1,531 @@
import { KEYS } from 'Types/filter/customFilter';
import Record from 'Types/Record';
import { FilterType, FilterKey, FilterCategory } from './filterType'
import { FilterType, FilterKey, FilterCategory } from './filterType';
import filterOptions, { countries, platformOptions } from 'App/constants';
import { capitalize } from 'App/utils';
const countryOptions = Object.keys(countries).map(i => ({ label: countries[i], value: i }));
const containsFilters = [{ key: 'contains', label: 'contains', text: 'contains', value: 'contains' }]
const containsFilters = [{ key: 'contains', label: 'contains', text: 'contains', value: 'contains' }];
export const filters = [
{ key: FilterKey.CLICK, type: FilterType.MULTIPLE, category: FilterCategory.INTERACTIONS, label: 'Click', operator: 'on', operatorOptions: filterOptions.targetOperators, icon: 'filters/click', isEvent: true },
{ key: FilterKey.INPUT, type: FilterType.MULTIPLE, category: FilterCategory.INTERACTIONS, label: 'Text Input', placeholder: 'Enter input label name', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/input', isEvent: true },
{ key: FilterKey.LOCATION, type: FilterType.MULTIPLE, category: FilterCategory.INTERACTIONS, label: 'Visited URL', placeholder: 'Enter path', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/location', isEvent: true },
{ key: FilterKey.CUSTOM, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Custom Events', placeholder: 'Enter event key', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/custom', isEvent: true },
{
key: FilterKey.CLICK,
type: FilterType.MULTIPLE,
category: FilterCategory.INTERACTIONS,
label: 'Click',
operator: 'on',
operatorOptions: filterOptions.targetOperators,
icon: 'filters/click',
isEvent: true
},
{
key: FilterKey.INPUT,
type: FilterType.MULTIPLE,
category: FilterCategory.INTERACTIONS,
label: 'Text Input',
placeholder: 'Enter input label name',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/input',
isEvent: true
},
{
key: FilterKey.LOCATION,
type: FilterType.MULTIPLE,
category: FilterCategory.INTERACTIONS,
label: 'Visited URL',
placeholder: 'Enter path',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/location',
isEvent: true
},
{
key: FilterKey.CUSTOM,
type: FilterType.MULTIPLE,
category: FilterCategory.JAVASCRIPT,
label: 'Custom Events',
placeholder: 'Enter event key',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/custom',
isEvent: true
},
// { key: FilterKey.REQUEST, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Fetch', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/fetch', isEvent: true },
{ key: FilterKey.FETCH, type: FilterType.SUB_FILTERS, category: FilterCategory.JAVASCRIPT, operator: 'is', label: 'Network Request', filters: [
{ key: FilterKey.FETCH_URL, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'with URL', placeholder: 'Enter path or URL', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/fetch' },
{ key: FilterKey.FETCH_STATUS_CODE, type: FilterType.NUMBER_MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'with status code', placeholder: 'Enter status code', operator: '=', operatorOptions: filterOptions.customOperators, icon: 'filters/fetch' },
{ key: FilterKey.FETCH_METHOD, type: FilterType.MULTIPLE_DROPDOWN, category: FilterCategory.PERFORMANCE, label: 'with method', operator: 'is', placeholder: 'Select method type', operatorOptions: filterOptions.stringOperatorsLimited, icon: 'filters/fetch', options: filterOptions.methodOptions },
{ key: FilterKey.FETCH_DURATION, type: FilterType.NUMBER, category: FilterCategory.PERFORMANCE, label: 'with duration (ms)', placeholder: 'E.g. 12', operator: '=', operatorOptions: filterOptions.customOperators, icon: 'filters/fetch' },
{ key: FilterKey.FETCH_REQUEST_BODY, type: FilterType.STRING, category: FilterCategory.PERFORMANCE, label: 'with request body', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/fetch' },
{ key: FilterKey.FETCH_RESPONSE_BODY, type: FilterType.STRING, category: FilterCategory.PERFORMANCE, label: 'with response body', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/fetch' },
], icon: 'filters/fetch', isEvent: true },
{ key: FilterKey.GRAPHQL, type: FilterType.SUB_FILTERS, category: FilterCategory.JAVASCRIPT, label: 'GraphQL', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/graphql', isEvent: true, filters: [
{ key: FilterKey.GRAPHQL_NAME, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'with name', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/fetch' },
{ key: FilterKey.GRAPHQL_METHOD, type: FilterType.MULTIPLE_DROPDOWN, category: FilterCategory.PERFORMANCE, label: 'with method', operator: 'is', operatorOptions: filterOptions.stringOperatorsLimited, icon: 'filters/fetch', options: filterOptions.methodOptions },
{ key: FilterKey.GRAPHQL_REQUEST_BODY, type: FilterType.STRING, category: FilterCategory.PERFORMANCE, label: 'with request body', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/fetch' },
{ key: FilterKey.GRAPHQL_RESPONSE_BODY, type: FilterType.STRING, category: FilterCategory.PERFORMANCE, label: 'with response body', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/fetch' },
]},
{ key: FilterKey.STATEACTION, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'State Action', placeholder: 'E.g. 12', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/state-action', isEvent: true },
{ key: FilterKey.ERROR, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Error Message', placeholder: 'E.g. Uncaught SyntaxError', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/error', isEvent: true },
{
key: FilterKey.FETCH,
type: FilterType.SUB_FILTERS,
category: FilterCategory.JAVASCRIPT,
operator: 'is',
label: 'Network Request',
filters: [
{
key: FilterKey.FETCH_URL,
type: FilterType.MULTIPLE,
category: FilterCategory.PERFORMANCE,
label: 'with URL',
placeholder: 'Enter path or URL',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/fetch'
},
{
key: FilterKey.FETCH_STATUS_CODE,
type: FilterType.NUMBER_MULTIPLE,
category: FilterCategory.PERFORMANCE,
label: 'with status code',
placeholder: 'Enter status code',
operator: '=',
operatorOptions: filterOptions.customOperators,
icon: 'filters/fetch'
},
{
key: FilterKey.FETCH_METHOD,
type: FilterType.MULTIPLE_DROPDOWN,
category: FilterCategory.PERFORMANCE,
label: 'with method',
operator: 'is',
placeholder: 'Select method type',
operatorOptions: filterOptions.stringOperatorsLimited,
icon: 'filters/fetch',
options: filterOptions.methodOptions
},
{
key: FilterKey.FETCH_DURATION,
type: FilterType.NUMBER,
category: FilterCategory.PERFORMANCE,
label: 'with duration (ms)',
placeholder: 'E.g. 12',
operator: '=',
operatorOptions: filterOptions.customOperators,
icon: 'filters/fetch'
},
{
key: FilterKey.FETCH_REQUEST_BODY,
type: FilterType.STRING,
category: FilterCategory.PERFORMANCE,
label: 'with request body',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/fetch'
},
{
key: FilterKey.FETCH_RESPONSE_BODY,
type: FilterType.STRING,
category: FilterCategory.PERFORMANCE,
label: 'with response body',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/fetch'
}
],
icon: 'filters/fetch',
isEvent: true
},
{
key: FilterKey.GRAPHQL,
type: FilterType.SUB_FILTERS,
category: FilterCategory.JAVASCRIPT,
label: 'GraphQL',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/graphql',
isEvent: true,
filters: [
{
key: FilterKey.GRAPHQL_NAME,
type: FilterType.MULTIPLE,
category: FilterCategory.PERFORMANCE,
label: 'with name',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/fetch'
},
{
key: FilterKey.GRAPHQL_METHOD,
type: FilterType.MULTIPLE_DROPDOWN,
category: FilterCategory.PERFORMANCE,
label: 'with method',
operator: 'is',
operatorOptions: filterOptions.stringOperatorsLimited,
icon: 'filters/fetch',
options: filterOptions.methodOptions
},
{
key: FilterKey.GRAPHQL_REQUEST_BODY,
type: FilterType.STRING,
category: FilterCategory.PERFORMANCE,
label: 'with request body',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/fetch'
},
{
key: FilterKey.GRAPHQL_RESPONSE_BODY,
type: FilterType.STRING,
category: FilterCategory.PERFORMANCE,
label: 'with response body',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/fetch'
}
]
},
{
key: FilterKey.STATEACTION,
type: FilterType.MULTIPLE,
category: FilterCategory.JAVASCRIPT,
label: 'State Action',
placeholder: 'E.g. 12',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/state-action',
isEvent: true
},
{
key: FilterKey.ERROR,
type: FilterType.MULTIPLE,
category: FilterCategory.JAVASCRIPT,
label: 'Error Message',
placeholder: 'E.g. Uncaught SyntaxError',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/error',
isEvent: true
},
// { key: FilterKey.METADATA, type: FilterType.MULTIPLE, category: FilterCategory.METADATA, label: 'Metadata', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/metadata', isEvent: true },
// FILTERS
{ key: FilterKey.USER_OS, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User OS', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/os' },
{ key: FilterKey.USER_BROWSER, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User Browser', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/browser' },
{ key: FilterKey.USER_DEVICE, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User Device', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/device' },
{ key: FilterKey.PLATFORM, type: FilterType.MULTIPLE_DROPDOWN, category: FilterCategory.GEAR, label: 'Platform', operator: 'is', operatorOptions: filterOptions.baseOperators, icon: 'filters/platform', options: platformOptions },
{ key: FilterKey.REVID, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'Version ID', placeholder: 'E.g. v1.0.8', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'collection' },
{ key: FilterKey.REFERRER, type: FilterType.MULTIPLE, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'Referrer', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/arrow-return-right' },
{ key: FilterKey.DURATION, type: FilterType.DURATION, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'Duration', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is']), icon: 'filters/duration' },
{ key: FilterKey.USER_COUNTRY, type: FilterType.MULTIPLE_DROPDOWN, category: FilterCategory.USER, label: 'User Country', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', options: countryOptions },
{ key: FilterKey.USER_CITY, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User City', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', options: countryOptions },
{ key: FilterKey.USER_STATE, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User State', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', options: countryOptions },
// { key: FilterKey.CONSOLE, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Console', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/console' },
{ key: FilterKey.USERID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User Id', placeholder: 'E.g. Alex, or alex@domain.com, or EMP123', operator: 'is', operatorOptions: filterOptions.stringOperators.concat([{ label: 'is undefined', value: 'isUndefined'}]), icon: 'filters/userid' },
{ key: FilterKey.USERANONYMOUSID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User AnonymousId', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/userid' },
// FILTERS
{
key: FilterKey.USER_OS,
type: FilterType.MULTIPLE,
category: FilterCategory.GEAR,
label: 'User OS',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/os'
},
{
key: FilterKey.USER_BROWSER,
type: FilterType.MULTIPLE,
category: FilterCategory.GEAR,
label: 'User Browser',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/browser'
},
{
key: FilterKey.USER_DEVICE,
type: FilterType.MULTIPLE,
category: FilterCategory.GEAR,
label: 'User Device',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/device'
},
{
key: FilterKey.PLATFORM,
type: FilterType.MULTIPLE_DROPDOWN,
category: FilterCategory.GEAR,
label: 'Platform',
operator: 'is',
operatorOptions: filterOptions.baseOperators,
icon: 'filters/platform',
options: platformOptions
},
{
key: FilterKey.REVID,
type: FilterType.MULTIPLE,
category: FilterCategory.GEAR,
label: 'Version ID',
placeholder: 'E.g. v1.0.8',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'collection'
},
{
key: FilterKey.REFERRER,
type: FilterType.MULTIPLE,
category: FilterCategory.RECORDING_ATTRIBUTES,
label: 'Referrer',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/arrow-return-right'
},
{
key: FilterKey.DURATION,
type: FilterType.DURATION,
category: FilterCategory.RECORDING_ATTRIBUTES,
label: 'Duration',
operator: 'is',
operatorOptions: filterOptions.getOperatorsByKeys(['is']),
icon: 'filters/duration'
},
{
key: FilterKey.USER_COUNTRY,
type: FilterType.MULTIPLE_DROPDOWN,
category: FilterCategory.USER,
label: 'User Country',
operator: 'is',
operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']),
icon: 'filters/country',
options: countryOptions
},
{
key: FilterKey.USER_CITY,
type: FilterType.MULTIPLE,
category: FilterCategory.USER,
label: 'User City',
operator: 'is',
operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']),
icon: 'filters/country',
options: countryOptions
},
{
key: FilterKey.USER_STATE,
type: FilterType.MULTIPLE,
category: FilterCategory.USER,
label: 'User State',
operator: 'is',
operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']),
icon: 'filters/country',
options: countryOptions
},
// { key: FilterKey.CONSOLE, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Console', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/console' },
{
key: FilterKey.USERID,
type: FilterType.MULTIPLE,
category: FilterCategory.USER,
label: 'User Id',
placeholder: 'E.g. Alex, or alex@domain.com, or EMP123',
operator: 'is',
operatorOptions: filterOptions.stringOperators.concat([{ label: 'is undefined', value: 'isUndefined' }]),
icon: 'filters/userid'
},
{
key: FilterKey.USERANONYMOUSID,
type: FilterType.MULTIPLE,
category: FilterCategory.USER,
label: 'User AnonymousId',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/userid'
},
// PERFORMANCE
{ key: FilterKey.DOM_COMPLETE, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'DOM Complete', placeholder: 'Enter path', operator: 'isAny', operatorOptions: filterOptions.stringOperatorsPerformance, source: [], icon: 'filters/dom-complete', isEvent: true, hasSource: true, sourceOperator: '>=', sourcePlaceholder: 'E.g. 12', sourceUnit: 'ms', sourceType: FilterType.NUMBER, sourceOperatorOptions: filterOptions.customOperators },
{ key: FilterKey.LARGEST_CONTENTFUL_PAINT_TIME, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'Largest Contentful Paint', placeholder: 'Enter path', operator: 'isAny', operatorOptions: filterOptions.stringOperatorsPerformance, source: [], icon: 'filters/lcpt', isEvent: true, hasSource: true, sourceOperator: '>=', sourcePlaceholder: 'E.g. 12', sourceUnit: 'ms', sourceType: FilterType.NUMBER, sourceOperatorOptions: filterOptions.customOperators },
{ key: FilterKey.TTFB, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'Time to First Byte', placeholder: 'Enter path', operator: 'isAny', operatorOptions: filterOptions.stringOperatorsPerformance, source: [], icon: 'filters/ttfb', isEvent: true, hasSource: true, sourceOperator: '>=', sourceUnit: 'ms', sourceType: FilterType.NUMBER, sourceOperatorOptions: filterOptions.customOperators, sourcePlaceholder: 'E.g. 12', },
{ key: FilterKey.AVG_CPU_LOAD, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'Avg CPU Load', placeholder: 'Enter path', operator: 'isAny', operatorOptions: filterOptions.stringOperatorsPerformance, source: [], icon: 'filters/cpu-load', isEvent: true, hasSource: true, sourceOperator: '>=', sourcePlaceholder: 'E.g. 12', sourceUnit: '%', sourceType: FilterType.NUMBER, sourceOperatorOptions: filterOptions.customOperators },
{ key: FilterKey.AVG_MEMORY_USAGE, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'Avg Memory Usage', placeholder: 'Enter path', operator: 'isAny', operatorOptions: filterOptions.stringOperatorsPerformance, source: [], icon: 'filters/memory-load', isEvent: true, hasSource: true, sourceOperator: '>=', sourcePlaceholder: 'E.g. 12', sourceUnit: 'mb', sourceType: FilterType.NUMBER, sourceOperatorOptions: filterOptions.customOperators },
{ key: FilterKey.FETCH_FAILED, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'Failed Request', placeholder: 'Enter path', operator: 'isAny', operatorOptions: filterOptions.stringOperatorsPerformance, icon: 'filters/fetch-failed', isEvent: true },
{ key: FilterKey.ISSUE, type: FilterType.ISSUE, category: FilterCategory.JAVASCRIPT, label: 'Issue', placeholder: 'Select an issue', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/click', options: filterOptions.issueOptions },
// PERFORMANCE
{
key: FilterKey.DOM_COMPLETE,
type: FilterType.MULTIPLE,
category: FilterCategory.PERFORMANCE,
label: 'DOM Complete',
placeholder: 'Enter path',
operator: 'isAny',
operatorOptions: filterOptions.stringOperatorsPerformance,
source: [],
icon: 'filters/dom-complete',
isEvent: true,
hasSource: true,
sourceOperator: '>=',
sourcePlaceholder: 'E.g. 12',
sourceUnit: 'ms',
sourceType: FilterType.NUMBER,
sourceOperatorOptions: filterOptions.customOperators
},
{
key: FilterKey.LARGEST_CONTENTFUL_PAINT_TIME,
type: FilterType.MULTIPLE,
category: FilterCategory.PERFORMANCE,
label: 'Largest Contentful Paint',
placeholder: 'Enter path',
operator: 'isAny',
operatorOptions: filterOptions.stringOperatorsPerformance,
source: [],
icon: 'filters/lcpt',
isEvent: true,
hasSource: true,
sourceOperator: '>=',
sourcePlaceholder: 'E.g. 12',
sourceUnit: 'ms',
sourceType: FilterType.NUMBER,
sourceOperatorOptions: filterOptions.customOperators
},
{
key: FilterKey.TTFB,
type: FilterType.MULTIPLE,
category: FilterCategory.PERFORMANCE,
label: 'Time to First Byte',
placeholder: 'Enter path',
operator: 'isAny',
operatorOptions: filterOptions.stringOperatorsPerformance,
source: [],
icon: 'filters/ttfb',
isEvent: true,
hasSource: true,
sourceOperator: '>=',
sourceUnit: 'ms',
sourceType: FilterType.NUMBER,
sourceOperatorOptions: filterOptions.customOperators,
sourcePlaceholder: 'E.g. 12'
},
{
key: FilterKey.AVG_CPU_LOAD,
type: FilterType.MULTIPLE,
category: FilterCategory.PERFORMANCE,
label: 'Avg CPU Load',
placeholder: 'Enter path',
operator: 'isAny',
operatorOptions: filterOptions.stringOperatorsPerformance,
source: [],
icon: 'filters/cpu-load',
isEvent: true,
hasSource: true,
sourceOperator: '>=',
sourcePlaceholder: 'E.g. 12',
sourceUnit: '%',
sourceType: FilterType.NUMBER,
sourceOperatorOptions: filterOptions.customOperators
},
{
key: FilterKey.AVG_MEMORY_USAGE,
type: FilterType.MULTIPLE,
category: FilterCategory.PERFORMANCE,
label: 'Avg Memory Usage',
placeholder: 'Enter path',
operator: 'isAny',
operatorOptions: filterOptions.stringOperatorsPerformance,
source: [],
icon: 'filters/memory-load',
isEvent: true,
hasSource: true,
sourceOperator: '>=',
sourcePlaceholder: 'E.g. 12',
sourceUnit: 'mb',
sourceType: FilterType.NUMBER,
sourceOperatorOptions: filterOptions.customOperators
},
{
key: FilterKey.FETCH_FAILED,
type: FilterType.MULTIPLE,
category: FilterCategory.PERFORMANCE,
label: 'Failed Request',
placeholder: 'Enter path',
operator: 'isAny',
operatorOptions: filterOptions.stringOperatorsPerformance,
icon: 'filters/fetch-failed',
isEvent: true
},
{
key: FilterKey.ISSUE,
type: FilterType.ISSUE,
category: FilterCategory.JAVASCRIPT,
label: 'Issue',
placeholder: 'Select an issue',
operator: 'is',
operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']),
icon: 'filters/click',
options: filterOptions.issueOptions
}
];
export const flagConditionFilters = [
{ key: FilterKey.USER_OS, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User OS', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/os' },
{ key: FilterKey.USER_BROWSER, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User Browser', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/browser' },
{ key: FilterKey.USER_DEVICE, type: FilterType.MULTIPLE, category: FilterCategory.GEAR, label: 'User Device', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/device' },
{ key: FilterKey.REFERRER, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'Referrer', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/arrow-return-right' },
{ key: FilterKey.USER_COUNTRY, type: FilterType.MULTIPLE_DROPDOWN, category: FilterCategory.USER, label: 'User Country', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', options: countryOptions },
{ key: FilterKey.USER_CITY, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User City', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', options: countryOptions },
{ key: FilterKey.USER_STATE, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User State', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', options: countryOptions },
{ key: FilterKey.USERID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User Id', operator: 'isUndefined', operatorOptions: [{ label: 'is undefined', value: 'isUndefined'}, { key: 'isAny', label: 'is any', value: 'isAny' }], icon: 'filters/userid' },
]
{
key: FilterKey.USER_OS,
type: FilterType.MULTIPLE,
category: FilterCategory.GEAR,
label: 'User OS',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/os'
},
{
key: FilterKey.USER_BROWSER,
type: FilterType.MULTIPLE,
category: FilterCategory.GEAR,
label: 'User Browser',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/browser'
},
{
key: FilterKey.USER_DEVICE,
type: FilterType.MULTIPLE,
category: FilterCategory.GEAR,
label: 'User Device',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/device'
},
{
key: FilterKey.REFERRER,
type: FilterType.MULTIPLE,
category: FilterCategory.USER,
label: 'Referrer',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/arrow-return-right'
},
{
key: FilterKey.USER_COUNTRY,
type: FilterType.MULTIPLE_DROPDOWN,
category: FilterCategory.USER,
label: 'User Country',
operator: 'is',
operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']),
icon: 'filters/country',
options: countryOptions
},
{
key: FilterKey.USER_CITY,
type: FilterType.MULTIPLE,
category: FilterCategory.USER,
label: 'User City',
operator: 'is',
operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']),
icon: 'filters/country',
options: countryOptions
},
{
key: FilterKey.USER_STATE,
type: FilterType.MULTIPLE,
category: FilterCategory.USER,
label: 'User State',
operator: 'is',
operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']),
icon: 'filters/country',
options: countryOptions
},
{
key: FilterKey.USERID,
type: FilterType.MULTIPLE,
category: FilterCategory.USER,
label: 'User Id',
operator: 'isUndefined',
operatorOptions: [{ label: 'is undefined', value: 'isUndefined' }, {
key: 'isAny',
label: 'is any',
value: 'isAny'
}],
icon: 'filters/userid'
}
];
const pathAnalysisStartPoint = [
{
key: FilterKey.LOCATION,
type: FilterType.MULTIPLE,
category: FilterCategory.INTERACTIONS,
label: 'Visited URL',
placeholder: 'Enter path',
operator: 'is',
operatorOptions: filterOptions.stringOperators,
icon: 'filters/location',
isEvent: true
}
];
export const eventKeys = filters.filter((i) => i.isEvent).map(i => i.key);
export const nonFlagFilters = filters.filter(i => {
return flagConditionFilters.findIndex(f => f.key === i.key) === -1
return flagConditionFilters.findIndex(f => f.key === i.key) === -1;
}).map(i => i.key);
export const clickmapFilter = {
@ -80,15 +536,16 @@ export const clickmapFilter = {
operator: filterOptions.pageUrlOperators[0].value,
operatorOptions: filterOptions.pageUrlOperators,
icon: 'filters/location',
isEvent: true,
}
isEvent: true
};
const mapFilters = (list) => {
return list.reduce((acc, filter) => {
filter.value = [''];
acc[filter.key] = filter;
return acc;
}, {});
}
};
const liveFilterSupportedOperators = ['is', 'contains'];
const mapLiveFilters = (list) => {
@ -101,25 +558,25 @@ const mapLiveFilters = (list) => {
filter.key !== FilterKey.DURATION &&
filter.key !== FilterKey.REFERRER
) {
obj[filter.key] = {...filter};
obj[filter.key] = { ...filter };
obj[filter.key].operatorOptions = filter.operatorOptions.filter(operator => liveFilterSupportedOperators.includes(operator.value));
if (filter.key === FilterKey.PLATFORM) {
obj[filter.key].operator = 'is';
}
}
})
});
return obj;
}
};
export const filterLabelMap = filters.reduce((acc, filter) => {
acc[filter.key] = filter.label
return acc
}, {})
acc[filter.key] = filter.label;
return acc;
}, {});
export let filtersMap = mapFilters(filters)
export let liveFiltersMap = mapLiveFilters(filters)
export let fflagsConditionsMap = mapFilters(flagConditionFilters)
export let filtersMap = mapFilters(filters);
export let liveFiltersMap = mapLiveFilters(filters);
export let fflagsConditionsMap = mapFilters(flagConditionFilters);
export const clearMetaFilters = () => {
filtersMap = mapFilters(filters);
@ -143,19 +600,37 @@ export const addElementToFiltersMap = (
operatorOptions = filterOptions.stringOperators,
icon = 'filters/metadata'
) => {
filtersMap[key] = { key, type, category, label: capitalize(key), operator: operator, operatorOptions, icon, isLive: true }
}
filtersMap[key] = {
key,
type,
category,
label: capitalize(key),
operator: operator,
operatorOptions,
icon,
isLive: true
};
};
export const addElementToFlagConditionsMap = (
category = FilterCategory.METADATA,
key,
type = FilterType.MULTIPLE,
operator = 'is',
operatorOptions = filterOptions.stringOperators,
icon = 'filters/metadata'
category = FilterCategory.METADATA,
key,
type = FilterType.MULTIPLE,
operator = 'is',
operatorOptions = filterOptions.stringOperators,
icon = 'filters/metadata'
) => {
fflagsConditionsMap[key] = { key, type, category, label: capitalize(key), operator: operator, operatorOptions, icon, isLive: true }
}
fflagsConditionsMap[key] = {
key,
type,
category,
label: capitalize(key),
operator: operator,
operatorOptions,
icon,
isLive: true
};
};
export const addElementToLiveFiltersMap = (
category = FilterCategory.METADATA,
@ -166,14 +641,14 @@ export const addElementToLiveFiltersMap = (
icon = 'filters/metadata'
) => {
liveFiltersMap[key] = {
key, type, category, label: capitalize(key),
operator: operator,
operatorOptions,
icon,
operatorDisabled: true,
isLive: true
}
}
key, type, category, label: capitalize(key),
operator: operator,
operatorOptions,
icon,
operatorDisabled: true,
isLive: true
};
};
export default Record({
timestamp: 0,
@ -182,8 +657,8 @@ export default Record({
placeholder: '',
icon: '',
type: '',
value: [""],
source: [""],
value: [''],
source: [''],
category: '',
custom: '',
@ -195,7 +670,7 @@ export default Record({
actualValue: '',
hasSource: false,
source: [""],
source: [''],
sourceType: '',
sourceOperator: '=',
sourcePlaceholder: '',
@ -208,20 +683,19 @@ export default Record({
isEvent: false,
index: 0,
options: [],
filters: [],
excludes: []
}, {
keyKey: "_key",
keyKey: '_key',
fromJS: ({ value, type, subFilter = false, ...filter }) => {
let _filter = {};
if (subFilter) {
const mainFilter = filtersMap[subFilter];
const subFilterMap = {}
const subFilterMap = {};
mainFilter.filters.forEach(option => {
subFilterMap[option.key] = option
})
_filter = subFilterMap[type]
subFilterMap[option.key] = option;
});
_filter = subFilterMap[type];
} else {
if (type === FilterKey.METADATA) {
_filter = filtersMap[filter.source];
@ -233,8 +707,8 @@ export default Record({
if (!_filter) {
_filter = {
key: filter.key,
type: "MULTIPLE",
}
type: 'MULTIPLE'
};
}
return {
@ -242,10 +716,10 @@ export default Record({
...filter,
key: _filter.key,
type: _filter.type, // camelCased(filter.type.toLowerCase()),
value: value.length === 0 || !value ? [""] : value,
}
},
})
value: value.length === 0 || !value ? [''] : value
};
}
});
/**
* Group filters by category
@ -263,7 +737,7 @@ export const generateFilterOptions = (map) => {
}
});
return filterSection;
}
};
export const generateFlagConditionOptions = (map) => {
const filterSection = {};
@ -276,8 +750,7 @@ export const generateFlagConditionOptions = (map) => {
}
});
return filterSection;
}
};
export const generateLiveFilterOptions = (map) => {
@ -294,4 +767,4 @@ export const generateLiveFilterOptions = (map) => {
}
});
return filterSection;
}
};

View file

@ -70,7 +70,7 @@
"react-tippy": "^1.4.0",
"react-toastify": "^9.1.1",
"react-virtualized": "^9.22.3",
"recharts": "^2.1.13",
"recharts": "^2.8.0",
"redux": "^4.0.5",
"redux-immutable": "^4.0.0",
"redux-thunk": "^2.3.0",

View file

@ -11270,10 +11270,10 @@ __metadata:
languageName: node
linkType: hard
"fast-equals@npm:^2.0.0":
version: 2.0.4
resolution: "fast-equals@npm:2.0.4"
checksum: 2867aa148c995e4ea921242ab605b157e5cbdc44793ebddf83684a5e6673be5016eb790ec4d8329317b92887e1108fb67ed3d4334529f2a7650c1338e6aa2c5f
"fast-equals@npm:^5.0.0":
version: 5.0.1
resolution: "fast-equals@npm:5.0.1"
checksum: d7077b8b681036c2840ed9860a3048e44fc268fad2b525b8f25b43458be0c8ad976152eb4b475de9617170423c5b802121ebb61ed6641c3ac035fadaf805c8c0
languageName: node
linkType: hard
@ -17733,7 +17733,7 @@ __metadata:
react-tippy: ^1.4.0
react-toastify: ^9.1.1
react-virtualized: ^9.22.3
recharts: ^2.1.13
recharts: ^2.8.0
redux: ^4.0.5
redux-immutable: ^4.0.0
redux-thunk: ^2.3.0
@ -20589,15 +20589,15 @@ __metadata:
languageName: node
linkType: hard
"react-resize-detector@npm:^7.1.2":
version: 7.1.2
resolution: "react-resize-detector@npm:7.1.2"
"react-resize-detector@npm:^8.0.4":
version: 8.1.0
resolution: "react-resize-detector@npm:8.1.0"
dependencies:
lodash: ^4.17.21
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
checksum: 2285b0024bcc736c7d5e80279e819835a5e8bef0899778100d2434b1c4b10971e6ae253df073ca96a20cacc47b3c249bc675479e5fc4ec1f6652fcca7f48ec22
checksum: 2ae9927c6e53de460a1f216e008acd30d84d11297bbb2c38687206b998309dfc3928992141e2176a00af642ef8b1c7f56e97681cc7d1c3a4f389e29d46a443af
languageName: node
linkType: hard
@ -20657,17 +20657,17 @@ __metadata:
languageName: node
linkType: hard
"react-smooth@npm:^2.0.1":
version: 2.0.1
resolution: "react-smooth@npm:2.0.1"
"react-smooth@npm:^2.0.2":
version: 2.0.4
resolution: "react-smooth@npm:2.0.4"
dependencies:
fast-equals: ^2.0.0
fast-equals: ^5.0.0
react-transition-group: 2.9.0
peerDependencies:
prop-types: ^15.6.0
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
checksum: 3227aecfb2c2783a53045e0b5931432fed3376c98893d1190c259ed4281182dc0b25f200f50b55733e01d9d6d1950641d1b5177d899277e3d4fe8264b6bddcc2
checksum: a67103136ef7f7378183dce3baecb28f1828ac71ce9901f1dfd92a4ec3a637014a7a6787f79552bb5cd2b779d27afd978d0f5eb33693b6a1210f774ab6e131cf
languageName: node
linkType: hard
@ -20964,16 +20964,16 @@ __metadata:
languageName: node
linkType: hard
"recharts@npm:^2.1.13":
version: 2.3.2
resolution: "recharts@npm:2.3.2"
"recharts@npm:^2.8.0":
version: 2.8.0
resolution: "recharts@npm:2.8.0"
dependencies:
classnames: ^2.2.5
eventemitter3: ^4.0.1
lodash: ^4.17.19
react-is: ^16.10.2
react-resize-detector: ^7.1.2
react-smooth: ^2.0.1
react-resize-detector: ^8.0.4
react-smooth: ^2.0.2
recharts-scale: ^0.4.4
reduce-css-calc: ^2.1.8
victory-vendor: ^36.6.8
@ -20981,7 +20981,7 @@ __metadata:
prop-types: ^15.6.0
react: ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
checksum: 88e86118388df94c07dc9b39d4e0a0c47defb1a6348fdbc0e2ad457f1329e5fbc4525fbd67bb94485a7ef0e8b23320e4e9066640b18241b3e0fad80b5eabfd28
checksum: 9f78bdf67fd5394f472a37679d2f9c9194f1d572c5bc086caae578409fceb7bc87a4df07dd096820c103d2002d9714c8db838c99b2722950ab9edab38be2daf9
languageName: node
linkType: hard