Untitled
unknown
plain_text
a year ago
26 kB
1
Indexable
Never
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;