feat(widget-sessions): improve session filtering logic

- Refactored session filtering logic to handle nested filters properly.
- Enhanced `fetchSessions` to ensure null checks and avoid errors.
- Updated `loadData` to handle `USER_PATH` and `HEATMAP` metric types.
- Improved UI consistency by adjusting spacing and formatting.
- Replaced redundant code with cleaner, more maintainable patterns.

This change improves the reliability and readability of the session
filtering and loading logic in the WidgetSessions component.
This commit is contained in:
Shekar Siri 2025-04-15 18:14:10 +02:00
parent 80a7817e7d
commit 30ce37896c

View file

@ -1,376 +1,395 @@
import React, { useEffect, useState } from 'react'; import React, {useEffect, useState} from 'react';
import { NoContent, Loader, Pagination } from 'UI'; import {NoContent, Loader, Pagination} from 'UI';
import { Button, Tag, Tooltip, Dropdown, message } from 'antd'; import {Button, Tag, Tooltip, Dropdown, message} from 'antd';
import { UndoOutlined, DownOutlined } from '@ant-design/icons'; import {UndoOutlined, DownOutlined} from '@ant-design/icons';
import cn from 'classnames'; import cn from 'classnames';
import { useStore } from 'App/mstore'; import {useStore} from 'App/mstore';
import SessionItem from 'Shared/SessionItem'; import SessionItem from 'Shared/SessionItem';
import { observer } from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import { DateTime } from 'luxon'; import {DateTime} from 'luxon';
import { debounce, numberWithCommas } from 'App/utils'; import {debounce, numberWithCommas} from 'App/utils';
import useIsMounted from 'App/hooks/useIsMounted'; import useIsMounted from 'App/hooks/useIsMounted';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import AnimatedSVG, {ICONS} from 'Shared/AnimatedSVG/AnimatedSVG';
import { HEATMAP, USER_PATH, FUNNEL } from 'App/constants/card'; import {HEATMAP, USER_PATH, FUNNEL} from 'App/constants/card';
import { useTranslation } from 'react-i18next'; import {useTranslation} from 'react-i18next';
interface Props { interface Props {
className?: string; className?: string;
} }
function WidgetSessions(props: Props) { function WidgetSessions(props: Props) {
const { t } = useTranslation(); const {t} = useTranslation();
const listRef = React.useRef<HTMLDivElement>(null); const listRef = React.useRef<HTMLDivElement>(null);
const { className = '' } = props; const {className = ''} = props;
const [activeSeries, setActiveSeries] = useState('all'); const [activeSeries, setActiveSeries] = useState('all');
const [data, setData] = useState<any>([]); const [data, setData] = useState<any>([]);
const isMounted = useIsMounted(); const isMounted = useIsMounted();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// all filtering done through series now // all filtering done through series now
const filteredSessions = getListSessionsBySeries(data, 'all'); const filteredSessions = getListSessionsBySeries(data, 'all');
const { dashboardStore, metricStore, sessionStore, customFieldStore } = const {dashboardStore, metricStore, sessionStore, customFieldStore} =
useStore(); useStore();
const focusedSeries = metricStore.focusedSeriesName; const focusedSeries = metricStore.focusedSeriesName;
const filter = dashboardStore.drillDownFilter; const filter = dashboardStore.drillDownFilter;
const widget = metricStore.instance; const widget = metricStore.instance;
const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat( const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat(
'LLL dd, yyyy HH:mm', 'LLL dd, yyyy HH:mm',
); );
const endTime = DateTime.fromMillis(filter.endTimestamp).toFormat( const endTime = DateTime.fromMillis(filter.endTimestamp).toFormat(
'LLL dd, yyyy HH:mm', 'LLL dd, yyyy HH:mm',
); );
const [seriesOptions, setSeriesOptions] = useState([ const [seriesOptions, setSeriesOptions] = useState([
{ label: t('All'), value: 'all' }, {label: t('All'), value: 'all'},
]); ]);
const hasFilters = const hasFilters =
filter.filters.length > 0 || filter.filters.length > 0 ||
filter.startTimestamp !== dashboardStore.drillDownPeriod.start || filter.startTimestamp !== dashboardStore.drillDownPeriod.start ||
filter.endTimestamp !== dashboardStore.drillDownPeriod.end; filter.endTimestamp !== dashboardStore.drillDownPeriod.end;
const filterText = filter.filters.length > 0 ? filter.filters[0].value : ''; const filterText = filter.filters.length > 0 ? filter.filters[0].value : '';
const metaList = customFieldStore.list.map((i: any) => i.key); const metaList = customFieldStore.list.map((i: any) => i.key);
const seriesDropdownItems = seriesOptions.map((option) => ({ const seriesDropdownItems = seriesOptions.map((option) => ({
key: option.value, key: option.value,
label: ( label: (
<div onClick={() => setActiveSeries(option.value)}>{option.label}</div> <div onClick={() => setActiveSeries(option.value)}>{option.label}</div>
), ),
}));
useEffect(() => {
if (!widget.series) return;
const seriesOptions = widget.series.map((item: any) => ({
label: item.name,
value: item.seriesId ?? item.name,
})); }));
setSeriesOptions([{ label: t('All'), value: 'all' }, ...seriesOptions]);
}, [widget.series.length]);
const fetchSessions = (metricId: any, filter: any) => { useEffect(() => {
if (!isMounted()) return; if (!widget.series) return;
setLoading(true); const seriesOptions = widget.series.map((item: any) => ({
delete filter.eventsOrderSupport; label: item.name,
if (widget.metricType === FUNNEL) { value: item.seriesId ?? item.name,
if (filter.series[0].filter.filters.length === 0) { }));
setLoading(false); setSeriesOptions([{label: t('All'), value: 'all'}, ...seriesOptions]);
return setData([]); }, [widget.series.length]);
}
}
widget const fetchSessions = (metricId: any, filter: any) => {
.fetchSessions(metricId, filter) if (!isMounted()) return;
.then((res: any) => {
setData(res); if (widget.metricType === FUNNEL) {
if (metricStore.drillDown) { if (filter.series[0].filter.filters.length === 0) {
setTimeout(() => { setLoading(false);
message.info(t('Sessions Refreshed!')); return setData([]);
listRef.current?.scrollIntoView({ behavior: 'smooth' }); }
metricStore.setDrillDown(false);
}, 0);
} }
})
.finally(() => {
setLoading(false);
});
};
const fetchClickmapSessions = (customFilters: Record<string, any>) => {
sessionStore.getSessions(customFilters).then((data) => {
setData([{ ...data, seriesId: 1, seriesName: 'Clicks' }]);
});
};
const debounceRequest: any = React.useCallback(
debounce(fetchSessions, 1000),
[],
);
const debounceClickMapSearch = React.useCallback(
debounce(fetchClickmapSessions, 1000),
[],
);
const depsString = JSON.stringify(widget.series);
const loadData = () => { setLoading(true);
if (widget.metricType === HEATMAP && metricStore.clickMapSearch) { const filterCopy = {...filter};
const clickFilter = { delete filterCopy.eventsOrderSupport;
value: [metricStore.clickMapSearch],
type: 'CLICK', try {
operator: 'onSelector', // Handle filters properly with null checks
isEvent: true, if (filterCopy.filters && filterCopy.filters.length > 0) {
// @ts-ignore // Ensure the nested path exists before pushing
filters: [], if (filterCopy.series?.[0]?.filter) {
}; if (!filterCopy.series[0].filter.filters) {
const timeRange = { filterCopy.series[0].filter.filters = [];
rangeValue: dashboardStore.drillDownPeriod.rangeValue, }
startDate: dashboardStore.drillDownPeriod.start, filterCopy.series[0].filter.filters.push(...filterCopy.filters);
endDate: dashboardStore.drillDownPeriod.end, }
}; filterCopy.filters = [];
const customFilter = { }
...filter, } catch (e) {
...timeRange, // do nothing
filters: [...sessionStore.userFilter.filters, clickFilter],
};
debounceClickMapSearch(customFilter);
} else {
const hasStartPoint =
!!widget.startPoint && widget.metricType === USER_PATH;
const onlyFocused = focusedSeries
? widget.series.filter((s) => s.name === focusedSeries)
: widget.series;
const activeSeries = metricStore.disabledSeries.length
? onlyFocused.filter(
(s) => !metricStore.disabledSeries.includes(s.name),
)
: onlyFocused;
const seriesJson = activeSeries.map((s) => s.toJson());
if (hasStartPoint) {
seriesJson[0].filter.filters.push(widget.startPoint.toJson());
}
if (widget.metricType === USER_PATH) {
if (
seriesJson[0].filter.filters[0].value[0] === '' &&
widget.data.nodes
) {
seriesJson[0].filter.filters[0].value = widget.data.nodes[0].name;
} else if (
seriesJson[0].filter.filters[0].value[0] === '' &&
!widget.data.nodes?.length
) {
// no point requesting if we don't have starting point picked by api
return;
} }
} widget
debounceRequest(widget.metricId, { .fetchSessions(metricId, filterCopy)
...filter, .then((res: any) => {
series: seriesJson, setData(res);
page: metricStore.sessionsPage, if (metricStore.drillDown) {
limit: metricStore.sessionsPageSize, setTimeout(() => {
}); message.info(t('Sessions Refreshed!'));
} listRef.current?.scrollIntoView({behavior: 'smooth'});
}; metricStore.setDrillDown(false);
useEffect(() => { }, 0);
metricStore.updateKey('sessionsPage', 1); }
loadData(); })
}, [ .finally(() => {
filter.startTimestamp, setLoading(false);
filter.endTimestamp, });
filter.filters, };
depsString, const fetchClickmapSessions = (customFilters: Record<string, any>) => {
metricStore.clickMapSearch, sessionStore.getSessions(customFilters).then((data) => {
focusedSeries, setData([{...data, seriesId: 1, seriesName: 'Clicks'}]);
widget.startPoint, });
widget.data.nodes, };
metricStore.disabledSeries.length, const debounceRequest: any = React.useCallback(
]); debounce(fetchSessions, 1000),
useEffect(loadData, [metricStore.sessionsPage]); [],
useEffect(() => { );
if (activeSeries === 'all') { const debounceClickMapSearch = React.useCallback(
metricStore.setFocusedSeriesName(null); debounce(fetchClickmapSessions, 1000),
} else { [],
metricStore.setFocusedSeriesName( );
seriesOptions.find((option) => option.value === activeSeries)?.label,
false,
);
}
}, [activeSeries]);
useEffect(() => {
if (focusedSeries) {
setActiveSeries(
seriesOptions.find((option) => option.label === focusedSeries)?.value ||
'all',
);
} else {
setActiveSeries('all');
}
}, [focusedSeries]);
const clearFilters = () => { const depsString = JSON.stringify(widget.series);
metricStore.updateKey('sessionsPage', 1);
dashboardStore.resetDrillDownFilter();
};
return ( const loadData = () => {
<div if (widget.metricType === HEATMAP && metricStore.clickMapSearch) {
className={cn( const clickFilter = {
className, value: [metricStore.clickMapSearch],
'bg-white p-3 pb-0 rounded-xl shadow-sm border mt-3', type: 'CLICK',
)} operator: 'onSelector',
> isEvent: true,
<div className="flex items-center justify-between"> // @ts-ignore
<div> filters: [],
<div className="flex items-baseline gap-2"> };
<h2 className="text-xl"> const timeRange = {
{metricStore.clickMapSearch ? t('Clicks') : t('Sessions')} rangeValue: dashboardStore.drillDownPeriod.rangeValue,
</h2> startDate: dashboardStore.drillDownPeriod.start,
<div className="ml-2 color-gray-medium"> endDate: dashboardStore.drillDownPeriod.end,
{metricStore.clickMapLabel };
? `on "${metricStore.clickMapLabel}" ` const customFilter = {
: null} ...filter,
{t('between')}{' '} ...timeRange,
<span className="font-medium color-gray-darkest"> filters: [...sessionStore.userFilter.filters, clickFilter],
};
debounceClickMapSearch(customFilter);
} else {
const hasStartPoint =
!!widget.startPoint && widget.metricType === USER_PATH;
const onlyFocused = focusedSeries
? widget.series.filter((s) => s.name === focusedSeries)
: widget.series;
const activeSeries = metricStore.disabledSeries.length
? onlyFocused.filter(
(s) => !metricStore.disabledSeries.includes(s.name),
)
: onlyFocused;
const seriesJson = activeSeries.map((s) => s.toJson());
if (hasStartPoint) {
seriesJson[0].filter.filters.push(widget.startPoint.toJson());
}
if (widget.metricType === USER_PATH) {
if (
seriesJson[0].filter.filters[0].value[0] === '' &&
widget.data.nodes
) {
seriesJson[0].filter.filters[0].value = widget.data.nodes[0].name;
} else if (
seriesJson[0].filter.filters[0].value[0] === '' &&
!widget.data.nodes?.length
) {
// no point requesting if we don't have starting point picked by api
return;
}
}
debounceRequest(widget.metricId, {
...filter,
series: seriesJson,
page: metricStore.sessionsPage,
limit: metricStore.sessionsPageSize,
});
}
};
useEffect(() => {
metricStore.updateKey('sessionsPage', 1);
loadData();
}, [
filter.startTimestamp,
filter.endTimestamp,
filter.filters,
depsString,
metricStore.clickMapSearch,
focusedSeries,
widget.startPoint,
widget.data.nodes,
metricStore.disabledSeries.length,
]);
useEffect(loadData, [metricStore.sessionsPage]);
useEffect(() => {
if (activeSeries === 'all') {
metricStore.setFocusedSeriesName(null);
} else {
metricStore.setFocusedSeriesName(
seriesOptions.find((option) => option.value === activeSeries)?.label,
false,
);
}
}, [activeSeries]);
useEffect(() => {
if (focusedSeries) {
setActiveSeries(
seriesOptions.find((option) => option.label === focusedSeries)?.value ||
'all',
);
} else {
setActiveSeries('all');
}
}, [focusedSeries]);
const clearFilters = () => {
metricStore.updateKey('sessionsPage', 1);
dashboardStore.resetDrillDownFilter();
};
return (
<div
className={cn(
className,
'bg-white p-3 pb-0 rounded-xl shadow-sm border mt-3',
)}
>
<div className="flex items-center justify-between">
<div>
<div className="flex items-baseline gap-2">
<h2 className="text-xl">
{metricStore.clickMapSearch ? t('Clicks') : t('Sessions')}
</h2>
<div className="ml-2 color-gray-medium">
{metricStore.clickMapLabel
? `on "${metricStore.clickMapLabel}" `
: null}
{t('between')}{' '}
<span className="font-medium color-gray-darkest">
{startTime} {startTime}
</span>{' '} </span>{' '}
{t('and')}{' '} {t('and')}{' '}
<span className="font-medium color-gray-darkest"> <span className="font-medium color-gray-darkest">
{endTime} {endTime}
</span>{' '} </span>{' '}
</div> </div>
{hasFilters && ( {hasFilters && (
<Tooltip title={t('Clear Drilldown')} placement="top"> <Tooltip title={t('Clear Drilldown')} placement="top">
<Button type="text" size="small" onClick={clearFilters}> <Button type="text" size="small" onClick={clearFilters}>
<UndoOutlined /> <UndoOutlined/>
</Button> </Button>
</Tooltip> </Tooltip>
)} )}
</div> </div>
{hasFilters && widget.metricType === 'table' && ( {hasFilters && widget.metricType === 'table' && (
<div className="py-2"> <div className="py-2">
<Tag <Tag
closable closable
onClose={clearFilters} onClose={clearFilters}
className="truncate max-w-44 rounded-lg" className="truncate max-w-44 rounded-lg"
> >
{filterText} {filterText}
</Tag> </Tag>
</div> </div>
)} )}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && ( {widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
<div className="flex items-center ml-6"> <div className="flex items-center ml-6">
<span className="mr-2 color-gray-medium"> <span className="mr-2 color-gray-medium">
{t('Filter by Series')} {t('Filter by Series')}
</span> </span>
<Dropdown <Dropdown
menu={{ menu={{
items: seriesDropdownItems, items: seriesDropdownItems,
selectable: true, selectable: true,
selectedKeys: [activeSeries], selectedKeys: [activeSeries],
}} }}
trigger={['click']} trigger={['click']}
> >
<Button type="text" size="small"> <Button type="text" size="small">
{seriesOptions.find((option) => option.value === activeSeries) {seriesOptions.find((option) => option.value === activeSeries)
?.label || t('Select Series')} ?.label || t('Select Series')}
<DownOutlined /> <DownOutlined/>
</Button> </Button>
</Dropdown> </Dropdown>
</div> </div>
)} )}
</div>
</div>
<div className="mt-3">
<Loader loading={loading}>
<NoContent
title={
<div className="flex items-center justify-center flex-col">
<AnimatedSVG name={ICONS.NO_SESSIONS} size={60} />
<div className="mt-4" />
<div className="text-center">
{t('No relevant sessions found for the selected time period')}
</div> </div>
</div> </div>
}
show={filteredSessions.sessions.length === 0}
>
{filteredSessions.sessions.map((session: any) => (
<React.Fragment key={session.sessionId}>
<SessionItem
disableUser
session={session}
metaList={metaList}
/>
<div className="border-b" />
</React.Fragment>
))}
<div <div className="mt-3">
className="flex items-center justify-between p-5" <Loader loading={loading}>
ref={listRef} <NoContent
> title={
<div> <div className="flex items-center justify-center flex-col">
{t('Showing')}{' '} <AnimatedSVG name={ICONS.NO_SESSIONS} size={60}/>
<span className="font-medium"> <div className="mt-4"/>
<div className="text-center">
{t('No relevant sessions found for the selected time period')}
</div>
</div>
}
show={filteredSessions.sessions.length === 0}
>
{filteredSessions.sessions.map((session: any) => (
<React.Fragment key={session.sessionId}>
<SessionItem
disableUser
session={session}
metaList={metaList}
/>
<div className="border-b"/>
</React.Fragment>
))}
<div
className="flex items-center justify-between p-5"
ref={listRef}
>
<div>
{t('Showing')}{' '}
<span className="font-medium">
{(metricStore.sessionsPage - 1) * {(metricStore.sessionsPage - 1) *
metricStore.sessionsPageSize + metricStore.sessionsPageSize +
1} 1}
</span>{' '} </span>{' '}
{t('to')}{' '} {t('to')}{' '}
<span className="font-medium"> <span className="font-medium">
{(metricStore.sessionsPage - 1) * {(metricStore.sessionsPage - 1) *
metricStore.sessionsPageSize + metricStore.sessionsPageSize +
filteredSessions.sessions.length} filteredSessions.sessions.length}
</span>{' '} </span>{' '}
{t('of')}{' '} {t('of')}{' '}
<span className="font-medium"> <span className="font-medium">
{numberWithCommas(filteredSessions.total)} {numberWithCommas(filteredSessions.total)}
</span>{' '} </span>{' '}
{t('sessions.')} {t('sessions.')}
</div> </div>
<Pagination <Pagination
page={metricStore.sessionsPage} page={metricStore.sessionsPage}
total={filteredSessions.total} total={filteredSessions.total}
onPageChange={(page: any) => onPageChange={(page: any) =>
metricStore.updateKey('sessionsPage', page) metricStore.updateKey('sessionsPage', page)
} }
limit={metricStore.sessionsPageSize} limit={metricStore.sessionsPageSize}
debounceRequest={500} debounceRequest={500}
/> />
</div>
</NoContent>
</Loader>
</div> </div>
</NoContent> </div>
</Loader> );
</div>
</div>
);
} }
const getListSessionsBySeries = (data: any, seriesId: any) => { const getListSessionsBySeries = (data: any, seriesId: any) => {
const arr = data.reduce( const arr = data.reduce(
(arr: any, element: any) => { (arr: any, element: any) => {
if (seriesId === 'all') { if (seriesId === 'all') {
const sessionIds = arr.sessions.map((i: any) => i.sessionId); const sessionIds = arr.sessions.map((i: any) => i.sessionId);
const sessions = element.sessions.filter( const sessions = element.sessions.filter(
(i: any) => !sessionIds.includes(i.sessionId), (i: any) => !sessionIds.includes(i.sessionId),
); );
arr.sessions.push(...sessions); arr.sessions.push(...sessions);
} else if (element.seriesId === seriesId) { } else if (element.seriesId === seriesId) {
const sessionIds = arr.sessions.map((i: any) => i.sessionId); const sessionIds = arr.sessions.map((i: any) => i.sessionId);
const sessions = element.sessions.filter( const sessions = element.sessions.filter(
(i: any) => !sessionIds.includes(i.sessionId), (i: any) => !sessionIds.includes(i.sessionId),
); );
const duplicates = element.sessions.length - sessions.length; const duplicates = element.sessions.length - sessions.length;
arr.sessions.push(...sessions); arr.sessions.push(...sessions);
arr.total = element.total - duplicates; arr.total = element.total - duplicates;
} }
return arr; return arr;
}, },
{ sessions: [] }, {sessions: []},
); );
arr.total = arr.total =
seriesId === 'all' seriesId === 'all'
? Math.max(...data.map((i: any) => i.total)) ? Math.max(...data.map((i: any) => i.total))
: data.find((i: any) => i.seriesId === seriesId).total; : data.find((i: any) => i.seriesId === seriesId).total;
return arr; return arr;
}; };
export default observer(WidgetSessions); export default observer(WidgetSessions);