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
20 changes: 20 additions & 0 deletions .changeset/summary-filter-editor.md
Original file line number Diff line number Diff line change
@@ -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).
14 changes: 14 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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',
Expand Down Expand Up @@ -2329,6 +2336,13 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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': '精度',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1187,13 +1187,106 @@ interface SummaryOps {
field?: string;
function?: string;
relationshipField?: string;
/** FilterCondition (query `where` object) restricting which child rows aggregate. */
filter?: Record<string, unknown>;
}

function readSummaryOps(def: Record<string, unknown>): SummaryOps {
const raw = def.summaryOperations;
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<string, string> = {
ne: '$ne', gt: '$gt', lt: '$lt', gte: '$gte', lte: '$lte',
contains: '$contains', in: '$in', notIn: '$nin',
};
const FC_TO_SUMMARY_OP: Record<string, string> = {
$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<string, unknown>) => {
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<string, unknown>);
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<string, unknown>);
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<string, unknown>);
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<string, unknown> | 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`
Expand Down Expand Up @@ -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<SummaryFilterRow>) =>
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 (
<div className="space-y-2">
<ObjectPicker
Expand Down Expand Up @@ -1295,6 +1402,76 @@ function SummaryConfigFields({
disabled={readOnly}
mono
/>

{/* Optional filter — only child rows matching these conditions aggregate
(framework#1868). Reads/writes summaryOperations.filter as a
FilterCondition; rows mirror the lookupFilters editor. */}
<div className="space-y-1.5 border-t pt-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Label className="text-xs text-muted-foreground">{tr('designer.field.summary.filterSection')}</Label>
{!filterAdvanced && <Badge variant="outline" className="text-[10px]">{filterRows.length}</Badge>}
</div>
{!readOnly && !filterAdvanced && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 gap-1 px-1.5 text-[11px]"
onClick={addFilterRow}
disabled={aggregateOptions.length === 0}
>
<Plus className="h-3 w-3" /> {tr('designer.field.summary.addFilter')}
</Button>
)}
</div>
{filterAdvanced ? (
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-2 text-[11px] text-muted-foreground">
{tr('designer.field.summary.filterAdvanced')}
</p>
) : filterRows.length === 0 ? (
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-2 text-center text-[11px] text-muted-foreground">
{tr('designer.field.summary.noFilter')}
</p>
) : (
filterRows.map((f, i) => (
<div key={i} className="rounded-md border p-2 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">{tFormat('designer.field.lookup.filterN', locale, { n: i + 1 })}</span>
{!readOnly && (
<Button type="button" variant="ghost" size="sm" aria-label={tr('designer.field.lookup.removeFilter')} className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive" onClick={() => removeFilterRow(i)}>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
<InspectorComboField
label={tr('designer.field.lookup.filterField')}
value={f.field ?? ''}
onCommit={(v) => patchFilterRow(i, { field: v })}
options={aggregateOptions}
loading={loading}
placeholder={fieldPlaceholder}
searchPlaceholder={tr('designer.field.lookup.searchFields')}
disabled={readOnly}
mono
/>
<InspectorSelectField label={tr('designer.field.lookup.filterOperator')} value={f.operator ?? 'eq'} options={LOOKUP_OPERATORS} onCommit={(v) => patchFilterRow(i, { operator: v })} disabled={readOnly} />
<InspectorTextField
label={tr('designer.field.lookup.filterValue')}
value={summaryValueToText(f.value)}
onCommit={(v) => patchFilterRow(i, { value: v })}
placeholder={f.operator === 'in' || f.operator === 'notIn' ? 'comma,separated,values' : 'value'}
disabled={readOnly}
mono
/>
</div>
))
)}
<p className="text-[11px] text-muted-foreground/80 px-0.5 leading-snug">
{tr('designer.field.summary.filterHint')}
</p>
</div>

<p className="text-[11px] text-muted-foreground/80 px-0.5 leading-snug">
{tr('designer.field.summary.hint')}
</p>
Expand Down
Loading