first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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';
}

View 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" />;
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);

View 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>
);
},
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}
}

View 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,
};
}

View 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,
];

View File

@@ -0,0 +1 @@
export const BackendFiltersUrlKey = 'filters';

View 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} />}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
});

View 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>
);
}

View 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'),
};

View File

@@ -0,0 +1,7 @@
export interface NormalizedModel {
id: number | string;
name: string;
description?: string;
image?: string;
model_type: string;
}

View File

@@ -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;
}

View 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>
);
}

View 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}
/>
);
}

View File

@@ -0,0 +1,5 @@
import {BackendFilter, FilterControl} from '../backend-filter';
export interface FilterPanelProps<T = FilterControl> {
filter: BackendFilter<T>;
}

View 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>
);
}

View File

@@ -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}`}
/>
);
}

View 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>
);
}

View 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,
});
}

View 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;
}

View 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;
}

View 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>;
}

View 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
}
/>
);
}

View 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>
);
}

View 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();
}
},
});
}}
/>
);
}

View 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);
}

View 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;
}

View 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);
}

View 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>
);
}