Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { WIDGETS } from './widgets';

afterEach(cleanup);

const FilterMode = WIDGETS['filter-mode'];
const ctx = {
objectFields: [
{ name: 'status', label: 'Status' },
{ name: 'priority', label: 'Priority' },
{ name: 'owner', label: 'Owner' },
],
};

/**
* ADR-0047 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'.
*/
describe('filter-mode widget', () => {
it('shows None active when value is absent', () => {
render(<FilterMode value={undefined} onChange={() => {}} context={ctx} schema={{}} />);
expect(screen.getByTestId('filter-mode-none')).toHaveAttribute('aria-checked', 'true');
expect(screen.queryByTestId('filter-mode-fields')).not.toBeInTheDocument();
});

it('offers None / Tabs / Dropdown only — Toggle is deprecated and not authorable (ADR-0047 §3.4a)', () => {
render(<FilterMode value={undefined} onChange={() => {}} context={ctx} schema={{}} />);
expect(screen.getByTestId('filter-mode-none')).toBeInTheDocument();
expect(screen.getByTestId('filter-mode-tabs')).toBeInTheDocument();
expect(screen.getByTestId('filter-mode-dropdown')).toBeInTheDocument();
expect(screen.queryByTestId('filter-mode-toggle')).not.toBeInTheDocument();
});

it('keeps a deprecated element:"toggle" config editable (field picker still shows)', () => {
render(<FilterMode value={{ element: 'toggle', fields: [{ field: 'is_active' }] }} onChange={() => {}} context={ctx} schema={{}} />);
// No Toggle button to re-select, but its fields remain editable.
expect(screen.queryByTestId('filter-mode-toggle')).not.toBeInTheDocument();
expect(screen.getByTestId('filter-mode-fields')).toBeInTheDocument();
});

it('selecting None removes the config (onChange undefined)', () => {
const onChange = vi.fn();
render(<FilterMode value={{ element: 'dropdown', fields: [{ field: 'status' }] }} onChange={onChange} context={ctx} schema={{}} />);
fireEvent.click(screen.getByTestId('filter-mode-none'));
expect(onChange).toHaveBeenCalledWith(undefined);
});

it('selecting Dropdown sets element and reveals the field picker', () => {
const onChange = vi.fn();
render(<FilterMode value={undefined} onChange={onChange} context={ctx} schema={{}} />);
fireEvent.click(screen.getByTestId('filter-mode-dropdown'));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ element: 'dropdown' }));
});

it('dropdown mode renders existing fields and an add-field picker from the source object', () => {
render(
<FilterMode
value={{ element: 'dropdown', fields: [{ field: 'status' }] }}
onChange={() => {}}
context={ctx}
schema={{}}
/>,
);
const box = screen.getByTestId('filter-mode-fields');
expect(box.textContent).toContain('status');
expect(screen.getByTestId('filter-mode-add-field')).toBeInTheDocument();
});

it('tabs mode shows the source-view hint, not a field picker', () => {
render(<FilterMode value={{ element: 'tabs' }} onChange={() => {}} context={ctx} schema={{}} />);
expect(screen.getByTestId('filter-mode-tabs-hint')).toBeInTheDocument();
expect(screen.queryByTestId('filter-mode-fields')).not.toBeInTheDocument();
});
});
38 changes: 36 additions & 2 deletions packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,9 +461,43 @@ function MetadataResourceEditPageImpl({
cancelled = true;
};
}, [client]);
// Field catalog of the draft's bound/source object — fuels field-picker
// widgets (e.g. the interface-page filter-mode selector). For a page the
// source is `interfaceConfig.source` (interface mode) or the bound
// `object`; other types fall back to their own `object`/`objectName`.
const sourceObjectName: string | undefined =
((draft as any)?.interfaceConfig?.source as string | undefined) ||
((draft as any)?.object as string | undefined) ||
((draft as any)?.objectName as string | undefined);
const [objectFields, setObjectFields] = React.useState<Array<{ name: string; label?: string; type?: string }>>([]);
const [objectFieldsLoading, setObjectFieldsLoading] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
if (!sourceObjectName) { setObjectFields([]); return; }
setObjectFieldsLoading(true);
(async () => {
try {
const obj = (await client.get('object', sourceObjectName)) as { fields?: Record<string, any> | Array<any> } | null;
if (cancelled) return;
const raw = obj?.fields;
const list = Array.isArray(raw)
? raw.map((f: any) => ({ name: f?.name, label: f?.label, type: f?.type }))
: raw && typeof raw === 'object'
? Object.entries(raw).map(([name, f]: [string, any]) => ({ name, label: f?.label, type: f?.type }))
: [];
setObjectFields(list.filter((f) => !!f.name));
} catch {
if (!cancelled) setObjectFields([]);
} finally {
if (!cancelled) setObjectFieldsLoading(false);
}
})();
return () => { cancelled = true; };
}, [client, sourceObjectName]);

const widgetContext = React.useMemo(
() => ({ objectNames, objectsLoading }),
[objectNames, objectsLoading],
() => ({ objectNames, objectsLoading, objectFields, objectFieldsLoading }),
[objectNames, objectsLoading, objectFields, objectFieldsLoading],
);

// Load layered view + initial draft.
Expand Down
144 changes: 144 additions & 0 deletions packages/app-shell/src/views/metadata-admin/widgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
Input,
Button,
Label,
Switch,
} from '@object-ui/components';
import { Plus, Trash2 } from 'lucide-react';
import { detectLocale, t } from './i18n';
Expand Down Expand Up @@ -832,8 +833,151 @@ function FieldRefMultiWidget({ id, value, onChange, readOnly, context }: WidgetP
// now use the built-in `RecordField` engine in SchemaForm, which renders
// inline cards with the full per-entry sub-form derived from the protocol.

/* -------------------------------------------------------------------------- */
/* FilterModeWidget — ADR-0047 end-user filter element selector. */
/* */
/* Airtable-parity authoring control for `interfaceConfig.userFilters`. The */
/* protocol stores "no filter bar" as ABSENCE of the field (omit-is-none), */
/* not a literal `element: 'none'` — so this widget exposes None as a */
/* first-class, selectable UI state that maps to `onChange(undefined)`, */
/* keeping the metadata clean while giving authors the explicit tri/quad- */
/* state selector they expect. Dropdown/Toggle modes edit the exposed fields */
/* inline (matching Airtable's "Dropdowns: <fields>"). */
/* -------------------------------------------------------------------------- */

// `toggle` remains a valid (deprecated) element in the protocol for
// back-compat, but is intentionally NOT offered as an authoring mode here:
// it overlaps tabs (presets) + dropdown (per-field values) without adding
// expressive power, needs per-field defaultValues to be useful, and the
// matching tool (Airtable) converged on None/Tabs/Dropdown. See ADR-0047 §3.4a.
type UFElement = 'dropdown' | 'tabs' | 'toggle';
type UFMode = 'dropdown' | 'tabs';
interface UFField { field: string; showCount?: boolean; label?: string; [k: string]: unknown }
interface UFValue { element?: UFElement; fields?: UFField[]; tabs?: unknown[]; showAllRecords?: boolean; [k: string]: unknown }

const FILTER_MODES: Array<{ key: 'none' | UFMode; label: string }> = [
{ key: 'none', label: 'None' },
{ key: 'tabs', label: 'Tabs' },
{ key: 'dropdown', label: 'Dropdown' },
];

function FilterModeWidget({ value, onChange, readOnly, context }: WidgetProps) {
const uf = (value && typeof value === 'object' ? value : undefined) as UFValue | undefined;
const mode: 'none' | UFElement = uf?.element ?? (uf ? 'dropdown' : 'none');
const objectFields = context?.objectFields ?? [];

const setMode = (next: 'none' | UFMode) => {
if (readOnly) return;
if (next === 'none') { onChange(undefined); return; } // omit-is-none
onChange({ ...(uf ?? {}), element: next });
};

const fields: UFField[] = Array.isArray(uf?.fields) ? (uf!.fields as UFField[]) : [];
const patchFields = (nextFields: UFField[]) =>
onChange({ ...(uf ?? {}), element: mode === 'none' ? 'dropdown' : mode, fields: nextFields });

const selected = new Set(fields.map((f) => f.field));
const remaining = objectFields.filter((f) => !selected.has(f.name));
const labelFor = (name: string) => objectFields.find((f) => f.name === name)?.label || name;

// A deprecated `element: 'toggle'` config still lands here — render its
// field picker too so it stays editable, even though Toggle isn't offered
// as a new authoring choice.
const isFieldMode = mode === 'dropdown' || mode === 'toggle';

return (
<div className="space-y-3">
{/* Segmented mode selector */}
<div className="inline-flex rounded-md border border-input bg-background p-0.5" role="radiogroup" aria-label="Filter element">
{FILTER_MODES.map((m) => {
const active = mode === m.key;
return (
<button
key={m.key}
type="button"
role="radio"
aria-checked={active}
data-testid={`filter-mode-${m.key}`}
disabled={readOnly}
onClick={() => setMode(m.key)}
className={
'px-3 py-1 text-xs font-medium rounded transition-colors ' +
(active
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-muted')
}
>
{m.label}
</button>
);
})}
</div>

{/* Field picker for dropdown / toggle modes */}
{isFieldMode && (
<div className="space-y-2" data-testid="filter-mode-fields">
{fields.length > 0 && (
<div className="space-y-1">
{fields.map((f, i) => (
<div key={f.field} className="flex items-center gap-2 rounded border border-input bg-background px-2 py-1 text-sm">
<span className="flex-1 truncate">
{labelFor(f.field)}
<code className="ml-2 text-xs text-muted-foreground">{f.field}</code>
</span>
<label className="flex items-center gap-1 text-xs text-muted-foreground">
<Switch
checked={!!f.showCount}
disabled={readOnly}
onCheckedChange={(c) => patchFields(fields.map((x, j) => (j === i ? { ...x, showCount: c } : x)))}
/>
count
</label>
{!readOnly && (
<button
type="button"
aria-label="Remove field"
onClick={() => patchFields(fields.filter((_, j) => j !== i))}
className="px-1 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
)}
{!readOnly && remaining.length > 0 && (
<Select onValueChange={(name) => patchFields([...fields, { field: name }])}>
<SelectTrigger className="h-8 text-xs" data-testid="filter-mode-add-field">
<SelectValue placeholder="+ Add filter field…" />
</SelectTrigger>
<SelectContent>
{remaining.map((f) => (
<SelectItem key={f.name} value={f.name}>
{f.label || f.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{objectFields.length === 0 && (
<p className="text-xs text-muted-foreground">Bind a source object to pick filter fields.</p>
)}
</div>
)}

{mode === 'tabs' && (
<p className="text-xs text-muted-foreground" data-testid="filter-mode-tabs-hint">
Tab presets (name + filter rules) are edited in the source / JSON view.
</p>
)}
</div>
);
}

export const WIDGETS: Record<string, WidgetRenderer> = {
'ref:object': RefObjectWidget,
'filter-mode': FilterModeWidget,
'object-selector': ObjectSelectorWidget,
'field-selector': FieldSelectorWidget,
'field-ref': FieldRefWidget,
Expand Down
Loading