From bfade0e88acf60fda0e6894281580076e261fa2e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:37:32 +0000 Subject: [PATCH] feat(app-shell): Studio CEL editor for list-view conditional formatting (#1584 / #1582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conditionalFormatting had no authoring UI in Studio — a low-code author could only hand-write the JSON. Adds a ConditionalFormattingEditor to the View inspector (ViewVariantInspector, list-family views; also hosted by the runtime ObjectView right-rail editor): an ordered list of rules, each a CEL predicate authored with CelPredicateField (inline lint + field autocomplete on the canonical @objectstack/formula engine) plus background / text / border colors. Rules are first-match-wins, so move up / down is supported. Reads/writes the spec-canonical { condition, style } shape (what the list / grid / kanban renderers evaluate since #1584). Legacy shapes — native { field, operator, value }, top-level color props, or a { dialect, source } condition envelope — are normalized to { condition, style } on read, upgrading an existing rule in place. English + Chinese labels. Component tests cover normalization + add/remove/reorder/edit; full metadata-admin suite (689) green; app-shell type-check green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019LwrWThBJgvcfPtqNHpstB --- .../studio-conditional-formatting-editor.md | 20 ++ .../ConditionalFormattingEditor.test.tsx | 130 ++++++++ .../ConditionalFormattingEditor.tsx | 295 ++++++++++++++++++ .../src/views/metadata-admin/i18n.ts | 26 ++ .../inspectors/ViewVariantInspector.tsx | 25 +- 5 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 .changeset/studio-conditional-formatting-editor.md create mode 100644 packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.tsx diff --git a/.changeset/studio-conditional-formatting-editor.md b/.changeset/studio-conditional-formatting-editor.md new file mode 100644 index 000000000..9f0acdf92 --- /dev/null +++ b/.changeset/studio-conditional-formatting-editor.md @@ -0,0 +1,20 @@ +--- +"@object-ui/app-shell": minor +--- + +Studio: author list/grid `conditionalFormatting` rules with a CEL editor (#1584 / #1582 follow-up). + +`conditionalFormatting` previously had no authoring UI in Studio — a low-code +author could only hand-write the JSON. Adds a `ConditionalFormattingEditor` to the +View inspector (`ViewVariantInspector`, list-family views; also hosted by the +runtime ObjectView's right-rail view editor): an ordered list of rules, each a +**CEL predicate** authored with `CelPredicateField` (inline lint + field +autocomplete on the canonical `@objectstack/formula` engine — the same engine the +runtime and server use) plus background / text / border colors. Rules are +first-match-wins, so the editor supports move up / down. + +It reads and writes the spec-canonical `{ condition, style }` shape (what the list +/ grid / kanban renderers evaluate since #1584). Legacy rule shapes — native +`{ field, operator, value }`, top-level color props, or a `{ dialect, source }` +condition envelope — are normalized to `{ condition, style }` on read, so opening +an existing rule upgrades it in place. English + Chinese labels included. diff --git a/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx b/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx new file mode 100644 index 000000000..0edd2163c --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx @@ -0,0 +1,130 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import * as React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { + ConditionalFormattingEditor, + normalizeRule, + type ConditionalFormattingRuleDraft, +} from './ConditionalFormattingEditor'; +import { __setCelFormulaLoader } from './celAuthoring'; + +afterEach(() => { + cleanup(); + __setCelFormulaLoader(undefined); +}); + +const t = (k: string) => k; + +/** Controlled harness — holds the rule list so edits round-trip. */ +function Harness({ initial = [] as any[] }: { initial?: any[] }) { + const [rules, setRules] = React.useState(initial); + return ( +
+ void} + objectName="invoice" + fieldNames={['status', 'amount']} + t={t} + /> +
{JSON.stringify(rules)}
+
+ ); +} + +const state = () => JSON.parse(screen.getByTestId('state').textContent || '[]'); + +describe('normalizeRule', () => { + it('passes a spec { condition, style } rule through', () => { + expect(normalizeRule({ condition: "record.status == 'x'", style: { backgroundColor: '#fee' } })).toEqual({ + condition: "record.status == 'x'", + style: { backgroundColor: '#fee' }, + }); + }); + + it('unwraps a { dialect, source } condition envelope', () => { + expect( + normalizeRule({ condition: { dialect: 'cel', source: 'record.amount > 100' } as any, style: {} }), + ).toEqual({ condition: 'record.amount > 100', style: {} }); + }); + + it('translates a legacy { field, operator, value } rule to CEL + folds color props', () => { + expect( + normalizeRule({ field: 'status', operator: 'equals', value: 'overdue', backgroundColor: '#f00', textColor: '#fff' }), + ).toEqual({ + condition: `record["status"] == "overdue"`, + style: { backgroundColor: '#f00', color: '#fff' }, + }); + }); + + it('translates the `in` operator', () => { + expect(normalizeRule({ field: 'tier', operator: 'in', value: ['a', 'b'] }).condition).toBe( + `record["tier"] in ["a", "b"]`, + ); + }); + + it('reads the ObjectUI `expression` shape', () => { + expect(normalizeRule({ expression: 'record.x == 1', backgroundColor: 'red' })).toEqual({ + condition: 'record.x == 1', + style: { backgroundColor: 'red' }, + }); + }); +}); + +describe('ConditionalFormattingEditor', () => { + it('shows the empty state and adds a rule', () => { + render(); + expect(screen.getByText('engine.inspector.view.cf.empty')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('cf-add')); + expect(screen.getByTestId('cf-rule-0')).toBeInTheDocument(); + expect(state()).toEqual([{ condition: '', style: {} }]); + }); + + it('edits the CEL condition', () => { + render(); + const ta = document.getElementById('cf-condition-0') as HTMLTextAreaElement; + fireEvent.change(ta, { target: { value: "record.status == 'overdue'" } }); + expect(state()[0].condition).toBe("record.status == 'overdue'"); + }); + + it('sets a background color into style', () => { + render(); + const rule = screen.getByTestId('cf-rule-0'); + const bg = rule.querySelectorAll('input[placeholder="#RRGGBB"]')[0] as HTMLInputElement; + fireEvent.change(bg, { target: { value: '#fee2e2' } }); + expect(state()[0].style).toEqual({ backgroundColor: '#fee2e2' }); + }); + + it('clearing a color removes the style key', () => { + render(); + const rule = screen.getByTestId('cf-rule-0'); + const bg = rule.querySelectorAll('input[placeholder="#RRGGBB"]')[0] as HTMLInputElement; + fireEvent.change(bg, { target: { value: '' } }); + expect(state()[0].style).toEqual({}); + }); + + it('removes a rule', () => { + render(); + fireEvent.click(screen.getByTestId('cf-remove-0')); + expect(state().map((r: any) => r.condition)).toEqual(['b']); + }); + + it('reorders rules (first-match-wins order matters)', () => { + render(); + fireEvent.click(screen.getByTestId('cf-down-0')); + expect(state().map((r: any) => r.condition)).toEqual(['b', 'a']); + fireEvent.click(screen.getByTestId('cf-up-1')); + expect(state().map((r: any) => r.condition)).toEqual(['a', 'b']); + }); + + it('normalizes a legacy native rule when rendered (upgrades in place on edit)', () => { + render(); + const ta = document.getElementById('cf-condition-0') as HTMLTextAreaElement; + expect(ta.value).toBe(`record["status"] == "x"`); + // an edit commits the normalized { condition, style } shape + fireEvent.change(ta, { target: { value: "record.status == 'x'" } }); + expect(state()[0]).toEqual({ condition: "record.status == 'x'", style: { backgroundColor: '#f00' } }); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.tsx b/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.tsx new file mode 100644 index 000000000..f7cdf3722 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.tsx @@ -0,0 +1,295 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Author a list/grid view's `conditionalFormatting` rules in Studio (#1584 / + * #1582 follow-up). + * + * Before this, `conditionalFormatting` had no authoring UI at all — a low-code + * author could only hand-write the JSON. This is the missing editor: an ordered + * list of rules, each a **CEL predicate** (authored with {@link CelPredicateField} + * — inline lint + field autocomplete, the same canonical `@objectstack/formula` + * engine the runtime and server use) plus a style (background / text / border + * color). Rules are first-match-wins, so order matters — hence move up / down. + * + * It reads and writes the spec-canonical `{ condition, style }` shape (the one + * `@object-ui/plugin-list`/`-grid`/`-kanban` evaluate since #1584). Legacy rule + * shapes (`{ field, operator, value }`, top-level color props, or a + * `{ dialect, source }` condition envelope) are normalized to `{ condition, + * style }` on read, so opening an old rule upgrades it in place. + */ + +import * as React from 'react'; +import { Button, Input, cn } from '@object-ui/components'; +import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'; +import { CelPredicateField } from './CelPredicateField'; + +/** The canonical authoring shape this editor reads and writes. */ +export interface ConditionalFormattingRuleDraft { + condition: string; + style: Record; +} + +/** Any historical rule shape that may already be persisted. */ +type AnyRule = { + condition?: string | { dialect?: string; source?: string }; + expression?: string; + field?: string; + operator?: string; + value?: unknown; + style?: Record; + backgroundColor?: string; + textColor?: string; + borderColor?: string; +}; + +/** Serialize a JS value as a CEL literal (for translating a legacy native rule). */ +function celLiteral(v: unknown): string { + if (v === null || v === undefined) return 'null'; + const t = typeof v; + if (t === 'string') return JSON.stringify(v); + if (t === 'number' || t === 'boolean') return String(v); + if (Array.isArray(v)) return `[${v.map(celLiteral).join(', ')}]`; + return JSON.stringify(String(v)); +} + +/** Translate a legacy `{ field, operator, value }` rule to a CEL predicate. */ +function nativeToCel(rule: AnyRule): string { + if (!rule.field || !rule.operator) return ''; + const ref = `record[${JSON.stringify(rule.field)}]`; + switch (rule.operator) { + case 'equals': return `${ref} == ${celLiteral(rule.value)}`; + case 'not_equals': return `${ref} != ${celLiteral(rule.value)}`; + case 'greater_than': return `${ref} > ${celLiteral(rule.value)}`; + case 'less_than': return `${ref} < ${celLiteral(rule.value)}`; + case 'contains': return `${ref}.contains(${celLiteral(rule.value)})`; + case 'in': return `${ref} in ${celLiteral(Array.isArray(rule.value) ? rule.value : [rule.value])}`; + default: return ''; + } +} + +/** Resolve a rule's CEL condition string across every historical shape. */ +function resolveCondition(rule: AnyRule): string { + if (typeof rule.condition === 'string') return rule.condition; + if (rule.condition && typeof rule.condition === 'object' && typeof rule.condition.source === 'string') { + return rule.condition.source; + } + if (typeof rule.expression === 'string') return rule.expression; + return nativeToCel(rule); +} + +/** Normalize any persisted rule shape into the `{ condition, style }` draft. */ +export function normalizeRule(rule: AnyRule): ConditionalFormattingRuleDraft { + const condition = resolveCondition(rule); + + const style: Record = {}; + if (rule.style && typeof rule.style === 'object') { + for (const [k, v] of Object.entries(rule.style)) if (v != null) style[k] = String(v); + } + if (rule.backgroundColor) style.backgroundColor = String(rule.backgroundColor); + if (rule.textColor) style.color = String(rule.textColor); + if (rule.borderColor) style.borderColor = String(rule.borderColor); + + return { condition, style }; +} + +export interface ConditionalFormattingEditorProps { + /** The current rules (any persisted shape). */ + rules?: readonly AnyRule[]; + /** Emits the full, normalized `{ condition, style }` rule list on any edit. */ + onChange: (rules: ConditionalFormattingRuleDraft[]) => void; + /** Bound object api-name — powers CEL field lint + autocomplete. */ + objectName?: string; + /** Field names of {@link objectName} for autocomplete. */ + fieldNames?: string[]; + disabled?: boolean; + /** i18n resolver (`(key) => string`). */ + t: (key: string) => string; +} + +/** A native color swatch + free-text value (hex / CSS / Tailwind), like the + * spec-form color widget. Empty clears the style key. */ +function ColorInput({ + label, + value, + onChange, + disabled, +}: { + label: string; + value: string; + onChange: (next: string) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +export function ConditionalFormattingEditor({ + rules, + onChange, + objectName, + fieldNames, + disabled, + t, +}: ConditionalFormattingEditorProps) { + // Normalize the persisted rules to the authoring shape once per input change. + const drafts = React.useMemo( + () => (Array.isArray(rules) ? rules.map(normalizeRule) : []), + [rules], + ); + + const commit = (next: ConditionalFormattingRuleDraft[]) => onChange(next); + + const setRule = (i: number, patch: Partial) => { + const next = drafts.map((r, idx) => (idx === i ? { ...r, ...patch } : r)); + commit(next); + }; + const setStyleKey = (i: number, key: string, val: string) => { + const nextStyle = { ...drafts[i].style }; + if (val) nextStyle[key] = val; + else delete nextStyle[key]; + setRule(i, { style: nextStyle }); + }; + const addRule = () => commit([...drafts, { condition: '', style: {} }]); + const removeRule = (i: number) => commit(drafts.filter((_, idx) => idx !== i)); + const move = (i: number, dir: -1 | 1) => { + const j = i + dir; + if (j < 0 || j >= drafts.length) return; + const next = drafts.slice(); + [next[i], next[j]] = [next[j], next[i]]; + commit(next); + }; + + return ( +
+
+ + {t('engine.inspector.view.cf.title')} + + +
+ + {drafts.length === 0 && ( +

{t('engine.inspector.view.cf.empty')}

+ )} + + {drafts.map((rule, i) => ( +
+
+ + {t('engine.inspector.view.cf.rule')} {i + 1} + +
+ + + +
+
+ + setRule(i, { condition: v })} + t={t} + id={`cf-condition-${i}`} + /> + +
+ setStyleKey(i, 'backgroundColor', v)} + /> + setStyleKey(i, 'color', v)} + /> + setStyleKey(i, 'borderColor', v)} + /> +
+ + {/* Live preview chip of the resolved style. */} +
+ {t('engine.inspector.view.cf.preview')} +
+
+ ))} +
+ ); +} diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index fc1938b25..e6f441d9b 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -432,6 +432,19 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.view.object': 'Object', 'engine.inspector.view.objectPlaceholder': 'e.g. crm_lead', 'engine.inspector.view.noSchema': 'Spec schema unavailable — basic properties only.', + // Conditional formatting editor (list/grid views) + 'engine.inspector.view.cf.title': 'Conditional formatting', + 'engine.inspector.view.cf.add': 'Add rule', + 'engine.inspector.view.cf.empty': 'No rules. Add one to color rows by a CEL condition.', + 'engine.inspector.view.cf.rule': 'Rule', + 'engine.inspector.view.cf.when': 'When (CEL)', + 'engine.inspector.view.cf.background': 'Background', + 'engine.inspector.view.cf.text': 'Text', + 'engine.inspector.view.cf.border': 'Border', + 'engine.inspector.view.cf.preview': 'Preview', + 'engine.inspector.view.cf.remove': 'Remove rule', + 'engine.inspector.view.cf.moveUp': 'Move up', + 'engine.inspector.view.cf.moveDown': 'Move down', 'engine.inspector.view.type.grid': 'Table / List', 'engine.inspector.view.type.kanban': 'Kanban', 'engine.inspector.view.type.calendar': 'Calendar', @@ -1735,6 +1748,19 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.view.object': '对象', 'engine.inspector.view.objectPlaceholder': '例如:crm_lead', 'engine.inspector.view.noSchema': 'spec 模式不可用 —— 仅显示基础属性。', + // 条件格式化编辑器(列表/表格视图) + 'engine.inspector.view.cf.title': '条件格式化', + 'engine.inspector.view.cf.add': '添加规则', + 'engine.inspector.view.cf.empty': '暂无规则。添加一条以按 CEL 条件为行着色。', + 'engine.inspector.view.cf.rule': '规则', + 'engine.inspector.view.cf.when': '条件(CEL)', + 'engine.inspector.view.cf.background': '背景色', + 'engine.inspector.view.cf.text': '文字色', + 'engine.inspector.view.cf.border': '边框色', + 'engine.inspector.view.cf.preview': '预览', + 'engine.inspector.view.cf.remove': '删除规则', + 'engine.inspector.view.cf.moveUp': '上移', + 'engine.inspector.view.cf.moveDown': '下移', 'engine.inspector.view.type.grid': '表格 / 列表', 'engine.inspector.view.type.kanban': '看板', 'engine.inspector.view.type.calendar': '日历', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx index e28c38024..9f6454bc9 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx @@ -73,6 +73,7 @@ function ViewObjectPicker({ ); } import { FieldsListEditor } from '../previews/FieldsListEditor'; +import { ConditionalFormattingEditor } from '../ConditionalFormattingEditor'; import { getViewForm, getListVariantSchema, @@ -88,7 +89,7 @@ import { t } from '../i18n'; * `hiddenFields` passed to SchemaForm (`type`/`object`/`label`) plus the * canvas-owned `columns`. */ -const VIEW_CURATED_FIELDS = new Set(['type', 'object', 'label', 'columns']); +const VIEW_CURATED_FIELDS = new Set(['type', 'object', 'label', 'columns', 'conditionalFormatting']); export interface ViewVariantInspectorProps extends MetadataDefaultInspectorProps { /** @@ -224,6 +225,13 @@ export function ViewVariantInspector({ binding.value || undefined, objectFieldsOverride, ); + // Locale-bound `t` for child components that take a bare `(key) => string` + // (e.g. CelPredicateField / ConditionalFormattingEditor). + const tLocal = React.useCallback((k: string) => t(k, locale), [locale]); + const fieldNames = React.useMemo(() => objectFields.map((f) => f.name), [objectFields]); + const cfRules = Array.isArray(variant.conditionalFormatting) + ? (variant.conditionalFormatting as unknown[]) + : []; const widgetContext = React.useMemo( () => ({ objectFields: objectFields.map((f) => ({ @@ -340,6 +348,21 @@ export function ViewVariantInspector({ )} + {!isFormFamily && ( +
+ + writeVariant({ conditionalFormatting: rules.length > 0 ? rules : undefined }) + } + /> +
+ )} +
{schema ? (