Skip to content

Commit cd25015

Browse files
feat(SchemaForm): field-type-aware operator + value widgets for view filter
- ResourceEditPage: read draft.data.object for view resources so the object field catalog is populated (was always empty for standalone views, breaking type-aware widget rendering). - SchemaForm: add detectOperatorWidget + detectValueWidget name- convention detectors that intercept `operator` / `value` fields inside a filter repeater row and render custom widgets. - widgets.tsx: OperatorWidget (field-type-aware operator dropdown with human-readable labels matching FilterBuilder) and ValueWidget (type- aware input: boolean→toggle, number→number input, date→date picker, between→range, in→comma-separated, unary→disabled placeholder). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 21faf18 commit cd25015

3 files changed

Lines changed: 220 additions & 0 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,7 @@ function MetadataResourceEditPageImpl({
584584
// `object`; other types fall back to their own `object`/`objectName`.
585585
const sourceObjectName: string | undefined =
586586
((draft as any)?.interfaceConfig?.source as string | undefined) ||
587+
((draft as any)?.data?.object as string | undefined) ||
587588
((draft as any)?.object as string | undefined) ||
588589
((draft as any)?.objectName as string | undefined);
589590
const [objectFields, setObjectFields] = React.useState<Array<{ name: string; label?: string; type?: string }>>([]);

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,29 @@ function detectColorWidget(name: string, schema: JsonSchema | undefined): string
311311
return undefined;
312312
}
313313

314+
const OPERATOR_FIELD_NAMES = new Set(['operator', 'filterOperator']);
315+
/** Detect an operator field by NAME CONVENTION so it renders as a
316+
* field-type-aware dropdown that narrows the operator list to what's
317+
* valid for the selected field's type. */
318+
function detectOperatorWidget(name: string, schema: JsonSchema | undefined, formData?: Record<string, unknown>, widgetContext?: WidgetContext): string | undefined {
319+
if (!OPERATOR_FIELD_NAMES.has(name) && !/Operator$/.test(name)) return undefined;
320+
// Only intercept when the schema has an enum (operator choices) — if it's
321+
// already a plain string we let the default string widget handle it.
322+
if (!schema?.enum && !Array.isArray(schema?.anyOf)) return undefined;
323+
return 'operator';
324+
}
325+
326+
const VALUE_FIELD_NAMES = new Set(['value', 'filterValue']);
327+
/** Detect a filter value field by NAME CONVENTION — renders a field-type-aware
328+
* input that changes based on the sibling {@code field} and {@code operator} values. */
329+
function detectValueWidget(name: string, schema: JsonSchema | undefined, formData?: Record<string, unknown>, widgetContext?: WidgetContext): string | undefined {
330+
if (!VALUE_FIELD_NAMES.has(name) && !/Value$/.test(name)) return undefined;
331+
// Only intercept when we have a sibling {@code field} in formData so we can
332+
// determine what type of input to render (text, number, date, select...)
333+
if (!formData || typeof formData.field !== 'string' || !formData.field) return undefined;
334+
return 'filter-value';
335+
}
336+
314337
const CONDITION_FIELD_NAMES = new Set(['visible', 'hidden', 'disabled', 'visibleOn', 'condition', 'predicate']);
315338
/**
316339
* Detect a CEL predicate field by NAME CONVENTION (`visible` / `hidden` /
@@ -833,6 +856,14 @@ function FieldRow({
833856
else {
834857
const condWidget = detectConditionWidget(name, schema);
835858
if (condWidget) widget = condWidget;
859+
else {
860+
const opWidget = detectOperatorWidget(name, schema, formData, widgetContext);
861+
if (opWidget) widget = opWidget;
862+
else {
863+
const valWidget = detectValueWidget(name, schema, formData, widgetContext);
864+
if (valWidget) widget = valWidget;
865+
}
866+
}
836867
}
837868
}
838869
}

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

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1667,6 +1667,192 @@ function ActionMultiWidget({ id, value, onChange, readOnly, context }: WidgetPro
16671667
}
16681668

16691669
/* -------------------------------------------------------------------------- */
1670+
/* Operator lists match FilterBuilder exactly — same operator sets per field type.
1671+
Values are snake_case (spec protocol), labels are human-readable. */
1672+
const OP_LABELS: Record<string, string> = {
1673+
equals: 'Equals', not_equals: 'Does not equal',
1674+
contains: 'Contains', not_contains: 'Does not contain',
1675+
starts_with: 'Starts with', ends_with: 'Ends with',
1676+
is_empty: 'Is empty', is_not_empty: 'Is not empty',
1677+
is_null: 'Is null', is_not_null: 'Is not null',
1678+
greater_than: 'Greater than', less_than: 'Less than',
1679+
greater_than_or_equal: 'Greater than or equal', less_than_or_equal: 'Less than or equal',
1680+
before: 'Before', after: 'After', between: 'Between',
1681+
in: 'In', not_in: 'Not in',
1682+
};
1683+
1684+
/** Per-type operator lists — match FilterBuilder textOperators / numberOperators / etc.
1685+
* in snake_case (spec ViewFilterRuleSchema enum). starts_with / ends_with / is_null /
1686+
* is_not_null are valid spec values but only shown when the current value uses them. */
1687+
const TEXT_OPS = ['equals','not_equals','contains','not_contains','is_empty','is_not_empty'];
1688+
const NUMBER_OPS = ['equals','not_equals','greater_than','less_than','greater_than_or_equal','less_than_or_equal','is_empty','is_not_empty'];
1689+
const BOOLEAN_OPS = ['equals','not_equals'];
1690+
const DATE_OPS = ['equals','not_equals','before','after','between','is_empty','is_not_empty'];
1691+
const SELECT_OPS = ['equals','not_equals','in','not_in','is_empty','is_not_empty'];
1692+
1693+
const OPS_BY_TYPE: Record<string,string[]> = {
1694+
text: TEXT_OPS, email: TEXT_OPS, url: TEXT_OPS, phone: TEXT_OPS, password: TEXT_OPS, textarea: TEXT_OPS, markdown: TEXT_OPS, html: TEXT_OPS, richtext: TEXT_OPS,
1695+
number: NUMBER_OPS, currency: NUMBER_OPS, percent: NUMBER_OPS, rating: NUMBER_OPS, integer: NUMBER_OPS,
1696+
boolean: BOOLEAN_OPS, toggle: BOOLEAN_OPS,
1697+
date: DATE_OPS, datetime: DATE_OPS, time: DATE_OPS,
1698+
select: SELECT_OPS, status: SELECT_OPS, multiselect: SELECT_OPS, radio: SELECT_OPS,
1699+
lookup: SELECT_OPS, master_detail: SELECT_OPS, user: SELECT_OPS, owner: SELECT_OPS,
1700+
};
1701+
1702+
function lbl(op: string) { return OP_LABELS[op] || op; }
1703+
1704+
function OperatorWidget({ value, onChange, readOnly, context, formData, schema }: WidgetProps) {
1705+
const locale = useMetadataLocale();
1706+
const fieldName = formData?.field as string | undefined;
1707+
const objectFields = context?.objectFields ?? [];
1708+
const allOps: string[] = schema?.enum ?? [];
1709+
1710+
// If we can determine the field type, filter operators accordingly
1711+
let ops = allOps;
1712+
if (fieldName && objectFields.length) {
1713+
const fieldInfo = objectFields.find((f: any) => f.name === fieldName || f.field === fieldName);
1714+
if (fieldInfo?.type) {
1715+
ops = OPS_BY_TYPE[fieldInfo.type] ?? allOps;
1716+
}
1717+
}
1718+
// Always include the current value so it doesn't disappear
1719+
const current = value == null ? '' : String(value);
1720+
if (current && !ops.includes(current)) ops = [current, ...ops];
1721+
1722+
return (
1723+
<Select value={current} onValueChange={(v: string) => onChange(v)} disabled={readOnly}>
1724+
<SelectTrigger className="h-8 text-sm">
1725+
<SelectValue placeholder={t('engine.form.selectEllipsis', locale)} />
1726+
</SelectTrigger>
1727+
<SelectContent>
1728+
{ops.map((op: string) => (
1729+
<SelectItem key={op} value={op}>{lbl(op)}</SelectItem>
1730+
))}
1731+
</SelectContent>
1732+
</Select>
1733+
);
1734+
}
1735+
1736+
function ValueWidget({ value, onChange, readOnly, context, formData }: WidgetProps) {
1737+
const locale = useMetadataLocale();
1738+
const fieldName = formData?.field as string | undefined;
1739+
const operator = (formData?.operator as string | undefined) ?? '';
1740+
const objectFields = context?.objectFields ?? [];
1741+
const current = value == null ? '' : String(value);
1742+
1743+
// Unary operators that don't need a value
1744+
const UNARY = new Set(['is_empty','is_not_empty','is_null','is_not_null']);
1745+
if (UNARY.has(operator)) {
1746+
return (
1747+
<Input value="\u2014" disabled className="h-8 text-sm text-muted-foreground" />
1748+
);
1749+
}
1750+
1751+
// Determine the field type from the object field catalog
1752+
let fieldType = 'text';
1753+
if (fieldName && objectFields.length) {
1754+
const fi = objectFields.find((f: any) => f.name === fieldName || f.field === fieldName);
1755+
if (fi?.type) fieldType = fi.type;
1756+
}
1757+
1758+
// Boolean -> toggle
1759+
if (fieldType === 'boolean' || fieldType === 'toggle') {
1760+
return (
1761+
<div className="flex items-center gap-2 h-8">
1762+
<Switch
1763+
checked={current === 'true' || current === '1'}
1764+
disabled={readOnly}
1765+
onCheckedChange={(c) => onChange(String(c))}
1766+
/>
1767+
<span className="text-xs text-muted-foreground">{current === 'true' || current === '1' ? 'true' : 'false'}</span>
1768+
</div>
1769+
);
1770+
}
1771+
1772+
// Number / Currency / Percent -> number input
1773+
const isNum = fieldType === 'number' || fieldType === 'currency' || fieldType === 'percent' || fieldType === 'integer' || fieldType === 'rating';
1774+
if (isNum) {
1775+
return (
1776+
<Input
1777+
type="number"
1778+
value={current}
1779+
disabled={readOnly}
1780+
placeholder={t('engine.form.valuePlaceholder', locale)}
1781+
className="h-8 text-sm"
1782+
onChange={(e) => {
1783+
const v = e.target.value;
1784+
onChange(v === '' ? undefined : Number(v));
1785+
}}
1786+
/>
1787+
);
1788+
}
1789+
1790+
// Date / DateTime -> date input
1791+
if (fieldType === 'date' || fieldType === 'datetime') {
1792+
return (
1793+
<Input
1794+
type={fieldType === 'datetime' ? 'datetime-local' : 'date'}
1795+
value={current}
1796+
disabled={readOnly}
1797+
className="h-8 text-sm"
1798+
onChange={(e) => onChange(e.target.value || undefined)}
1799+
/>
1800+
);
1801+
}
1802+
1803+
// Between operator -> two inputs (start + end)
1804+
if (operator === 'between') {
1805+
const arr = Array.isArray(value) ? value.map(String) : ['', ''];
1806+
return (
1807+
<div className="flex items-center gap-1">
1808+
<Input
1809+
type="text"
1810+
value={arr[0] ?? ''}
1811+
disabled={readOnly}
1812+
placeholder="from"
1813+
className="h-8 text-sm flex-1"
1814+
onChange={(e) => onChange([e.target.value || undefined, arr[1] || undefined])}
1815+
/>
1816+
<span className="text-xs text-muted-foreground shrink-0">&ndash;</span>
1817+
<Input
1818+
type="text"
1819+
value={arr[1] ?? ''}
1820+
disabled={readOnly}
1821+
placeholder="to"
1822+
className="h-8 text-sm flex-1"
1823+
onChange={(e) => onChange([arr[0] || undefined, e.target.value || undefined])}
1824+
/>
1825+
</div>
1826+
);
1827+
}
1828+
1829+
// In / NotIn -> comma-separated values
1830+
if (operator === 'in' || operator === 'not_in') {
1831+
return (
1832+
<Input
1833+
value={current}
1834+
disabled={readOnly}
1835+
placeholder="value1, value2, ..."
1836+
className="h-8 text-sm"
1837+
onChange={(e) => onChange(e.target.value || undefined)}
1838+
/>
1839+
);
1840+
}
1841+
1842+
// Default: text input
1843+
return (
1844+
<Input
1845+
value={current}
1846+
disabled={readOnly}
1847+
placeholder={t('engine.form.valuePlaceholder', locale)}
1848+
className="h-8 text-sm"
1849+
onChange={(e) => onChange(e.target.value || undefined)}
1850+
/>
1851+
);
1852+
}
1853+
1854+
1855+
16701856
/* filter-builder — the SAME runtime FilterBuilder used by the list toolbar, */
16711857
/* reused in Studio for tab presets and the page base filter (unified UX). */
16721858
/* Stored format stays spec ViewFilterRule[] ({field,operator,value}); the */
@@ -1952,6 +2138,8 @@ export const WIDGETS: Record<string, WidgetRenderer> = {
19522138
'field-multi': FieldRefMultiWidget,
19532139
'action-multi': ActionMultiWidget,
19542140
'filter-builder': FilterBuilderWidget,
2141+
'operator': OperatorWidget,
2142+
'filter-value': ValueWidget,
19552143
'view-ref': ViewRefWidget,
19562144
'icon': IconPickerWidget,
19572145
'color-picker': ColorPickerWidget,

0 commit comments

Comments
 (0)