Browse Source

feat(dashboard): Improve support for data table filtering

Michael Bromley 8 months ago
parent
commit
d8729cfb57

+ 61 - 0
packages/dashboard/src/lib/components/data-table/add-filter-menu.tsx

@@ -0,0 +1,61 @@
+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 '@/components/data-table/data-table-filter-dialog.js';
+import { Column, ColumnDef } from '@tanstack/react-table';
+import { PlusCircle } from 'lucide-react';
+import { Trans } from '@/lib/trans.js';
+import React, { useState } from 'react';
+import { camelCaseToTitleCase } from '@/lib/utils.js';
+
+export interface AddFilterMenuProps {
+    columns: Column<any, unknown>[];
+}
+
+export function AddFilterMenu({ columns }: AddFilterMenuProps) {
+    const [selectedColumn, setSelectedColumn] = useState<ColumnDef<any> | null>(null);
+    const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+    const filterableColumns = columns.filter(column => column.getCanFilter());
+
+    return (
+        <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
+            <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                    <Button variant="outline" size="sm" className="h-8 border-dashed">
+                        <PlusCircle className="mr-2 h-4 w-4" />
+                        <Trans>Add filter</Trans>
+                    </Button>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent align="end" className="w-[200px]">
+                    {filterableColumns.map(column => (
+                        <DropdownMenuItem
+                            key={column.id}
+                            onSelect={() => {
+                                setSelectedColumn(column);
+                                setIsDialogOpen(true);
+                            }}
+                        >
+                            {camelCaseToTitleCase(column.id)}
+                        </DropdownMenuItem>
+                    ))}
+                </DropdownMenuContent>
+            </DropdownMenu>
+            {selectedColumn && (
+                <DataTableFilterDialog column={selectedColumn as any} />
+            )}
+        </Dialog>
+    );
+}

+ 0 - 13
packages/dashboard/src/lib/components/data-table/data-table-column-header.tsx

@@ -29,7 +29,6 @@ export interface DataTableColumnHeaderProps {
 export function DataTableColumnHeader({ headerContext, customConfig }: DataTableColumnHeaderProps) {
     const { column } = headerContext;
     const isSortable = column.getCanSort();
-    const isFilterable = column.getCanFilter();
 
     const customHeader = customConfig.header;
     let display = camelCaseToTitleCase(column.id);
@@ -40,7 +39,6 @@ export function DataTableColumnHeader({ headerContext, customConfig }: DataTable
     }
 
     const columSort = column.getIsSorted();
-    const columnFilter = column.getFilterValue();
     const nextSort = columSort === 'asc' ? true : columSort === 'desc' ? undefined : false;
 
     return (
@@ -57,17 +55,6 @@ export function DataTableColumnHeader({ headerContext, customConfig }: DataTable
                 </Button>
             )}
             <div>{display}</div>
-
-            {isFilterable && (
-                <Dialog>
-                    <DialogTrigger asChild>
-                        <Button size="icon-sm" variant="ghost">
-                            <Filter className={columnFilter ? '' : 'opacity-50'} />
-                        </Button>
-                    </DialogTrigger>
-                    <DataTableFilterDialog column={column} />
-                </Dialog>
-            )}
         </div>
     );
 }

+ 75 - 0
packages/dashboard/src/lib/components/data-table/data-table-filter-badge.tsx

@@ -0,0 +1,75 @@
+import { Filter } from 'lucide-react';
+
+import { CircleX } from 'lucide-react';
+import { Badge } from '../ui/badge.js';
+import { useLocalFormat } from '@/hooks/use-local-format.js';
+import { ColumnDataType } from './data-table-types.js';
+import { HumanReadableOperator } from './human-readable-operator.js';
+
+export function DataTableFilterBadge({
+    filter,
+    onRemove,
+    dataType,
+    currencyCode,
+}: {
+    filter: any;
+    onRemove: (filter: any) => void;
+    dataType: ColumnDataType;
+    currencyCode: string;
+}) {
+    const [operator, value] = Object.entries(filter.value as Record<string, unknown>)[0];
+    return (
+        <Badge key={filter.id} className="flex gap-1 items-center" variant="secondary">
+            <Filter size="12" className="opacity-50" />
+            <div>{filter.id}</div>
+            <div className="text-muted-foreground"><HumanReadableOperator operator={operator} mode="short" /></div>
+            <FilterValue value={value} dataType={dataType} currencyCode={currencyCode} />
+            <button className="cursor-pointer" onClick={() => onRemove(filter)}>
+                <CircleX size="14" />
+            </button>
+        </Badge>
+    );
+}
+
+function FilterValue({ value, dataType, currencyCode }: { value: unknown, dataType: ColumnDataType, currencyCode: string }) {
+    const { formatDate, formatCurrency } = useLocalFormat();
+    if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
+        return Object.entries(value as Record<string, unknown>).map(([key, value]) => (
+            <div key={key} className="flex gap-1 items-center">
+                <span className="text-muted-foreground">{key}: </span>
+                <FilterValue value={value} dataType={dataType} currencyCode={currencyCode} />
+            </div>
+        ));
+    }
+    if (Array.isArray(value)) {
+        return (
+            <div className="flex gap-1 items-center">
+                [
+                {value.map(v => (
+                    <FilterValue value={v} dataType={dataType} currencyCode={currencyCode} key={v} />
+                ))}
+                ]
+            </div>
+        );
+    }
+    if (typeof value === 'string' && isDateIsoString(value)) {
+        return <div>{formatDate(value, { dateStyle: 'short', timeStyle: 'short' })}</div>;
+    }
+    if (typeof value === 'boolean') {
+        return <div>{value ? 'true' : 'false'}</div>;
+    }
+    if (typeof value === 'number' && dataType === 'Money') {
+        return <div>{formatCurrency(value, currencyCode)}</div>;
+    }
+    if (typeof value === 'number') {
+        return <div>{value}</div>;
+    }
+    if (typeof value === 'string') {
+        return <div>{value}</div>;
+    }
+    return <div>{value as string}</div>;
+}
+
+function isDateIsoString(value: string) {
+    return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value);
+}

+ 27 - 28
packages/dashboard/src/lib/components/data-table/data-table-filter-dialog.tsx

@@ -7,23 +7,25 @@ import {
     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 '@/lib/trans.js';
 import { Column } from '@tanstack/react-table';
-import React, { useState } from 'react';
+import { useState } from 'react';
+import { DataTableBooleanFilter } from './filters/data-table-boolean-filter.js';
+import { DataTableDateTimeFilter } from './filters/data-table-datetime-filter.js';
+import { DataTableIdFilter } from './filters/data-table-id-filter.js';
+import { DataTableNumberFilter } from './filters/data-table-number-filter.js';
+import { DataTableStringFilter } from './filters/data-table-string-filter.js';
+import { ColumnDataType } from './data-table-types.js';
 
 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 [filter, setFilter] = useState(columnFilter);
+
+    const columnDataType = (column.columnDef.meta as any)?.fieldInfo?.type as ColumnDataType;
     const columnId = column.id;
     return (
         <DialogContent>
@@ -33,25 +35,19 @@ export function DataTableFilterDialog({ column }: DataTableFilterDialogProps) {
                 </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>
+            {columnDataType === 'String' ? (
+                <DataTableStringFilter value={filter} onChange={e => setFilter(e)} />
+            ) : columnDataType === 'Int' || columnDataType === 'Float' ? (
+                <DataTableNumberFilter value={filter} onChange={e => setFilter(e)} mode='number' />
+            ) : columnDataType === 'DateTime' ? (
+                <DataTableDateTimeFilter value={filter} onChange={e => setFilter(e)} />
+            ) : columnDataType === 'Boolean' ? (
+                <DataTableBooleanFilter value={filter} onChange={e => setFilter(e)} />
+            ) : columnDataType === 'ID' ? (
+                <DataTableIdFilter value={filter} onChange={e => setFilter(e)} />
+            ) : columnDataType === 'Money' ? (
+                <DataTableNumberFilter value={filter} onChange={e => setFilter(e)} mode='money' />
+            ) : null}
             <DialogFooter className="sm:justify-end">
                 {columnFilter && (
                     <Button type="button" variant="secondary" onClick={e => column.setFilterValue(undefined)}>
@@ -62,7 +58,10 @@ export function DataTableFilterDialog({ column }: DataTableFilterDialogProps) {
                     <Button
                         type="button"
                         variant="secondary"
-                        onClick={e => column.setFilterValue({ [operator]: value })}
+                            onClick={e => {
+                            column.setFilterValue(filter);
+                            setFilter(undefined);
+                        }}
                     >
                         <Trans>Apply filter</Trans>
                     </Button>

+ 1 - 0
packages/dashboard/src/lib/components/data-table/data-table-types.ts

@@ -0,0 +1 @@
+export type ColumnDataType = 'String' | 'Int' | 'Float' | 'DateTime' | 'Boolean' | 'ID' | 'Money';

+ 16 - 24
packages/dashboard/src/lib/components/data-table/data-table.tsx

@@ -2,7 +2,6 @@
 
 import { DataTablePagination } from '@/components/data-table/data-table-pagination.js';
 import { DataTableViewOptions } from '@/components/data-table/data-table-view-options.js';
-import { Badge } from '@/components/ui/badge.js';
 import { Input } from '@/components/ui/input.js';
 import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table.js';
 import {
@@ -19,9 +18,11 @@ import {
     VisibilityState,
 } from '@tanstack/react-table';
 import { TableOptions } from '@tanstack/table-core';
-import { CircleX, Filter } from 'lucide-react';
 import React, { Suspense, useEffect } from 'react';
+import { AddFilterMenu } from './add-filter-menu.js';
 import { DataTableFacetedFilter, DataTableFacetedFilterOption } from './data-table-faceted-filter.js';
+import { DataTableFilterBadge } from './data-table-filter-badge.js';
+import { useChannel } from '@/hooks/use-channel.js';
 
 export interface FacetedFilter {
     title: string;
@@ -73,6 +74,7 @@ export function DataTable<TData, TValue>({
 }: DataTableProps<TData, TValue>) {
     const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
     const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
+    const { activeChannel } = useChannel();
     const [pagination, setPagination] = React.useState<PaginationState>({
         pageIndex: (page ?? 1) - 1,
         pageSize: itemsPerPage ?? 10,
@@ -149,30 +151,20 @@ export function DataTable<TData, TValue>({
                                 />
                             ))}
                         </Suspense>
+                        <AddFilterMenu columns={table.getAllColumns()} />
                     </div>
                     <div className="flex gap-1">
                         {columnFilters
                             .filter(f => !facetedFilters?.[f.id])
                             .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={() =>
-                                                setColumnFilters(old => old.filter(x => x.id !== f.id))
-                                            }
-                                        >
-                                            <CircleX size="14" />
-                                        </button>
-                                    </Badge>
-                                );
+                                const column = table.getColumn(f.id);
+                                const currency = activeChannel?.defaultCurrencyCode ?? 'USD';
+                                return <DataTableFilterBadge
+                                            key={f.id}
+                                            filter={f}
+                                            currencyCode={currency}
+                                            dataType={(column?.columnDef.meta as any)?.fieldInfo?.type ?? 'String'}
+                                            onRemove={() => setColumnFilters(old => old.filter(x => x.id !== f.id))} />;
                             })}
                     </div>
                 </div>
@@ -189,9 +181,9 @@ export function DataTable<TData, TValue>({
                                             {header.isPlaceholder
                                                 ? null
                                                 : flexRender(
-                                                      header.column.columnDef.header,
-                                                      header.getContext(),
-                                                  )}
+                                                    header.column.columnDef.header,
+                                                    header.getContext(),
+                                                )}
                                         </TableHead>
                                     );
                                 })}

+ 57 - 0
packages/dashboard/src/lib/components/data-table/filters/data-table-boolean-filter.tsx

@@ -0,0 +1,57 @@
+import { Trans } from "@/lib/trans.js";
+
+import { Select, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select.js";
+
+import { SelectContent } from "@/components/ui/select.js";
+import { useEffect, useState } from "react";
+import { HumanReadableOperator } from "../human-readable-operator.js";
+
+export interface DataTableBooleanFilterProps {
+    value: Record<string, any> | undefined;
+    onChange: (filter: Record<string, any>) => void;
+}
+
+export const BOOLEAN_OPERATORS = ['eq', 'isNull'] as const;
+
+export function DataTableBooleanFilter({ value: incomingValue, onChange }: DataTableBooleanFilterProps) {
+    const initialOperator = incomingValue ? Object.keys(incomingValue)[0] ?? 'eq' : 'eq';
+    const initialValue = incomingValue ? Object.values(incomingValue)[0] : true;
+    const [operator, setOperator] = useState<string>(initialOperator ?? 'eq');
+    const [value, setValue] = useState<boolean>(initialValue as boolean ?? true);
+
+    useEffect(() => {
+        onChange({ [operator]: value });
+    }, [operator, value]);
+
+    return (
+        <div className="flex flex-col md:flex-row gap-2">
+            <Select value={operator} onValueChange={value => setOperator(value)}>
+                <SelectTrigger>
+                    <SelectValue placeholder="Select operator" />
+                </SelectTrigger>
+                <SelectContent>
+                    {BOOLEAN_OPERATORS.map(op => (
+                        <SelectItem key={op} value={op}>
+                            <HumanReadableOperator operator={op} />
+                        </SelectItem>
+                    ))}
+                </SelectContent>
+            </Select>
+            {operator !== 'isNull' && (
+                <Select value={value.toString()} onValueChange={v => setValue(v === 'true')}>
+                    <SelectTrigger>
+                        <SelectValue placeholder="Select value" />
+                    </SelectTrigger>
+                    <SelectContent>
+                        <SelectItem value="true">
+                            <Trans>True</Trans>
+                        </SelectItem>
+                        <SelectItem value="false">
+                            <Trans>False</Trans>
+                        </SelectItem>
+                    </SelectContent>
+                </Select>
+            )}
+        </div>
+    );
+}

+ 93 - 0
packages/dashboard/src/lib/components/data-table/filters/data-table-datetime-filter.tsx

@@ -0,0 +1,93 @@
+import { Trans } from "@/lib/trans.js";
+import { Select, SelectValue, SelectTrigger, SelectItem } from "@/components/ui/select.js";
+import { SelectContent } from "@/components/ui/select.js";
+import { useEffect, useState } from "react";
+import { DateTimeInput } from "@/components/data-input/datetime-input.js";
+import { HumanReadableOperator } from "../human-readable-operator.js";
+
+export interface DataTableDateTimeFilterProps {
+    value: Record<string, any> | undefined;
+    onChange: (filter: Record<string, any>) => void;
+}
+
+export const DATETIME_OPERATORS = ['eq', 'before', 'after', 'between', 'isNull'] as const;
+
+export function DataTableDateTimeFilter({ value: incomingValue, onChange }: DataTableDateTimeFilterProps) {
+    const initialOperator = incomingValue ? Object.keys(incomingValue)[0] : 'eq';
+    const initialValue = incomingValue ? Object.values(incomingValue)[0] : '';
+    const [operator, setOperator] = useState<string>(initialOperator ?? 'eq');
+    const [value, setValue] = useState<Date | undefined>(initialValue ? new Date(initialValue) : undefined);
+    const [startDate, setStartDate] = useState<Date | undefined>(undefined);
+    const [endDate, setEndDate] = useState<Date | undefined>(undefined);
+    const [error, setError] = useState<string>('');
+
+    useEffect(() => {
+        if (operator === 'isNull') {
+            onChange({ [operator]: true });
+            return;
+        }
+
+        if (operator === 'between') {
+            if (!startDate && !endDate) {
+                onChange({});
+                return;
+            }
+            if (!startDate || !endDate) {
+                setError('Please enter both start and end dates');
+                return;
+            }
+            if (startDate > endDate) {
+                setError('Start date must be before end date');
+                return;
+            }
+            setError('');
+            onChange({ [operator]: { start: startDate.toISOString(), end: endDate.toISOString() } });
+        } else {
+            if (!value) {
+                onChange({});
+                return;
+            }
+            setError('');
+            onChange({ [operator]: value.toISOString() });
+        }
+    }, [operator, value, startDate, endDate]);
+
+    return (
+        <div className="flex flex-col gap-2">
+            <div className="flex flex-col md:flex-row gap-2">
+                <Select value={operator} onValueChange={value => setOperator(value)}>
+                    <SelectTrigger>
+                        <SelectValue placeholder="Select operator" />
+                    </SelectTrigger>
+                    <SelectContent>
+                        {DATETIME_OPERATORS.map(op => (
+                            <SelectItem key={op} value={op}>
+                                <HumanReadableOperator operator={op} />
+                            </SelectItem>
+                        ))}
+                    </SelectContent>
+                </Select>
+                {operator !== 'isNull' && (
+                    operator === 'between' ? (
+                        <div className="space-y-2">
+                            <DateTimeInput
+                                value={startDate}
+                                onChange={setStartDate}
+                            />
+                            <DateTimeInput
+                                value={endDate}
+                                onChange={setEndDate}
+                            />
+                        </div>
+                    ) : (
+                        <DateTimeInput
+                            value={value}
+                            onChange={setValue}
+                        />
+                    )
+                )}
+            </div>
+            {error && <p className="text-sm text-red-500">{error}</p>}
+        </div>
+    );
+}

+ 58 - 0
packages/dashboard/src/lib/components/data-table/filters/data-table-id-filter.tsx

@@ -0,0 +1,58 @@
+import { Trans } from "@/lib/trans.js";
+
+import { Select, SelectValue, SelectTrigger, SelectItem } from "@/components/ui/select.js";
+
+import { SelectContent } from "@/components/ui/select.js";
+import { Input } from "@/components/ui/input.js";
+import { useEffect, useState } from "react";
+import { HumanReadableOperator } from "../human-readable-operator.js";
+
+export interface DataTableIdFilterProps {
+    value: Record<string, any> | undefined;
+    onChange: (filter: Record<string, any>) => void;
+}
+
+export const ID_OPERATORS = ['eq', 'notEq', 'in', 'notIn', 'isNull'] as const;
+
+export function DataTableIdFilter({ value: incomingValue, onChange }: DataTableIdFilterProps) {
+    const initialOperator = incomingValue ? Object.keys(incomingValue)[0] : 'eq';
+    const initialValue = incomingValue ? Object.values(incomingValue)[0] : '';
+    const [operator, setOperator] = useState<string>(initialOperator ?? 'eq');
+    const [value, setValue] = useState<string>(initialValue ?? '');
+
+    useEffect(() => {
+        if (operator === 'isNull') {
+            onChange({ [operator]: true });
+        } else if (operator === 'in' || operator === 'notIn') {
+            // Split by comma and trim whitespace
+            const values = value.split(',').map(v => v.trim()).filter(v => v);
+            onChange({ [operator]: values });
+        } else {
+            onChange({ [operator]: value });
+        }
+    }, [operator, value]);
+
+    return (
+        <div className="flex flex-col md:flex-row gap-2">
+            <Select value={operator} onValueChange={value => setOperator(value)}>
+                <SelectTrigger>
+                    <SelectValue placeholder="Select operator" />
+                </SelectTrigger>
+                <SelectContent>
+                    {ID_OPERATORS.map(op => (
+                        <SelectItem key={op} value={op}>
+                            <HumanReadableOperator operator={op} />
+                        </SelectItem>
+                    ))}
+                </SelectContent>
+            </Select>
+            {operator !== 'isNull' && (
+                <Input
+                    placeholder={operator === 'in' || operator === 'notIn' ? "Enter comma-separated IDs..." : "Enter ID..."}
+                    value={value}
+                    onChange={e => setValue(e.target.value)}
+                />
+            )}
+        </div>
+    );
+}

+ 119 - 0
packages/dashboard/src/lib/components/data-table/filters/data-table-number-filter.tsx

@@ -0,0 +1,119 @@
+import { Trans } from "@/lib/trans.js";
+import { Select, SelectValue, SelectTrigger, SelectItem } from "@/components/ui/select.js";
+import { SelectContent } from "@/components/ui/select.js";
+import { Input } from "@/components/ui/input.js";
+import { MoneyInput } from "@/components/data-input/money-input.js";
+import { useEffect, useState } from "react";
+import { useChannel } from "@/hooks/use-channel.js";
+import { HumanReadableOperator } from "../human-readable-operator.js";
+
+export interface DataTableNumberFilterProps {
+    mode: 'number' | 'money';
+    value: Record<string, any> | undefined;
+    onChange: (filter: Record<string, any>) => void;
+}
+
+export const NUMBER_OPERATORS = ['eq', 'gt', 'gte', 'lt', 'lte', 'isNull', 'between'] as const;
+
+export function DataTableNumberFilter({ mode, value: incomingValue, onChange }: DataTableNumberFilterProps) {
+    const { activeChannel } = useChannel();
+    const initialOperator = incomingValue ? Object.keys(incomingValue)[0] : 'eq';
+    const initialValue = incomingValue ? Object.values(incomingValue)[0] : 0;
+    const [operator, setOperator] = useState<string>(initialOperator ?? 'eq');
+    const [value, setValue] = useState<string>(initialValue?.toString() ?? '');
+    const [minValue, setMinValue] = useState<string>('');
+    const [maxValue, setMaxValue] = useState<string>('');
+    const [error, setError] = useState<string>('');
+
+    useEffect(() => {
+        if (operator === 'isNull') {
+            onChange({ [operator]: true });
+            return;
+        }
+
+        if (operator === 'between') {
+            if (!minValue && !maxValue) {
+                onChange({});
+                return;
+            }
+            if (!minValue || !maxValue) {
+                setError('Please enter both min and max values');
+                return;
+            }
+            const minNum = parseFloat(minValue);
+            const maxNum = parseFloat(maxValue);
+            if (isNaN(minNum) || isNaN(maxNum)) {
+                setError('Please enter valid numbers');
+                return;
+            }
+            if (minNum > maxNum) {
+                setError('Min value must be less than max value');
+                return;
+            }
+            setError('');
+            onChange({ [operator]: { start: minNum, end: maxNum } });
+        } else {
+            if (!value) {
+                onChange({});
+                return;
+            }
+            const numValue = parseFloat(value);
+            if (isNaN(numValue)) {
+                setError('Please enter a valid number');
+                return;
+            }
+            setError('');
+            onChange({ [operator]: numValue });
+        }
+    }, [operator, value, minValue, maxValue]);
+
+    const renderInput = (value: string, onChange: (value: string) => void, placeholder: string) => {
+        if (mode === 'money') {
+            return (
+                <MoneyInput
+                    value={parseFloat(value) || 0}
+                    onChange={(newValue) => onChange(newValue.toString())}
+                    currency={activeChannel?.defaultCurrencyCode ?? 'USD'}
+                />
+            );
+        }
+        return (
+            <Input
+                type="number"
+                placeholder={placeholder}
+                value={value}
+                onChange={e => onChange(e.target.value)}
+            />
+        );
+    };
+
+    return (
+        <div className="flex flex-col gap-2">
+            <div className="flex flex-col md:flex-row gap-2">
+                <Select value={operator} onValueChange={value => setOperator(value)}>
+                    <SelectTrigger>
+                        <SelectValue placeholder="Select operator" />
+                    </SelectTrigger>
+                    <SelectContent>
+                        {NUMBER_OPERATORS.map(op => (
+                            <SelectItem key={op} value={op}>
+                                <HumanReadableOperator operator={op} />
+                            </SelectItem>
+                        ))}
+                    </SelectContent>
+                </Select>
+                {operator !== 'isNull' && (
+                    operator === 'between' ? (
+                        <div className="flex gap-2">
+                            {renderInput(minValue, setMinValue, "Min")}
+                            {renderInput(maxValue, setMaxValue, "Max")}
+                        </div>
+                    ) : (
+                        renderInput(value, setValue, "Enter value...")
+                    )
+                )}
+            </div>
+            {error && <p className="text-sm text-red-500">{error}</p>}
+        </div>
+    );
+}

+ 62 - 0
packages/dashboard/src/lib/components/data-table/filters/data-table-string-filter.tsx

@@ -0,0 +1,62 @@
+import { Trans } from "@/lib/trans.js";
+
+import { Select, SelectValue, SelectTrigger, SelectItem } from "@/components/ui/select.js";
+
+import { SelectContent } from "@/components/ui/select.js";
+import { Input } from "@/components/ui/input.js";
+import { useEffect, useState } from "react";
+import { HumanReadableOperator } from "../human-readable-operator.js";
+
+export interface DataTableStringFilterProps {
+    value: Record<string, any> | undefined;
+    onChange: (filter: Record<string, any>) => void;
+}
+
+export const STRING_OPERATORS = ['eq', 'notEq', 'contains', 'notContains', 'in', 'notIn', 'regex', 'isNull'] as const;
+
+export function DataTableStringFilter({ value: incomingValue, onChange }: DataTableStringFilterProps) {
+    const initialOperator = incomingValue ? Object.keys(incomingValue)[0] : 'contains';
+    const initialValue = incomingValue ? Object.values(incomingValue)[0] : '';
+    const [operator, setOperator] = useState<string>(initialOperator ?? 'contains');
+    const [value, setValue] = useState((initialValue as string) ?? '');
+
+    useEffect(() => {
+        if (operator === 'isNull') {
+            onChange({ [operator]: true });
+        } else if (operator === 'in' || operator === 'notIn') {
+            // Split by comma and trim whitespace
+            if (typeof value === 'string') {
+                const values = value.split(',').map(v => v.trim()).filter(v => v);
+                onChange({ [operator]: values });
+            } else {
+                onChange({ [operator]: [] });
+            }
+        } else {
+            onChange({ [operator]: value });
+        }
+    }, [operator, value]);
+
+    return (
+        <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}>
+                            <HumanReadableOperator operator={op} />
+                        </SelectItem>
+                    ))}
+                </SelectContent>
+            </Select>
+            {operator !== 'isNull' && (
+                <Input
+                    placeholder={operator === 'in' || operator === 'notIn' ? "Enter comma-separated values..." : "Enter filter value..."}
+                    value={value}
+                    onChange={e => setValue(e.target.value)}
+                />
+            )}
+        </div>
+    )
+}

+ 65 - 0
packages/dashboard/src/lib/components/data-table/human-readable-operator.tsx

@@ -0,0 +1,65 @@
+import { DATETIME_OPERATORS } from './filters/data-table-datetime-filter.js';
+import { BOOLEAN_OPERATORS } from './filters/data-table-boolean-filter.js';
+import { ID_OPERATORS } from './filters/data-table-id-filter.js';
+import { NUMBER_OPERATORS } from './filters/data-table-number-filter.js';
+import { STRING_OPERATORS } from './filters/data-table-string-filter.js';
+import { Trans } from '@/lib/trans.js';
+
+type Operator =
+    | (typeof DATETIME_OPERATORS)[number]
+    | (typeof BOOLEAN_OPERATORS)[number]
+    | (typeof ID_OPERATORS)[number]
+    | (typeof NUMBER_OPERATORS)[number]
+    | (typeof STRING_OPERATORS)[number];
+
+export function HumanReadableOperator({
+    operator,
+    mode = 'long',
+}: {
+    operator: Operator;
+    mode?: 'short' | 'long';
+}) {
+    switch (operator) {
+        case 'eq':
+            return mode === 'short' ? <Trans>=</Trans> : <Trans>is equal to</Trans>;
+        case 'notEq':
+            return mode === 'short' ? <Trans>!=</Trans> : <Trans>is not equal to</Trans>;
+        case 'before':
+            return mode === 'short' ? <Trans>before</Trans> : <Trans>is before</Trans>;
+        case 'after':
+            return mode === 'short' ? <Trans>after</Trans> : <Trans>is after</Trans>;
+        case 'between':
+            return mode === 'short' ? <Trans>between</Trans> : <Trans>is between</Trans>;
+        case 'isNull':
+            return mode === 'short' ? <Trans>is null</Trans> : <Trans>is null</Trans>;
+        case 'in':
+            return mode === 'short' ? <Trans>in</Trans> : <Trans>is in</Trans>;
+        case 'notIn':
+            return mode === 'short' ? <Trans>not in</Trans> : <Trans>is not in</Trans>;
+        case 'gt':
+            return mode === 'short' ? <Trans>greater than</Trans> : <Trans>is greater than</Trans>;
+        case 'gte':
+            return mode === 'short' ? (
+                <Trans>greater than or equal</Trans>
+            ) : (
+                <Trans>is greater than or equal to</Trans>
+            );
+        case 'lt':
+            return mode === 'short' ? <Trans>less than</Trans> : <Trans>is less than</Trans>;
+        case 'lte':
+            return mode === 'short' ? (
+                <Trans>less than or equal</Trans>
+            ) : (
+                <Trans>is less than or equal to</Trans>
+            );
+        case 'contains':
+            return mode === 'short' ? <Trans>contains</Trans> : <Trans>contains</Trans>;
+        case 'notContains':
+            return mode === 'short' ? <Trans>does not contain</Trans> : <Trans>does not contain</Trans>;
+        case 'regex':
+            return mode === 'short' ? <Trans>matches regex</Trans> : <Trans>matches regex</Trans>;
+        default:
+            operator satisfies never;
+            return <Trans>{operator}</Trans>;
+    }
+}

+ 5 - 0
packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx

@@ -333,6 +333,10 @@ export function PaginatedListDataTable<
                 meta: { fieldInfo, isCustomField },
                 enableColumnFilter,
                 enableSorting: fieldInfo.isScalar,
+                // Filtering is done on the server side, but we set this to 'equalsString' because
+                // otherwise the TanStack Table with apply an "auto" function which somehow
+                // prevents certain filters from working.
+                filterFn: 'equalsString',
                 cell: ({ cell, row }) => {
                     const value = !isCustomField
                         ? cell.getValue()
@@ -437,6 +441,7 @@ function getRowActions(
         id: 'actions',
         accessorKey: 'actions',
         header: 'Actions',
+        enableColumnFilter: false,
         cell: ({ row }) => {
             return (
                 <DropdownMenu>