diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index bee39e99b..6667e0731 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -584,6 +584,7 @@ function MetadataResourceEditPageImpl({ // `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)?.data?.object as string | undefined) || ((draft as any)?.object as string | undefined) || ((draft as any)?.objectName as string | undefined); const [objectFields, setObjectFields] = React.useState>([]); diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx index 804eba520..56801c0f1 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx @@ -311,6 +311,29 @@ function detectColorWidget(name: string, schema: JsonSchema | undefined): string return undefined; } +const OPERATOR_FIELD_NAMES = new Set(['operator', 'filterOperator']); +/** Detect an operator field by NAME CONVENTION so it renders as a + * field-type-aware dropdown that narrows the operator list to what's + * valid for the selected field's type. */ +function detectOperatorWidget(name: string, schema: JsonSchema | undefined, formData?: Record, widgetContext?: WidgetContext): string | undefined { + if (!OPERATOR_FIELD_NAMES.has(name) && !/Operator$/.test(name)) return undefined; + // Only intercept when the schema has an enum (operator choices) — if it's + // already a plain string we let the default string widget handle it. + if (!schema?.enum && !Array.isArray(schema?.anyOf)) return undefined; + return 'operator'; +} + +const VALUE_FIELD_NAMES = new Set(['value', 'filterValue']); +/** Detect a filter value field by NAME CONVENTION — renders a field-type-aware + * input that changes based on the sibling {@code field} and {@code operator} values. */ +function detectValueWidget(name: string, schema: JsonSchema | undefined, formData?: Record, widgetContext?: WidgetContext): string | undefined { + if (!VALUE_FIELD_NAMES.has(name) && !/Value$/.test(name)) return undefined; + // Only intercept when we have a sibling {@code field} in formData so we can + // determine what type of input to render (text, number, date, select...) + if (!formData || typeof formData.field !== 'string' || !formData.field) return undefined; + return 'filter-value'; +} + const CONDITION_FIELD_NAMES = new Set(['visible', 'hidden', 'disabled', 'visibleOn', 'condition', 'predicate']); /** * Detect a CEL predicate field by NAME CONVENTION (`visible` / `hidden` / @@ -833,6 +856,14 @@ function FieldRow({ else { const condWidget = detectConditionWidget(name, schema); if (condWidget) widget = condWidget; + else { + const opWidget = detectOperatorWidget(name, schema, formData, widgetContext); + if (opWidget) widget = opWidget; + else { + const valWidget = detectValueWidget(name, schema, formData, widgetContext); + if (valWidget) widget = valWidget; + } + } } } } diff --git a/packages/app-shell/src/views/metadata-admin/widgets.tsx b/packages/app-shell/src/views/metadata-admin/widgets.tsx index 560a851ec..a5e569c1f 100644 --- a/packages/app-shell/src/views/metadata-admin/widgets.tsx +++ b/packages/app-shell/src/views/metadata-admin/widgets.tsx @@ -1667,6 +1667,192 @@ function ActionMultiWidget({ id, value, onChange, readOnly, context }: WidgetPro } /* -------------------------------------------------------------------------- */ +/* Operator lists match FilterBuilder exactly — same operator sets per field type. + Values are snake_case (spec protocol), labels are human-readable. */ +const OP_LABELS: Record = { + equals: 'Equals', not_equals: 'Does not equal', + contains: 'Contains', not_contains: 'Does not contain', + starts_with: 'Starts with', ends_with: 'Ends with', + is_empty: 'Is empty', is_not_empty: 'Is not empty', + is_null: 'Is null', is_not_null: 'Is not null', + greater_than: 'Greater than', less_than: 'Less than', + greater_than_or_equal: 'Greater than or equal', less_than_or_equal: 'Less than or equal', + before: 'Before', after: 'After', between: 'Between', + in: 'In', not_in: 'Not in', +}; + +/** Per-type operator lists — match FilterBuilder textOperators / numberOperators / etc. + * in snake_case (spec ViewFilterRuleSchema enum). starts_with / ends_with / is_null / + * is_not_null are valid spec values but only shown when the current value uses them. */ +const TEXT_OPS = ['equals','not_equals','contains','not_contains','is_empty','is_not_empty']; +const NUMBER_OPS = ['equals','not_equals','greater_than','less_than','greater_than_or_equal','less_than_or_equal','is_empty','is_not_empty']; +const BOOLEAN_OPS = ['equals','not_equals']; +const DATE_OPS = ['equals','not_equals','before','after','between','is_empty','is_not_empty']; +const SELECT_OPS = ['equals','not_equals','in','not_in','is_empty','is_not_empty']; + +const OPS_BY_TYPE: Record = { + 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, + number: NUMBER_OPS, currency: NUMBER_OPS, percent: NUMBER_OPS, rating: NUMBER_OPS, integer: NUMBER_OPS, + boolean: BOOLEAN_OPS, toggle: BOOLEAN_OPS, + date: DATE_OPS, datetime: DATE_OPS, time: DATE_OPS, + select: SELECT_OPS, status: SELECT_OPS, multiselect: SELECT_OPS, radio: SELECT_OPS, + lookup: SELECT_OPS, master_detail: SELECT_OPS, user: SELECT_OPS, owner: SELECT_OPS, +}; + +function lbl(op: string) { return OP_LABELS[op] || op; } + +function OperatorWidget({ value, onChange, readOnly, context, formData, schema }: WidgetProps) { + const locale = useMetadataLocale(); + const fieldName = formData?.field as string | undefined; + const objectFields = context?.objectFields ?? []; + const allOps: string[] = schema?.enum ?? []; + + // If we can determine the field type, filter operators accordingly + let ops = allOps; + if (fieldName && objectFields.length) { + const fieldInfo = objectFields.find((f: any) => f.name === fieldName || f.field === fieldName); + if (fieldInfo?.type) { + ops = OPS_BY_TYPE[fieldInfo.type] ?? allOps; + } + } + // Always include the current value so it doesn't disappear + const current = value == null ? '' : String(value); + if (current && !ops.includes(current)) ops = [current, ...ops]; + + return ( + + ); +} + +function ValueWidget({ value, onChange, readOnly, context, formData }: WidgetProps) { + const locale = useMetadataLocale(); + const fieldName = formData?.field as string | undefined; + const operator = (formData?.operator as string | undefined) ?? ''; + const objectFields = context?.objectFields ?? []; + const current = value == null ? '' : String(value); + + // Unary operators that don't need a value + const UNARY = new Set(['is_empty','is_not_empty','is_null','is_not_null']); + if (UNARY.has(operator)) { + return ( + + ); + } + + // Determine the field type from the object field catalog + let fieldType = 'text'; + if (fieldName && objectFields.length) { + const fi = objectFields.find((f: any) => f.name === fieldName || f.field === fieldName); + if (fi?.type) fieldType = fi.type; + } + + // Boolean -> toggle + if (fieldType === 'boolean' || fieldType === 'toggle') { + return ( +
+ onChange(String(c))} + /> + {current === 'true' || current === '1' ? 'true' : 'false'} +
+ ); + } + + // Number / Currency / Percent -> number input + const isNum = fieldType === 'number' || fieldType === 'currency' || fieldType === 'percent' || fieldType === 'integer' || fieldType === 'rating'; + if (isNum) { + return ( + { + const v = e.target.value; + onChange(v === '' ? undefined : Number(v)); + }} + /> + ); + } + + // Date / DateTime -> date input + if (fieldType === 'date' || fieldType === 'datetime') { + return ( + onChange(e.target.value || undefined)} + /> + ); + } + + // Between operator -> two inputs (start + end) + if (operator === 'between') { + const arr = Array.isArray(value) ? value.map(String) : ['', '']; + return ( +
+ onChange([e.target.value || undefined, arr[1] || undefined])} + /> + + onChange([arr[0] || undefined, e.target.value || undefined])} + /> +
+ ); + } + + // In / NotIn -> comma-separated values + if (operator === 'in' || operator === 'not_in') { + return ( + onChange(e.target.value || undefined)} + /> + ); + } + + // Default: text input + return ( + onChange(e.target.value || undefined)} + /> + ); +} + + + /* filter-builder — the SAME runtime FilterBuilder used by the list toolbar, */ /* reused in Studio for tab presets and the page base filter (unified UX). */ /* Stored format stays spec ViewFilterRule[] ({field,operator,value}); the */ @@ -1952,6 +2138,8 @@ export const WIDGETS: Record = { 'field-multi': FieldRefMultiWidget, 'action-multi': ActionMultiWidget, 'filter-builder': FilterBuilderWidget, + 'operator': OperatorWidget, + 'filter-value': ValueWidget, 'view-ref': ViewRefWidget, 'icon': IconPickerWidget, 'color-picker': ColorPickerWidget,