feat(metrics): implement server-side pagination and sorting

Refactors metrics list view to use server-side pagination and sorting
instead of client-side implementation. This improves performance for
large datasets by reducing client workload and network payload size.

Key changes:
- Add pagination API endpoint in MetricService
- Update MetricStore to handle server pagination
- Refactor ListView component to use server-side sorting
- Remove client-side sorting and pagination logic
This commit is contained in:
Shekar Siri 2025-03-10 14:54:27 +01:00
parent 4b09213448
commit 687ab05f22
5 changed files with 193 additions and 256 deletions

View file

@ -41,9 +41,9 @@ function MetricViewHeader() {
// Show header if there are cards or if a filter is active
const showHeader = cardsLength > 0 || isFilterActive;
useEffect(() => {
metricStore.updateKey('sort', { by: 'desc' });
}, [metricStore]);
// useEffect(() => {
// metricStore.updateKey('sort', { by: 'desc' });
// }, [metricStore]);
const handleMenuClick = ({ key }: { key: string }) => {
metricStore.updateKey('filter', { ...filter, type: key });

View file

@ -8,13 +8,13 @@ import {
Button,
Dropdown,
Modal as AntdModal,
Avatar,
Avatar, TableColumnType
} from 'antd';
import {
TeamOutlined,
LockOutlined,
EditOutlined,
DeleteOutlined,
DeleteOutlined
} from '@ant-design/icons';
import { EllipsisVertical } from 'lucide-react';
import { TablePaginationConfig, SorterResult } from 'antd/lib/table/interface';
@ -37,90 +37,41 @@ interface Props {
toggleSelection?: (metricId: number | number[]) => void;
disableSelection?: boolean;
inLibrary?: boolean;
loading?: boolean;
}
const ListView: React.FC<Props> = ({
list,
siteId,
selectedList,
toggleSelection,
disableSelection = false,
inLibrary = false
}) => {
list,
siteId,
selectedList,
toggleSelection,
disableSelection = false,
inLibrary = false,
loading = false
}) => {
const { t } = useTranslation();
const [sorter, setSorter] = useState<{ field: string; order: 'ascend' | 'descend' }>({
field: 'lastModified',
order: 'descend',
});
const [pagination, setPagination] = useState<TablePaginationConfig>({
current: 1,
pageSize: 10,
});
const [editingMetricId, setEditingMetricId] = useState<number | null>(null);
const [newName, setNewName] = useState('');
const { metricStore } = useStore();
const history = useHistory();
const sortedData = useMemo(
() =>
[...list].sort((a, b) => {
if (sorter.field === 'lastModified') {
return sorter.order === 'ascend'
? new Date(a.lastModified).getTime() -
new Date(b.lastModified).getTime()
: new Date(b.lastModified).getTime() -
new Date(a.lastModified).getTime();
}
if (sorter.field === 'name') {
return sorter.order === 'ascend'
? a.name?.localeCompare(b.name) || 0
: b.name?.localeCompare(a.name) || 0;
}
if (sorter.field === 'owner') {
return sorter.order === 'ascend'
? a.owner?.localeCompare(b.owner) || 0
: b.owner?.localeCompare(a.owner) || 0;
}
return 0;
}),
[list, sorter],
);
const paginatedData = useMemo(() => {
const start = ((pagination.current || 1) - 1) * (pagination.pageSize || 10);
return sortedData.slice(start, start + (pagination.pageSize || 10));
}, [sortedData, pagination]);
const totalMessage = (
<>
{t('Showing')}{' '}
<Text strong>
{(pagination.pageSize || 10) * ((pagination.current || 1) - 1) + 1}
{(metricStore.pageSize || 10) * ((metricStore.page || 1) - 1) + 1}
</Text>{' '}
{t('to')}{' '}
<Text strong>
{Math.min(
(pagination.pageSize || 10) * (pagination.current || 1),
list.length,
(metricStore.pageSize || 10) * (metricStore.page || 1),
list.length
)}
</Text>{' '}
{t('of')}&nbsp;<Text strong>{list.length}</Text>&nbsp;{t('cards')}
</>
);
const handleTableChange = (
pag: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorterParam: SorterResult<Widget> | SorterResult<Widget>[],
) => {
const sortRes = sorterParam as SorterResult<Widget>;
setSorter({
field: sortRes.field as string,
order: sortRes.order as 'ascend' | 'descend',
});
setPagination(pag);
};
const parseDate = (dateString: string) => {
let date = new Date(dateString);
if (isNaN(date.getTime())) {
@ -182,7 +133,7 @@ const ListView: React.FC<Props> = ({
cancelText: t('No'),
onOk: async () => {
await metricStore.delete(metric);
},
}
});
}
if (key === 'rename') {
@ -206,7 +157,7 @@ const ListView: React.FC<Props> = ({
const menuItems = [
{ key: 'rename', icon: <EditOutlined />, label: t('Rename') },
{ key: 'delete', icon: <DeleteOutlined />, label: t('Delete') },
{ key: 'delete', icon: <DeleteOutlined />, label: t('Delete') }
];
const renderTitle = (_text: string, metric: Widget) => (
@ -245,80 +196,109 @@ const ListView: React.FC<Props> = ({
</div>
);
const columns = [
const columns: TableColumnType<any>[] = [
{
title: t('Title'),
dataIndex: 'name',
key: 'title',
className: 'cap-first pl-4',
sorter: true,
sortOrder: metricStore.sort.field === 'name' ? metricStore.sort.order : undefined,
width: inLibrary ? '31%' : '25%',
render: renderTitle,
render: renderTitle
},
{
title: t('Owner'),
dataIndex: 'owner',
dataIndex: 'owner_email',
key: 'owner',
className: 'capitalize',
sorter: true,
sortOrder: metricStore.sort.field === 'owner_email' ? metricStore.sort.order : undefined,
width: inLibrary ? '31%' : '25%',
render: renderOwner,
render: renderOwner
},
{
title: t('Last Modified'),
dataIndex: 'lastModified',
dataIndex: 'edited_at',
key: 'lastModified',
sorter: true,
sortOrder: metricStore.sort.field === 'edited_at' ? metricStore.sort.order : undefined,
width: inLibrary ? '31%' : '25%',
render: renderLastModified,
},
render: renderLastModified
}
];
if (!inLibrary) {
columns.push({
title: '',
key: 'options',
className: 'text-right',
width: '5%',
render: renderOptions,
render: renderOptions
});
}
// if (metricStore.sort.field) {
// columns.forEach((col) => {
// col.sortOrder = col.key === metricStore.sort.field ? metricStore.sort.order : false;
// });
// }
console.log('store', metricStore.sort);
const handleTableChange = (
pag: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorterParam: SorterResult<Widget> | SorterResult<Widget>[]
) => {
const sorter = Array.isArray(sorterParam) ? sorterParam[0] : sorterParam;
let order = sorter.order;
if (metricStore.sort.field === sorter.field) {
order = metricStore.sort.order === 'ascend' ? 'descend' : 'ascend';
}
console.log('sorter', { field: sorter.field, order });
metricStore.updateKey('sort', { field: sorter.field, order });
metricStore.updateKey('page', pag.current || 1);
};
return (
<>
<Table
loading={loading}
columns={columns}
dataSource={paginatedData}
dataSource={list}
rowKey="metricId"
showSorterTooltip={false}
onChange={handleTableChange}
sortDirections={['ascend', 'descend']}
onRow={
inLibrary
? (record) => ({
onClick: () => {
if (!disableSelection) toggleSelection?.(record?.metricId);
},
})
onClick: () => {
if (!disableSelection) toggleSelection?.(record?.metricId);
}
})
: undefined
}
rowSelection={
!disableSelection
? {
selectedRowKeys: selectedList,
onChange: (keys) => toggleSelection && toggleSelection(keys),
columnWidth: 16,
}
selectedRowKeys: selectedList,
onChange: (keys) => toggleSelection && toggleSelection(keys),
columnWidth: 16
}
: undefined
}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: sortedData.length,
current: metricStore.page,
pageSize: metricStore.pageSize,
total: metricStore.total,
showSizeChanger: false,
className: 'px-4',
showLessItems: true,
showTotal: () => totalMessage,
size: 'small',
simple: true,
simple: true
}}
/>
<AntdModal

View file

@ -6,16 +6,15 @@ import { sliceListPerPage } from 'App/utils';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { Popover, Button } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import GridView from './GridView';
import ListView from './ListView';
import AddCardSection from '../AddCardSection/AddCardSection';
import { useTranslation } from 'react-i18next';
function MetricsList({
siteId,
onSelectionChange,
inLibrary,
}: {
siteId,
onSelectionChange,
inLibrary
}: {
siteId: string;
onSelectionChange?: (selected: any[]) => void;
inLibrary?: boolean;
@ -23,28 +22,27 @@ function MetricsList({
const { t } = useTranslation();
const { metricStore, dashboardStore } = useStore();
const metricsSearch = metricStore.filter.query;
const listView = inLibrary ? true : metricStore.listView;
const [selectedMetrics, setSelectedMetrics] = useState<any>([]);
const dashboard = dashboardStore.selectedDashboard;
const existingCardIds = useMemo(
() => dashboard?.widgets?.map((i) => parseInt(i.metricId)),
[dashboard],
[dashboard]
);
const cards = useMemo(
() =>
onSelectionChange
? metricStore.filteredCards.filter(
(i) => !existingCardIds?.includes(parseInt(i.metricId)),
)
(i) => !existingCardIds?.includes(parseInt(i.metricId))
)
: metricStore.filteredCards,
[metricStore.filteredCards, existingCardIds, onSelectionChange],
[metricStore.filteredCards, existingCardIds, onSelectionChange]
);
const loading = metricStore.isLoading;
useEffect(() => {
void metricStore.fetchList();
}, [metricStore]);
}, [metricStore.page, metricStore.filter, metricStore.sort]);
useEffect(() => {
if (!onSelectionChange) return;
@ -69,14 +67,8 @@ function MetricsList({
metricStore.updateKey('sessionsPage', 1);
}, [metricStore]);
const showOwn = metricStore.filter.showMine;
const toggleOwn = () => {
metricStore.updateKey('showMine', !showOwn);
};
const isFiltered =
metricsSearch !== '' ||
(metricStore.filter.type && metricStore.filter.type !== 'all');
const isFiltered = metricStore.filter.query !== '' || metricStore.filter.type !== 'all';
const searchImageDimensions = { width: 60, height: 'auto' };
const defaultImageDimensions = { width: 600, height: 'auto' };
@ -86,101 +78,65 @@ function MetricsList({
: defaultImageDimensions;
return (
<Loader loading={loading}>
<NoContent
show={length === 0}
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={emptyImage} size={imageDimensions.width} />
<div className="text-center mt-3 text-lg font-medium">
{isFiltered
? t('No matching results')
: t('Unlock insights with data cards')}
</div>
<NoContent
show={!loading && length === 0}
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={emptyImage} size={imageDimensions.width} />
<div className="text-center mt-3 text-lg font-medium">
{isFiltered
? t('No matching results')
: t('Unlock insights with data cards')}
</div>
}
subtext={
isFiltered ? (
''
) : (
<div className="flex flex-col items-center">
<div>
{t('Create and customize cards to analyze trends and user behavior effectively.')}
</div>
<Popover
arrow={false}
overlayInnerStyle={{ padding: 0, borderRadius: '0.75rem' }}
content={<AddCardSection fit inCards />}
trigger="click"
>
<Button
type="primary"
icon={<PlusOutlined />}
className="btn-create-card mt-3"
>
{t('Create Card')}
</Button>
</Popover>
</div>
)
}
>
{listView ? (
<ListView
disableSelection={!onSelectionChange}
siteId={siteId}
list={cards}
inLibrary={inLibrary}
selectedList={selectedMetrics}
existingCardIds={existingCardIds}
toggleSelection={toggleMetricSelection}
allSelected={cards.length === selectedMetrics.length}
showOwn={showOwn}
toggleOwn={toggleOwn}
toggleAll={({ target: { checked } }) =>
setSelectedMetrics(
checked
? cards
.map((i: any) => i.metricId)
.slice(0, 30 - (existingCardIds?.length || 0))
: [],
)
}
/>
</div>
}
subtext={
isFiltered ? (
''
) : (
<>
<GridView
siteId={siteId}
list={sliceListPerPage(
cards,
metricStore.page - 1,
metricStore.pageSize,
)}
selectedList={selectedMetrics}
toggleSelection={toggleMetricSelection}
/>
<div className="w-full flex items-center justify-between py-4 px-6 border-t">
<div>
{t('Showing')}{' '}
<span className="font-medium">
{Math.min(cards.length, metricStore.pageSize)}
</span>{' '}
{t('out of')}&nbsp;
<span className="font-medium">{cards.length}</span>&nbsp;
{t('cards')}
</div>
<Pagination
page={metricStore.page}
total={length}
onPageChange={(page) => metricStore.updateKey('page', page)}
limit={metricStore.pageSize}
debounceRequest={100}
/>
<div className="flex flex-col items-center">
<div>
{t('Create and customize cards to analyze trends and user behavior effectively.')}
</div>
</>
)}
</NoContent>
</Loader>
<Popover
arrow={false}
overlayInnerStyle={{ padding: 0, borderRadius: '0.75rem' }}
content={<AddCardSection fit inCards />}
trigger="click"
>
<Button
type="primary"
icon={<PlusOutlined />}
className="btn-create-card mt-3"
>
{t('Create Card')}
</Button>
</Popover>
</div>
)
}
>
<ListView
loading={loading}
disableSelection={!onSelectionChange}
siteId={siteId}
list={cards}
inLibrary={inLibrary}
selectedList={selectedMetrics}
// existingCardIds={existingCardIds}
toggleSelection={toggleMetricSelection}
// allSelected={cards.length === selectedMetrics.length}
// toggleAll={({ target: { checked } }) =>
// setSelectedMetrics(
// checked
// ? cards
// .map((i: any) => i.metricId)
// .slice(0, 30 - (existingCardIds?.length || 0))
// : []
// )
// }
/>
</NoContent>
);
}

View file

@ -10,7 +10,7 @@ import {
HEATMAP,
USER_PATH,
RETENTION,
CATEGORIES,
CATEGORIES
} from 'App/constants/card';
import { clickmapFilter } from 'App/types/filter/newFilter';
import { getRE } from 'App/utils';
@ -31,7 +31,7 @@ const handleFilter = (card: Widget, filterType?: string) => {
FilterKey.ERRORS,
FilterKey.FETCH,
`${TIMESERIES}_4xx_requests`,
`${TIMESERIES}_slow_network_requests`,
`${TIMESERIES}_slow_network_requests`
].includes(metricOf);
}
if (filterType === CATEGORIES.web_analytics) {
@ -41,7 +41,7 @@ const handleFilter = (card: Widget, filterType?: string) => {
FilterKey.REFERRER,
FilterKey.USERID,
FilterKey.LOCATION,
FilterKey.USER_DEVICE,
FilterKey.USER_DEVICE
].includes(metricOf);
}
} else {
@ -75,58 +75,42 @@ interface MetricFilter {
query?: string;
showMine?: boolean;
type?: string;
dashboard?: [];
// dashboard?: [];
}
export default class MetricStore {
isLoading: boolean = false;
isSaving: boolean = false;
metrics: Widget[] = [];
instance = new Widget();
page: number = 1;
total: number = 0;
pageSize: number = 10;
metricsSearch: string = '';
sort: any = { by: 'desc' };
filter: MetricFilter = { type: 'all', dashboard: [], query: '' };
sort: any = { columnKey: '', field: '', order: false };
filter: any = { type: '', query: '' };
sessionsPage: number = 1;
sessionsPageSize: number = 10;
listView?: boolean = true;
clickMapFilter: boolean = false;
clickMapSearch = '';
clickMapLabel = '';
cardCategory: string | null = CATEGORIES.product_analytics;
focusedSeriesName: string | null = null;
disabledSeries: string[] = [];
drillDown = false;
constructor() {
makeAutoObservable(this);
}
get sortedWidgets() {
return [...this.metrics].sort((a, b) =>
this.sort.by === 'desc'
? b.lastModified - a.lastModified
: a.lastModified - b.lastModified,
);
}
// get sortedWidgets() {
// return [...this.metrics].sort((a, b) =>
// this.sort.by === 'desc'
// ? b.lastModified - a.lastModified
// : a.lastModified - b.lastModified
// );
// }
get filteredCards() {
const filterRE = this.filter.query ? getRE(this.filter.query, 'i') : null;
@ -138,7 +122,7 @@ export default class MetricStore {
(card) =>
(this.filter.showMine
? card.owner ===
JSON.parse(localStorage.getItem('user')!).account.email
JSON.parse(localStorage.getItem('user')!).account.email
: true) &&
handleFilter(card, this.filter.type) &&
(!dbIds.length ||
@ -147,13 +131,13 @@ export default class MetricStore {
.some((id) => dbIds.includes(id))) &&
// @ts-ignore
(!filterRE ||
['name', 'owner'].some((key) => filterRE.test(card[key]))),
)
.sort((a, b) =>
this.sort.by === 'desc'
? b.lastModified - a.lastModified
: a.lastModified - b.lastModified,
['name', 'owner'].some((key) => filterRE.test(card[key])))
);
// .sort((a, b) =>
// this.sort.by === 'desc'
// ? b.lastModified - a.lastModified
// : a.lastModified - b.lastModified
// );
}
// State Actions
@ -182,6 +166,7 @@ export default class MetricStore {
}
updateKey(key: string, value: any) {
console.log('key', key, value);
// @ts-ignore
this[key] = value;
@ -207,7 +192,7 @@ export default class MetricStore {
this.instance.series[i].filter.eventsOrderSupport = [
'then',
'or',
'and',
'and'
];
});
if (type === HEATMAP && 'series' in obj) {
@ -254,7 +239,7 @@ export default class MetricStore {
namesMap: {},
avg: 0,
percentiles: [],
values: [],
values: []
};
const obj: any = { metricType: value, data: defaultData };
obj.series = this.instance.series;
@ -311,7 +296,7 @@ export default class MetricStore {
if (obj.series[0] && obj.series[0].filter.filters.length < 1) {
obj.series[0].filter.addFilter({
...clickmapFilter,
value: [''],
value: ['']
});
}
}
@ -341,7 +326,7 @@ export default class MetricStore {
updateInList(metric: Widget) {
// @ts-ignore
const index = this.metrics.findIndex(
(m: Widget) => m[Widget.ID_KEY] === metric[Widget.ID_KEY],
(m: Widget) => m[Widget.ID_KEY] === metric[Widget.ID_KEY]
);
if (index >= 0) {
this.metrics[index] = metric;
@ -358,12 +343,6 @@ export default class MetricStore {
this.metrics = this.metrics.filter((m) => m[Widget.ID_KEY] !== id);
}
get paginatedList(): Widget[] {
const start = (this.page - 1) * this.pageSize;
const end = start + this.pageSize;
return this.metrics.slice(start, end);
}
// API Communication
async save(metric: Widget): Promise<Widget> {
this.isSaving = true;
@ -396,16 +375,27 @@ export default class MetricStore {
this.metrics = metrics;
}
fetchList() {
async fetchList() {
this.setLoading(true);
return metricService
.getMetrics()
.then((metrics: any[]) => {
this.setMetrics(metrics.map((m) => new Widget().fromJson(m)));
})
.finally(() => {
this.setLoading(false);
});
try {
const resp = await metricService
.getMetricsPaginated({
page: this.page,
limit: this.pageSize,
sort: {
field: this.sort.field,
order: this.sort.order === 'ascend' ? 'asc' : 'desc'
},
filter: {
query: this.filter.query,
type: this.filter.type === 'all' ? '' : this.filter.type,
}
});
this.total = resp.total;
this.setMetrics(resp.list.map((m) => new Widget().fromJson(m)));
} finally {
this.setLoading(false);
}
}
fetch(id: string, period?: any) {

View file

@ -24,6 +24,17 @@ export default class MetricService {
.then((response: { data: any }) => response.data || []);
}
/**
* Get all metrics paginated.
* @returns {Promise<any>}
*/
getMetricsPaginated(params: any): Promise<any> {
return this.client
.post('/cards/search', params)
.then((response: { json: () => any }) => response.json())
.then((response: { data: any }) => response.data || []);
}
/**
* Get a metric by metricId.
* @param metricId