feat(filters): dynamic filter framework — schema-driven filters with URL state (#48)

* feat(filters): dynamic filter framework — typed schema, URL state, FilterBar

Port of dflow's filter.utils pattern, adapted to Containr:

- lib/dynamic-filter.ts: FilterConfig schema (search/select/multiselect/
  boolean/date-range/status), FilterEngine with client-side apply,
  derived options with counts, active-filter tracking, removal.
- lib/use-dynamic-filter.ts: filter state lives in ?f.<key> search
  params — filtered views are shareable links. useFilterState exposes
  URL state before data loads (server-backed filters); useDynamicFilter
  adds the engine over fetched data.
- shared/components/FilterBar.tsx: renders a schema as compact controls
  + active-filter chips, matching design tokens.
- Applied: audit logs (server-backed — state in URL, params to API),
  builds (server-backed + client date-range), activity (severity to the
  server query, category/search on the fetched page).
- 8 unit tests for the engine.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(filters): erasableSyntaxOnly compat + BuildStatus cast

- Constructor parameter properties are banned under erasableSyntaxOnly —
  declare fields explicitly.
- listBuilds status param needs the BuildStatus type, not raw string.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-09 12:12:17 +02:00
1 parent 8cfe2ca826
commit d4a4cde1fd
10 files changed
+639 -150

No files matched your search

@@ -2,8 +2,10 @@ import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { listActivity, type ActivityEntry } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
import { formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import { DemoRestricted, FilterBar } from '@/shared/components';
import {
Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key,
Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench,
@@ -22,23 +24,37 @@ const SEVERITY_STYLE: Record<string, { icon: typeof Info; cls: string; dot: stri
info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' },
};
const SEVERITIES = ['', 'error', 'warning', 'success', 'info'] as const;
const SCHEMA: FilterConfig<ActivityEntry>[] = [
{ key: 'q', label: 'Search', type: 'search', searchFields: ['label', 'action', 'user_email'] },
{
key: 'severity', label: 'Severity', type: 'select',
options: [
{ value: 'error', label: 'Error' },
{ value: 'warning', label: 'Warning' },
{ value: 'success', label: 'Success' },
{ value: 'info', label: 'Info' },
],
},
{ key: 'category', label: 'Category', type: 'select' },
];
export function ActivityPage() {
const isDemoMode = useDemoMode();
const [severity, setSeverity] = useState('');
const [page, setPage] = useState(1);
const { filters } = useFilterState(SCHEMA);
const severityParam = filters.severity === 'all' ? '' : String(filters.severity ?? '');
const query = useQuery({
queryKey: ['activity-feed', severity, page],
queryFn: () => listActivity({ severity: severity || undefined, page, limit: 50 }),
queryKey: ['activity-feed', severityParam, page],
queryFn: () => listActivity({ severity: severityParam || undefined, page, limit: 50 }),
enabled: !isDemoMode,
refetchInterval: 15000,
});
const filter = useDynamicFilter<ActivityEntry>({ data: query.data?.activity ?? [], schema: SCHEMA });
if (isDemoMode) return <DemoRestricted feature="Activity" />;
const entries = query.data?.activity ?? [];
const entries = filter.filteredData;
return (
<div className="min-h-screen">
@@ -52,21 +68,11 @@ export function ActivityPage() {
</div>
<div className="px-8 py-6 space-y-4">
<div className="flex items-center gap-2">
{SEVERITIES.map((s) => (
<button
key={s || 'all'}
onClick={() => { setSeverity(s); setPage(1); }}
className={`v-mono text-[10px] uppercase px-3 py-1.5 rounded-md border transition-colors ${
severity === s
? 'border-[var(--accent-primary)] text-[var(--accent-primary)] bg-[var(--accent-primary)]/10'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
}`}
>
{s || 'all'}
</button>
))}
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" />}
<div className="flex items-start gap-3">
<div className="flex-1">
<FilterBar {...filter} />
</div>
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)] mt-3" />}
</div>
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
@@ -2,8 +2,10 @@ import { useDeferredValue, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listAuditLogs } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter } from '@/lib/use-dynamic-filter';
import { formatDate, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import { DemoRestricted, FilterBar } from '@/shared/components';
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
const PAGE_SIZE = 50;
@@ -15,20 +17,34 @@ const rangeOptions = [
{ value: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
] as const;
const inputClass =
'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
// Server-backed filters — the engine runs over empty data; values map to
// API params. URL state makes filtered views shareable.
const AUDIT_SCHEMA: FilterConfig<unknown>[] = [
{ key: 'resource', label: 'Resource', type: 'search' },
{ key: 'action', label: 'Action', type: 'search' },
{ key: 'actor', label: 'Actor', type: 'search' },
{
key: 'range', label: 'Time', type: 'select',
options: rangeOptions.filter((o) => o.value !== '').map((o) => ({ value: o.value, label: o.label })),
},
];
export function AuditLogsPage() {
const isDemoMode = useDemoMode();
const [resource, setResource] = useState('');
const [action, setAction] = useState('');
const [actor, setActor] = useState('');
const [range, setRange] = useState('');
const filter = useDynamicFilter<unknown>({ data: [], schema: AUDIT_SCHEMA });
const [page, setPage] = useState(1);
// Back to page 1 whenever the filter set changes.
const filtersJson = JSON.stringify(filter.filters);
const [prevFiltersJson, setPrevFiltersJson] = useState(filtersJson);
if (prevFiltersJson !== filtersJson) {
setPrevFiltersJson(filtersJson);
setPage(1);
}
const deferredResource = useDeferredValue(resource);
const deferredAction = useDeferredValue(action);
const deferredActor = useDeferredValue(actor);
const deferredResource = useDeferredValue(String(filter.filters.resource ?? ''));
const deferredAction = useDeferredValue(String(filter.filters.action ?? ''));
const deferredActor = useDeferredValue(String(filter.filters.actor ?? ''));
const range = filter.filters.range === 'all' ? '' : String(filter.filters.range ?? '');
const filters = {
resource: deferredResource.trim() || undefined,
@@ -54,15 +70,7 @@ export function AuditLogsPage() {
const logs = logsQuery.data ?? [];
const hasNext = logs.length === PAGE_SIZE;
const hasFilters = Boolean(resource || action || actor || range);
const resetFilters = () => {
setResource('');
setAction('');
setActor('');
setRange('');
setPage(1);
};
const hasFilters = filter.activeCount > 0;
return (
<div className="min-h-screen">
@@ -79,63 +87,7 @@ export function AuditLogsPage() {
<div className="w-full px-8 py-6">
<section className="panel p-6">
<div className="flex flex-wrap items-end gap-3 mb-5">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Resource
</label>
<input
value={resource}
onChange={(e) => { setResource(e.target.value); setPage(1); }}
placeholder="service, project…"
className={`${inputClass} w-44`}
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Action
</label>
<input
value={action}
onChange={(e) => { setAction(e.target.value); setPage(1); }}
placeholder="create, deploy…"
className={`${inputClass} w-44`}
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Actor
</label>
<input
value={actor}
onChange={(e) => { setActor(e.target.value); setPage(1); }}
placeholder="user email"
className={`${inputClass} w-52`}
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
Time
</label>
<select
value={range}
onChange={(e) => { setRange(e.target.value); setPage(1); }}
className={`${inputClass} w-40`}
>
{rangeOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{hasFilters && (
<button
onClick={resetFilters}
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
Clear
</button>
)}
</div>
<FilterBar {...filter} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -9,6 +9,9 @@ import {
} from '@/lib/api-client';
import { useBuildUpdates } from '@/lib/use-build-updates';
import { useDemoMode } from '@/lib/demo-mode';
import { type FilterConfig } from '@/lib/dynamic-filter';
import { useDynamicFilter, useFilterState } from '@/lib/use-dynamic-filter';
import { FilterBar } from '@/shared/components';
import { demoBuilds } from '@/lib/demo-data';
import { formatRelative } from '@/lib/time';
import {
@@ -34,6 +37,19 @@ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
{ value: 'cancelled', label: 'Cancelled' },
];
const BUILD_SCHEMA: FilterConfig<BuildEntity>[] = [
{
key: 'status', label: 'Status', type: 'select',
options: statusOptions.filter((o) => o.value !== '').map((o) => ({ value: o.value, label: o.label })),
},
{ key: 'project', label: 'Project ID', type: 'search', searchFields: ['projectId'] },
{ key: 'service', label: 'Service ID', type: 'search', searchFields: ['serviceId'] },
{
key: 'created', label: 'Time', type: 'date-range',
accessor: (b) => b.startedAt ?? b.completedAt,
},
];
function StatusBadge({ status }: { status: BuildStatus }) {
const config = {
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
@@ -72,9 +88,10 @@ export function BuildsPage() {
const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const [projectFilter, setProjectFilter] = useState('');
const [serviceFilter, setServiceFilter] = useState('');
const [statusFilter, setStatusFilter] = useState<'' | BuildStatus>('');
const { filters } = useFilterState(BUILD_SCHEMA);
const projectFilter = String(filters.project ?? '');
const serviceFilter = String(filters.service ?? '');
const statusFilter = filters.status === 'all' ? '' : String(filters.status ?? '');
const [limit, setLimit] = useState(50);
const [selectedBuild, setSelectedBuild] = useState<BuildEntity | null>(null);
@@ -85,20 +102,22 @@ export function BuildsPage() {
listBuilds({
projectId: projectFilter || undefined,
serviceId: serviceFilter || undefined,
status: statusFilter || undefined,
status: (statusFilter || undefined) as BuildStatus | undefined,
page: 1,
limit,
}),
});
const builds = useMemo(
const rawBuilds = useMemo(
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
[isDemoMode, buildsQuery.data?.builds],
);
const filter = useDynamicFilter<BuildEntity>({ data: rawBuilds, schema: BUILD_SCHEMA });
const builds = filter.filteredData;
const subscribedBuildIds = useMemo(
() => (isDemoMode ? [] : builds.map((build) => build.id)),
[isDemoMode, builds],
() => (isDemoMode ? [] : rawBuilds.map((build) => build.id)),
[isDemoMode, rawBuilds],
);
const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
@@ -217,46 +236,13 @@ export function BuildsPage() {
{/* Filters */}
<div className="w-full px-8">
<div className="panel p-4">
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-2">
<Filter size={16} className="text-[var(--text-tertiary)]" />
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Status
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as '' | BuildStatus)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
>
{statusOptions.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Project ID
</label>
<input
value={projectFilter}
onChange={(e) => setProjectFilter(e.target.value)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="project-123"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Service ID
</label>
<input
value={serviceFilter}
onChange={(e) => setServiceFilter(e.target.value)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="service-abc"
/>
<div className="flex flex-wrap items-end gap-3 mt-3">
<div className="flex-1">
<FilterBar {...filter} />
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md",
"section": "guides",
"title": "Containr CLI",
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nbanners list|creatLine truncated
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nactivity Line truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import {
emptyFilterState,
FilterEngine,
type FilterConfig,
} from '@/lib/dynamic-filter';
interface Row {
status: string;
name: string;
labels: string[];
createdAt: string;
}
const ROWS: Row[] = [
{ status: 'success', name: 'web-api', labels: ['prod'], createdAt: new Date().toISOString() },
{ status: 'failed', name: 'worker', labels: [], createdAt: '2020-01-01T00:00:00Z' },
{ status: 'running', name: 'web-admin', labels: ['prod', 'canary'], createdAt: '2020-06-01T00:00:00Z' },
];
const SCHEMA: FilterConfig<Row>[] = [
{ key: 'q', label: 'Search', type: 'search', searchFields: ['name'] },
{ key: 'status', label: 'Status', type: 'select' },
{ key: 'labels', label: 'Labels', type: 'multiselect' },
{ key: 'createdAt', label: 'Time', type: 'date-range' },
];
const f = (over: Partial<Record<string, unknown>>) => ({
...emptyFilterState(SCHEMA),
...over,
});
describe('FilterEngine', () => {
it('passes everything on an empty filter state', () => {
expect(new FilterEngine(ROWS, SCHEMA).apply(f({}))).toHaveLength(3);
});
it('searches across configured fields case-insensitively', () => {
const engine = new FilterEngine(ROWS, SCHEMA);
expect(engine.apply(f({ q: 'WEB' })).map((r) => r.name)).toEqual(['web-api', 'web-admin']);
expect(engine.apply(f({ q: 'worker' }))).toHaveLength(1);
});
it('select matches exactly and "all" is a no-op', () => {
const engine = new FilterEngine(ROWS, SCHEMA);
expect(engine.apply(f({ status: 'failed' }))).toHaveLength(1);
expect(engine.apply(f({ status: 'all' }))).toHaveLength(3);
});
it('multiselect intersects array values', () => {
const engine = new FilterEngine(ROWS, SCHEMA);
expect(engine.apply(f({ labels: ['canary'] })).map((r) => r.name)).toEqual(['web-admin']);
});
it('date-range bounds by preset', () => {
const engine = new FilterEngine(ROWS, SCHEMA);
expect(engine.apply(f({ createdAt: 'today' })).map((r) => r.name)).toEqual(['web-api']);
expect(engine.apply(f({ createdAt: 'all' }))).toHaveLength(3);
});
it('combines filters conjunctively', () => {
const engine = new FilterEngine(ROWS, SCHEMA);
expect(engine.apply(f({ q: 'web', status: 'running' })).map((r) => r.name)).toEqual(['web-admin']);
});
it('derives options with counts from data', () => {
const opts = new FilterEngine(ROWS, SCHEMA).options().find((c) => c.key === 'status');
expect(opts?.options?.find((o) => o.value === 'success')?.count).toBe(1);
expect(opts?.options?.map((o) => o.value).sort()).toEqual(['failed', 'running', 'success']);
});
it('counts active filters and lists them with raw + display values', () => {
const engine = new FilterEngine(ROWS, SCHEMA);
const state = f({ status: 'failed', q: 'w' });
expect(engine.activeCount(state)).toBe(2);
const active = engine.active(state);
expect(active.find((a) => a.key === 'status')).toMatchObject({ value: 'failed', displayValue: 'Failed' });
expect(active.find((a) => a.key === 'q')).toMatchObject({ displayValue: 'w' });
});
});
+208
View File
@@ -0,0 +1,208 @@
// Dynamic filter framework — typed filter configs → client-side filtering
// with optional URL-state sync. Port of dflow's filter.utils pattern,
// adapted to Containr (no external date lib, searchParams-driven).
//
// A page declares a FilterConfig<T>[] schema, passes fetched data +
// schema to useDynamicFilter(), and renders <FilterBar/>. Filter state
// lives in the URL when `urlSync` is on, so filtered views are
// shareable links.
export type FilterType =
| 'search'
| 'select'
| 'multiselect'
| 'boolean'
| 'date-range'
| 'status';
export interface FilterOption {
value: string;
label: string;
count?: number;
}
export interface FilterConfig<T> {
key: string;
label: string;
type: FilterType;
options?: FilterOption[];
/** fields searched by type 'search' (defaults to [key]) */
searchFields?: (keyof T & string)[];
/** custom value accessor (defaults to item[key]) */
accessor?: (item: T) => unknown;
}
export type FilterState = Record<string, unknown>;
const EMPTY: Record<FilterType, unknown> = {
search: '',
select: 'all',
multiselect: [],
boolean: [],
'date-range': 'all',
status: [],
};
export function emptyValueFor(type: FilterType): unknown {
return structuredClone(EMPTY[type]);
}
export function emptyFilterState<T>(schema: FilterConfig<T>[]): FilterState {
const state: FilterState = {};
for (const c of schema) state[c.key] = structuredClone(EMPTY[c.type]);
return state;
}
function accessorOf<T>(config: FilterConfig<T>): (item: T) => unknown {
if (config.accessor) return config.accessor;
return (item) => (item as Record<string, unknown>)[config.key];
}
export class FilterEngine<T> {
private data: T[];
private schema: FilterConfig<T>[];
constructor(data: T[], schema: FilterConfig<T>[]) {
this.data = data;
this.schema = schema;
}
/** Fill in option lists (with counts) for configs that didn't declare them. */
options(): FilterConfig<T>[] {
return this.schema.map((config) => {
if (config.options?.length) return config;
const counts = new Map<string, number>();
for (const item of this.data) {
const v = accessorOf(config)(item);
for (const each of Array.isArray(v) ? v : [v]) {
if (each != null && each !== '') {
const key = String(each);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
}
const options = [...counts.entries()]
.map(([value, count]) => ({ value, label: formatLabel(value), count }))
.sort((a, b) => b.count! - a.count!);
return { ...config, options };
});
}
apply(filters: FilterState): T[] {
return this.data.filter((item) =>
this.schema.every((config) => {
const value = filters[config.key];
if (value == null || value === '' || value === 'all') return true;
if (Array.isArray(value) && value.length === 0) return true;
return this.evaluate(item, config, value);
}),
);
}
private evaluate(item: T, config: FilterConfig<T>, value: unknown): boolean {
const itemValue = accessorOf(config)(item);
switch (config.type) {
case 'search': {
const term = String(value).toLowerCase();
const fields = config.searchFields ?? [config.key as keyof T & string];
return fields.some((f) =>
String((item as Record<string, unknown>)[f] ?? '').toLowerCase().includes(term),
);
}
case 'select':
return String(itemValue) === String(value);
case 'multiselect':
case 'status': {
const values = value as string[];
if (Array.isArray(itemValue)) return itemValue.some((v) => values.includes(String(v)));
return values.includes(String(itemValue));
}
case 'boolean': {
const values = value as string[];
return values.includes(String(Boolean(itemValue)));
}
case 'date-range':
return dateInRange(itemValue, String(value));
default:
return true;
}
}
activeCount(filters: FilterState): number {
let n = 0;
for (const c of this.schema) {
const v = filters[c.key];
if (v == null || v === '' || v === 'all') continue;
n += Array.isArray(v) ? v.length : 1;
}
return n;
}
active(filters: FilterState): { key: string; label: string; value: string; displayValue: string }[] {
const out: { key: string; label: string; value: string; displayValue: string }[] = [];
const opts = this.options();
for (const c of this.schema) {
const v = filters[c.key];
if (v == null || v === '' || v === 'all') continue;
const optFor = (raw: unknown) => {
if (c.type === 'search') return String(raw);
if (c.type === 'date-range') {
return DATE_RANGE_OPTIONS.find((o) => o.value === raw)?.label ?? String(raw);
}
return (
opts.find((o) => o.key === c.key)?.options?.find((o) => o.value === raw)?.label ??
formatLabel(String(raw))
);
};
if (Array.isArray(v)) {
for (const each of v) {
out.push({ key: c.key, label: c.label, value: String(each), displayValue: optFor(each) });
}
} else {
out.push({ key: c.key, label: c.label, value: String(v), displayValue: optFor(v) });
}
}
return out;
}
}
function formatLabel(value: string): string {
if (value === 'true') return 'Yes';
if (value === 'false') return 'No';
return value.charAt(0).toUpperCase() + value.slice(1).replace(/[_-]/g, ' ');
}
function dateInRange(itemValue: unknown, range: string): boolean {
if (!itemValue || range === 'all') return true;
const date = new Date(String(itemValue));
if (Number.isNaN(date.getTime())) return false;
const now = new Date();
let boundary: Date;
switch (range) {
case 'today':
boundary = new Date(now.getFullYear(), now.getMonth(), now.getDate());
break;
case 'week': {
const day = (now.getDay() + 6) % 7; // monday start
boundary = new Date(now.getFullYear(), now.getMonth(), now.getDate() - day);
break;
}
case 'month':
boundary = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case '30d':
boundary = new Date(now.getTime() - 30 * 86400000);
break;
default:
return true;
}
return date >= boundary;
}
export const DATE_RANGE_OPTIONS: FilterOption[] = [
{ value: 'all', label: 'All time' },
{ value: 'today', label: 'Today' },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: '30d', label: 'Last 30 days' },
];
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
emptyFilterState,
emptyValueFor,
FilterEngine,
type FilterConfig,
type FilterState,
} from '@/lib/dynamic-filter';
// Filter state lives in `?f.<key>=...` search params — filtered views are
// shareable links. Arrays join with commas.
function stateFromParams<T>(schema: FilterConfig<T>[], params: URLSearchParams): FilterState {
const state = emptyFilterState(schema);
for (const c of schema) {
const raw = params.get(`f.${c.key}`);
if (raw == null) continue;
switch (c.type) {
case 'multiselect':
case 'boolean':
case 'status':
state[c.key] = raw.split(',').filter(Boolean);
break;
default:
state[c.key] = raw;
}
}
return state;
}
function stateToParams<T>(schema: FilterConfig<T>[], state: FilterState): URLSearchParams {
const params = new URLSearchParams();
const empty = emptyFilterState(schema);
for (const c of schema) {
const v = state[c.key];
if (v == null || v === '' || v === 'all') continue;
if (Array.isArray(v) && v.length === 0) continue;
if (JSON.stringify(v) === JSON.stringify(empty[c.key])) continue;
params.set(`f.${c.key}`, Array.isArray(v) ? v.join(',') : String(v));
}
return params;
}
/** URL-backed filter state — usable before any data is fetched. */
export function useFilterState<T>(schema: FilterConfig<T>[]) {
const [searchParams, setSearchParams] = useSearchParams();
const filters = useMemo(() => stateFromParams(schema, searchParams), [schema, searchParams]);
const setFilters = useCallback(
(updater: (prev: FilterState) => FilterState) => {
const next = updater(filters);
setSearchParams((prev) => {
const merged = new URLSearchParams(prev);
for (const key of [...merged.keys()]) {
if (key.startsWith('f.')) merged.delete(key);
}
stateToParams(schema, next).forEach((v, k) => merged.set(k, v));
return merged;
}, { replace: true });
},
[filters, schema, setSearchParams],
);
const updateFilter = useCallback(
(key: string, value: unknown) => setFilters((prev) => ({ ...prev, [key]: value })),
[setFilters],
);
const clearAll = useCallback(
() => setFilters(() => emptyFilterState(schema)),
[setFilters, schema],
);
const removeFilter = useCallback(
(key: string, rawValue?: string) =>
setFilters((prev) => {
const v = prev[key];
if (Array.isArray(v) && rawValue !== undefined) {
return { ...prev, [key]: v.filter((x) => String(x) !== rawValue) };
}
const config = schema.find((c) => c.key === key);
return config ? { ...prev, [key]: emptyValueFor(config.type) } : prev;
}),
[setFilters, schema],
);
return { filters, updateFilter, removeFilter, clearAll };
}
/** Full client-side filter: URL state + engine over `data`. */
export function useDynamicFilter<T>({ data, schema }: { data: T[]; schema: FilterConfig<T>[] }) {
const state = useFilterState(schema);
const engine = useMemo(() => new FilterEngine(data, schema), [data, schema]);
return {
filteredData: useMemo(() => engine.apply(state.filters), [engine, state.filters]),
filters: state.filters,
schema: useMemo(() => engine.options(), [engine]),
activeCount: useMemo(() => engine.activeCount(state.filters), [engine, state.filters]),
active: useMemo(() => engine.active(state.filters), [engine, state.filters]),
updateFilter: state.updateFilter,
removeFilter: state.removeFilter,
clearAll: state.clearAll,
};
}
@@ -0,0 +1,147 @@
import { Search, X } from 'lucide-react';
import {
DATE_RANGE_OPTIONS,
type FilterConfig,
type FilterState,
} from '@/lib/dynamic-filter';
const inputClass =
'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
// Dynamic filter bar — renders a FilterConfig schema as compact controls.
// State is owned by useDynamicFilter (URL-synced); this is a pure view.
export function FilterBar<T>({
schema,
filters,
activeCount,
active,
updateFilter,
removeFilter,
clearAll,
}: {
schema: FilterConfig<T>[];
filters: FilterState;
activeCount: number;
active: { key: string; label: string; value: string; displayValue: string }[];
updateFilter: (key: string, value: unknown) => void;
removeFilter: (key: string, rawValue?: string) => void;
clearAll: () => void;
}) {
return (
<div className="mb-5">
<div className="flex flex-wrap items-end gap-3">
{schema.map((config) => {
const value = filters[config.key];
switch (config.type) {
case 'search':
return (
<div key={config.key} className="relative">
<Search
size={13}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]"
/>
<input
value={String(value ?? '')}
onChange={(e) => updateFilter(config.key, e.target.value)}
placeholder={`Search ${config.label.toLowerCase()}…`}
className={`${inputClass} w-56 pl-8`}
aria-label={config.label}
/>
</div>
);
case 'select':
case 'date-range': {
const options =
config.type === 'date-range'
? DATE_RANGE_OPTIONS
: [{ value: 'all', label: `All ${config.label.toLowerCase()}` }, ...(config.options ?? [])];
return (
<select
key={config.key}
value={String(value ?? 'all')}
onChange={(e) => updateFilter(config.key, e.target.value)}
className={`${inputClass} w-40`}
aria-label={config.label}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.count != null ? `${o.label} (${o.count})` : o.label}
</option>
))}
</select>
);
}
case 'multiselect':
case 'boolean':
case 'status': {
const selected = Array.isArray(value) ? (value as string[]) : [];
const options =
config.type === 'boolean'
? [
{ value: 'true', label: 'Yes' },
{ value: 'false', label: 'No' },
]
: (config.options ?? []);
return (
<div key={config.key} className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--text-muted)]">
{config.label}:
</span>
{options.map((o) => {
const on = selected.includes(o.value);
return (
<button
key={o.value}
onClick={() =>
updateFilter(
config.key,
on ? selected.filter((s) => s !== o.value) : [...selected, o.value],
)
}
className={`h-7 px-2.5 rounded-[var(--radius-sm)] text-xs font-medium border transition-colors ${
on
? 'bg-[var(--accent-primary-soft)] border-[var(--accent-primary)]/40 text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--border-default)]'
}`}
>
{o.label}
{o.count != null && <span className="ml-1 opacity-60">{o.count}</span>}
</button>
);
})}
</div>
);
}
default:
return null;
}
})}
{activeCount > 0 && (
<button
onClick={clearAll}
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
Clear ({activeCount})
</button>
)}
</div>
{active.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mt-3">
{active.map((a, i) => (
<button
key={`${a.key}-${a.value}-${i}`}
onClick={() => removeFilter(a.key, a.value)}
className="group inline-flex items-center gap-1.5 h-6 px-2 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] text-[11px] font-medium text-[var(--accent-primary)]"
>
<span className="opacity-70">{a.label}</span>
{a.displayValue}
<X size={11} className="opacity-50 group-hover:opacity-100" />
</button>
))}
</div>
)}
</div>
);
}
@@ -11,3 +11,4 @@ export * from './StatusBadge';
export { useToast } from '../hooks/use-toast';
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
export * from './BrandWordmark';
export * from './FilterBar';