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)]">