FILE 1 (the one that needs to be modified)
import {
Filter,
Grid,
Table,
TableAction,
TableColumnType,
Text,
Button,
} from '@trustly/backoffice-components';
import type { FilterConfig, FilterValue } from '@trustly/backoffice-components';
import React, { useMemo, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { startOfDay, endOfDay, formatISO } from 'date-fns';
import { useAppQuery } from '../../shared/useQuery';
import { SearchField } from '../SearchField';
const PAGE_SIZE = 30;
const COLUMN_STORAGE_KEY_PREFIX = 'ECOM_';
const MAX_COLUMNS = 7;
export interface DataTableProps<R extends object> {
header?: string;
columns: TableColumnType<R>[];
apiPath: string;
dataKey: string;
initialFilterConfig?: FilterConfig[];
onRowClick?: ({ row }: { row: R }) => void;
actions?: TableAction<R>[];
onActionClick?: ({ row, id }: { row: R; id: string }) => void;
columnSelector?: boolean;
isLoading?: boolean;
showSearchField?: boolean;
hasPagination?: boolean;
flattenProps?: string[];
}
export function DataTable<R extends object>(props: DataTableProps<R>) {
return <PageTable {...props} />;
}
type PageTableProps<R extends object> = DataTableProps<R>;
const buildFilterConfig = (
searchParams: URLSearchParams,
initialFilterConfig?: FilterConfig[],
): FilterConfig[] | undefined =>
// @ts-ignore TODO: Fix backoffice-components?
initialFilterConfig?.map((config) => {
const name = String(config.name);
const value = searchParams.get(name);
if (value && config.type === 'date-range') {
const { dateTimeFrom, dateTimeTo } = JSON.parse(value);
const dateValue = {
from: dateTimeFrom ? startOfDay(new Date(dateTimeFrom)) : config.value?.from,
to: dateTimeTo ? endOfDay(new Date(dateTimeTo)) : config.value?.to,
};
return { ...config, value: dateValue };
}
return {
...config,
value: value || config.value,
} as FilterConfig;
});
const buildFilterValue = (filterConfig: FilterConfig[]) =>
filterConfig?.reduce((prev, curr) => {
const { value, type } = curr;
if (type === 'date-range') {
const newValue = {
dateTimeFrom: value?.from ? formatISO(value?.from as Date) : undefined,
dateTimeTo: value?.to ? formatISO(value?.to as Date) : undefined,
};
return { ...prev, ...newValue };
}
return { ...prev, [curr.name as string]: value };
}, {}) as Record<string, FilterValue | undefined>;
function PageTable<RowItem extends object>({
columns,
apiPath,
dataKey,
initialFilterConfig,
onRowClick,
actions,
onActionClick,
columnSelector,
isLoading,
showSearchField,
hasPagination = true,
flattenProps,
}: PageTableProps<RowItem>) {
const [searchParams, setSearchParams] = useSearchParams();
const [orderNotFound, setOrderNotFound] = useState(false);
const [filterConfig, setFilterConfig] = useState<FilterConfig[]>(
buildFilterConfig(searchParams, initialFilterConfig) ?? [],
);
const [filterValue, setFilterValue] = useState<Record<string, FilterValue | undefined>>(
buildFilterValue(filterConfig) ?? {},
);
const initialPage = searchParams.get('page');
const searchTermParam = searchParams.get('searchTerm');
const [page, setPage] = useState(Number(initialPage) || 0);
const [searchTerm, setSearchTerm] = useState(searchTermParam || '');
const {
data,
isLoading: isFetching,
error,
} = useAppQuery<{ [key: string]: RowItem[] }>({
queryKey: [apiPath, page, filterValue, searchTerm],
apiPath: apiPath,
params: {
...filterValue,
skip: page * PAGE_SIZE,
count: PAGE_SIZE,
from: (filterValue.dateTimeFrom as string) || undefined,
to: (filterValue.dateTimeTo as string) || undefined,
searchTerm,
},
options: { keepPreviousData: true, staleTime: 5000 },
});
const rowMapper: RowItem[] = useMemo(() => {
if (!data) {
return [];
}
if (!flattenProps?.length) {
return data[dataKey];
}
let flattenedData: RowItem[] = [];
const rowData = [...data[dataKey]];
rowData.forEach((rowItem) => {
flattenProps.forEach((prop) => {
const flattenPropsData = (rowItem as any)[prop];
flattenedData = [...flattenedData, { ...rowItem, ...flattenPropsData }];
});
});
return flattenedData;
}, [data, dataKey, flattenProps]);
const handlePageChange = ({ page }: { page: number }) => {
searchParams.set('page', String(page));
setSearchParams(searchParams);
setPage(page);
};
const handleFilterChange = ({
config,
values,
}: {
config: FilterConfig[];
values: Record<string, FilterValue>;
}) => {
const value = {
...values,
dateTimeFrom: values.dateFrom ? formatISO(startOfDay(values.dateFrom as Date)) : undefined,
dateTimeTo: values.dateTo ? formatISO(endOfDay(values.dateTo as Date)) : undefined,
};
setFilterConfig(config);
setFilterValue(value);
setPage(0);
filterConfig.forEach((config) => {
const name = String(config.name);
if (config.type === 'date-range') {
const { dateTimeFrom, dateTimeTo } = value;
const dateFilterValues = { dateTimeFrom, dateTimeTo };
dateTimeFrom || dateTimeTo
? searchParams.set(name, JSON.stringify(dateFilterValues))
: searchParams.delete(name);
setSearchParams(searchParams);
} else {
values[name] ? searchParams.set(name, String(values[name])) : searchParams.delete(name);
setSearchParams(searchParams);
}
});
};
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' });
const getQueryParamsValuesAndString = (localFilters, localPage, forSaving) => {
const newQueryValues = {};
const queryParamParts = [];
if (localPage !== undefined && localPage !== 0) {
queryParamParts.push(`page=${localPage}`);
}
localFilters.forEach(({ name, value }) => {
const originalValue = value;
const isDate = value instanceof Date;
if (isDate) {
value = formatLocalDateTime(value, "yyyy-MM-dd'T'HH:mm':00.000'xxx");
}
newQueryValues[name] = value;
if (value) {
let valueString = Array.isArray(value) ? value.join(',') : value;
if (valueString && (!forSaving || (name !== 'dateFrom' && name !== 'dateTo'))) {
if (isDate) {
valueString = formatLocalDateTime(originalValue, 'yyyy-MM-dd-HH-mm');
}
queryParamParts.push(`${name}=${encodeURIComponent(valueString)}`);
}
}
});
return [newQueryValues, `${queryParamParts.join('&')}`];
};
const [selectedPersistedFilter, setSelectedPersistedFilter] = useState('');
const onFilterSaveClicked = () => {
const query = getQueryParamsValuesAndString(
{ name: '', type: '', label: '', value: [] },
undefined,
true,
)[1];
let defaultValue = selectedPersistedFilter || ''; // fix
if (!defaultValue) {
defaultValue = query.replaceAll('&', '; ');
}
const name = prompt('Name of filter', defaultValue);
if (name) {
if (window.localStorage) {
const storageValue = window.localStorage.getItem('filters') || '{}';
const existingFilters = JSON.parse(storageValue);
existingFilters[name] = query;
window.localStorage.setItem('filters', JSON.stringify(existingFilters));
setSelectedPersistedFilter(name);
}
}
};
useEffect(() => {
scrollToTop();
}, [page]);
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<>
<Grid container alignItems="flex-end" columnSpacing={2}>
{showSearchField && (
<Grid item>
<SearchField
value={searchTerm}
onSearch={() => setOrderNotFound(false)}
onUpdateValue={(searchTerm) => {
setSearchTerm(searchTerm);
searchTerm
? searchParams.set('searchTerm', searchTerm)
: searchParams.delete('searchTerm');
setSearchParams(searchParams);
}}
/>
</Grid>
)}
{initialFilterConfig && (
<Grid item>
<Filter
config={filterConfig}
onChange={handleFilterChange}
returnTypeFormatting={{ flat: true }}
/>
</Grid>
)}
</Grid>
<Grid item>
<Button onClick={() => onFilterSaveClicked()} type="primary" size="small" icon="star">
Save filter
</Button>
</Grid>
{orderNotFound ? (
<>
<br />
<Text color="medium">Order not found</Text>
</>
) : (
<Table
columns={columns}
rows={rowMapper}
loading={isLoading || isFetching}
onRowClick={onRowClick}
pagination={
hasPagination
? {
page,
pageSize: PAGE_SIZE,
onPageChange: handlePageChange,
}
: undefined
}
actions={actions}
onActionClick={onActionClick}
columnSelector={columnSelector}
columnStorageKey={COLUMN_STORAGE_KEY_PREFIX + apiPath}
maxColumns={MAX_COLUMNS}
/>
)}
</>
);
}
FILE 2: the original
import React, { useEffect, useState } from 'react';
import {
Filter,
Header,
PageHead,
Table,
Dialog,
Text,
Button,
useDebounce,
Grid,
Tag,
} from '@trustly/backoffice-components';
import { useNotifications } from '../../contexts/NotificationsContext';
import { PAGE_SIZE } from '../../contexts/actions';
import { useHistory } from 'react-router-dom';
import { formatLocalDateTime, parseDate } from '../../utils/dateTimeHelpers';
import NotificationTag from '../NotificationTag';
import { mapValues2LabelAndValue } from '../../utils/mapValueHelper';
import Menu from '../Menu';
import ExportToCSV from '../ExportToCSV';
import { getQueryParams } from '../../utils/queryParams';
import { parse } from 'query-string';
import { addDays } from 'date-fns';
export const debounceTime = 1000;
const numberOfSkeletonRows = 3;
const getRequestParameterValue = (queryParams, key, asArray, asDate) => {
const stringValue = queryParams[key];
if (!stringValue) {
return undefined;
}
let values;
if (asArray) {
values = stringValue.split(',');
} else {
values = [stringValue];
}
if (asDate) {
values = values.map((v) => parseDate(v));
}
return asArray ? values : values[0];
};
const columns = [
{
id: 'createdAt',
label: 'Created',
},
{
id: 'orderId',
label: 'Order ID',
},
{
id: 'processingAccountName',
label: 'Processing Account',
},
{
id: 'method',
label: 'Method',
},
{
id: 'lastTryAt',
label: 'Last Try',
},
{
id: 'state',
label: 'Status',
},
];
const createFilterConfig = () => {
const queryParams = getQueryParams();
return [
{
name: 'searchTerm',
type: 'text',
label: 'Search',
value: getRequestParameterValue(queryParams, 'searchTerm', false) || null,
},
{
name: 'states',
type: 'select',
label: 'Status',
appearance: 'dropdown',
multiple: false,
value: getRequestParameterValue(queryParams, 'states', false) || null,
options: [],
},
{
name: 'methods',
type: 'select',
label: 'Methods',
appearance: 'dropdown',
multiple: true,
value: getRequestParameterValue(queryParams, 'methods', true) || [],
options: [],
},
{
name: 'processingAccountNames',
type: 'select',
label: 'Processing Account',
appearance: 'dropdown',
value:
getRequestParameterValue(
queryParams,
'processingAccountNames',
false
) || null,
multiple: false,
options: [],
},
{
name: 'dateFrom',
type: 'datetime',
label: 'Start Date',
value:
getRequestParameterValue(queryParams, 'dateFrom', false, true) ||
new Date(new Date().setHours(0, 0, 0, 0)),
},
{
name: 'dateTo',
type: 'datetime',
label: 'End Date',
value:
getRequestParameterValue(queryParams, 'dateTo', false, true) ||
addDays(new Date().setHours(0, 0, 0, 0), 1),
},
];
};
const populateFilterOptions = (
filters,
states,
methods,
processingAccountNames
) =>
filters.map((filter) => {
if (filter.name === 'states') {
return {
...filter,
options: [
{ value: '', label: '- [All] -' },
...states.map(({ state }) => ({
value: state,
label: state,
})),
{ value: 'delayed', label: 'delayed' },
{ value: 'stopped', label: 'stopped' },
],
};
} else if (filter.name === 'methods') {
return {
...filter,
options: [
...methods.map((method) => ({
value: method,
label: method,
})),
],
};
} else if (filter.name === 'processingAccountNames') {
return {
...filter,
options: [
{ value: '', label: '- [All] -' },
...mapValues2LabelAndValue(processingAccountNames),
],
};
}
return filter;
});
const getQueryParamsValuesAndString = (localFilters, localPage, forSaving) => {
const newQueryValues = {};
const queryParamParts = [];
if (localPage !== undefined && localPage !== 0) {
queryParamParts.push(`page=${localPage}`);
}
localFilters.forEach(({ name, value }) => {
const originalValue = value;
const isDate = value instanceof Date;
if (isDate) {
value = formatLocalDateTime(value, "yyyy-MM-dd'T'HH:mm':00.000'xxx");
}
newQueryValues[name] = value;
if (value) {
let valueString = Array.isArray(value) ? value.join(',') : value;
if (
valueString &&
(!forSaving || (name !== 'dateFrom' && name !== 'dateTo'))
) {
if (isDate) {
valueString = formatLocalDateTime(originalValue, 'yyyy-MM-dd-HH-mm');
}
queryParamParts.push(`${name}=${encodeURIComponent(valueString)}`);
}
}
});
return [newQueryValues, `${queryParamParts.join('&')}`];
};
const filterNotSet = 'not-set';
const NotificationsTable = () => {
const history = useHistory();
const { notificationsState, actions } = useNotifications();
const { states, methods, processingAccountNames } = notificationsState;
const [page, setPage] = useState(() =>
Number.parseInt(getQueryParams().page || '0')
);
const [openResend, setOpenResend] = useState(false);
const [openStop, setOpenStop] = useState(false);
const [throttled, setThrottled] = useState(false);
const [lastActionNotificationId, setLastActionNotificationId] =
useState(undefined);
const [filters, setFilters] = useState(() => createFilterConfig());
const [filterString, setFilterString] = useState(filterNotSet);
const [selectedPersistedFilter, setSelectedPersistedFilter] = useState('');
const goToNotification = (notificationId) => {
const valuesAndString = getQueryParamsValuesAndString(filters, page);
const newQueryString = valuesAndString[1];
if (newQueryString) {
history.push({
pathname: notificationId,
search: newQueryString,
});
} else {
history.push(notificationId);
}
};
const mapTableRows = (rows) => {
return rows.map((row) => {
return {
...row,
__original: row,
orderId: row.orderId,
state: <NotificationTag {...row} />,
createdAt: formatLocalDateTime(row.createdAt),
lastTryAt: formatLocalDateTime(row.lastTryAt),
};
});
};
const debouncedFilters = useDebounce(filters, debounceTime);
useEffect(() => {
if (debouncedFilters) {
setThrottled(false);
const valuesAndString = getQueryParamsValuesAndString(filters, page);
const newQueryValues = valuesAndString[0];
const newQueryString = valuesAndString[1];
if (newQueryString !== filterString) {
if (newQueryString || (filterString && filterString !== filterNotSet)) {
// We push to history if new is a filter,
// or if we previously had a filter and it's not the initial filter.
history.push({ search: newQueryString });
}
actions.loadNotifications(newQueryValues, page);
// We only load, and push to history, if anything has actually changed
setFilterString(newQueryString);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedFilters]);
useEffect(() => {
setThrottled(true);
}, [filters]);
const onFilterChange = ({ config }) => {
const hasSearchTermSet =
config.filter((f) => f.name === 'searchTerm' && !!f.value).length > 0;
if (hasSearchTermSet) {
for (const c of config) {
if (c.name === 'dateFrom' || c.name === 'dateTo') {
c.value = undefined;
}
}
}
setFilters(config);
setPage(0);
};
const onPageChange = ({ page: newPage }) => {
const valuesAndString = getQueryParamsValuesAndString(filters, newPage);
const newQueryString = valuesAndString[1];
if (newQueryString !== filterString) {
setFilterString(newQueryString);
history.push({ search: newQueryString });
actions.loadNotifications(valuesAndString[0], newPage);
}
setPage(newPage);
};
const onActionClick = ({ id, row }) => {
setLastActionNotificationId(row.notificationId);
switch (id) {
case 'resend':
setOpenResend(true);
break;
case 'stop':
setOpenStop(true);
break;
case 'details':
goToNotification(row.notificationId);
break;
}
};
const handleClose = () => {
setOpenResend(false);
setOpenStop(false);
setLastActionNotificationId(undefined);
};
const handleResend = () => {
if (lastActionNotificationId) {
actions.resendNotification(lastActionNotificationId);
}
handleClose();
};
const handleStop = () => {
if (lastActionNotificationId) {
actions.stopNotification(lastActionNotificationId);
}
handleClose();
};
const onFilterSaveClicked = () => {
const query = getQueryParamsValuesAndString(filters, undefined, true)[1];
let defaultValue = selectedPersistedFilter || '';
if (!defaultValue) {
defaultValue = query.replaceAll('&', '; ');
}
let name = prompt('Name of filter', defaultValue);
if (name) {
if (window.localStorage) {
const storageValue = window.localStorage.getItem('filters') || '{}';
const existingFilters = JSON.parse(storageValue);
existingFilters[name] = query;
window.localStorage.setItem('filters', JSON.stringify(existingFilters));
setSelectedPersistedFilter(name);
}
}
};
const onFilterDeleteClicked = () => {
if (selectedPersistedFilter) {
if (confirm(`Do you really want to delete ${selectedPersistedFilter}?`)) {
const storageValue = window.localStorage.getItem('filters') || '{}';
const existingFilters = JSON.parse(storageValue);
if (existingFilters[selectedPersistedFilter]) {
delete existingFilters[selectedPersistedFilter];
}
window.localStorage.setItem('filters', JSON.stringify(existingFilters));
setSelectedPersistedFilter(undefined);
}
}
};
const onFilterLoadClicked = (key) => {
const storageValue = window.localStorage.getItem('filters') || '{}';
const existingFilters = JSON.parse(storageValue);
const filterQueryParams = existingFilters[key];
if (filterQueryParams) {
const queryParams = parse(filterQueryParams);
const newFilters = [...filters];
for (const filter of newFilters) {
const isArray = Array.isArray(filter.value);
const queryValue = getRequestParameterValue(
queryParams,
filter.name,
isArray
);
filter.value = isArray ? queryValue || [] : queryValue || null;
}
setSelectedPersistedFilter(key);
setFilters(newFilters);
}
};
const persistedFilterLinks = [];
const persistedFiltersString = window.localStorage.getItem('filters');
if (persistedFiltersString) {
const persistedFilters = JSON.parse(persistedFiltersString);
for (const key in persistedFilters) {
// eslint-disable-next-line no-prototype-builtins
if (persistedFilters.hasOwnProperty(key)) {
persistedFilterLinks.push(
<Button
key={key}
onClick={() => onFilterLoadClicked(key)}
type={selectedPersistedFilter === key ? 'linkSecondary' : 'link'}
size="small"
>
{key}
</Button>
);
}
}
}
useEffect(() => {
const filtersWithOptions = populateFilterOptions(
filters,
states,
methods,
processingAccountNames
);
setFilters(filtersWithOptions);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [states, methods, processingAccountNames, populateFilterOptions]);
useEffect(() => {
actions.loadMethods();
actions.loadProcessingAccountNames();
actions.loadStates();
}, [actions]);
const filterClearOnClick = () => {
const copiedFilters = [...filters];
for (const filter of copiedFilters) {
if (Array.isArray(filter.value)) {
filter.value = [];
} else {
filter.value = null;
}
}
setSelectedPersistedFilter('');
setFilters(copiedFilters);
};
const filterSetCount = filters.filter((f) =>
Array.isArray(f.value) ? f.value.length > 0 : !!f.value
).length;
const tempFilterParams = getQueryParamsValuesAndString(filters, page)[1];
return (
<div>
<PageHead>
<Grid container spacing={3} alignItems={'center'}>
<Grid item>
<Header size="large">Notifications</Header>
</Grid>
<Grid item>
{filterSetCount > 0 ? (
<Tag border={true} type="success" label={'' + filterSetCount} />
) : (
<></>
)}
</Grid>
<Grid item>
{filterSetCount > 0 ? (
<Button
onClick={filterClearOnClick}
type={'link'}
icon={'delete'}
>
Clear filters
</Button>
) : (
<></>
)}
</Grid>
<Grid item>
{notificationsState.isLoading ? <span>Loading ...</span> : <></>}
{!notificationsState.isLoading && throttled ? (
<span>Waiting ...</span>
) : (
<></>
)}
</Grid>
</Grid>
<Filter config={filters} onChange={onFilterChange} />
<br />
{persistedFilterLinks}
<Button
onClick={() => onFilterSaveClicked()}
type="primary"
size="small"
icon="star"
>
Save filter
</Button>
<span style={{ marginRight: '1em' }} />
{selectedPersistedFilter ? (
<Button
onClick={() => onFilterDeleteClicked()}
type="danger"
size="small"
icon="delete"
>
Delete
</Button>
) : (
<></>
)}
</PageHead>
<ExportToCSV filters={filters} />
<Menu />
<Table
columns={columns}
loading={notificationsState.isLoading}
numberOfSkeletonRows={numberOfSkeletonRows}
rows={mapTableRows(notificationsState.notifications)}
pagination={{
page,
onPageChange: onPageChange,
pageSize: PAGE_SIZE,
}}
actions={[
{
id: 'resend',
text: 'Resend',
type: 'icon',
icon: 'refresh',
filter: (row) =>
(row.__original.state || '').toLowerCase() !== 'verified' &&
row.__original.stopped !== true,
},
{
id: 'stop',
text: 'Stop',
type: 'icon',
icon: 'close',
filter: (row) =>
(row.__original.state || '').toLowerCase() !== 'verified' &&
(row.__original.state || '').toLowerCase() != 'abandoned' &&
row.__original.stopped !== true,
},
{
id: 'details',
text: 'Details',
type: 'icon',
icon: 'view',
href: (o) =>
'notifications/' +
o.notificationId +
(tempFilterParams ? '?' + tempFilterParams : ''),
},
]}
onActionClick={onActionClick}
/>
<Dialog
title="Are you sure you want to resend?"
open={openResend}
icon="alert-triangle"
onClose={handleClose}
actions={
<>
<Button type="danger" onClick={handleResend}>
Yes, resend
</Button>
<Button type="linkSecondary" onClick={handleClose}>
No, thanks
</Button>
</>
}
>
<Text>Doing this action can not be undone.</Text>
</Dialog>
<Dialog
title="Are you sure you want to stop?"
open={openStop}
icon="alert-triangle"
onClose={handleClose}
actions={
<>
<Button type="danger" onClick={handleStop}>
Yes, stop
</Button>
<Button type="linkSecondary" onClick={handleClose}>
No, thanks
</Button>
</>
}
>
<Text>Doing this action can not be undone.</Text>
</Dialog>
</div>
);
};
export default NotificationsTable;