diff --git a/.changeset/object-config-panels-validations-hooks-actions.md b/.changeset/object-config-panels-validations-hooks-actions.md new file mode 100644 index 000000000..30a395df1 --- /dev/null +++ b/.changeset/object-config-panels-validations-hooks-actions.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': minor +--- + +Studio Data tab: metadata-driven config panels for Validations, Hooks and Actions (with add). + +The object **Validations**, **Hooks** and **Actions** sub-tabs are now no-code config panels driven by the corresponding metadata, each able to **create** new entries: + +- **Validations** — the panel covers every spec rule type, not just `script`: `cross_field`, `state_machine`, `format`, `json_schema` and `conditional` are all authorable (previously they were read-only "maintain in code"). The **New** menu adds any type seeded with a valid, never-firing skeleton, and a rule's type can be switched in place; CEL predicates reuse the shared `ConditionBuilder`. +- **Hooks** — a new curated `HookDefaultInspector` replaces the flat generic form: the target object is an **object picker** (multi-select + `*`, not a free-text box), lifecycle events are grouped checkboxes, and the handler is a **dedicated code editor** (language + body). Advanced props fall through to a `SchemaForm` fed the live `hook` JSONSchema from `/meta/types`. +- **Actions** — the `ActionDefaultInspector` now receives the live `action` JSONSchema as `serverSchema`, so its "More fields" section can edit any spec property not curated above (nothing is un-editable). + +`DataPillar` resolves the per-type schemas once (via `useMetadataTypes`) and passes them down. diff --git a/content/docs/guide/console.md b/content/docs/guide/console.md index dd11505f1..97924d7e9 100644 --- a/content/docs/guide/console.md +++ b/content/docs/guide/console.md @@ -33,6 +33,24 @@ The console opens at **http://localhost:5175** with MSW (Mock Service Worker) pr | **App Creation Wizard** | 4-step wizard (Basic Info → Objects → Navigation → Branding) to create or edit apps. | | **Error Boundary** | Graceful error handling with a retry button. | +### Object design (Studio Data tab) + +Selecting an object in Studio's **Data** pillar (`/studio/:packageId/data`) opens a +tab strip over that object — **Records · Form · Validations · Hooks · Actions · +API · Settings**. Each of Validations, Hooks and Actions is a no-code **config +panel driven by the corresponding metadata**, and each supports **adding** new +entries — no code round-trip required: + +| Tab | Edits | Panel | +|-----|-------|-------| +| **Validations** | the object's inline `validations[]` (spec `ValidationRuleSchema`) | Master-detail covering **every** rule type — `script`, `cross_field`, `state_machine`, `format`, `json_schema`, `conditional`. The **New** menu adds any type (seeded with a valid, never-firing skeleton); a rule's type can be switched in place. CEL predicates reuse the shared `ConditionBuilder`, fed the object's draft fields. | +| **Hooks** | the separate `hook` metadata type targeting this object | Master-detail whose editor is the platform `SchemaForm` **driven by the live `hook` JSONSchema from `/meta/types`**, so its fields and enums always match the running server's contract. | +| **Actions** | the object's inline `actions[]` (spec `ActionSchema`) | Master-detail using the type-aware `ActionDefaultInspector`; anything not curated falls through to a **"More fields"** form fed the live `action` JSONSchema, so no spec property is un-editable. | + +Validations and Actions persist with the object's own **Save draft**; Hooks (a +distinct metadata type) save per-hook. Nothing goes live until the package is +published from the top-bar **Publish** flow. + ## Configuration The console reads its configuration from `objectstack.config.ts`: diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index bb2c9fcf1..6b03fb9fa 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -486,6 +486,7 @@ const ENGINE_STRINGS_EN: Record = { // Action default (home) inspector 'engine.inspector.action.kind': 'Action', 'engine.inspector.action.close': 'Close action', + 'engine.inspector.hook.kind': 'Hook', // Dashboard default (home) inspector 'engine.inspector.dashboard.kind': 'Dashboard', 'engine.inspector.dashboard.close': 'Close dashboard', @@ -984,6 +985,36 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.rules.severityError': 'error (rejects the save)', 'engine.studio.rules.enabled': 'Enabled', 'engine.studio.rules.delete': 'Delete', + 'engine.studio.rules.type': 'Type', + 'engine.studio.rules.newType': 'New validation type', + 'engine.studio.rules.typeScript': 'Script — CEL fail condition', + 'engine.studio.rules.typeCrossField': 'Cross-field — CEL over multiple fields', + 'engine.studio.rules.typeStateMachine': 'State machine — allowed transitions', + 'engine.studio.rules.typeFormat': 'Format — regex / built-in format', + 'engine.studio.rules.typeJsonSchema': 'JSON schema — validate a JSON field', + 'engine.studio.rules.typeConditional': 'Conditional — apply a rule when a guard holds', + 'engine.studio.rules.label': 'Label (optional)', + 'engine.studio.rules.field': 'Field', + 'engine.studio.rules.statusField': 'Status field', + 'engine.studio.rules.fields': 'Participating fields', + 'engine.studio.rules.noFields': 'This object has no fields yet.', + 'engine.studio.rules.pickField': '— pick a field —', + 'engine.studio.rules.format': 'Built-in format', + 'engine.studio.rules.formatNone': '— none —', + 'engine.studio.rules.regex': 'Regex pattern', + 'engine.studio.rules.transitions': 'Allowed transitions (from → next states)', + 'engine.studio.rules.transitionFrom': 'From state', + 'engine.studio.rules.transitionTo': 'Next states (comma-separated)', + 'engine.studio.rules.addTransition': 'Add transition', + 'engine.studio.rules.jsonSchema': 'JSON Schema', + 'engine.studio.rules.invalidJson': 'Invalid JSON', + 'engine.studio.rules.then': 'Then — rule applied when the condition holds (JSON)', + 'engine.studio.rules.otherwise': 'Otherwise — rule applied when it does not (JSON, optional)', + 'engine.studio.rules.events': 'Runs on', + 'engine.studio.rules.event.insert': 'Create', + 'engine.studio.rules.event.update': 'Update', + 'engine.studio.rules.event.delete': 'Delete', + 'engine.studio.rules.priority': 'Priority', 'engine.studio.settings.basics': 'Basics', 'engine.studio.settings.noInspector': 'No default object inspector registered.', 'engine.studio.settings.semanticRoles': 'Semantic roles', @@ -1495,6 +1526,7 @@ const ENGINE_STRINGS_ZH: Record = { // Action default (home) inspector 'engine.inspector.action.kind': '动作', 'engine.inspector.action.close': '关闭动作', + 'engine.inspector.hook.kind': '钩子', // Dashboard default (home) inspector 'engine.inspector.dashboard.kind': '仪表盘', 'engine.inspector.dashboard.close': '关闭仪表盘', @@ -1988,6 +2020,36 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.rules.severityError': 'error(拒绝保存)', 'engine.studio.rules.enabled': '启用', 'engine.studio.rules.delete': '删除', + 'engine.studio.rules.type': '类型', + 'engine.studio.rules.newType': '新建验证类型', + 'engine.studio.rules.typeScript': 'Script —— CEL 失败条件', + 'engine.studio.rules.typeCrossField': 'Cross-field —— 跨字段 CEL', + 'engine.studio.rules.typeStateMachine': 'State machine —— 允许的状态流转', + 'engine.studio.rules.typeFormat': 'Format —— 正则 / 内置格式', + 'engine.studio.rules.typeJsonSchema': 'JSON schema —— 校验 JSON 字段', + 'engine.studio.rules.typeConditional': 'Conditional —— 条件成立时应用规则', + 'engine.studio.rules.label': '标签(可选)', + 'engine.studio.rules.field': '字段', + 'engine.studio.rules.statusField': '状态字段', + 'engine.studio.rules.fields': '参与字段', + 'engine.studio.rules.noFields': '该对象暂无字段。', + 'engine.studio.rules.pickField': '—— 选择字段 ——', + 'engine.studio.rules.format': '内置格式', + 'engine.studio.rules.formatNone': '—— 无 ——', + 'engine.studio.rules.regex': '正则表达式', + 'engine.studio.rules.transitions': '允许的状态流转(从 → 下一状态)', + 'engine.studio.rules.transitionFrom': '起始状态', + 'engine.studio.rules.transitionTo': '下一状态(逗号分隔)', + 'engine.studio.rules.addTransition': '新增流转', + 'engine.studio.rules.jsonSchema': 'JSON Schema', + 'engine.studio.rules.invalidJson': 'JSON 格式无效', + 'engine.studio.rules.then': 'Then —— 条件成立时应用的规则(JSON)', + 'engine.studio.rules.otherwise': 'Otherwise —— 条件不成立时应用的规则(JSON,可选)', + 'engine.studio.rules.events': '触发时机', + 'engine.studio.rules.event.insert': '新建', + 'engine.studio.rules.event.update': '更新', + 'engine.studio.rules.event.delete': '删除', + 'engine.studio.rules.priority': '优先级', 'engine.studio.settings.basics': '基础信息', 'engine.studio.settings.noInspector': '未注册对象默认检查器。', 'engine.studio.settings.semanticRoles': '语义角色', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/HookDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/HookDefaultInspector.tsx new file mode 100644 index 000000000..63b38398d --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/HookDefaultInspector.tsx @@ -0,0 +1,278 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * HookDefaultInspector — curated authoring panel for a database Hook. + * + * Replaces the flat, everything-at-once SchemaForm for `hook` with a panel + * organised around how a hook is actually designed: + * + * 1. Basics — label / name / which object(s) it fires on (a PICKER, not a + * free-text box — the reason this exists) + * 2. Events — the lifecycle events it subscribes to (write + query) + * 3. Function — language (expression / sandboxed JS) + the handler body in a + * DEDICATED code editor (the second reason this exists) + * 4. Options — priority / async / a CEL run condition + * + * Rare/advanced props (handler ref, js capabilities/timeout/memory, …) fall + * through to a collapsed "More fields" SchemaForm fed the live server schema, + * with the curated keys hidden so nothing is double-edited or lost. + * + * `object` is spec `string | string[]` (or `'*'` for every object). The picker + * normalises to/from that: `'*'` ⇒ all, one selection ⇒ a string, many ⇒ an + * array. Any already-selected object not in the live catalog is preserved as a + * synthesized option so a value is never silently dropped. + */ + +import * as React from 'react'; +import { Label, Textarea } from '@object-ui/components'; +import type { MetadataDefaultInspectorProps } from '../default-inspector-registry'; +import { SchemaForm } from '../SchemaForm'; +import { t } from '../i18n'; +import { + InspectorShell, + InspectorTextField, + InspectorSelectField, + InspectorNumberField, + InspectorCheckboxField, +} from './_shared'; +import { useObjectOptions } from '../previews/useObjectOptions'; +import { ConditionBuilder } from './ConditionBuilder'; + +/* ─────────────── constants ─────────────── */ + +const BODY_LANG_OPTS = [ + { value: 'expression', label: 'Expression (L1)' }, + { value: 'js', label: 'Sandboxed JS (L2)' }, +]; + +/** Lifecycle events, grouped by the operation they hang off. */ +const WRITE_EVENTS = [ + 'beforeInsert', 'afterInsert', + 'beforeUpdate', 'afterUpdate', + 'beforeDelete', 'afterDelete', + 'beforeUpdateMany', 'afterUpdateMany', + 'beforeDeleteMany', 'afterDeleteMany', +]; +const QUERY_EVENTS = [ + 'beforeFind', 'afterFind', + 'beforeFindOne', 'afterFindOne', + 'beforeCount', 'afterCount', + 'beforeAggregate', 'afterAggregate', +]; + +const ALL_OBJECTS = '*'; + +/** Keys this inspector edits with its own controls — hidden from the fallback. */ +const CURATED_FIELDS = [ + 'name', 'label', 'object', 'events', 'body', 'priority', 'async', 'condition', +]; + +/* ─────────────── helpers ─────────────── */ + +function SectionHeader({ title, hint }: { title: string; hint?: string }) { + return ( +
+
{title}
+ {hint &&
{hint}
} +
+ ); +} + +function localize(v: unknown): string { + if (v == null) return ''; + if (typeof v === 'string') return v; + if (typeof v === 'object') { + const o = v as Record; + return o.en ?? o['en-US'] ?? Object.values(o)[0] ?? ''; + } + return String(v); +} + +/** Read the spec `object` (string | string[] | '*') into a selection set. */ +function readObjects(value: unknown): { all: boolean; names: string[] } { + if (value === ALL_OBJECTS) return { all: true, names: [] }; + if (typeof value === 'string') return { all: false, names: value ? [value] : [] }; + if (Array.isArray(value)) { + const names = value.filter((v): v is string => typeof v === 'string' && v !== ALL_OBJECTS); + return { all: value.includes(ALL_OBJECTS), names }; + } + return { all: false, names: [] }; +} + +/** Write a selection set back to the narrowest valid spec shape. */ +function writeObjects(all: boolean, names: string[]): string | string[] { + if (all) return ALL_OBJECTS; + if (names.length === 1) return names[0]; + return names; +} + +/* ─────────────── inspector ─────────────── */ + +export function HookDefaultInspector({ + draft, + onPatch, + readOnly, + locale, + serverSchema, +}: MetadataDefaultInspectorProps) { + const tr = React.useCallback((key: string) => t(key, locale), [locale]); + + const str = (k: string): string => (typeof draft[k] === 'string' ? (draft[k] as string) : ''); + const body = (draft.body && typeof draft.body === 'object' ? draft.body : {}) as Record; + const events: string[] = Array.isArray(draft.events) ? (draft.events as string[]) : []; + const { all: allObjects, names: objectNames } = readObjects(draft.object); + + const { options: objectOptions } = useObjectOptions(); + // Preserve any selected object missing from the live catalog (draft-only / + // cross-package) so it is never dropped from the picker. + const pickerOptions = React.useMemo(() => { + const known = new Set(objectOptions.map((o) => o.value)); + const extra = objectNames + .filter((n) => !known.has(n)) + .map((n) => ({ value: n, label: `${n} (not published)` })); + return [...extra, ...objectOptions]; + }, [objectOptions, objectNames]); + + const patchBody = (p: Record) => onPatch({ body: { ...body, ...p } }); + + const toggleObject = (name: string, on: boolean) => { + const next = on ? [...new Set([...objectNames, name])] : objectNames.filter((n) => n !== name); + onPatch({ object: writeObjects(false, next) }); + }; + const toggleEvent = (ev: string, on: boolean) => { + const next = on ? [...new Set([...events, ev])] : events.filter((e) => e !== ev); + onPatch({ events: next }); + }; + + // A single object → give ConditionBuilder its fields; '*' / multi → raw mode. + const conditionObject = !allObjects && objectNames.length === 1 ? objectNames[0] : undefined; + const language = typeof body.language === 'string' ? (body.language as string) : 'expression'; + const fallbackSchema = serverSchema as Record | undefined; + + return ( + {}} + closeLabel={tr('engine.inspector.action.close')} + hideClose + > + {/* 1 ─ Basics */} + + onPatch({ label: v || undefined })} placeholder="Human-readable name" disabled={readOnly} /> + onPatch({ name: v })} placeholder="snake_case identifier" disabled={readOnly} mono testId="hook-name" /> + +
+ + onPatch({ object: on ? ALL_OBJECTS : writeObjects(false, objectNames) })} + disabled={readOnly} + /> + {!allObjects && ( +
+ {pickerOptions.length === 0 ? ( +

No objects found — publish an object, then pick it here.

+ ) : ( + pickerOptions.map((o) => ( + toggleObject(o.value, on)} + disabled={readOnly} + /> + )) + )} +
+ )} + {!allObjects && objectNames.length === 0 && ( +

Pick at least one object (or All objects).

+ )} +
+ + {/* 2 ─ Events */} +
+ +
Write
+
+ {WRITE_EVENTS.map((ev) => ( + toggleEvent(ev, on)} disabled={readOnly} /> + ))} +
+
Query
+
+ {QUERY_EVENTS.map((ev) => ( + toggleEvent(ev, on)} disabled={readOnly} /> + ))} +
+ {events.length === 0 && ( +

Select at least one event.

+ )} +
+ + {/* 3 ─ Function */} +
+ + patchBody({ language: v })} + disabled={readOnly} + /> +
+ +