Browse Source

feat(dashboard): Implement list filtering

Michael Bromley 10 months ago
parent
commit
3f229abf37

+ 46 - 0
packages/dashboard/src/components/ui/badge.tsx

@@ -0,0 +1,46 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+  "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-auto",
+  {
+    variants: {
+      variant: {
+        default:
+          "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+        secondary:
+          "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+        destructive:
+          "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
+        outline:
+          "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+function Badge({
+  className,
+  variant,
+  asChild = false,
+  ...props
+}: React.ComponentProps<"span"> &
+  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
+  const Comp = asChild ? Slot : "span"
+
+  return (
+    <Comp
+      data-slot="badge"
+      className={cn(badgeVariants({ variant }), className)}
+      {...props}
+    />
+  )
+}
+
+export { Badge, badgeVariants }

+ 133 - 0
packages/dashboard/src/components/ui/dialog.tsx

@@ -0,0 +1,133 @@
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { XIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Dialog({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Root>) {
+  return <DialogPrimitive.Root data-slot="dialog" {...props} />
+}
+
+function DialogTrigger({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
+  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
+}
+
+function DialogPortal({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
+  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
+}
+
+function DialogClose({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Close>) {
+  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
+}
+
+function DialogOverlay({
+  className,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
+  return (
+    <DialogPrimitive.Overlay
+      data-slot="dialog-overlay"
+      className={cn(
+        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DialogContent({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Content>) {
+  return (
+    <DialogPortal data-slot="dialog-portal">
+      <DialogOverlay />
+      <DialogPrimitive.Content
+        data-slot="dialog-content"
+        className={cn(
+          "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
+          className
+        )}
+        {...props}
+      >
+        {children}
+        <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
+          <XIcon />
+          <span className="sr-only">Close</span>
+        </DialogPrimitive.Close>
+      </DialogPrimitive.Content>
+    </DialogPortal>
+  )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="dialog-header"
+      className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
+      {...props}
+    />
+  )
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="dialog-footer"
+      className={cn(
+        "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DialogTitle({
+  className,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Title>) {
+  return (
+    <DialogPrimitive.Title
+      data-slot="dialog-title"
+      className={cn("text-lg leading-none font-semibold", className)}
+      {...props}
+    />
+  )
+}
+
+function DialogDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Description>) {
+  return (
+    <DialogPrimitive.Description
+      data-slot="dialog-description"
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  )
+}
+
+export {
+  Dialog,
+  DialogClose,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogOverlay,
+  DialogPortal,
+  DialogTitle,
+  DialogTrigger,
+}

+ 1 - 1
packages/dashboard/src/framework/internal/component-registry/data-types/boolean.tsx

@@ -1,5 +1,5 @@
 import { CheckIcon, XIcon, LucideIcon } from 'lucide-react';
 
 export function BooleanDisplayCheckbox({ value }: { value: boolean }) {
-    return value ? <CheckIcon /> : <XIcon />;
+    return value ? <CheckIcon className="opacity-70" /> : <XIcon className="opacity-70" />;
 }

+ 70 - 0
packages/dashboard/src/framework/internal/data-table/data-table-column-header.tsx

@@ -0,0 +1,70 @@
+import { Button } from '@/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogDescription,
+    DialogHeader,
+    DialogTitle,
+    DialogTrigger,
+} from '@/components/ui/dialog.js';
+import { DataTableFilterDialog } from '@/framework/internal/data-table/data-table-filter-dialog.js';
+import { FieldInfo } from '@/framework/internal/document-introspection/get-document-structure.js';
+import { Trans } from '@lingui/react/macro';
+import { ColumnDef, HeaderContext } from '@tanstack/table-core';
+import { ArrowDown, ArrowUp, ArrowUpDown, EllipsisVertical, Filter } from 'lucide-react';
+import React from 'react';
+
+export interface DataTableColumnHeaderProps {
+    customConfig: Partial<ColumnDef<any>>;
+    headerContext: HeaderContext<any, any>;
+}
+
+export function DataTableColumnHeader({ headerContext, customConfig }: DataTableColumnHeaderProps) {
+    const { column } = headerContext;
+    const isSortable = column.getCanSort();
+    const isFilterable = column.getCanFilter();
+
+    const customHeader = customConfig.header;
+    let display = column.id;
+    if (typeof customHeader === 'function') {
+        display = customHeader(headerContext);
+    } else if (typeof customHeader === 'string') {
+        display = customHeader;
+    }
+
+    const columSort = column.getIsSorted();
+    const columnFilter = column.getFilterValue();
+    const nextSort = columSort === 'asc' ? true : columSort === 'desc' ? undefined : false;
+    return (
+        <div className="flex items-center">
+            <div>{display}</div>
+            {isSortable && (
+                <Button size="icon" variant="ghost" onClick={() => column.toggleSorting(nextSort)}>
+                    {columSort === 'desc' ? (
+                        <ArrowUp />
+                    ) : columSort === 'asc' ? (
+                        <ArrowDown />
+                    ) : (
+                        <ArrowUpDown className="opacity-50" />
+                    )}
+                </Button>
+            )}
+            {isFilterable && (
+                <Dialog>
+                    <DialogTrigger asChild>
+                        <Button size="icon" variant="ghost">
+                            <Filter className={columnFilter ? '' : 'opacity-50'} />
+                        </Button>
+                    </DialogTrigger>
+                    <DataTableFilterDialog column={column} />
+                </Dialog>
+            )}
+        </div>
+    );
+}

+ 73 - 0
packages/dashboard/src/framework/internal/data-table/data-table-filter-dialog.tsx

@@ -0,0 +1,73 @@
+import { Button } from '@/components/ui/button.js';
+import {
+    DialogClose,
+    DialogContent,
+    DialogDescription,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+} from '@/components/ui/dialog.js';
+import { Input } from '@/components/ui/input.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
+import { Trans } from '@lingui/react/macro';
+import { Column } from '@tanstack/react-table';
+import React, { useState } from 'react';
+
+export interface DataTableFilterDialogProps {
+    column: Column<any>;
+}
+
+const STRING_OPERATORS = ['eq', 'notEq', 'contains', 'notContains', 'in', 'notIn', 'regex', 'isNull'];
+
+export function DataTableFilterDialog({ column }: DataTableFilterDialogProps) {
+    const columnFilter = column.getFilterValue() as Record<string, string> | undefined;
+    const [initialOperator, initialValue] = columnFilter ? Object.entries(columnFilter as any)[0] : [];
+    const [operator, setOperator] = useState<string>(initialOperator ?? 'contains');
+    const [value, setValue] = useState((initialValue as string) ?? '');
+    const columnId = column.id;
+    return (
+        <DialogContent>
+            <DialogHeader>
+                <DialogTitle>
+                    <Trans>Filter by {columnId}</Trans>
+                </DialogTitle>
+                <DialogDescription></DialogDescription>
+            </DialogHeader>
+            <div className="flex flex-col md:flex-row gap-2">
+                <Select value={operator} onValueChange={value => setOperator(value)}>
+                    <SelectTrigger>
+                        <SelectValue placeholder="Select operator" />
+                    </SelectTrigger>
+                    <SelectContent>
+                        {STRING_OPERATORS.map(op => (
+                            <SelectItem key={op} value={op}>
+                                <Trans context="filter-operator">{op}</Trans>
+                            </SelectItem>
+                        ))}
+                    </SelectContent>
+                </Select>
+                <Input
+                    placeholder="Enter filter value..."
+                    value={value}
+                    onChange={e => setValue(e.target.value)}
+                />
+            </div>
+            <DialogFooter className="sm:justify-end">
+                {columnFilter && (
+                    <Button type="button" variant="secondary" onClick={e => column.setFilterValue(undefined)}>
+                        <Trans>Clear filter</Trans>
+                    </Button>
+                )}
+                <DialogClose asChild>
+                    <Button
+                        type="button"
+                        variant="secondary"
+                        onClick={e => column.setFilterValue({ [operator]: value })}
+                    >
+                        <Trans>Apply filter</Trans>
+                    </Button>
+                </DialogClose>
+            </DialogFooter>
+        </DialogContent>
+    );
+}

+ 50 - 27
packages/dashboard/src/framework/internal/data-table/data-table-view-options.tsx

@@ -1,8 +1,9 @@
 'use client';
 
+import { Badge } from '@/components/ui/badge.js';
 import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
 import { Table } from '@tanstack/react-table';
-import { Settings2 } from 'lucide-react';
+import { CircleX, Cross, Filter, Settings2 } from 'lucide-react';
 
 import { Button } from '@/components/ui/button.js';
 import {
@@ -18,33 +19,55 @@ interface DataTableViewOptionsProps<TData> {
 }
 
 export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps<TData>) {
+    const columnFilters = table.getState().columnFilters;
     return (
-        <DropdownMenu>
-            <DropdownMenuTrigger asChild>
-                <Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex">
-                    <Settings2 />
-                    View
-                </Button>
-            </DropdownMenuTrigger>
-            <DropdownMenuContent align="end" className="w-[150px]">
-                <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
-                <DropdownMenuSeparator />
-                {table
-                    .getAllColumns()
-                    .filter(column => typeof column.accessorFn !== 'undefined' && column.getCanHide())
-                    .map(column => {
-                        return (
-                            <DropdownMenuCheckboxItem
-                                key={column.id}
-                                className="capitalize"
-                                checked={column.getIsVisible()}
-                                onCheckedChange={value => column.toggleVisibility(!!value)}
+        <div className="flex items-center gap-2">
+            <div className="flex gap-1">
+                {columnFilters.map(f => {
+                    const [operator, value] = Object.entries(f.value as Record<string, string>)[0];
+                    return (
+                        <Badge key={f.id} className="flex gap-1 items-center" variant="secondary">
+                            <Filter size="12" className="opacity-50" />
+                            <div>{f.id}</div>
+                            <div>{operator}</div>
+                            <div>{value}</div>
+                            <button
+                                className="cursor-pointer"
+                                onClick={() => table.setColumnFilters(old => old.filter(x => x.id !== f.id))}
                             >
-                                {column.id}
-                            </DropdownMenuCheckboxItem>
-                        );
-                    })}
-            </DropdownMenuContent>
-        </DropdownMenu>
+                                <CircleX size="14" />
+                            </button>
+                        </Badge>
+                    );
+                })}
+            </div>
+            <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                    <Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex">
+                        <Settings2 />
+                        View
+                    </Button>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent align="end" className="w-[150px]">
+                    <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
+                    <DropdownMenuSeparator />
+                    {table
+                        .getAllColumns()
+                        .filter(column => typeof column.accessorFn !== 'undefined' && column.getCanHide())
+                        .map(column => {
+                            return (
+                                <DropdownMenuCheckboxItem
+                                    key={column.id}
+                                    className="capitalize"
+                                    checked={column.getIsVisible()}
+                                    onCheckedChange={value => column.toggleVisibility(!!value)}
+                                >
+                                    {column.id}
+                                </DropdownMenuCheckboxItem>
+                            );
+                        })}
+                </DropdownMenuContent>
+            </DropdownMenu>
+        </div>
     );
 }

+ 18 - 1
packages/dashboard/src/framework/internal/data-table/data-table.tsx

@@ -14,6 +14,8 @@ import {
     SortingState,
     Table as TableType,
     useReactTable,
+    ColumnFilter,
+    ColumnFiltersState,
 } from '@tanstack/react-table';
 import React, { useEffect } from 'react';
 
@@ -23,8 +25,11 @@ interface DataTableProps<TData, TValue> {
     totalItems: number;
     page?: number;
     itemsPerPage?: number;
+    sorting?: SortingState;
+    columnFilters?: ColumnFiltersState;
     onPageChange?: (table: TableType<TData>, page: number, itemsPerPage: number) => void;
     onSortChange?: (table: TableType<TData>, sorting: SortingState) => void;
+    onFilterChange?: (table: TableType<TData>, columnFilters: ColumnFilter[]) => void;
     defaultColumnVisibility?: VisibilityState;
 }
 
@@ -34,11 +39,15 @@ export function DataTable<TData, TValue>({
     totalItems,
     page,
     itemsPerPage,
+    sorting: sortingInitialState,
+    columnFilters: filtersInitialState,
     onPageChange,
     onSortChange,
+    onFilterChange,
     defaultColumnVisibility,
 }: DataTableProps<TData, TValue>) {
-    const [sorting, setSorting] = React.useState<SortingState>([]);
+    const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
+    const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
     const [pagination, setPagination] = React.useState<PaginationState>({
         pageIndex: (page ?? 1) - 1,
         pageSize: itemsPerPage ?? 10,
@@ -46,6 +55,7 @@ export function DataTable<TData, TValue>({
     const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(
         defaultColumnVisibility ?? {},
     );
+
     const table = useReactTable({
         data,
         columns,
@@ -53,14 +63,17 @@ export function DataTable<TData, TValue>({
         getPaginationRowModel: getPaginationRowModel(),
         manualPagination: true,
         manualSorting: true,
+        manualFiltering: true,
         rowCount: totalItems,
         onPaginationChange: setPagination,
         onSortingChange: setSorting,
         onColumnVisibilityChange: setColumnVisibility,
+        onColumnFiltersChange: setColumnFilters,
         state: {
             pagination,
             sorting,
             columnVisibility,
+            columnFilters,
         },
     });
 
@@ -72,6 +85,10 @@ export function DataTable<TData, TValue>({
         onSortChange?.(table, sorting);
     }, [sorting]);
 
+    useEffect(() => {
+        onFilterChange?.(table, columnFilters);
+    }, [columnFilters]);
+
     return (
         <>
             <DataTableViewOptions table={table} />

+ 42 - 47
packages/dashboard/src/framework/internal/page/list-page.tsx

@@ -1,5 +1,5 @@
-import { Button } from '@/components/ui/button.js';
 import { useComponentRegistry } from '@/framework/internal/component-registry/component-registry.js';
+import { DataTableColumnHeader } from '@/framework/internal/data-table/data-table-column-header.js';
 import { DataTable } from '@/framework/internal/data-table/data-table.js';
 import {
     getListQueryFields,
@@ -10,10 +10,15 @@ import { api } from '@/graphql/api.js';
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { useQuery } from '@tanstack/react-query';
 import { AnyRoute, AnyRouter, useNavigate } from '@tanstack/react-router';
-import { createColumnHelper, SortingState, Table } from '@tanstack/react-table';
+import {
+    ColumnFiltersState,
+    ColumnSort,
+    createColumnHelper,
+    SortingState,
+    Table,
+} from '@tanstack/react-table';
 import { ColumnDef } from '@tanstack/table-core';
 import { ResultOf } from 'gql.tada';
-import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
 import React from 'react';
 
 type ListQueryFields<T extends TypedDocumentNode> = {
@@ -39,6 +44,7 @@ export interface ListPageProps<T extends TypedDocumentNode<U>, U extends ListQue
     title: string;
     listQuery: T;
     customizeColumns?: CustomizeColumnConfig<T>;
+    // TODO: not yet implemented
     defaultColumnOrder?: (keyof ListQueryFields<T>)[];
     defaultVisibility?: Partial<Record<keyof ListQueryFields<T>, boolean>>;
     route: AnyRoute;
@@ -58,16 +64,25 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
         page: routeSearch.page ? parseInt(routeSearch.page) : 1,
         itemsPerPage: routeSearch.perPage ? parseInt(routeSearch.perPage) : 10,
     };
-    const sorting = routeSearch.sort;
-    const sort = (sorting?.split(',') || [])?.reduce((acc: any, sort: string) => {
-        const direction = sort.startsWith('-') ? 'DESC' : 'ASC';
-        const field = sort.replace(/^-/, '');
+    const sorting: SortingState = (routeSearch.sort ?? '').split(',').map((s: string) => {
+        return {
+            id: s.replace(/^-/, ''),
+            desc: s.startsWith('-'),
+        };
+    });
+    const sort = sorting?.reduce((acc: any, sort: ColumnSort) => {
+        const direction = sort.desc ? 'DESC' : 'ASC';
+        const field = sort.id;
 
         if (!field || !direction) {
             return acc;
         }
         return { ...acc, [field]: direction };
     }, {});
+    const columnFilters = routeSearch.filters;
+    const filter = columnFilters?.length
+        ? { _and: (routeSearch.filters as ColumnFiltersState).map(f => ({ [f.id]: f.value })) }
+        : undefined;
     const { data } = useQuery({
         queryFn: () =>
             api.query(listQuery, {
@@ -75,9 +90,10 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
                     take: pagination.itemsPerPage,
                     skip: (pagination.page - 1) * pagination.itemsPerPage,
                     sort,
+                    filter,
                 },
             }),
-        queryKey: ['ListPage', route.id, pagination, sorting],
+        queryKey: ['ListPage', route.id, pagination, sorting, filter],
     });
     const fields = getListQueryFields(listQuery);
     const queryName = getQueryName(listQuery);
@@ -87,7 +103,9 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
         const customConfig = customizeColumns?.[field.name as keyof ListQueryFields<T>] ?? {};
         const { header, ...customConfigRest } = customConfig;
         return columnHelper.accessor(field.name as any, {
-            meta: { type: field.type },
+            meta: { field },
+            enableColumnFilter: field.isScalar,
+            enableSorting: field.isScalar,
             cell: ({ cell }) => {
                 const value = cell.getValue();
                 if (field.list && Array.isArray(value)) {
@@ -95,7 +113,7 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
                 }
                 let Cmp: React.ComponentType<{ value: any }> | undefined = undefined;
                 if ((field.type === 'DateTime' && typeof value === 'string') || value instanceof Date) {
-                    Cmp = getComponent('dateTime.display');
+                    Cmp = getComponent('boolean.display');
                 }
                 if (field.type === 'Boolean') {
                     Cmp = getComponent('boolean.display');
@@ -110,37 +128,7 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
                 return value;
             },
             header: headerContext => {
-                const column = headerContext.column;
-                // By default, we only enable sorting on scalar type fields,
-                // unless explicitly configured otherwise.
-                const isSortable = customConfig.enableSorting || field.isScalar;
-
-                const customHeader = customConfig.header;
-                let display = field.name;
-                if (typeof customHeader === 'function') {
-                    display = customHeader(headerContext);
-                } else if (typeof customHeader === 'string') {
-                    display = customHeader;
-                }
-
-                const columSort = column.getIsSorted();
-                const nextSort = columSort === 'asc' ? true : columSort === 'desc' ? undefined : false;
-                return (
-                    <>
-                        {display}
-                        {isSortable && (
-                            <Button variant="ghost" onClick={() => column.toggleSorting(nextSort)}>
-                                {columSort === 'desc' ? (
-                                    <ArrowUp />
-                                ) : columSort === 'asc' ? (
-                                    <ArrowDown />
-                                ) : (
-                                    <ArrowUpDown className="opacity-30" />
-                                )}
-                            </Button>
-                        )}
-                    </>
-                );
+                return <DataTableColumnHeader headerContext={headerContext} customConfig={customConfig} />;
             },
             ...customConfigRest,
         });
@@ -153,25 +141,26 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
         ...(defaultVisibility ?? {}),
     };
 
+    function sortToString(sortingStates?: SortingState) {
+        return sortingStates?.map(s => `${s.desc ? '-' : ''}${s.id}`).join(',');
+    }
+
     function persistListStateToUrl(
         table: Table<any>,
         listState: {
             page?: number;
             perPage?: number;
             sort?: SortingState;
+            filters?: ColumnFiltersState;
         },
     ) {
         const tableState = table.getState();
         const page = listState.page || tableState.pagination.pageIndex + 1;
         const perPage = listState.perPage || tableState.pagination.pageSize;
-
-        function sortToString(sortingStates?: SortingState) {
-            return sortingStates?.map(s => `${s.desc ? '-' : ''}${s.id}`).join(',');
-        }
-
         const sort = sortToString(listState.sort ?? tableState.sorting);
+        const filters = listState.filters ?? tableState.columnFilters;
         navigate({
-            search: () => ({ sort, page, perPage }) as never,
+            search: () => ({ sort, page, perPage, filters: filters.length ? filters : undefined }) as never,
         });
     }
 
@@ -183,6 +172,8 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
                 data={(data as any)?.[queryName]?.items ?? []}
                 page={pagination.page}
                 itemsPerPage={pagination.itemsPerPage}
+                sorting={sorting}
+                columnFilters={columnFilters}
                 totalItems={(data as any)?.[queryName]?.totalItems ?? 0}
                 onPageChange={(table, page, perPage) => {
                     persistListStateToUrl(table, { page, perPage });
@@ -190,6 +181,10 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
                 onSortChange={(table, sorting) => {
                     persistListStateToUrl(table, { sort: sorting });
                 }}
+                onFilterChange={(table, filters) => {
+                    console.log('filters', filters);
+                    persistListStateToUrl(table, { filters });
+                }}
                 defaultColumnVisibility={columnVisibility}
             ></DataTable>
         </div>

+ 14 - 0
packages/dashboard/src/i18n/locales/de.po

@@ -13,6 +13,19 @@ msgstr ""
 "Language-Team: \n"
 "Plural-Forms: \n"
 
+#: src/framework/internal/data-table/data-table-filter-dialog.tsx:44
+msgctxt "filter-operator"
+msgid "{op}"
+msgstr ""
+
+#: src/framework/internal/data-table/data-table-filter-dialog.tsx:62
+msgid "Apply filter"
+msgstr ""
+
+#: src/framework/internal/data-table/data-table-filter-dialog.tsx:32
+msgid "Filter by {columnId}"
+msgstr ""
+
 #: src/components/login-form.tsx:57
 msgid "Password"
 msgstr "Passwort"
@@ -21,5 +34,6 @@ msgstr "Passwort"
 msgid "User"
 msgstr "Benutzer"
 
+#: src/components/login-form.tsx:36
 msgid "Welcome back!"
 msgstr "Willkommen zurück!"

+ 13 - 0
packages/dashboard/src/i18n/locales/en.po

@@ -13,6 +13,19 @@ msgstr ""
 "Language-Team: \n"
 "Plural-Forms: \n"
 
+#: src/framework/internal/data-table/data-table-filter-dialog.tsx:44
+msgctxt "filter-operator"
+msgid "{op}"
+msgstr "{op}"
+
+#: src/framework/internal/data-table/data-table-filter-dialog.tsx:62
+msgid "Apply filter"
+msgstr "Apply filter"
+
+#: src/framework/internal/data-table/data-table-filter-dialog.tsx:32
+msgid "Filter by {columnId}"
+msgstr "Filter by {columnId}"
+
 #: src/components/login-form.tsx:57
 msgid "Password"
 msgstr "Password"

+ 0 - 1
packages/dashboard/src/routes/_authenticated/products.tsx

@@ -46,7 +46,6 @@ export function ProductListPage() {
             title="Products"
             listQuery={productListDocument}
             customizeColumns={{
-                id: { enableHiding: true },
                 name: { header: 'Product Name' },
                 featuredAsset: {
                     header: 'Image',