Skip to content

Commit 1496e6d

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(studio): filter-mode selector for interface-page user filters (#1694)
* feat(studio): filter-mode selector for interface-page user filters Airtable-parity authoring control for interfaceConfig.userFilters: a None / Tabs / Dropdown / Toggle segmented selector (the 'filter-mode' widget). None is a first-class UI state that maps to ABSENCE of userFilters (onChange(undefined)) — the protocol stores 'no filter bar' as omission, not a literal element: 'none' (ADR-0047 §3.4a). Dropdown/ Toggle modes edit the exposed fields inline (per-field showCount + remove + an add-field picker sourced from the bound object), matching Airtable's 'Dropdowns: <fields>'. ResourceEditPage now feeds widgetContext.objectFields from the draft's source object (interfaceConfig.source / object), so field-picker widgets in the page editor list the real fields instead of being empty. Browser-verified on the showcase Task Workbench page editor: the segmented selector reflects the saved element, None removes userFilters from the draft, Dropdown reveals the field list + add-picker with the source object's fields. Locked by FilterModeWidget.test.tsx (5 cases). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(studio): drop Toggle from filter-mode authoring (ADR-0047 §3.4a) Per design review (Airtable converged on None/Tabs/Dropdown): the filter-mode selector now offers None / Tabs / Dropdown only. `toggle` overlaps tabs (presets) + dropdown (per-field values) without adding expressive power and needs per-field defaultValues to be useful — it's the least-exercised path. A deprecated `element: 'toggle'` config still renders its field picker so it stays editable; it just isn't offered as a new choice. Tests assert Toggle is absent + a toggle config stays editable. --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 24d18fc commit 1496e6d

3 files changed

Lines changed: 258 additions & 2 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, afterEach } from 'vitest';
4+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
5+
import { WIDGETS } from './widgets';
6+
7+
afterEach(cleanup);
8+
9+
const FilterMode = WIDGETS['filter-mode'];
10+
const ctx = {
11+
objectFields: [
12+
{ name: 'status', label: 'Status' },
13+
{ name: 'priority', label: 'Priority' },
14+
{ name: 'owner', label: 'Owner' },
15+
],
16+
};
17+
18+
/**
19+
* ADR-0047 filter-mode widget. None is a first-class UI state that maps to
20+
* ABSENCE of userFilters (onChange(undefined)) — the protocol stores "no
21+
* filter bar" as omission, not a literal element: 'none'.
22+
*/
23+
describe('filter-mode widget', () => {
24+
it('shows None active when value is absent', () => {
25+
render(<FilterMode value={undefined} onChange={() => {}} context={ctx} schema={{}} />);
26+
expect(screen.getByTestId('filter-mode-none')).toHaveAttribute('aria-checked', 'true');
27+
expect(screen.queryByTestId('filter-mode-fields')).not.toBeInTheDocument();
28+
});
29+
30+
it('offers None / Tabs / Dropdown only — Toggle is deprecated and not authorable (ADR-0047 §3.4a)', () => {
31+
render(<FilterMode value={undefined} onChange={() => {}} context={ctx} schema={{}} />);
32+
expect(screen.getByTestId('filter-mode-none')).toBeInTheDocument();
33+
expect(screen.getByTestId('filter-mode-tabs')).toBeInTheDocument();
34+
expect(screen.getByTestId('filter-mode-dropdown')).toBeInTheDocument();
35+
expect(screen.queryByTestId('filter-mode-toggle')).not.toBeInTheDocument();
36+
});
37+
38+
it('keeps a deprecated element:"toggle" config editable (field picker still shows)', () => {
39+
render(<FilterMode value={{ element: 'toggle', fields: [{ field: 'is_active' }] }} onChange={() => {}} context={ctx} schema={{}} />);
40+
// No Toggle button to re-select, but its fields remain editable.
41+
expect(screen.queryByTestId('filter-mode-toggle')).not.toBeInTheDocument();
42+
expect(screen.getByTestId('filter-mode-fields')).toBeInTheDocument();
43+
});
44+
45+
it('selecting None removes the config (onChange undefined)', () => {
46+
const onChange = vi.fn();
47+
render(<FilterMode value={{ element: 'dropdown', fields: [{ field: 'status' }] }} onChange={onChange} context={ctx} schema={{}} />);
48+
fireEvent.click(screen.getByTestId('filter-mode-none'));
49+
expect(onChange).toHaveBeenCalledWith(undefined);
50+
});
51+
52+
it('selecting Dropdown sets element and reveals the field picker', () => {
53+
const onChange = vi.fn();
54+
render(<FilterMode value={undefined} onChange={onChange} context={ctx} schema={{}} />);
55+
fireEvent.click(screen.getByTestId('filter-mode-dropdown'));
56+
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ element: 'dropdown' }));
57+
});
58+
59+
it('dropdown mode renders existing fields and an add-field picker from the source object', () => {
60+
render(
61+
<FilterMode
62+
value={{ element: 'dropdown', fields: [{ field: 'status' }] }}
63+
onChange={() => {}}
64+
context={ctx}
65+
schema={{}}
66+
/>,
67+
);
68+
const box = screen.getByTestId('filter-mode-fields');
69+
expect(box.textContent).toContain('status');
70+
expect(screen.getByTestId('filter-mode-add-field')).toBeInTheDocument();
71+
});
72+
73+
it('tabs mode shows the source-view hint, not a field picker', () => {
74+
render(<FilterMode value={{ element: 'tabs' }} onChange={() => {}} context={ctx} schema={{}} />);
75+
expect(screen.getByTestId('filter-mode-tabs-hint')).toBeInTheDocument();
76+
expect(screen.queryByTestId('filter-mode-fields')).not.toBeInTheDocument();
77+
});
78+
});

packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,9 +461,43 @@ function MetadataResourceEditPageImpl({
461461
cancelled = true;
462462
};
463463
}, [client]);
464+
// Field catalog of the draft's bound/source object — fuels field-picker
465+
// widgets (e.g. the interface-page filter-mode selector). For a page the
466+
// source is `interfaceConfig.source` (interface mode) or the bound
467+
// `object`; other types fall back to their own `object`/`objectName`.
468+
const sourceObjectName: string | undefined =
469+
((draft as any)?.interfaceConfig?.source as string | undefined) ||
470+
((draft as any)?.object as string | undefined) ||
471+
((draft as any)?.objectName as string | undefined);
472+
const [objectFields, setObjectFields] = React.useState<Array<{ name: string; label?: string; type?: string }>>([]);
473+
const [objectFieldsLoading, setObjectFieldsLoading] = React.useState(false);
474+
React.useEffect(() => {
475+
let cancelled = false;
476+
if (!sourceObjectName) { setObjectFields([]); return; }
477+
setObjectFieldsLoading(true);
478+
(async () => {
479+
try {
480+
const obj = (await client.get('object', sourceObjectName)) as { fields?: Record<string, any> | Array<any> } | null;
481+
if (cancelled) return;
482+
const raw = obj?.fields;
483+
const list = Array.isArray(raw)
484+
? raw.map((f: any) => ({ name: f?.name, label: f?.label, type: f?.type }))
485+
: raw && typeof raw === 'object'
486+
? Object.entries(raw).map(([name, f]: [string, any]) => ({ name, label: f?.label, type: f?.type }))
487+
: [];
488+
setObjectFields(list.filter((f) => !!f.name));
489+
} catch {
490+
if (!cancelled) setObjectFields([]);
491+
} finally {
492+
if (!cancelled) setObjectFieldsLoading(false);
493+
}
494+
})();
495+
return () => { cancelled = true; };
496+
}, [client, sourceObjectName]);
497+
464498
const widgetContext = React.useMemo(
465-
() => ({ objectNames, objectsLoading }),
466-
[objectNames, objectsLoading],
499+
() => ({ objectNames, objectsLoading, objectFields, objectFieldsLoading }),
500+
[objectNames, objectsLoading, objectFields, objectFieldsLoading],
467501
);
468502

469503
// Load layered view + initial draft.

packages/app-shell/src/views/metadata-admin/widgets.tsx

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
Input,
3030
Button,
3131
Label,
32+
Switch,
3233
} from '@object-ui/components';
3334
import { Plus, Trash2 } from 'lucide-react';
3435
import { detectLocale, t } from './i18n';
@@ -832,8 +833,151 @@ function FieldRefMultiWidget({ id, value, onChange, readOnly, context }: WidgetP
832833
// now use the built-in `RecordField` engine in SchemaForm, which renders
833834
// inline cards with the full per-entry sub-form derived from the protocol.
834835

836+
/* -------------------------------------------------------------------------- */
837+
/* FilterModeWidget — ADR-0047 end-user filter element selector. */
838+
/* */
839+
/* Airtable-parity authoring control for `interfaceConfig.userFilters`. The */
840+
/* protocol stores "no filter bar" as ABSENCE of the field (omit-is-none), */
841+
/* not a literal `element: 'none'` — so this widget exposes None as a */
842+
/* first-class, selectable UI state that maps to `onChange(undefined)`, */
843+
/* keeping the metadata clean while giving authors the explicit tri/quad- */
844+
/* state selector they expect. Dropdown/Toggle modes edit the exposed fields */
845+
/* inline (matching Airtable's "Dropdowns: <fields>"). */
846+
/* -------------------------------------------------------------------------- */
847+
848+
// `toggle` remains a valid (deprecated) element in the protocol for
849+
// back-compat, but is intentionally NOT offered as an authoring mode here:
850+
// it overlaps tabs (presets) + dropdown (per-field values) without adding
851+
// expressive power, needs per-field defaultValues to be useful, and the
852+
// matching tool (Airtable) converged on None/Tabs/Dropdown. See ADR-0047 §3.4a.
853+
type UFElement = 'dropdown' | 'tabs' | 'toggle';
854+
type UFMode = 'dropdown' | 'tabs';
855+
interface UFField { field: string; showCount?: boolean; label?: string; [k: string]: unknown }
856+
interface UFValue { element?: UFElement; fields?: UFField[]; tabs?: unknown[]; showAllRecords?: boolean; [k: string]: unknown }
857+
858+
const FILTER_MODES: Array<{ key: 'none' | UFMode; label: string }> = [
859+
{ key: 'none', label: 'None' },
860+
{ key: 'tabs', label: 'Tabs' },
861+
{ key: 'dropdown', label: 'Dropdown' },
862+
];
863+
864+
function FilterModeWidget({ value, onChange, readOnly, context }: WidgetProps) {
865+
const uf = (value && typeof value === 'object' ? value : undefined) as UFValue | undefined;
866+
const mode: 'none' | UFElement = uf?.element ?? (uf ? 'dropdown' : 'none');
867+
const objectFields = context?.objectFields ?? [];
868+
869+
const setMode = (next: 'none' | UFMode) => {
870+
if (readOnly) return;
871+
if (next === 'none') { onChange(undefined); return; } // omit-is-none
872+
onChange({ ...(uf ?? {}), element: next });
873+
};
874+
875+
const fields: UFField[] = Array.isArray(uf?.fields) ? (uf!.fields as UFField[]) : [];
876+
const patchFields = (nextFields: UFField[]) =>
877+
onChange({ ...(uf ?? {}), element: mode === 'none' ? 'dropdown' : mode, fields: nextFields });
878+
879+
const selected = new Set(fields.map((f) => f.field));
880+
const remaining = objectFields.filter((f) => !selected.has(f.name));
881+
const labelFor = (name: string) => objectFields.find((f) => f.name === name)?.label || name;
882+
883+
// A deprecated `element: 'toggle'` config still lands here — render its
884+
// field picker too so it stays editable, even though Toggle isn't offered
885+
// as a new authoring choice.
886+
const isFieldMode = mode === 'dropdown' || mode === 'toggle';
887+
888+
return (
889+
<div className="space-y-3">
890+
{/* Segmented mode selector */}
891+
<div className="inline-flex rounded-md border border-input bg-background p-0.5" role="radiogroup" aria-label="Filter element">
892+
{FILTER_MODES.map((m) => {
893+
const active = mode === m.key;
894+
return (
895+
<button
896+
key={m.key}
897+
type="button"
898+
role="radio"
899+
aria-checked={active}
900+
data-testid={`filter-mode-${m.key}`}
901+
disabled={readOnly}
902+
onClick={() => setMode(m.key)}
903+
className={
904+
'px-3 py-1 text-xs font-medium rounded transition-colors ' +
905+
(active
906+
? 'bg-primary text-primary-foreground shadow-sm'
907+
: 'text-muted-foreground hover:text-foreground hover:bg-muted')
908+
}
909+
>
910+
{m.label}
911+
</button>
912+
);
913+
})}
914+
</div>
915+
916+
{/* Field picker for dropdown / toggle modes */}
917+
{isFieldMode && (
918+
<div className="space-y-2" data-testid="filter-mode-fields">
919+
{fields.length > 0 && (
920+
<div className="space-y-1">
921+
{fields.map((f, i) => (
922+
<div key={f.field} className="flex items-center gap-2 rounded border border-input bg-background px-2 py-1 text-sm">
923+
<span className="flex-1 truncate">
924+
{labelFor(f.field)}
925+
<code className="ml-2 text-xs text-muted-foreground">{f.field}</code>
926+
</span>
927+
<label className="flex items-center gap-1 text-xs text-muted-foreground">
928+
<Switch
929+
checked={!!f.showCount}
930+
disabled={readOnly}
931+
onCheckedChange={(c) => patchFields(fields.map((x, j) => (j === i ? { ...x, showCount: c } : x)))}
932+
/>
933+
count
934+
</label>
935+
{!readOnly && (
936+
<button
937+
type="button"
938+
aria-label="Remove field"
939+
onClick={() => patchFields(fields.filter((_, j) => j !== i))}
940+
className="px-1 text-muted-foreground hover:text-destructive"
941+
>
942+
<Trash2 className="h-3.5 w-3.5" />
943+
</button>
944+
)}
945+
</div>
946+
))}
947+
</div>
948+
)}
949+
{!readOnly && remaining.length > 0 && (
950+
<Select onValueChange={(name) => patchFields([...fields, { field: name }])}>
951+
<SelectTrigger className="h-8 text-xs" data-testid="filter-mode-add-field">
952+
<SelectValue placeholder="+ Add filter field…" />
953+
</SelectTrigger>
954+
<SelectContent>
955+
{remaining.map((f) => (
956+
<SelectItem key={f.name} value={f.name}>
957+
{f.label || f.name}
958+
</SelectItem>
959+
))}
960+
</SelectContent>
961+
</Select>
962+
)}
963+
{objectFields.length === 0 && (
964+
<p className="text-xs text-muted-foreground">Bind a source object to pick filter fields.</p>
965+
)}
966+
</div>
967+
)}
968+
969+
{mode === 'tabs' && (
970+
<p className="text-xs text-muted-foreground" data-testid="filter-mode-tabs-hint">
971+
Tab presets (name + filter rules) are edited in the source / JSON view.
972+
</p>
973+
)}
974+
</div>
975+
);
976+
}
977+
835978
export const WIDGETS: Record<string, WidgetRenderer> = {
836979
'ref:object': RefObjectWidget,
980+
'filter-mode': FilterModeWidget,
837981
'object-selector': ObjectSelectorWidget,
838982
'field-selector': FieldSelectorWidget,
839983
'field-ref': FieldRefWidget,

0 commit comments

Comments
 (0)