From 40924d85e5530b5fad89e6f435eadd4ac61902cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 16:46:55 +0000 Subject: [PATCH] feat(studio): filter editor for roll-up summary fields (framework#1868) The object-designer field inspector now edits `summaryOperations.filter` on a `summary` field, backing the framework's filtered roll-ups (one child object feeding several parent totals, each aggregating only the rows a predicate matches). - SummaryConfigFields gains a structured field/operator/value row editor under Rollup Options (mirrors the lookupFilters editor), reading/writing the spec's FilterCondition object via summaryFilterToRows / summaryRowsToFilter. - Values are coerced to the child field's stored type: a boolean field emits `{ billable: true }` (not "true"), a numeric operator emits `{ amount: { $gte: 500 } }`, so the FilterCondition matches the real column. - Rows map to/from the flat form and a top-level `$and`; an advanced filter (`$or` / nested) is shown read-only with a note rather than clobbered. - New designer.field.summary.filter* i18n keys (en + zh-CN); 6 new inspector tests (render, read existing, add condition, boolean coercion, numeric operator coercion, advanced-filter note) + a changeset. Verified live against a running showcase backend: the editor parses a seeded `{ status: 'approved' }` into a Field=Line Status / Operator==equals / Value=approved row in the Studio inspector. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HbK4FqcHwp9jSwdhTtxYuC --- .changeset/summary-filter-editor.md | 20 ++ .../src/views/metadata-admin/i18n.ts | 14 ++ .../inspectors/ObjectFieldInspector.test.tsx | 60 ++++++ .../inspectors/ObjectFieldInspector.tsx | 177 ++++++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 .changeset/summary-filter-editor.md diff --git a/.changeset/summary-filter-editor.md b/.changeset/summary-filter-editor.md new file mode 100644 index 000000000..b56f153d7 --- /dev/null +++ b/.changeset/summary-filter-editor.md @@ -0,0 +1,20 @@ +--- +"@object-ui/app-shell": minor +--- + +feat(studio): filter editor for roll-up `summary` fields (framework#1868) + +The object-designer field inspector now edits `summaryOperations.filter` on a +`summary` field. Backing the framework's new filtered roll-ups — where one child +object feeds several parent totals, each aggregating only the child rows a +predicate matches (an approved-only sum vs the grand total) — the inspector adds +a structured field/operator/value row editor under Rollup Options (mirroring the +lookupFilters editor), reading and writing the spec's FilterCondition object. + +- Values are coerced to the child field's stored type, so a `boolean` field emits + `{ billable: true }` (not the string `"true"`) and a numeric operator emits + `{ amount: { $gte: 500 } }` — the FilterCondition then matches the real column. +- Rows map to/from the flat FilterCondition (and a top-level `$and`); a filter + using logic the rows can't represent (`$or` / nested) is shown read-only with a + note instead of being clobbered on edit. +- New `designer.field.summary.filter*` i18n keys (en + zh-CN). diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 472d0d166..ca891c74f 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -967,6 +967,13 @@ const ENGINE_STRINGS_EN: Record = { 'designer.field.summary.relationshipField': 'Child relationship field (optional)', 'designer.field.summary.setObjectFirst': 'Set the child object first', 'designer.field.summary.countFieldHint': 'Ignored for count, but still required — pick any child field.', + 'designer.field.summary.filterSection': 'Filter (rows to aggregate)', + 'designer.field.summary.addFilter': 'Add condition', + 'designer.field.summary.noFilter': 'No filter — every child row is aggregated.', + 'designer.field.summary.filterHint': + 'Only child rows matching ALL conditions are aggregated — this is how one child object can feed several different totals (e.g. an approved-only sum vs the grand total).', + 'designer.field.summary.filterAdvanced': + 'This filter uses an advanced shape (OR / nested logic) that the row editor can’t show. Edit it as raw metadata to preserve it.', 'designer.field.summary.hint': 'Recomputed automatically when child records are created, updated, or deleted. The relationship field is auto-detected when the child has exactly one relation back to this object.', 'designer.field.precision': 'Precision', @@ -2329,6 +2336,13 @@ const ENGINE_STRINGS_ZH: Record = { 'designer.field.summary.relationshipField': '子关系字段(可选)', 'designer.field.summary.setObjectFirst': '请先选择子对象', 'designer.field.summary.countFieldHint': '计数时不参与计算,但规范要求必填——任选一个子字段即可。', + 'designer.field.summary.filterSection': '筛选(参与汇总的子记录)', + 'designer.field.summary.addFilter': '添加条件', + 'designer.field.summary.noFilter': '未设筛选 — 所有子记录都参与汇总。', + 'designer.field.summary.filterHint': + '仅满足全部条件的子记录参与汇总——这样同一个子对象就能生成多个不同的汇总值(例如“仅已批准金额”与“总金额”)。', + 'designer.field.summary.filterAdvanced': + '该筛选使用了行编辑器无法展示的高级结构(OR / 嵌套逻辑)。请直接编辑元数据以保留它。', 'designer.field.summary.hint': '子记录新增、更新或删除时自动重新计算;子对象只有一个指向本对象的关系字段时可自动识别,无需填写。', 'designer.field.precision': '精度', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx index ffff802fa..3f1055a5d 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx @@ -20,6 +20,8 @@ vi.mock('../previews/useObjectFields', () => ({ ? [ { name: 'name', label: 'Name', type: 'text', hidden: false }, { name: 'status', label: 'Status', type: 'select', hidden: false }, + { name: 'amount', label: 'Amount', type: 'currency', hidden: false }, + { name: 'billable', label: 'Billable', type: 'boolean', hidden: false }, ] : [], loading: false, @@ -567,4 +569,62 @@ describe('ObjectFieldInspector — summary roll-up editor', () => { expect(screen.getByText(/ignored for count/i)).toBeInTheDocument(); expect(screen.getByText('Child relationship field (optional)')).toBeInTheDocument(); }); + + /* ── filter editor (framework#1868): summaryOperations.filter ── */ + + it('renders the filter section for a summary field', () => { + renderField( + { total: { type: 'summary', summaryOperations: { object: 'crm_order', function: 'sum', field: 'amount' } } }, + 'total', + ); + expect(screen.getByText('Filter (rows to aggregate)')).toBeInTheDocument(); + expect(screen.getByText('No filter — every child row is aggregated.')).toBeInTheDocument(); + }); + + it('adds a condition, defaulting the field to the first child field', () => { + const { onPatch } = renderField( + { total: { type: 'summary', summaryOperations: { object: 'crm_order', function: 'sum', field: 'amount' } } }, + 'total', + ); + fireEvent.click(screen.getByText('Add condition')); + expect(onPatch.mock.calls.at(-1)![0].fields.total.summaryOperations.filter).toEqual({ name: '' }); + }); + + it('reads an existing filter into a row', () => { + renderField( + { total: { type: 'summary', summaryOperations: { object: 'crm_order', function: 'sum', field: 'amount', filter: { status: 'approved' } } } }, + 'total', + ); + expect(screen.getByText('Filter 1')).toBeInTheDocument(); + expect(screen.getByDisplayValue('approved')).toBeInTheDocument(); + }); + + it('coerces a boolean child field value to a real boolean in the emitted FilterCondition', () => { + const { onPatch } = renderField( + { total: { type: 'summary', summaryOperations: { object: 'crm_order', function: 'sum', field: 'amount', filter: { billable: '' } } } }, + 'total', + ); + fireEvent.change(controlFor('Value'), { target: { value: 'true' } }); + fireEvent.blur(controlFor('Value')); + expect(onPatch.mock.calls.at(-1)![0].fields.total.summaryOperations.filter).toEqual({ billable: true }); + }); + + it('coerces a numeric operator value to a number (amount >= 500)', () => { + const { onPatch } = renderField( + { total: { type: 'summary', summaryOperations: { object: 'crm_order', function: 'count', field: 'amount', filter: { amount: { $gte: 0 } } } } }, + 'total', + ); + fireEvent.change(controlFor('Value'), { target: { value: '500' } }); + fireEvent.blur(controlFor('Value')); + expect(onPatch.mock.calls.at(-1)![0].fields.total.summaryOperations.filter).toEqual({ amount: { $gte: 500 } }); + }); + + it('shows a read-only note for an advanced ($or) filter instead of clobbering it', () => { + renderField( + { total: { type: 'summary', summaryOperations: { object: 'crm_order', function: 'sum', field: 'amount', filter: { $or: [{ status: 'approved' }, { status: 'submitted' }] } } } }, + 'total', + ); + expect(screen.getByText(/advanced shape/i)).toBeInTheDocument(); + expect(screen.queryByText('Filter 1')).not.toBeInTheDocument(); + }); }); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index 68b1d9d37..9ad21c6af 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -1187,6 +1187,8 @@ interface SummaryOps { field?: string; function?: string; relationshipField?: string; + /** FilterCondition (query `where` object) restricting which child rows aggregate. */ + filter?: Record; } function readSummaryOps(def: Record): SummaryOps { @@ -1194,6 +1196,97 @@ function readSummaryOps(def: Record): SummaryOps { return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as SummaryOps) : {}; } +/* ─── summary `filter` ⇆ structured rows ─────────────────────────────────── + * The spec models `summaryOperations.filter` as a FilterCondition OBJECT + * (`{ status: 'approved' }`, `{ amount: { $gte: 500 } }`). The inspector edits + * it as field/operator/value rows (the same shape as lookupFilters), converting + * both ways. A filter using logic the rows can't represent ($or/$not/nested) is + * flagged `advanced` so the editor shows it read-only instead of clobbering it. */ +type SummaryFilterRow = { field: string; operator: string; value: unknown }; + +const SUMMARY_OP_TO_FC: Record = { + ne: '$ne', gt: '$gt', lt: '$lt', gte: '$gte', lte: '$lte', + contains: '$contains', in: '$in', notIn: '$nin', +}; +const FC_TO_SUMMARY_OP: Record = { + $eq: 'eq', $ne: 'ne', $gt: 'gt', $lt: 'lt', $gte: 'gte', $lte: 'lte', + $contains: 'contains', $in: 'in', $nin: 'notIn', +}; +const SUMMARY_NUMERIC_TYPES = new Set(['number', 'currency', 'percent', 'rating', 'slider', 'autonumber']); + +/** Coerce an editor string to the type the child field stores, so the emitted + * FilterCondition matches on the real column (a boolean, not the text "true"). */ +function coerceSummaryValue(raw: unknown, fieldType: string | undefined, isList: boolean): unknown { + const one = (s: unknown): unknown => { + if (typeof s !== 'string') return s; // already typed (round-tripped from an existing filter) + const t = s.trim(); + if (fieldType === 'boolean' || fieldType === 'toggle') return t === 'true' || t === '1'; + if (fieldType && SUMMARY_NUMERIC_TYPES.has(fieldType)) { + const n = Number(t); + return t !== '' && Number.isFinite(n) ? n : s; + } + return s; + }; + if (isList) { + const arr = Array.isArray(raw) ? raw : String(raw ?? '').split(',').map((x) => x.trim()).filter(Boolean); + return arr.map(one); + } + return one(raw); +} + +/** FilterCondition object → editor rows. Understands the flat form and a + * top-level `$and` of flat conditions; anything else sets `advanced`. */ +function summaryFilterToRows(filter: unknown): { rows: SummaryFilterRow[]; advanced: boolean } { + if (!filter || typeof filter !== 'object' || Array.isArray(filter)) return { rows: [], advanced: false }; + const rows: SummaryFilterRow[] = []; + let advanced = false; + const consume = (obj: Record) => { + for (const [key, v] of Object.entries(obj)) { + if (key === '$and' && Array.isArray(v)) { + for (const c of v) { + if (c && typeof c === 'object' && !Array.isArray(c)) consume(c as Record); + else advanced = true; + } + continue; + } + if (key.startsWith('$')) { advanced = true; continue; } // $or / $not / unknown + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const entries = Object.entries(v as Record); + if (entries.length === 0) { advanced = true; continue; } + for (const [op, val] of entries) { + const sop = FC_TO_SUMMARY_OP[op]; + if (sop) rows.push({ field: key, operator: sop, value: val }); + else advanced = true; + } + } else { + rows.push({ field: key, operator: 'eq', value: v }); // scalar/array ⇒ implicit equality + } + } + }; + consume(filter as Record); + return { rows, advanced }; +} + +/** Editor rows → FilterCondition object. Drops field-less rows; merges into a + * flat object when field keys are distinct, else wraps in `$and`. */ +function summaryRowsToFilter( + rows: SummaryFilterRow[], + typeOf: (field: string) => string | undefined, +): Record | undefined { + const valid = rows.filter((r) => r.field); + if (valid.length === 0) return undefined; + const conds = valid.map((r) => { + const isList = r.operator === 'in' || r.operator === 'notIn'; + const cv = coerceSummaryValue(r.value, typeOf(r.field), isList); + return r.operator === 'eq' ? { [r.field]: cv } : { [r.field]: { [SUMMARY_OP_TO_FC[r.operator] ?? '$eq']: cv } }; + }); + if (conds.length === 1) return conds[0]; + const keys = conds.map((c) => Object.keys(c)[0]); + return new Set(keys).size === keys.length ? Object.assign({}, ...conds) : { $and: conds }; +} + +const summaryValueToText = (v: unknown): string => (Array.isArray(v) ? v.join(', ') : v == null ? '' : String(v)); + /** * Structured editor for a `summary` field's roll-up definition. A summary * field has NO CEL expression — the spec models it as `summaryOperations` @@ -1245,6 +1338,20 @@ function SummaryConfigFields({ ? tr('designer.field.lookup.selectField') : tr('designer.field.summary.setObjectFirst'); + // Filter editor: derive rows from the FilterCondition each render, edit, and + // serialize back. `typeOf` coerces values to the child field's stored type. + const typeOf = React.useCallback( + (name: string) => childFields.find((f) => f.name === name)?.type, + [childFields], + ); + const { rows: filterRows, advanced: filterAdvanced } = summaryFilterToRows(ops.filter); + const commitFilterRows = (rows: SummaryFilterRow[]) => patchOps({ filter: summaryRowsToFilter(rows, typeOf) }); + const patchFilterRow = (i: number, patch: Partial) => + commitFilterRows(filterRows.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); + const addFilterRow = () => + commitFilterRows([...filterRows, { field: aggregateOptions[0]?.value ?? '', operator: 'eq', value: '' }]); + const removeFilterRow = (i: number) => commitFilterRows(filterRows.filter((_, idx) => idx !== i)); + return (
+ + {/* Optional filter — only child rows matching these conditions aggregate + (framework#1868). Reads/writes summaryOperations.filter as a + FilterCondition; rows mirror the lookupFilters editor. */} +
+
+
+ + {!filterAdvanced && {filterRows.length}} +
+ {!readOnly && !filterAdvanced && ( + + )} +
+ {filterAdvanced ? ( +

+ {tr('designer.field.summary.filterAdvanced')} +

+ ) : filterRows.length === 0 ? ( +

+ {tr('designer.field.summary.noFilter')} +

+ ) : ( + filterRows.map((f, i) => ( +
+
+ {tFormat('designer.field.lookup.filterN', locale, { n: i + 1 })} + {!readOnly && ( + + )} +
+ patchFilterRow(i, { field: v })} + options={aggregateOptions} + loading={loading} + placeholder={fieldPlaceholder} + searchPlaceholder={tr('designer.field.lookup.searchFields')} + disabled={readOnly} + mono + /> + patchFilterRow(i, { operator: v })} disabled={readOnly} /> + patchFilterRow(i, { value: v })} + placeholder={f.operator === 'in' || f.operator === 'notIn' ? 'comma,separated,values' : 'value'} + disabled={readOnly} + mono + /> +
+ )) + )} +

+ {tr('designer.field.summary.filterHint')} +

+
+

{tr('designer.field.summary.hint')}