24
common/resources/client/datatable/column-config.tsx
Executable file
24
common/resources/client/datatable/column-config.tsx
Executable file
@@ -0,0 +1,24 @@
|
||||
import React, {ReactElement, ReactNode} from 'react';
|
||||
import {TableDataItem} from '../ui/tables/types/table-data-item';
|
||||
|
||||
export interface RowContext {
|
||||
isHovered: boolean;
|
||||
index: number;
|
||||
isPlaceholder?: boolean;
|
||||
}
|
||||
|
||||
export interface ColumnConfig<T extends TableDataItem> {
|
||||
key: string;
|
||||
header: () => ReactElement;
|
||||
hideHeader?: boolean;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
padding?: string;
|
||||
className?: string;
|
||||
body: (item: T, rowContext: RowContext) => ReactNode;
|
||||
allowsSorting?: boolean;
|
||||
sortingKey?: string;
|
||||
width?: string;
|
||||
maxWidth?: string;
|
||||
minWidth?: string;
|
||||
visibleInMode?: 'compact' | 'regular' | 'all';
|
||||
}
|
||||
13
common/resources/client/datatable/column-templates/boolean-indicator.tsx
Executable file
13
common/resources/client/datatable/column-templates/boolean-indicator.tsx
Executable file
@@ -0,0 +1,13 @@
|
||||
import {CheckIcon} from '@common/icons/material/Check';
|
||||
import {CloseIcon} from '@common/icons/material/Close';
|
||||
import React from 'react';
|
||||
|
||||
interface BooleanIndicatorProps {
|
||||
value: boolean;
|
||||
}
|
||||
export function BooleanIndicator({value}: BooleanIndicatorProps) {
|
||||
if (value) {
|
||||
return <CheckIcon className="icon-md text-positive" />;
|
||||
}
|
||||
return <CloseIcon className="icon-md text-danger" />;
|
||||
}
|
||||
60
common/resources/client/datatable/column-templates/name-with-avatar.tsx
Executable file
60
common/resources/client/datatable/column-templates/name-with-avatar.tsx
Executable file
@@ -0,0 +1,60 @@
|
||||
import React, {ReactNode} from 'react';
|
||||
import {Avatar, AvatarProps} from '../../ui/images/avatar';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
image?: string;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
labelClassName?: string;
|
||||
avatarSize?: AvatarProps['size'];
|
||||
}
|
||||
export function NameWithAvatar({
|
||||
image,
|
||||
label,
|
||||
description,
|
||||
labelClassName,
|
||||
avatarSize = 'md',
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-12">
|
||||
{image && (
|
||||
<Avatar size={avatarSize} className="flex-shrink-0" src={image} />
|
||||
)}
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<div
|
||||
className={clsx(labelClassName, 'overflow-hidden overflow-ellipsis')}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{description && (
|
||||
<div className="overflow-hidden overflow-ellipsis text-xs text-muted">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NameWithAvatarPlaceholder({
|
||||
labelClassName,
|
||||
showDescription,
|
||||
}: Partial<Props> & {
|
||||
showDescription?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full max-w-4/5 items-center gap-12">
|
||||
<Skeleton size="w-40 h-40 md:w-32 md:h-32" variant="rect" />
|
||||
<div className="flex-auto">
|
||||
<div className={clsx(labelClassName, 'leading-3')}>
|
||||
<Skeleton />
|
||||
</div>
|
||||
{showDescription && (
|
||||
<div className="mt-4 leading-3 text-muted">{<Skeleton />}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
common/resources/client/datatable/csv-export/csv-export-info-dialog.tsx
Executable file
30
common/resources/client/datatable/csv-export/csv-export-info-dialog.tsx
Executable file
@@ -0,0 +1,30 @@
|
||||
import {DialogBody} from '../../ui/overlays/dialog/dialog-body';
|
||||
import {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';
|
||||
import {DialogHeader} from '../../ui/overlays/dialog/dialog-header';
|
||||
import {useDialogContext} from '../../ui/overlays/dialog/dialog-context';
|
||||
import {Dialog} from '../../ui/overlays/dialog/dialog';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
|
||||
export function CsvExportInfoDialog() {
|
||||
const {close} = useDialogContext();
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogHeader>
|
||||
<Trans message="Csv export" />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Trans
|
||||
message="Your request is being processed. We'll email you when the report is ready to download. In
|
||||
certain cases, it might take a little longer, depending on the number of items beings
|
||||
exported and the volume of activity."
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="flat" color="primary" onClick={close}>
|
||||
<Trans message="Got it" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {IconButton} from '../../ui/buttons/icon-button';
|
||||
import {FileDownloadIcon} from '../../icons/material/FileDownload';
|
||||
import React, {Fragment, useState} from 'react';
|
||||
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
|
||||
import {ExportCsvPayload, useExportCsv} from '../requests/use-export-csv';
|
||||
import {downloadFileFromUrl} from '../../uploads/utils/download-file-from-url';
|
||||
import {CsvExportInfoDialog} from './csv-export-info-dialog';
|
||||
|
||||
interface DataTableExportCsvButtonProps {
|
||||
endpoint: string;
|
||||
payload?: ExportCsvPayload;
|
||||
}
|
||||
export function DataTableExportCsvButton({
|
||||
endpoint,
|
||||
payload,
|
||||
}: DataTableExportCsvButtonProps) {
|
||||
const [dialogIsOpen, setDialogIsOpen] = useState(false);
|
||||
const exportCsv = useExportCsv(endpoint);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<IconButton
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
disabled={exportCsv.isPending}
|
||||
onClick={() => {
|
||||
exportCsv.mutate(payload, {
|
||||
onSuccess: response => {
|
||||
if (response.downloadPath) {
|
||||
downloadFileFromUrl(response.downloadPath);
|
||||
} else {
|
||||
setDialogIsOpen(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FileDownloadIcon />
|
||||
</IconButton>
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
isOpen={dialogIsOpen}
|
||||
onOpenChange={setDialogIsOpen}
|
||||
>
|
||||
<CsvExportInfoDialog />
|
||||
</DialogTrigger>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
5
common/resources/client/datatable/csv-export/export-csv-icon.tsx
Executable file
5
common/resources/client/datatable/csv-export/export-csv-icon.tsx
Executable file
@@ -0,0 +1,5 @@
|
||||
import {createSvgIcon} from '../../icons/create-svg-icon';
|
||||
|
||||
export const ExportCsvIcon = createSvgIcon(
|
||||
<path d="M 7 2 C 5.895 2 5 2.895 5 4 L 5 9 L 4 9 C 2.895 9 2 9.895 2 11 L 2 16 C 2 17.105 2.895 18 4 18 L 5 18 L 5 20 C 5 21.105 5.895 22 7 22 L 15.171875 22 L 13.171875 20 L 7 20 L 7 18 L 17 18 L 17 16 C 17 14.895 17.895 14 19 14 L 21 14 L 21 7 L 16 2 L 7 2 z M 7 4 L 15 4 L 15 8 L 19 8 L 19 9 L 7 9 L 7 4 z M 6 11 C 7.105 11 8 11.895 8 13 L 7 13 C 7 12.449 6.551 12 6 12 C 5.449 12 5 12.449 5 13 L 5 14 C 5 14.551 5.449 15 6 15 C 6.551 15 7 14.551 7 14 L 8 14 C 8 15.105 7.105 16 6 16 C 4.895 16 4 15.105 4 14 L 4 13 C 4 11.895 4.895 11 6 11 z M 10.644531 11 C 12.067531 11.041 12.154297 12.282906 12.154297 12.503906 L 11.1875 12.503906 C 11.1875 12.400906 11.204906 11.806641 10.628906 11.806641 C 10.453906 11.806641 10.059844 11.884188 10.089844 12.367188 C 10.118844 12.810188 10.703547 13.019406 10.810547 13.066406 C 11.034547 13.148406 12.141391 13.642391 12.150391 14.650391 C 12.152391 14.864391 12.097062 15.985 10.664062 16 C 9.1050625 16.017 9 14.675438 9 14.398438 L 9.9746094 14.398438 C 9.9746094 14.545438 9.9870625 15.256172 10.664062 15.201172 C 11.071063 15.167172 11.159828 14.87425 11.173828 14.65625 C 11.196828 14.28925 10.846563 14.068625 10.476562 13.890625 C 9.9565625 13.640625 9.1341406 13.333375 9.1191406 12.359375 C 9.1061406 11.482375 9.7505312 10.975 10.644531 11 z M 13 11 L 14.052734 11 L 14.992188 14.646484 L 15.9375 11 L 17 11 L 15.646484 16 L 14.345703 16 L 13 11 z M 19 16 L 19 20 L 16 20 L 20 24 L 24 20 L 21 20 L 21 16 L 19 16 z"></path>
|
||||
);
|
||||
67
common/resources/client/datatable/data-table-add-item-button.tsx
Executable file
67
common/resources/client/datatable/data-table-add-item-button.tsx
Executable file
@@ -0,0 +1,67 @@
|
||||
import {AddIcon} from '../icons/material/Add';
|
||||
import {Button} from '../ui/buttons/button';
|
||||
import React, {ReactElement, ReactNode} from 'react';
|
||||
import {useIsMobileMediaQuery} from '../utils/hooks/is-mobile-media-query';
|
||||
import {IconButton} from '../ui/buttons/icon-button';
|
||||
import {To} from 'react-router-dom';
|
||||
import {ButtonBaseProps} from '../ui/buttons/button-base';
|
||||
|
||||
export interface DataTableAddItemButtonProps {
|
||||
children: ReactNode;
|
||||
to?: To;
|
||||
href?: string;
|
||||
download?: boolean | string;
|
||||
elementType?: ButtonBaseProps['elementType'];
|
||||
onClick?: ButtonBaseProps['onClick'];
|
||||
icon?: ReactElement;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export const DataTableAddItemButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
DataTableAddItemButtonProps
|
||||
>(
|
||||
(
|
||||
{children, to, elementType, onClick, href, download, icon, disabled},
|
||||
ref,
|
||||
) => {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<IconButton
|
||||
ref={ref}
|
||||
variant="flat"
|
||||
color="primary"
|
||||
className="flex-shrink-0"
|
||||
size="sm"
|
||||
to={to}
|
||||
href={href}
|
||||
download={download}
|
||||
elementType={elementType}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon || <AddIcon />}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
startIcon={icon || <AddIcon />}
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="sm"
|
||||
to={to}
|
||||
href={href}
|
||||
download={download}
|
||||
elementType={elementType}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
60
common/resources/client/datatable/data-table-header.tsx
Executable file
60
common/resources/client/datatable/data-table-header.tsx
Executable file
@@ -0,0 +1,60 @@
|
||||
import React, {ComponentPropsWithoutRef, ReactNode} from 'react';
|
||||
import {BackendFilter} from './filters/backend-filter';
|
||||
import {useTrans} from '../i18n/use-trans';
|
||||
import {TextField} from '../ui/forms/input-field/text-field/text-field';
|
||||
import {SearchIcon} from '../icons/material/Search';
|
||||
import {AddFilterButton} from './filters/add-filter-button';
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
interface Props {
|
||||
actions?: ReactNode;
|
||||
filters?: BackendFilter[];
|
||||
filtersLoading?: boolean;
|
||||
searchPlaceholder?: MessageDescriptor;
|
||||
searchValue?: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
}
|
||||
export function DataTableHeader({
|
||||
actions,
|
||||
filters,
|
||||
filtersLoading,
|
||||
searchPlaceholder = message('Type to search...'),
|
||||
searchValue = '',
|
||||
onSearchChange,
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
return (
|
||||
<HeaderLayout>
|
||||
<TextField
|
||||
size="sm"
|
||||
className="mr-auto min-w-180 max-w-440 flex-auto"
|
||||
inputWrapperClassName="mr-24 md:mr-0"
|
||||
placeholder={trans(searchPlaceholder)}
|
||||
startAdornment={<SearchIcon size="sm" />}
|
||||
value={searchValue}
|
||||
onChange={e => {
|
||||
onSearchChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{filters && (
|
||||
<AddFilterButton filters={filters} disabled={filtersLoading} />
|
||||
)}
|
||||
{actions}
|
||||
</HeaderLayout>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnimatedHeaderProps extends ComponentPropsWithoutRef<'div'> {
|
||||
children: ReactNode;
|
||||
}
|
||||
export function HeaderLayout({children, ...domProps}: AnimatedHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className="hidden-scrollbar relative mb-24 flex h-42 items-center gap-8 overflow-x-auto text-muted md:gap-12"
|
||||
{...domProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
common/resources/client/datatable/data-table-pagination-footer.tsx
Executable file
99
common/resources/client/datatable/data-table-pagination-footer.tsx
Executable file
@@ -0,0 +1,99 @@
|
||||
import {UseQueryResult} from '@tanstack/react-query';
|
||||
import {
|
||||
hasNextPage,
|
||||
LengthAwarePaginationResponse,
|
||||
PaginatedBackendResponse,
|
||||
} from '../http/backend-response/pagination-response';
|
||||
import {useNumberFormatter} from '../i18n/use-number-formatter';
|
||||
import {Select} from '../ui/forms/select/select';
|
||||
import {Trans} from '../i18n/trans';
|
||||
import {Item} from '../ui/forms/listbox/item';
|
||||
import {IconButton} from '../ui/buttons/icon-button';
|
||||
import {KeyboardArrowLeftIcon} from '../icons/material/KeyboardArrowLeft';
|
||||
import {KeyboardArrowRightIcon} from '../icons/material/KeyboardArrowRight';
|
||||
import React from 'react';
|
||||
import {useIsMobileMediaQuery} from '../utils/hooks/is-mobile-media-query';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const defaultPerPage = 15;
|
||||
const perPageOptions = [{key: 10}, {key: 15}, {key: 20}, {key: 50}, {key: 100}];
|
||||
|
||||
type DataTablePaginationFooterProps = {
|
||||
query: UseQueryResult<PaginatedBackendResponse<unknown>, unknown>;
|
||||
onPerPageChange?: (perPage: number) => void;
|
||||
onPageChange?: (page: number) => void;
|
||||
className?: string;
|
||||
};
|
||||
export function DataTablePaginationFooter({
|
||||
query,
|
||||
onPerPageChange,
|
||||
onPageChange,
|
||||
className,
|
||||
}: DataTablePaginationFooterProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const numberFormatter = useNumberFormatter();
|
||||
const pagination = query.data
|
||||
?.pagination as LengthAwarePaginationResponse<any>;
|
||||
|
||||
if (!pagination) return null;
|
||||
|
||||
const perPageSelect = onPerPageChange ? (
|
||||
<Select
|
||||
minWidth="min-w-auto"
|
||||
selectionMode="single"
|
||||
disabled={query.isLoading}
|
||||
labelPosition="side"
|
||||
size="xs"
|
||||
label={<Trans message="Items per page" />}
|
||||
selectedValue={pagination.per_page || defaultPerPage}
|
||||
onSelectionChange={value => onPerPageChange(value as number)}
|
||||
>
|
||||
{perPageOptions.map(option => (
|
||||
<Item key={option.key} value={option.key}>
|
||||
{option.key}
|
||||
</Item>
|
||||
))}
|
||||
</Select>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-54 select-none items-center justify-end gap-20 px-20',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!isMobile && perPageSelect}
|
||||
{pagination.from && pagination.to && 'total' in pagination ? (
|
||||
<div className="text-sm">
|
||||
<Trans
|
||||
message=":from - :to of :total"
|
||||
values={{
|
||||
from: pagination.from,
|
||||
to: pagination.to,
|
||||
total: numberFormatter.format(pagination.total),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-muted">
|
||||
<IconButton
|
||||
disabled={query.isFetching || pagination.current_page < 2}
|
||||
onClick={() => {
|
||||
onPageChange?.(pagination?.current_page - 1);
|
||||
}}
|
||||
>
|
||||
<KeyboardArrowLeftIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={query.isFetching || !hasNextPage(pagination)}
|
||||
onClick={() => {
|
||||
onPageChange?.(pagination?.current_page + 1);
|
||||
}}
|
||||
>
|
||||
<KeyboardArrowRightIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
common/resources/client/datatable/data-table.tsx
Executable file
185
common/resources/client/datatable/data-table.tsx
Executable file
@@ -0,0 +1,185 @@
|
||||
import React, {
|
||||
cloneElement,
|
||||
ComponentProps,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {TableDataItem} from '../ui/tables/types/table-data-item';
|
||||
import {BackendFilter} from './filters/backend-filter';
|
||||
import {MessageDescriptor} from '../i18n/message-descriptor';
|
||||
import {ColumnConfig} from './column-config';
|
||||
import {useTrans} from '../i18n/use-trans';
|
||||
import {useBackendFilterUrlParams} from './filters/backend-filter-url-params';
|
||||
import {
|
||||
GetDatatableDataParams,
|
||||
useDatatableData,
|
||||
} from './requests/paginated-resources';
|
||||
import {DataTableContext} from './page/data-table-context';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {ProgressBar} from '../ui/progress/progress-bar';
|
||||
import {Table, TableProps} from '../ui/tables/table';
|
||||
import {DataTablePaginationFooter} from './data-table-pagination-footer';
|
||||
import {DataTableHeader} from './data-table-header';
|
||||
import {FilterList} from './filters/filter-list/filter-list';
|
||||
import {SelectedStateDatatableHeader} from '@common/datatable/selected-state-datatable-header';
|
||||
import clsx from 'clsx';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
import {BackendFiltersUrlKey} from '@common/datatable/filters/backend-filters-url-key';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {FilterListSkeleton} from '@common/datatable/filters/filter-list/filter-list-skeleton';
|
||||
|
||||
export interface DataTableProps<T extends TableDataItem> {
|
||||
filters?: BackendFilter[];
|
||||
filtersLoading?: boolean;
|
||||
columns: ColumnConfig<T>[];
|
||||
searchPlaceholder?: MessageDescriptor;
|
||||
queryParams?: Record<string, string | number | undefined | null>;
|
||||
endpoint: string;
|
||||
resourceName?: ReactNode;
|
||||
emptyStateMessage: ReactElement<{isFiltering: boolean}>;
|
||||
actions?: ReactNode;
|
||||
enableSelection?: boolean;
|
||||
selectionStyle?: TableProps<T>['selectionStyle'];
|
||||
selectedActions?: ReactNode;
|
||||
onRowAction?: TableProps<T>['onAction'];
|
||||
tableDomProps?: ComponentProps<'table'>;
|
||||
children?: ReactNode;
|
||||
collapseTableOnMobile?: boolean;
|
||||
cellHeight?: string;
|
||||
}
|
||||
export function DataTable<T extends TableDataItem>({
|
||||
filters,
|
||||
filtersLoading,
|
||||
columns,
|
||||
searchPlaceholder,
|
||||
queryParams,
|
||||
endpoint,
|
||||
actions,
|
||||
selectedActions,
|
||||
emptyStateMessage,
|
||||
tableDomProps,
|
||||
onRowAction,
|
||||
enableSelection = true,
|
||||
selectionStyle = 'checkbox',
|
||||
children,
|
||||
cellHeight,
|
||||
collapseTableOnMobile = true,
|
||||
}: DataTableProps<T>) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const {trans} = useTrans();
|
||||
const {encodedFilters} = useBackendFilterUrlParams(filters);
|
||||
const [params, setParams] = useState<GetDatatableDataParams>({perPage: 15});
|
||||
const [selectedRows, setSelectedRows] = useState<(string | number)[]>([]);
|
||||
const query = useDatatableData<T>(
|
||||
endpoint,
|
||||
{
|
||||
...params,
|
||||
...queryParams,
|
||||
[BackendFiltersUrlKey]: encodedFilters,
|
||||
},
|
||||
undefined,
|
||||
() => setSelectedRows([]),
|
||||
);
|
||||
|
||||
const isFiltering = !!(params.query || params.filters || encodedFilters);
|
||||
const pagination = query.data?.pagination;
|
||||
|
||||
return (
|
||||
<DataTableContext.Provider
|
||||
value={{
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
endpoint,
|
||||
params,
|
||||
setParams,
|
||||
query,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{selectedRows.length ? (
|
||||
<SelectedStateDatatableHeader
|
||||
selectedItemsCount={selectedRows.length}
|
||||
actions={selectedActions}
|
||||
key="selected"
|
||||
/>
|
||||
) : (
|
||||
<DataTableHeader
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
searchValue={params.query}
|
||||
onSearchChange={query => setParams({...params, query})}
|
||||
actions={actions}
|
||||
filters={filters}
|
||||
filtersLoading={filtersLoading}
|
||||
key="default"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{filters && (
|
||||
<div className="mb-14">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{filtersLoading && encodedFilters ? (
|
||||
<FilterListSkeleton />
|
||||
) : (
|
||||
<m.div key="filter-list" {...opacityAnimation}>
|
||||
<FilterList filters={filters} />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'relative rounded-panel',
|
||||
(!isMobile || !collapseTableOnMobile) && 'border',
|
||||
)}
|
||||
>
|
||||
{query.isFetching && (
|
||||
<ProgressBar
|
||||
isIndeterminate
|
||||
className="absolute left-0 top-0 z-10 w-full"
|
||||
aria-label={trans({message: 'Loading'})}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative overflow-x-auto md:overflow-hidden">
|
||||
<Table
|
||||
{...tableDomProps}
|
||||
columns={columns}
|
||||
data={pagination?.data || []}
|
||||
sortDescriptor={params}
|
||||
onSortChange={descriptor => {
|
||||
setParams({...params, ...descriptor});
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
enableSelection={enableSelection}
|
||||
selectionStyle={selectionStyle}
|
||||
onSelectionChange={setSelectedRows}
|
||||
onAction={onRowAction}
|
||||
collapseOnMobile={collapseTableOnMobile}
|
||||
cellHeight={cellHeight}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(query.isFetched || query.isPlaceholderData) &&
|
||||
!pagination?.data.length ? (
|
||||
<div className="pt-50">
|
||||
{cloneElement(emptyStateMessage, {
|
||||
isFiltering,
|
||||
})}
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
<DataTablePaginationFooter
|
||||
query={query}
|
||||
onPageChange={page => setParams({...params, page})}
|
||||
onPerPageChange={perPage => setParams({...params, perPage})}
|
||||
/>
|
||||
</div>
|
||||
</DataTableContext.Provider>
|
||||
);
|
||||
}
|
||||
62
common/resources/client/datatable/filters/add-filter-button.tsx
Executable file
62
common/resources/client/datatable/filters/add-filter-button.tsx
Executable file
@@ -0,0 +1,62 @@
|
||||
import {Button, ButtonProps} from '../../ui/buttons/button';
|
||||
import {BackendFilter} from './backend-filter';
|
||||
import {FilterAltIcon} from '../../icons/material/FilterAlt';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {useIsMobileMediaQuery} from '../../utils/hooks/is-mobile-media-query';
|
||||
import {IconButton} from '../../ui/buttons/icon-button';
|
||||
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
|
||||
import {AddFilterDialog} from './add-filter-dialog';
|
||||
import {ReactElement} from 'react';
|
||||
|
||||
interface AddFilterButtonProps {
|
||||
filters: BackendFilter[];
|
||||
icon?: ReactElement;
|
||||
color?: ButtonProps['color'];
|
||||
variant?: ButtonProps['variant'];
|
||||
disabled?: boolean;
|
||||
size?: ButtonProps['size'];
|
||||
className?: string;
|
||||
}
|
||||
export function AddFilterButton({
|
||||
filters,
|
||||
icon = <FilterAltIcon />,
|
||||
color = 'primary',
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
disabled,
|
||||
className,
|
||||
}: AddFilterButtonProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
|
||||
const desktopButton = (
|
||||
<Button
|
||||
variant={variant}
|
||||
color={color}
|
||||
startIcon={icon}
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
className={className}
|
||||
>
|
||||
<Trans message="Filter" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const mobileButton = (
|
||||
<IconButton
|
||||
color={color}
|
||||
size="sm"
|
||||
variant={variant}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{icon}
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogTrigger type="popover">
|
||||
{isMobile ? mobileButton : desktopButton}
|
||||
<AddFilterDialog filters={filters} />
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
233
common/resources/client/datatable/filters/add-filter-dialog.tsx
Executable file
233
common/resources/client/datatable/filters/add-filter-dialog.tsx
Executable file
@@ -0,0 +1,233 @@
|
||||
import {Dialog} from '../../ui/overlays/dialog/dialog';
|
||||
import {
|
||||
BackendFilter,
|
||||
CustomFilterControl,
|
||||
DatePickerFilterControl,
|
||||
FilterBooleanToggleControl,
|
||||
FilterChipFieldControl,
|
||||
FilterControlType,
|
||||
FilterOperator,
|
||||
FilterSelectControl,
|
||||
FilterSelectModelControl,
|
||||
FilterTextInputControl,
|
||||
} from './backend-filter';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {useState} from 'react';
|
||||
import {DialogHeader} from '../../ui/overlays/dialog/dialog-header';
|
||||
import {DialogBody} from '../../ui/overlays/dialog/dialog-body';
|
||||
import {useBackendFilterUrlParams} from './backend-filter-url-params';
|
||||
import {useDialogContext} from '../../ui/overlays/dialog/dialog-context';
|
||||
import {Accordion, AccordionItem} from '../../ui/accordion/accordion';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Form} from '../../ui/forms/form';
|
||||
import {Checkbox} from '../../ui/forms/toggle/checkbox';
|
||||
import {SelectFilterPanel} from './panels/select-filter-panel';
|
||||
import {DateRangeFilterPanel} from './panels/date-range-filter-panel';
|
||||
import {NormalizedModelFilterPanel} from './panels/normalized-model-filter-panel';
|
||||
import {InputFilterPanel} from './panels/input-filter-panel';
|
||||
import {BooleanFilterPanel} from './panels/boolean-filter-panel';
|
||||
import clsx from 'clsx';
|
||||
import {ChipFieldFilterPanel} from '@common/datatable/filters/panels/chip-field-filter-panel';
|
||||
|
||||
export interface FilterItemFormValue<T = any> {
|
||||
value: T;
|
||||
operator?: FilterOperator;
|
||||
}
|
||||
|
||||
interface AddFilterDialogProps {
|
||||
filters: BackendFilter[];
|
||||
}
|
||||
export function AddFilterDialog({filters}: AddFilterDialogProps) {
|
||||
const {decodedFilters} = useBackendFilterUrlParams(filters);
|
||||
const {formId} = useDialogContext();
|
||||
|
||||
// expand currently active filters
|
||||
const [expandedFilters, setExpandedFilters] = useState<(string | number)[]>(
|
||||
() => {
|
||||
return decodedFilters.map(f => f.key);
|
||||
},
|
||||
);
|
||||
|
||||
const clearButton = (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="mr-auto"
|
||||
onClick={() => {
|
||||
setExpandedFilters([]);
|
||||
}}
|
||||
>
|
||||
<Trans message="Clear" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const applyButton = (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
className="ml-auto"
|
||||
type="submit"
|
||||
form={formId}
|
||||
>
|
||||
<Trans message="Apply" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog className="min-w-[300px]" maxWidth="max-w-400" size="auto">
|
||||
<DialogHeader
|
||||
padding="px-14 py-10"
|
||||
leftAdornment={clearButton}
|
||||
rightAdornment={applyButton}
|
||||
>
|
||||
<Trans message="Filter" />
|
||||
</DialogHeader>
|
||||
<DialogBody padding="p-0">
|
||||
<FilterList
|
||||
filters={filters}
|
||||
expandedFilters={expandedFilters}
|
||||
setExpandedFilters={setExpandedFilters}
|
||||
/>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterListProps {
|
||||
filters: BackendFilter[];
|
||||
expandedFilters: (string | number)[];
|
||||
setExpandedFilters: (value: (string | number)[]) => void;
|
||||
}
|
||||
function FilterList({
|
||||
filters,
|
||||
expandedFilters,
|
||||
setExpandedFilters,
|
||||
}: FilterListProps) {
|
||||
const {decodedFilters, replaceAll} = useBackendFilterUrlParams(filters);
|
||||
|
||||
// either get value and operator from url params if filter is active, or get defaults from filter config
|
||||
const defaultValues: Record<string, FilterItemFormValue> = {};
|
||||
filters.forEach(filter => {
|
||||
const appliedFilter = decodedFilters.find(f => f.key === filter.key);
|
||||
defaultValues[filter.key] =
|
||||
appliedFilter?.value !== undefined
|
||||
? // there might be some extra keys set on filter besides
|
||||
// "value" and "operator", so add the whole object to form
|
||||
appliedFilter
|
||||
: {
|
||||
value: filter.control.defaultValue,
|
||||
operator: filter.defaultOperator,
|
||||
};
|
||||
});
|
||||
const form = useForm<Record<string, FilterItemFormValue>>({defaultValues});
|
||||
const {formId, close} = useDialogContext();
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
id={formId}
|
||||
onSubmit={formValue => {
|
||||
const filterValue = Object.entries(formValue)
|
||||
// remove undefined and non-expanded filters, so "clear" button will correctly remove active filters
|
||||
.filter(
|
||||
([key, fieldValue]) =>
|
||||
expandedFilters.includes(key) && fieldValue !== undefined,
|
||||
)
|
||||
.map(([key, fieldValue]) => ({
|
||||
key,
|
||||
...fieldValue, // value and operator from form
|
||||
}));
|
||||
|
||||
replaceAll(filterValue);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<Accordion
|
||||
mode="multiple"
|
||||
expandedValues={expandedFilters}
|
||||
onExpandedChange={setExpandedFilters}
|
||||
>
|
||||
{filters.map(filter => (
|
||||
<AccordionItem
|
||||
startIcon={
|
||||
<Checkbox checked={expandedFilters.includes(filter.key)} />
|
||||
}
|
||||
key={filter.key}
|
||||
value={filter.key}
|
||||
label={<Trans {...filter.label} />}
|
||||
bodyClassName="max-h-288 overflow-y-auto compact-scrollbar"
|
||||
>
|
||||
{filter.description && (
|
||||
<div
|
||||
className={clsx(
|
||||
'text-xs text-muted',
|
||||
// boolean filter will have nothing in the panel, no need to add margin
|
||||
filter.control.type !== FilterControlType.BooleanToggle &&
|
||||
'mb-14',
|
||||
)}
|
||||
>
|
||||
<Trans {...filter.description} />
|
||||
</div>
|
||||
)}
|
||||
<AddFilterDialogPanel filter={filter} />
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActiveFilterPanelProps {
|
||||
filter: BackendFilter;
|
||||
}
|
||||
export function AddFilterDialogPanel({filter}: ActiveFilterPanelProps) {
|
||||
switch (filter.control.type) {
|
||||
case FilterControlType.Select:
|
||||
return (
|
||||
<SelectFilterPanel
|
||||
filter={filter as BackendFilter<FilterSelectControl>}
|
||||
/>
|
||||
);
|
||||
case FilterControlType.ChipField:
|
||||
return (
|
||||
<ChipFieldFilterPanel
|
||||
filter={filter as BackendFilter<FilterChipFieldControl>}
|
||||
/>
|
||||
);
|
||||
case FilterControlType.DateRangePicker:
|
||||
return (
|
||||
<DateRangeFilterPanel
|
||||
filter={filter as BackendFilter<DatePickerFilterControl>}
|
||||
/>
|
||||
);
|
||||
case FilterControlType.SelectModel:
|
||||
return (
|
||||
<NormalizedModelFilterPanel
|
||||
filter={filter as BackendFilter<FilterSelectModelControl>}
|
||||
/>
|
||||
);
|
||||
case FilterControlType.Input:
|
||||
return (
|
||||
<InputFilterPanel
|
||||
filter={filter as BackendFilter<FilterTextInputControl>}
|
||||
/>
|
||||
);
|
||||
case FilterControlType.BooleanToggle:
|
||||
return (
|
||||
<BooleanFilterPanel
|
||||
filter={filter as BackendFilter<FilterBooleanToggleControl>}
|
||||
/>
|
||||
);
|
||||
case 'custom':
|
||||
const CustomComponent = filter.control.panel;
|
||||
return (
|
||||
<CustomComponent
|
||||
filter={filter as BackendFilter<CustomFilterControl>}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
96
common/resources/client/datatable/filters/backend-filter-url-params.ts
Executable file
96
common/resources/client/datatable/filters/backend-filter-url-params.ts
Executable file
@@ -0,0 +1,96 @@
|
||||
import {useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {Key, useCallback, useMemo} from 'react';
|
||||
import {BackendFilter} from './backend-filter';
|
||||
import {BackendFiltersUrlKey} from './backend-filters-url-key';
|
||||
import {decodeBackendFilters} from './utils/decode-backend-filters';
|
||||
import {
|
||||
encodeBackendFilters,
|
||||
FilterListValue,
|
||||
} from './utils/encode-backend-filters';
|
||||
|
||||
export function useBackendFilterUrlParams(
|
||||
filters?: BackendFilter[],
|
||||
pinnedFilters?: string[]
|
||||
) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const encodedFilters = searchParams.get(BackendFiltersUrlKey);
|
||||
|
||||
const decodedFilters: FilterListValue[] = useMemo(() => {
|
||||
if (!filters) return [];
|
||||
const decoded = decodeBackendFilters(encodedFilters);
|
||||
|
||||
// if filter is pinned, and it is not applied yet, add a placeholder
|
||||
(pinnedFilters || []).forEach(key => {
|
||||
if (!decoded.find(f => f.key === key)) {
|
||||
const config = filters.find(f => f.key === key)!;
|
||||
decoded.push({
|
||||
key,
|
||||
value: config.control.defaultValue,
|
||||
operator: config.defaultOperator,
|
||||
isInactive: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// preserve original filter order from configuration
|
||||
decoded.sort(
|
||||
(a, b) =>
|
||||
filters.findIndex(f => f.key === a.key) -
|
||||
filters.findIndex(f => f.key === b.key)
|
||||
);
|
||||
|
||||
return decoded;
|
||||
}, [encodedFilters, pinnedFilters, filters]);
|
||||
|
||||
const getDecodedWithoutKeys = useCallback(
|
||||
(values: (FilterListValue | Key)[]) => {
|
||||
const newFilters = [...decodedFilters];
|
||||
values.forEach(value => {
|
||||
const key = typeof value === 'object' ? value.key : value;
|
||||
const index = newFilters.findIndex(f => f.key === key);
|
||||
if (index > -1) {
|
||||
newFilters.splice(index, 1);
|
||||
}
|
||||
});
|
||||
return newFilters;
|
||||
},
|
||||
[decodedFilters]
|
||||
);
|
||||
|
||||
const replaceAll = useCallback(
|
||||
(filterValues: FilterListValue[]) => {
|
||||
const encodedFilters = encodeBackendFilters(filterValues, filters);
|
||||
if (encodedFilters) {
|
||||
searchParams.set(BackendFiltersUrlKey, encodedFilters);
|
||||
} else {
|
||||
searchParams.delete(BackendFiltersUrlKey);
|
||||
}
|
||||
navigate({search: `?${searchParams}`}, {replace: true});
|
||||
},
|
||||
[filters, navigate, searchParams]
|
||||
);
|
||||
|
||||
const add = useCallback(
|
||||
(filterValues: FilterListValue[]) => {
|
||||
const existing = getDecodedWithoutKeys(filterValues);
|
||||
const decodedFilters = [...existing, ...filterValues];
|
||||
replaceAll(decodedFilters);
|
||||
},
|
||||
[getDecodedWithoutKeys, replaceAll]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
(key: Key) => replaceAll(getDecodedWithoutKeys([key])),
|
||||
[getDecodedWithoutKeys, replaceAll]
|
||||
);
|
||||
|
||||
return {
|
||||
add,
|
||||
remove,
|
||||
replaceAll,
|
||||
decodedFilters,
|
||||
encodedFilters,
|
||||
};
|
||||
}
|
||||
119
common/resources/client/datatable/filters/backend-filter.ts
Executable file
119
common/resources/client/datatable/filters/backend-filter.ts
Executable file
@@ -0,0 +1,119 @@
|
||||
import {ComponentType} from 'react';
|
||||
import {MessageDescriptor} from '../../i18n/message-descriptor';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {ChipValue} from '@common/ui/forms/input-field/chip-field/chip-field';
|
||||
import {AbsoluteDateRange} from '@common/ui/forms/input-field/date/date-range-picker/form-date-range-picker';
|
||||
import {DateValue} from '@internationalized/date';
|
||||
import {FilterListControlProps} from '@common/datatable/filters/filter-list/filter-list-control';
|
||||
|
||||
export interface FilterSelectControl {
|
||||
type: FilterControlType.Select;
|
||||
options: {label: MessageDescriptor; key: string | number; value: any}[];
|
||||
defaultValue?: string | number | boolean;
|
||||
placeholder?: MessageDescriptor;
|
||||
searchPlaceholder?: MessageDescriptor;
|
||||
showSearchField?: boolean;
|
||||
}
|
||||
|
||||
export interface FilterNumberInputControl {
|
||||
type: FilterControlType.Input;
|
||||
placeholder?: MessageDescriptor;
|
||||
inputType: 'number';
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
defaultValue: number;
|
||||
}
|
||||
|
||||
export interface FilterTextInputControl {
|
||||
type: FilterControlType.Input;
|
||||
placeholder?: MessageDescriptor;
|
||||
inputType: 'string';
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
defaultValue: string;
|
||||
}
|
||||
|
||||
export interface FilterSelectModelControl {
|
||||
type: FilterControlType.SelectModel;
|
||||
model: string;
|
||||
defaultValue?: NormalizedModel;
|
||||
}
|
||||
|
||||
export interface FilterChipFieldControl {
|
||||
type: FilterControlType.ChipField;
|
||||
options: FilterSelectControl['options'];
|
||||
placeholder?: MessageDescriptor;
|
||||
defaultValue: ChipValue[];
|
||||
}
|
||||
|
||||
export interface FilterBooleanToggleControl {
|
||||
type: FilterControlType.BooleanToggle;
|
||||
// value can be something other than boolean, toggling will either send that value or nothing
|
||||
defaultValue: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export interface DatePickerFilterControl {
|
||||
type: FilterControlType.DateRangePicker;
|
||||
defaultValue: AbsoluteDateRange;
|
||||
min?: DateValue;
|
||||
max?: DateValue;
|
||||
}
|
||||
|
||||
export interface CustomFilterControl {
|
||||
type: FilterControlType.Custom;
|
||||
panel: ComponentType<{filter: BackendFilter<CustomFilterControl>}>;
|
||||
listItem: ComponentType<FilterListControlProps<number, CustomFilterControl>>;
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
export type FilterControl =
|
||||
| FilterSelectControl
|
||||
| FilterNumberInputControl
|
||||
| FilterTextInputControl
|
||||
| FilterSelectModelControl
|
||||
| FilterChipFieldControl
|
||||
| DatePickerFilterControl
|
||||
| FilterBooleanToggleControl
|
||||
| CustomFilterControl;
|
||||
|
||||
export interface BackendFilter<T = FilterControl> {
|
||||
key: string;
|
||||
label: MessageDescriptor;
|
||||
description?: MessageDescriptor;
|
||||
control: T;
|
||||
defaultOperator: FilterOperator;
|
||||
operators?: FilterOperator[];
|
||||
extraFilters?: {key: string; operator: FilterOperator; value: any}[];
|
||||
}
|
||||
|
||||
export enum FilterControlType {
|
||||
Select = 'select',
|
||||
DateRangePicker = 'dateRangePicker',
|
||||
SelectModel = 'selectModel',
|
||||
Input = 'input',
|
||||
BooleanToggle = 'booleanToggle',
|
||||
ChipField = 'chipField',
|
||||
Custom = 'custom',
|
||||
}
|
||||
|
||||
export enum FilterOperator {
|
||||
eq = '=',
|
||||
ne = '!=',
|
||||
gt = '>',
|
||||
gte = '>=',
|
||||
lt = '<',
|
||||
lte = '<=',
|
||||
has = 'has',
|
||||
hasAll = 'hasAll',
|
||||
doesntHave = 'doesntHave',
|
||||
between = 'between',
|
||||
}
|
||||
|
||||
export const ALL_PRIMITIVE_OPERATORS = [
|
||||
FilterOperator.eq,
|
||||
FilterOperator.ne,
|
||||
FilterOperator.gt,
|
||||
FilterOperator.gte,
|
||||
FilterOperator.lt,
|
||||
FilterOperator.lte,
|
||||
];
|
||||
1
common/resources/client/datatable/filters/backend-filters-url-key.ts
Executable file
1
common/resources/client/datatable/filters/backend-filters-url-key.ts
Executable file
@@ -0,0 +1 @@
|
||||
export const BackendFiltersUrlKey = 'filters';
|
||||
241
common/resources/client/datatable/filters/filter-list/filter-list-control.tsx
Executable file
241
common/resources/client/datatable/filters/filter-list/filter-list-control.tsx
Executable file
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
BackendFilter,
|
||||
CustomFilterControl,
|
||||
DatePickerFilterControl,
|
||||
FilterBooleanToggleControl,
|
||||
FilterChipFieldControl,
|
||||
FilterControl,
|
||||
FilterControlType,
|
||||
FilterNumberInputControl,
|
||||
FilterOperator,
|
||||
FilterSelectControl,
|
||||
FilterSelectModelControl,
|
||||
FilterTextInputControl,
|
||||
} from '../backend-filter';
|
||||
import {FilterListTriggerButton} from './filter-list-trigger-button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {SelectFilterPanel} from '../panels/select-filter-panel';
|
||||
import {FilterListItemDialogTrigger} from './filter-list-item-dialog-trigger';
|
||||
import {Avatar} from '@common/ui/images/avatar';
|
||||
import {NormalizedModelFilterPanel} from '../panels/normalized-model-filter-panel';
|
||||
import {DateRangeFilterPanel} from '../panels/date-range-filter-panel';
|
||||
import {Fragment, Key, ReactNode} from 'react';
|
||||
import {DateRangePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';
|
||||
import {FormattedDateTimeRange} from '@common/i18n/formatted-date-time-range';
|
||||
import {AbsoluteDateRange} from '@common/ui/forms/input-field/date/date-range-picker/form-date-range-picker';
|
||||
import {InputFilterPanel} from '../panels/input-filter-panel';
|
||||
import {FilterOperatorNames} from '../filter-operator-names';
|
||||
import {FilterItemFormValue} from '../add-filter-dialog';
|
||||
import {useNormalizedModel} from '@common/users/queries/use-normalized-model';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {ChipFieldFilterPanel} from '@common/datatable/filters/panels/chip-field-filter-panel';
|
||||
import {FormattedNumber} from '@common/i18n/formatted-number';
|
||||
|
||||
export interface FilterListControlProps<T = unknown, E = FilterControl> {
|
||||
filter: BackendFilter<E>;
|
||||
onValueChange: (value: FilterItemFormValue<T>) => void;
|
||||
value: T;
|
||||
operator?: FilterOperator;
|
||||
isInactive?: boolean;
|
||||
}
|
||||
export function FilterListControl(props: FilterListControlProps<any, any>) {
|
||||
switch (props.filter.control.type) {
|
||||
case FilterControlType.DateRangePicker:
|
||||
return <DatePickerControl {...props} />;
|
||||
case FilterControlType.BooleanToggle:
|
||||
return <BooleanToggleControl {...props} />;
|
||||
case FilterControlType.Select:
|
||||
return <SelectControl {...props} />;
|
||||
case FilterControlType.ChipField:
|
||||
return <ChipFieldControl {...props} />;
|
||||
case FilterControlType.Input:
|
||||
return <InputControl {...props} />;
|
||||
case FilterControlType.SelectModel:
|
||||
return <SelectModelControl {...props} />;
|
||||
case FilterControlType.Custom:
|
||||
const Control = (props.filter.control as CustomFilterControl).listItem;
|
||||
return <Control {...props} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function DatePickerControl(
|
||||
props: FilterListControlProps<
|
||||
Required<AbsoluteDateRange>,
|
||||
DatePickerFilterControl
|
||||
>,
|
||||
) {
|
||||
const {value, filter} = props;
|
||||
|
||||
let valueLabel: ReactNode;
|
||||
if (value.preset !== undefined) {
|
||||
valueLabel = <Trans {...DateRangePresets[value.preset].label} />;
|
||||
} else {
|
||||
valueLabel = (
|
||||
<FormattedDateTimeRange
|
||||
start={new Date(value.start)}
|
||||
end={new Date(value.end)}
|
||||
options={{dateStyle: 'medium'}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterListItemDialogTrigger
|
||||
{...props}
|
||||
label={valueLabel}
|
||||
panel={<DateRangeFilterPanel filter={filter} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BooleanToggleControl({
|
||||
filter,
|
||||
isInactive,
|
||||
onValueChange,
|
||||
}: FilterListControlProps<
|
||||
FilterBooleanToggleControl['defaultValue'],
|
||||
FilterBooleanToggleControl
|
||||
>) {
|
||||
// todo: toggle control on or off here
|
||||
return (
|
||||
<FilterListTriggerButton
|
||||
onClick={() => {
|
||||
onValueChange({value: filter.control.defaultValue});
|
||||
}}
|
||||
filter={filter}
|
||||
isInactive={isInactive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectControl(
|
||||
props: FilterListControlProps<Key, FilterSelectControl>,
|
||||
) {
|
||||
const {filter, value} = props;
|
||||
const option = filter.control.options.find(o => o.key === value);
|
||||
return (
|
||||
<FilterListItemDialogTrigger
|
||||
{...props}
|
||||
label={option ? <Trans {...option.label} /> : null}
|
||||
panel={<SelectFilterPanel filter={filter} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipFieldControl(
|
||||
props: FilterListControlProps<string[], FilterChipFieldControl>,
|
||||
) {
|
||||
return (
|
||||
<FilterListItemDialogTrigger
|
||||
{...props}
|
||||
label={<MultipleValues {...props} />}
|
||||
panel={<ChipFieldFilterPanel filter={props.filter} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MultipleValues(
|
||||
props: FilterListControlProps<string[], FilterChipFieldControl>,
|
||||
) {
|
||||
const {trans} = useTrans();
|
||||
const {filter, value} = props;
|
||||
const options = value.map(v => filter.control.options.find(o => o.key === v));
|
||||
const maxShownCount = 3;
|
||||
const notShownCount = value.length - maxShownCount;
|
||||
|
||||
// translate names, add commas and limit to 3
|
||||
const names = (
|
||||
<Fragment>
|
||||
{options
|
||||
.filter(Boolean)
|
||||
.slice(0, maxShownCount)
|
||||
.map((o, i) => {
|
||||
let name = '';
|
||||
if (i !== 0) {
|
||||
name += ', ';
|
||||
}
|
||||
name += trans(o!.label);
|
||||
return name;
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
// indicate that there are some names not shown
|
||||
return notShownCount > 0 ? (
|
||||
<Trans
|
||||
message=":names + :count more"
|
||||
values={{names: names, count: notShownCount}}
|
||||
/>
|
||||
) : (
|
||||
names
|
||||
);
|
||||
}
|
||||
|
||||
function InputControl(
|
||||
props: FilterListControlProps<
|
||||
string,
|
||||
FilterTextInputControl | FilterNumberInputControl
|
||||
>,
|
||||
) {
|
||||
const {filter, value, operator} = props;
|
||||
|
||||
const operatorLabel = operator ? (
|
||||
<Trans {...FilterOperatorNames[operator]} />
|
||||
) : null;
|
||||
|
||||
const formattedValue =
|
||||
filter.control.inputType === 'number' ? (
|
||||
<FormattedNumber value={value as any} />
|
||||
) : (
|
||||
value
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterListItemDialogTrigger
|
||||
{...props}
|
||||
label={
|
||||
<Fragment>
|
||||
{operatorLabel} {formattedValue}
|
||||
</Fragment>
|
||||
}
|
||||
panel={<InputFilterPanel filter={filter} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectModelControl(
|
||||
props: FilterListControlProps<string, FilterSelectModelControl>,
|
||||
) {
|
||||
const {value, filter} = props;
|
||||
const {isLoading, data} = useNormalizedModel(
|
||||
`normalized-models/${filter.control.model}/${value}`,
|
||||
undefined,
|
||||
{enabled: !!value},
|
||||
);
|
||||
|
||||
const skeleton = (
|
||||
<Fragment>
|
||||
<Skeleton variant="avatar" size="w-18 h-18 mr-6" />
|
||||
<Skeleton variant="rect" size="w-50" />
|
||||
</Fragment>
|
||||
);
|
||||
const modelPreview = (
|
||||
<Fragment>
|
||||
<Avatar size="xs" src={data?.model.image} className="mr-6" />
|
||||
{data?.model.name}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
const label = isLoading || !data ? skeleton : modelPreview;
|
||||
|
||||
return (
|
||||
<FilterListItemDialogTrigger
|
||||
{...props}
|
||||
label={label}
|
||||
panel={<NormalizedModelFilterPanel filter={filter} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {DialogTrigger} from '../../../ui/overlays/dialog/dialog-trigger';
|
||||
import {FilterListTriggerButton} from './filter-list-trigger-button';
|
||||
import {ReactNode} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {FilterItemFormValue} from '../add-filter-dialog';
|
||||
import {useDialogContext} from '../../../ui/overlays/dialog/dialog-context';
|
||||
import {Dialog} from '../../../ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '../../../ui/overlays/dialog/dialog-header';
|
||||
import {Trans} from '../../../i18n/trans';
|
||||
import {DialogBody} from '../../../ui/overlays/dialog/dialog-body';
|
||||
import {Form} from '../../../ui/forms/form';
|
||||
import {DialogFooter} from '../../../ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '../../../ui/buttons/button';
|
||||
import {FilterListControlProps} from './filter-list-control';
|
||||
|
||||
interface FilterListItemDialogTriggerProps extends FilterListControlProps<any> {
|
||||
label: ReactNode;
|
||||
panel: ReactNode;
|
||||
}
|
||||
export function FilterListItemDialogTrigger(
|
||||
props: FilterListItemDialogTriggerProps
|
||||
) {
|
||||
const {onValueChange, isInactive, filter, label} = props;
|
||||
return (
|
||||
<DialogTrigger
|
||||
offset={10}
|
||||
type="popover"
|
||||
onClose={(value?: FilterItemFormValue) => {
|
||||
if (value !== undefined) {
|
||||
onValueChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FilterListTriggerButton isInactive={isInactive} filter={filter}>
|
||||
{label}
|
||||
</FilterListTriggerButton>
|
||||
<FilterListControlDialog {...props} />
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterListControlDialog({
|
||||
filter,
|
||||
panel,
|
||||
value,
|
||||
operator,
|
||||
}: FilterListItemDialogTriggerProps) {
|
||||
const form = useForm<Record<string, FilterItemFormValue>>({
|
||||
defaultValues: {
|
||||
[filter.key]: {value, operator},
|
||||
},
|
||||
});
|
||||
const {close, formId} = useDialogContext();
|
||||
return (
|
||||
<Dialog size="xs">
|
||||
<DialogHeader>
|
||||
<Trans {...filter.label} />
|
||||
</DialogHeader>
|
||||
<DialogBody padding="px-14 pt-14 pb-4 max-h-288">
|
||||
<Form
|
||||
form={form}
|
||||
id={formId}
|
||||
onSubmit={formValue => {
|
||||
close(formValue[filter.key]);
|
||||
}}
|
||||
>
|
||||
{filter.description && (
|
||||
<div className="text-muted text-xs mb-14">
|
||||
<Trans {...filter.description} />
|
||||
</div>
|
||||
)}
|
||||
{panel}
|
||||
</Form>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
form={formId}
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="xs"
|
||||
>
|
||||
<Trans message="Apply" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import React from 'react';
|
||||
|
||||
export function FilterListSkeleton() {
|
||||
return (
|
||||
<m.div
|
||||
className="flex items-center gap-6 h-30"
|
||||
key="filter-list-skeleton"
|
||||
{...opacityAnimation}
|
||||
>
|
||||
<Skeleton variant="rect" size="h-full w-144" radius="rounded-md" />
|
||||
<Skeleton variant="rect" size="h-full w-112" radius="rounded-md" />
|
||||
<Skeleton variant="rect" size="h-full w-172" radius="rounded-md" />
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {BackendFilter, FilterControlType} from '../backend-filter';
|
||||
import {ComponentPropsWithRef, forwardRef, ReactNode} from 'react';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface TriggerButtonProps
|
||||
extends Omit<ComponentPropsWithRef<'button'>, 'color'> {
|
||||
isInactive?: boolean;
|
||||
filter: BackendFilter;
|
||||
children?: ReactNode;
|
||||
}
|
||||
export const FilterListTriggerButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
TriggerButtonProps
|
||||
>((props, ref) => {
|
||||
// pass through all props from menu trigger and dialog trigger to button
|
||||
const {isInactive, filter, ...domProps} = props;
|
||||
|
||||
if (isInactive) {
|
||||
return <InactiveFilterButton filter={filter} {...domProps} ref={ref} />;
|
||||
}
|
||||
|
||||
return <ActiveFilterButton filter={filter} {...domProps} ref={ref} />;
|
||||
});
|
||||
|
||||
interface InactiveFilterButtonProps
|
||||
extends Omit<ComponentPropsWithRef<'button'>, 'color'> {
|
||||
filter: BackendFilter;
|
||||
}
|
||||
export const InactiveFilterButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
InactiveFilterButtonProps
|
||||
>(({filter, ...domProps}, ref) => {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
color="paper"
|
||||
radius="rounded-md"
|
||||
border="border"
|
||||
ref={ref}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
{...domProps}
|
||||
>
|
||||
<Trans {...filter.label} />
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
export const ActiveFilterButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
InactiveFilterButtonProps
|
||||
>(({filter, children, ...domProps}, ref) => {
|
||||
const isBoolean = filter.control.type === FilterControlType.BooleanToggle;
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
color="primary"
|
||||
radius="rounded-r-md"
|
||||
border="border-y border-r"
|
||||
endIcon={!isBoolean && <KeyboardArrowDownIcon />}
|
||||
ref={ref}
|
||||
{...domProps}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
!isBoolean && 'border-r border-r-primary-light mr-8 pr-8'
|
||||
)}
|
||||
>
|
||||
<Trans {...filter.label} />
|
||||
</span>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
72
common/resources/client/datatable/filters/filter-list/filter-list.tsx
Executable file
72
common/resources/client/datatable/filters/filter-list/filter-list.tsx
Executable file
@@ -0,0 +1,72 @@
|
||||
import clsx from 'clsx';
|
||||
import {BackendFilter} from '../backend-filter';
|
||||
import {useBackendFilterUrlParams} from '../backend-filter-url-params';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {CloseIcon} from '@common/icons/material/Close';
|
||||
import {FilterListControl} from './filter-list-control';
|
||||
import {FilterItemFormValue} from '../add-filter-dialog';
|
||||
|
||||
interface FilterListProps {
|
||||
filters: BackendFilter[];
|
||||
// these filters will always be shown, even if value is not yet selected for filter
|
||||
pinnedFilters?: string[];
|
||||
className?: string;
|
||||
}
|
||||
export function FilterList({
|
||||
filters,
|
||||
pinnedFilters,
|
||||
className,
|
||||
}: FilterListProps) {
|
||||
const {decodedFilters, remove, replaceAll} = useBackendFilterUrlParams(
|
||||
filters,
|
||||
pinnedFilters
|
||||
);
|
||||
|
||||
if (!decodedFilters.length) return null;
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-center gap-6 overflow-x-auto', className)}>
|
||||
{decodedFilters.map((field, index) => {
|
||||
const filter = filters.find(f => f.key === field.key);
|
||||
|
||||
if (!filter) return null;
|
||||
|
||||
const handleValueChange = (payload: FilterItemFormValue) => {
|
||||
const newFilters = [...decodedFilters];
|
||||
newFilters.splice(index, 1, {
|
||||
key: filter.key,
|
||||
value: payload.value,
|
||||
isInactive: false,
|
||||
operator: payload.operator || filter.defaultOperator,
|
||||
});
|
||||
replaceAll(newFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={field.key}>
|
||||
{!field.isInactive && (
|
||||
<IconButton
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="xs"
|
||||
radius="rounded-l-md"
|
||||
onClick={() => {
|
||||
remove(field.key);
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<FilterListControl
|
||||
filter={filter}
|
||||
isInactive={field.isInactive}
|
||||
value={field.valueKey != null ? field.valueKey : field.value}
|
||||
operator={field.operator}
|
||||
onValueChange={handleValueChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
common/resources/client/datatable/filters/filter-operator-names.ts
Executable file
17
common/resources/client/datatable/filters/filter-operator-names.ts
Executable file
@@ -0,0 +1,17 @@
|
||||
import {FilterOperator} from './backend-filter';
|
||||
import {message} from '../../i18n/message';
|
||||
import {MessageDescriptor} from '../../i18n/message-descriptor';
|
||||
|
||||
export const FilterOperatorNames: {[op in FilterOperator]: MessageDescriptor} =
|
||||
{
|
||||
'=': message('is'),
|
||||
'!=': message('is not'),
|
||||
'>': message('is greater than'),
|
||||
'>=': message('is greater than or equal to'),
|
||||
'<': message('is less than'),
|
||||
'<=': message('is less than or equal to'),
|
||||
has: message('Include'),
|
||||
doesntHave: message('Do not include'),
|
||||
between: message('Is between'),
|
||||
hasAll: message('Include all'),
|
||||
};
|
||||
7
common/resources/client/datatable/filters/normalized-model.ts
Executable file
7
common/resources/client/datatable/filters/normalized-model.ts
Executable file
@@ -0,0 +1,7 @@
|
||||
export interface NormalizedModel {
|
||||
id: number | string;
|
||||
name: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
model_type: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {FilterPanelProps} from './filter-panel-props';
|
||||
import {FilterBooleanToggleControl} from '@common/datatable/filters/backend-filter';
|
||||
|
||||
export function BooleanFilterPanel({
|
||||
filter,
|
||||
}: FilterPanelProps<FilterBooleanToggleControl>) {
|
||||
// Toggling accordion in the dialog will already apply boolean filter, no need for any extra fields here
|
||||
return null;
|
||||
}
|
||||
39
common/resources/client/datatable/filters/panels/chip-field-filter-panel.tsx
Executable file
39
common/resources/client/datatable/filters/panels/chip-field-filter-panel.tsx
Executable file
@@ -0,0 +1,39 @@
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
import {FilterPanelProps} from '@common/datatable/filters/panels/filter-panel-props';
|
||||
import {FormChipField} from '@common/ui/forms/input-field/chip-field/form-chip-field';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {FilterChipFieldControl} from '@common/datatable/filters/backend-filter';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
export function ChipFieldFilterPanel({
|
||||
filter,
|
||||
}: FilterPanelProps<FilterChipFieldControl>) {
|
||||
const {trans} = useTrans();
|
||||
return (
|
||||
<FormChipField
|
||||
size="sm"
|
||||
name={`${filter.key}.value`}
|
||||
valueKey="id"
|
||||
allowCustomValue={false}
|
||||
showDropdownArrow
|
||||
placeholder={
|
||||
filter.control.placeholder
|
||||
? trans(filter.control.placeholder)
|
||||
: undefined
|
||||
}
|
||||
displayWith={chip =>
|
||||
filter.control.options.find(o => o.key === chip.id)?.label.message
|
||||
}
|
||||
suggestions={filter.control.options.map(o => ({
|
||||
id: o.key,
|
||||
name: o.label.message,
|
||||
}))}
|
||||
>
|
||||
{chip => (
|
||||
<Item key={chip.id} value={chip.id}>
|
||||
{<Trans message={chip.name} />}
|
||||
</Item>
|
||||
)}
|
||||
</FormChipField>
|
||||
);
|
||||
}
|
||||
18
common/resources/client/datatable/filters/panels/date-range-filter-panel.tsx
Executable file
18
common/resources/client/datatable/filters/panels/date-range-filter-panel.tsx
Executable file
@@ -0,0 +1,18 @@
|
||||
import {FormDateRangePicker} from '@common/ui/forms/input-field/date/date-range-picker/form-date-range-picker';
|
||||
import {FilterPanelProps} from '@common/datatable/filters/panels/filter-panel-props';
|
||||
import {DatePickerFilterControl} from '@common/datatable/filters/backend-filter';
|
||||
|
||||
export function DateRangeFilterPanel({
|
||||
filter,
|
||||
}: FilterPanelProps<DatePickerFilterControl>) {
|
||||
return (
|
||||
<FormDateRangePicker
|
||||
min={filter.control.min}
|
||||
max={filter.control.max}
|
||||
size="sm"
|
||||
name={`${filter.key}.value`}
|
||||
granularity="day"
|
||||
closeDialogOnSelection={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
common/resources/client/datatable/filters/panels/filter-panel-props.ts
Executable file
5
common/resources/client/datatable/filters/panels/filter-panel-props.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
import {BackendFilter, FilterControl} from '../backend-filter';
|
||||
|
||||
export interface FilterPanelProps<T = FilterControl> {
|
||||
filter: BackendFilter<T>;
|
||||
}
|
||||
44
common/resources/client/datatable/filters/panels/input-filter-panel.tsx
Executable file
44
common/resources/client/datatable/filters/panels/input-filter-panel.tsx
Executable file
@@ -0,0 +1,44 @@
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FormSelect} from '@common/ui/forms/select/select';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
import {FilterOperatorNames} from '@common/datatable/filters/filter-operator-names';
|
||||
import {Fragment} from 'react';
|
||||
import {FilterPanelProps} from '@common/datatable/filters/panels/filter-panel-props';
|
||||
import {
|
||||
FilterNumberInputControl,
|
||||
FilterTextInputControl,
|
||||
} from '@common/datatable/filters/backend-filter';
|
||||
|
||||
export function InputFilterPanel({
|
||||
filter,
|
||||
}: FilterPanelProps<FilterTextInputControl | FilterNumberInputControl>) {
|
||||
const control = filter.control;
|
||||
return (
|
||||
<Fragment>
|
||||
<FormSelect
|
||||
selectionMode="single"
|
||||
name={`${filter.key}.operator`}
|
||||
className="mb-14"
|
||||
size="sm"
|
||||
required
|
||||
>
|
||||
{filter.operators?.map(operator => (
|
||||
<Item key={operator} value={operator}>
|
||||
{<Trans {...FilterOperatorNames[operator]} />}
|
||||
</Item>
|
||||
))}
|
||||
</FormSelect>
|
||||
<FormTextField
|
||||
size="sm"
|
||||
name={`${filter.key}.value`}
|
||||
type={filter.control.inputType}
|
||||
min={'minValue' in control ? control.minValue : undefined}
|
||||
max={'maxValue' in control ? control.maxValue : undefined}
|
||||
minLength={'minLength' in control ? control.minLength : undefined}
|
||||
maxLength={'maxLength' in control ? control.maxLength : undefined}
|
||||
required
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {FilterPanelProps} from './filter-panel-props';
|
||||
import {FormNormalizedModelField} from '@common/ui/forms/normalized-model-field';
|
||||
import {FilterSelectModelControl} from '@common/datatable/filters/backend-filter';
|
||||
|
||||
export function NormalizedModelFilterPanel({
|
||||
filter,
|
||||
}: FilterPanelProps<FilterSelectModelControl>) {
|
||||
return (
|
||||
<FormNormalizedModelField
|
||||
name={`${filter.key}.value`}
|
||||
endpoint={`normalized-models/${filter.control.model}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
37
common/resources/client/datatable/filters/panels/select-filter-panel.tsx
Executable file
37
common/resources/client/datatable/filters/panels/select-filter-panel.tsx
Executable file
@@ -0,0 +1,37 @@
|
||||
import {FormSelect} from '@common/ui/forms/select/select';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FilterPanelProps} from '@common/datatable/filters/panels/filter-panel-props';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {FilterSelectControl} from '@common/datatable/filters/backend-filter';
|
||||
|
||||
export function SelectFilterPanel({
|
||||
filter,
|
||||
}: FilterPanelProps<FilterSelectControl>) {
|
||||
const {trans} = useTrans();
|
||||
|
||||
return (
|
||||
<FormSelect
|
||||
size="sm"
|
||||
name={`${filter.key}.value`}
|
||||
selectionMode="single"
|
||||
showSearchField={filter.control.showSearchField}
|
||||
placeholder={
|
||||
filter.control.placeholder
|
||||
? trans(filter.control.placeholder)
|
||||
: undefined
|
||||
}
|
||||
searchPlaceholder={
|
||||
filter.control.searchPlaceholder
|
||||
? trans(filter.control.searchPlaceholder)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{filter.control.options.map(option => (
|
||||
<Item key={option.key} value={option.key}>
|
||||
<Trans {...option.label} />
|
||||
</Item>
|
||||
))}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
53
common/resources/client/datatable/filters/timestamp-filters.ts
Executable file
53
common/resources/client/datatable/filters/timestamp-filters.ts
Executable file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
BackendFilter,
|
||||
DatePickerFilterControl,
|
||||
FilterControlType,
|
||||
FilterOperator,
|
||||
} from './backend-filter';
|
||||
import {
|
||||
DateRangePreset,
|
||||
DateRangePresets,
|
||||
} from '../../ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';
|
||||
import {message} from '../../i18n/message';
|
||||
import {dateRangeToAbsoluteRange} from '../../ui/forms/input-field/date/date-range-picker/form-date-range-picker';
|
||||
import {PartialWithRequired} from '@common/utils/ts/partial-with-required';
|
||||
|
||||
export function timestampFilter(
|
||||
options: PartialWithRequired<
|
||||
BackendFilter<DatePickerFilterControl>,
|
||||
'key' | 'label'
|
||||
>
|
||||
): BackendFilter<DatePickerFilterControl> {
|
||||
return {
|
||||
...options,
|
||||
defaultOperator: FilterOperator.between,
|
||||
control: {
|
||||
type: FilterControlType.DateRangePicker,
|
||||
defaultValue:
|
||||
options.control?.defaultValue ||
|
||||
dateRangeToAbsoluteRange(
|
||||
(DateRangePresets[3] as Required<DateRangePreset>).getRangeValue()
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createdAtFilter(
|
||||
options: Partial<BackendFilter<DatePickerFilterControl>>
|
||||
): BackendFilter<DatePickerFilterControl> {
|
||||
return timestampFilter({
|
||||
key: 'created_at',
|
||||
label: message('Date created'),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function updatedAtFilter(
|
||||
options: Partial<BackendFilter<DatePickerFilterControl>>
|
||||
): BackendFilter<DatePickerFilterControl> {
|
||||
return timestampFilter({
|
||||
key: 'updated_at',
|
||||
label: message('Last updated'),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
21
common/resources/client/datatable/filters/utils/decode-backend-filters.ts
Executable file
21
common/resources/client/datatable/filters/utils/decode-backend-filters.ts
Executable file
@@ -0,0 +1,21 @@
|
||||
import {FilterListValue} from './encode-backend-filters';
|
||||
|
||||
export function decodeBackendFilters(
|
||||
encodedFilters: string | null
|
||||
): FilterListValue[] {
|
||||
if (!encodedFilters) return [];
|
||||
let filtersFromQuery: FilterListValue[] = [];
|
||||
try {
|
||||
filtersFromQuery = JSON.parse(atob(decodeURIComponent(encodedFilters)));
|
||||
filtersFromQuery.map(filterValue => {
|
||||
// set value key as value so selects work properly
|
||||
if (filterValue.valueKey != null) {
|
||||
filterValue.value = filterValue.valueKey;
|
||||
}
|
||||
return filterValue;
|
||||
});
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
return filtersFromQuery;
|
||||
}
|
||||
57
common/resources/client/datatable/filters/utils/encode-backend-filters.ts
Executable file
57
common/resources/client/datatable/filters/utils/encode-backend-filters.ts
Executable file
@@ -0,0 +1,57 @@
|
||||
import {BackendFilter} from '../backend-filter';
|
||||
|
||||
export interface FilterListValue {
|
||||
key: string | number;
|
||||
value: BackendFilter['control']['defaultValue'];
|
||||
operator?: BackendFilter['defaultOperator'];
|
||||
valueKey?: string | number;
|
||||
isInactive?: boolean;
|
||||
extraFilters?: {key: string; operator: string; value: any}[];
|
||||
}
|
||||
|
||||
export function encodeBackendFilters(
|
||||
filterValues: FilterListValue[] | null,
|
||||
filters?: BackendFilter[],
|
||||
): string {
|
||||
if (!filterValues) return '';
|
||||
|
||||
// prepare values for backend
|
||||
filterValues = !filters
|
||||
? filterValues
|
||||
: filterValues
|
||||
.filter(item => item.value !== '')
|
||||
.map(item => transformValue(item, filters));
|
||||
|
||||
// remove all placeholder filters
|
||||
filterValues = filterValues.filter(fm => !fm.isInactive);
|
||||
|
||||
if (!filterValues.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return encodeURIComponent(btoa(JSON.stringify(filterValues)));
|
||||
}
|
||||
|
||||
function transformValue(
|
||||
filterValue: FilterListValue,
|
||||
filters: BackendFilter[],
|
||||
) {
|
||||
const filterConfig = filters.find(f => f.key === filterValue.key);
|
||||
// select components will use a key always, because we can't use objects as
|
||||
// value. Map over select options and replace key with actual value
|
||||
if (filterConfig?.control.type === 'select') {
|
||||
const option = (filterConfig.control.options || []).find(
|
||||
o => o.key === filterValue.value,
|
||||
);
|
||||
// if it's language or country select, there might not be an option
|
||||
if (option) {
|
||||
return {...filterValue, value: option.value, valueKey: option.key};
|
||||
}
|
||||
}
|
||||
|
||||
if (filterConfig?.extraFilters?.length) {
|
||||
filterValue['extraFilters'] = filterConfig.extraFilters;
|
||||
}
|
||||
|
||||
return filterValue;
|
||||
}
|
||||
21
common/resources/client/datatable/page/data-table-context.ts
Executable file
21
common/resources/client/datatable/page/data-table-context.ts
Executable file
@@ -0,0 +1,21 @@
|
||||
import React, {useContext} from 'react';
|
||||
import {GetDatatableDataParams} from '../requests/paginated-resources';
|
||||
import {UseQueryResult} from '@tanstack/react-query';
|
||||
import {PaginatedBackendResponse} from '../../http/backend-response/pagination-response';
|
||||
|
||||
export interface DataTableContextValue<T = unknown, A = unknown> {
|
||||
selectedRows: (string | number)[];
|
||||
setSelectedRows: (keys: (string | number)[]) => void;
|
||||
endpoint: string;
|
||||
params: GetDatatableDataParams;
|
||||
setParams: (value: GetDatatableDataParams) => void;
|
||||
query: UseQueryResult<PaginatedBackendResponse<T> & A, unknown>;
|
||||
}
|
||||
|
||||
export const DataTableContext = React.createContext<DataTableContextValue>(
|
||||
null!,
|
||||
);
|
||||
|
||||
export function useDataTable<T = unknown, A = unknown>() {
|
||||
return useContext(DataTableContext) as DataTableContextValue<T, A>;
|
||||
}
|
||||
42
common/resources/client/datatable/page/data-table-emty-state-message.tsx
Executable file
42
common/resources/client/datatable/page/data-table-emty-state-message.tsx
Executable file
@@ -0,0 +1,42 @@
|
||||
import React, {ReactNode} from 'react';
|
||||
import {IllustratedMessage} from '../../ui/images/illustrated-message';
|
||||
import {SvgImage} from '../../ui/images/svg-image/svg-image';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {useIsMobileMediaQuery} from '../../utils/hooks/is-mobile-media-query';
|
||||
|
||||
export interface DataTableEmptyStateMessageProps {
|
||||
isFiltering?: boolean;
|
||||
title: ReactNode;
|
||||
filteringTitle?: ReactNode;
|
||||
image: string;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
export function DataTableEmptyStateMessage({
|
||||
isFiltering,
|
||||
title,
|
||||
filteringTitle,
|
||||
image,
|
||||
size,
|
||||
className,
|
||||
}: DataTableEmptyStateMessageProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
if (!size) {
|
||||
size = isMobile ? 'sm' : 'md';
|
||||
}
|
||||
|
||||
// allow user to disable filtering message variation by not passing in "filteringTitle"
|
||||
return (
|
||||
<IllustratedMessage
|
||||
className={className}
|
||||
size={size}
|
||||
image={<SvgImage src={image} />}
|
||||
title={isFiltering && filteringTitle ? filteringTitle : title}
|
||||
description={
|
||||
isFiltering && filteringTitle ? (
|
||||
<Trans message="Try another search query or different filters" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
common/resources/client/datatable/page/data-table-page.tsx
Executable file
53
common/resources/client/datatable/page/data-table-page.tsx
Executable file
@@ -0,0 +1,53 @@
|
||||
import React, {ReactElement, ReactNode, useId} from 'react';
|
||||
import {TableDataItem} from '../../ui/tables/types/table-data-item';
|
||||
import {DataTable, DataTableProps} from '../data-table';
|
||||
import {TableProps} from '../../ui/tables/table';
|
||||
import {StaticPageTitle} from '../../seo/static-page-title';
|
||||
import {MessageDescriptor} from '../../i18n/message-descriptor';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props<T extends TableDataItem> extends DataTableProps<T> {
|
||||
title?: ReactElement<MessageDescriptor>;
|
||||
headerContent?: ReactNode;
|
||||
headerItemsAlign?: string;
|
||||
enableSelection?: boolean;
|
||||
onRowAction?: TableProps<T>['onAction'];
|
||||
padding?: string;
|
||||
className?: string;
|
||||
}
|
||||
export function DataTablePage<T extends TableDataItem>({
|
||||
title,
|
||||
headerContent,
|
||||
headerItemsAlign = 'items-end',
|
||||
className,
|
||||
padding,
|
||||
...dataTableProps
|
||||
}: Props<T>) {
|
||||
const titleId = useId();
|
||||
|
||||
return (
|
||||
<div className={clsx(padding ?? 'p-12 md:p-24', className)}>
|
||||
{title && (
|
||||
<div
|
||||
className={clsx(
|
||||
'mb-16',
|
||||
headerContent && `flex ${headerItemsAlign} gap-4`,
|
||||
)}
|
||||
>
|
||||
<StaticPageTitle>{title}</StaticPageTitle>
|
||||
<h1 className="text-3xl font-light first:capitalize" id={titleId}>
|
||||
{title}
|
||||
</h1>
|
||||
{headerContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
{...dataTableProps}
|
||||
tableDomProps={{
|
||||
'aria-labelledby': title ? titleId : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
common/resources/client/datatable/page/delete-selected-items-action.tsx
Executable file
53
common/resources/client/datatable/page/delete-selected-items-action.tsx
Executable file
@@ -0,0 +1,53 @@
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {ConfirmationDialog} from '../../ui/overlays/dialog/confirmation-dialog';
|
||||
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
|
||||
import React from 'react';
|
||||
import {useDeleteSelectedRows} from '../requests/delete-selected-rows';
|
||||
import {useDataTable} from './data-table-context';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
import {errorStatusIs} from '@common/utils/http/error-status-is';
|
||||
|
||||
export function DeleteSelectedItemsAction() {
|
||||
return (
|
||||
<DialogTrigger type="modal">
|
||||
<Button variant="flat" color="danger" className="ml-auto">
|
||||
<Trans message="Delete" />
|
||||
</Button>
|
||||
<DeleteItemsDialog />
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteItemsDialog() {
|
||||
const deleteSelectedRows = useDeleteSelectedRows();
|
||||
const {selectedRows, setSelectedRows} = useDataTable();
|
||||
const {close} = useDialogContext();
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
isLoading={deleteSelectedRows.isPending}
|
||||
title={
|
||||
<Trans
|
||||
message="Delete [one 1 item|other :count items]?"
|
||||
values={{count: selectedRows.length}}
|
||||
/>
|
||||
}
|
||||
body={
|
||||
<Trans message="This will permanently remove the items and cannot be undone." />
|
||||
}
|
||||
confirm={<Trans message="Delete" />}
|
||||
isDanger
|
||||
onConfirm={() => {
|
||||
deleteSelectedRows.mutate(undefined, {
|
||||
onSuccess: () => close(),
|
||||
onError: err => {
|
||||
if (errorStatusIs(err, 422)) {
|
||||
setSelectedRows([]);
|
||||
close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
37
common/resources/client/datatable/requests/delete-selected-rows.ts
Executable file
37
common/resources/client/datatable/requests/delete-selected-rows.ts
Executable file
@@ -0,0 +1,37 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient, queryClient} from '../../http/query-client';
|
||||
import {BackendResponse} from '../../http/backend-response/backend-response';
|
||||
import {toast} from '../../ui/toast/toast';
|
||||
import {DatatableDataQueryKey} from './paginated-resources';
|
||||
import {useDataTable} from '../page/data-table-context';
|
||||
import {message} from '../../i18n/message';
|
||||
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
|
||||
import {Key} from 'react';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
//
|
||||
}
|
||||
|
||||
export function useDeleteSelectedRows() {
|
||||
const {endpoint, selectedRows, setSelectedRows} = useDataTable();
|
||||
return useMutation({
|
||||
mutationFn: () => deleteSelectedRows(endpoint, selectedRows),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey(endpoint),
|
||||
});
|
||||
toast(
|
||||
message('Deleted [one 1 record|other :count records]', {
|
||||
values: {count: selectedRows.length},
|
||||
}),
|
||||
);
|
||||
setSelectedRows([]);
|
||||
},
|
||||
onError: err =>
|
||||
showHttpErrorToast(err, message('Could not delete records')),
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSelectedRows(endpoint: string, ids: Key[]): Promise<Response> {
|
||||
return apiClient.delete(`${endpoint}/${ids.join(',')}`).then(r => r.data);
|
||||
}
|
||||
73
common/resources/client/datatable/requests/paginated-resources.ts
Executable file
73
common/resources/client/datatable/requests/paginated-resources.ts
Executable file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
useQuery,
|
||||
UseQueryOptions,
|
||||
} from '@tanstack/react-query';
|
||||
import {PaginatedBackendResponse} from '../../http/backend-response/pagination-response';
|
||||
import {apiClient} from '../../http/query-client';
|
||||
|
||||
export interface GetDatatableDataParams {
|
||||
orderBy?: string;
|
||||
orderDir?: 'asc' | 'desc';
|
||||
filters?: string | null;
|
||||
query?: string;
|
||||
with?: string;
|
||||
perPage?: number | string | null;
|
||||
page?: number | string;
|
||||
paginate?: 'simple' | 'lengthAware' | 'cursor';
|
||||
[key: string]: string | number | boolean | undefined | null;
|
||||
}
|
||||
|
||||
export const DatatableDataQueryKey = (
|
||||
endpoint: string,
|
||||
params?: GetDatatableDataParams | Record<string, string | number | boolean>,
|
||||
) => {
|
||||
// split endpoint by slash, so we can clear cache from the root later,
|
||||
// for example, 'link-group' will clear 'link-group/1/links' endpoint
|
||||
const key: (string | GetDatatableDataParams)[] = endpoint.split('/');
|
||||
if (params) {
|
||||
key.push(params);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
export function useDatatableData<T = object>(
|
||||
endpoint: string,
|
||||
params: GetDatatableDataParams,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
PaginatedBackendResponse<T>,
|
||||
unknown,
|
||||
PaginatedBackendResponse<T>,
|
||||
any[]
|
||||
>,
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
onLoad?: (data: PaginatedBackendResponse<T>) => void,
|
||||
) {
|
||||
if (!params.paginate) {
|
||||
params.paginate = 'simple';
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: DatatableDataQueryKey(endpoint, params),
|
||||
queryFn: ({signal}) => paginate<T>(endpoint, params, onLoad, signal),
|
||||
placeholderData: keepPreviousData,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async function paginate<T>(
|
||||
endpoint: string,
|
||||
params: GetDatatableDataParams,
|
||||
onLoad?: (data: PaginatedBackendResponse<T>) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PaginatedBackendResponse<T>> {
|
||||
if (params.query) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
const response = await apiClient
|
||||
.get(endpoint, {params, signal: params.query ? signal : undefined})
|
||||
.then(response => response.data);
|
||||
onLoad?.(response);
|
||||
return response;
|
||||
}
|
||||
25
common/resources/client/datatable/requests/use-export-csv.ts
Executable file
25
common/resources/client/datatable/requests/use-export-csv.ts
Executable file
@@ -0,0 +1,25 @@
|
||||
import {apiClient} from '../../http/query-client';
|
||||
import {BackendResponse} from '../../http/backend-response/backend-response';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
downloadPath?: string;
|
||||
result?: 'jobQueued';
|
||||
}
|
||||
|
||||
export type ExportCsvPayload = Record<string, string | number | undefined>;
|
||||
|
||||
export function useExportCsv(endpoint: string) {
|
||||
return useMutation({
|
||||
mutationFn: (payload?: ExportCsvPayload) => exportCsv(endpoint, payload),
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function exportCsv(
|
||||
endpoint: string,
|
||||
payload: ExportCsvPayload | undefined,
|
||||
): Promise<Response> {
|
||||
return apiClient.post(endpoint, payload).then(r => r.data);
|
||||
}
|
||||
24
common/resources/client/datatable/selected-state-datatable-header.tsx
Executable file
24
common/resources/client/datatable/selected-state-datatable-header.tsx
Executable file
@@ -0,0 +1,24 @@
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {HeaderLayout} from '@common/datatable/data-table-header';
|
||||
|
||||
interface Props {
|
||||
actions?: ReactNode;
|
||||
selectedItemsCount: number;
|
||||
}
|
||||
export function SelectedStateDatatableHeader({
|
||||
actions,
|
||||
selectedItemsCount,
|
||||
}: Props) {
|
||||
return (
|
||||
<HeaderLayout data-testid="datatable-selected-header">
|
||||
<div className="mr-auto">
|
||||
<Trans
|
||||
message="[one 1 item|other :count items] selected"
|
||||
values={{count: selectedItemsCount}}
|
||||
/>
|
||||
</div>
|
||||
{actions}
|
||||
</HeaderLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user