@object-ui/app-shell 7.0.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +281 -0
  2. package/dist/console/AppContent.js +14 -2
  3. package/dist/console/ai/AiChatPage.js +11 -7
  4. package/dist/console/ai/LiveCanvas.d.ts +8 -2
  5. package/dist/console/ai/LiveCanvas.js +6 -4
  6. package/dist/hooks/useChatConversation.d.ts +30 -0
  7. package/dist/hooks/useChatConversation.js +63 -0
  8. package/dist/hooks/useConsoleActionRuntime.js +6 -2
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +5 -1
  11. package/dist/layout/ConsoleFloatingChatbot.d.ts +6 -4
  12. package/dist/layout/ConsoleFloatingChatbot.js +25 -8
  13. package/dist/layout/ContextSelectors.js +59 -35
  14. package/dist/layout/agentPicker.d.ts +56 -0
  15. package/dist/layout/agentPicker.js +40 -0
  16. package/dist/preview/CommitTimeline.d.ts +15 -0
  17. package/dist/preview/CommitTimeline.js +82 -0
  18. package/dist/preview/UnpublishedAppBar.js +11 -7
  19. package/dist/preview/commitHistory.d.ts +28 -0
  20. package/dist/preview/commitHistory.js +48 -0
  21. package/dist/providers/MetadataProvider.js +9 -0
  22. package/dist/views/FlowRunner.d.ts +2 -30
  23. package/dist/views/FlowRunner.js +18 -50
  24. package/dist/views/ScreenView.d.ts +70 -0
  25. package/dist/views/ScreenView.js +73 -0
  26. package/dist/views/metadata-admin/DirectoryPage.js +2 -14
  27. package/dist/views/metadata-admin/JsonSourceEditor.d.ts +3 -1
  28. package/dist/views/metadata-admin/JsonSourceEditor.js +21 -3
  29. package/dist/views/metadata-admin/PackagesPage.js +9 -1
  30. package/dist/views/metadata-admin/ResourceEditPage.js +47 -20
  31. package/dist/views/metadata-admin/ResourceListPage.js +8 -16
  32. package/dist/views/metadata-admin/StudioHomePage.js +6 -14
  33. package/dist/views/metadata-admin/anchors.js +20 -2
  34. package/dist/views/metadata-admin/i18n.js +88 -2
  35. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.d.ts +2 -2
  36. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.js +122 -8
  37. package/dist/views/metadata-admin/inspectors/FlowEdgeInspector.js +84 -3
  38. package/dist/views/metadata-admin/inspectors/FlowNodeInspector.js +67 -2
  39. package/dist/views/metadata-admin/inspectors/ObjectDefaultInspector.js +5 -4
  40. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +47 -12
  41. package/dist/views/metadata-admin/inspectors/ReportDefaultInspector.d.ts +1 -1
  42. package/dist/views/metadata-admin/inspectors/ReportDefaultInspector.js +60 -2
  43. package/dist/views/metadata-admin/inspectors/_shared.d.ts +5 -1
  44. package/dist/views/metadata-admin/inspectors/_shared.js +2 -2
  45. package/dist/views/metadata-admin/inspectors/datasetFilterCondition.d.ts +24 -0
  46. package/dist/views/metadata-admin/inspectors/datasetFilterCondition.js +97 -0
  47. package/dist/views/metadata-admin/inspectors/flow-node-config.js +46 -1
  48. package/dist/views/metadata-admin/issuePath.d.ts +22 -0
  49. package/dist/views/metadata-admin/issuePath.js +65 -0
  50. package/dist/views/metadata-admin/package-scope.d.ts +26 -0
  51. package/dist/views/metadata-admin/package-scope.js +43 -0
  52. package/dist/views/metadata-admin/previews/DatasetPreview.js +21 -5
  53. package/dist/views/metadata-admin/previews/FlowCanvas.d.ts +7 -1
  54. package/dist/views/metadata-admin/previews/FlowCanvas.js +104 -16
  55. package/dist/views/metadata-admin/previews/FlowPreview.js +31 -3
  56. package/dist/views/metadata-admin/previews/FlowSimulatorPanel.js +37 -3
  57. package/dist/views/metadata-admin/previews/PagePreview.js +112 -3
  58. package/dist/views/metadata-admin/previews/ScreenPreview.d.ts +38 -0
  59. package/dist/views/metadata-admin/previews/ScreenPreview.js +61 -0
  60. package/dist/views/metadata-admin/previews/flow-canvas-layout.d.ts +14 -0
  61. package/dist/views/metadata-admin/previews/flow-canvas-layout.js +37 -0
  62. package/dist/views/metadata-admin/previews/flow-canvas-parts.d.ts +9 -1
  63. package/dist/views/metadata-admin/previews/flow-canvas-parts.js +21 -6
  64. package/dist/views/metadata-admin/previews/object-fields-io.d.ts +21 -0
  65. package/dist/views/metadata-admin/previews/object-fields-io.js +37 -2
  66. package/dist/views/metadata-admin/previews/screen-spec.d.ts +43 -0
  67. package/dist/views/metadata-admin/previews/screen-spec.js +108 -0
  68. package/dist/views/metadata-admin/previews/simulator/flow-sim-types.d.ts +11 -0
  69. package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.d.ts +7 -0
  70. package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.js +72 -0
  71. package/dist/views/metadata-admin/previews/simulator/flow-simulator.d.ts +32 -3
  72. package/dist/views/metadata-admin/previews/simulator/flow-simulator.js +119 -9
  73. package/package.json +38 -38
@@ -19,6 +19,49 @@ import { fieldsForNodeType, isFieldVisible, getFieldValue, configKeyOf, FLOW_NOD
19
19
  import { jsonSchemaToFlowFields } from './json-schema-to-fields';
20
20
  import { useActionConfigSchemas } from '../previews/useFlowNodePalette';
21
21
  import { FlowNodeConfigField } from './FlowNodeConfigField';
22
+ import { ScreenPreview } from '../previews/ScreenPreview';
23
+ /**
24
+ * Mirror a decision node's `conditions` (branches) onto its outgoing sequence
25
+ * edges, in declared order: branch i -> the i-th out-edge. A branch whose
26
+ * expression is `true` marks its edge as the default/else path; an empty
27
+ * expression clears the guard. Fault / back edges are left untouched.
28
+ *
29
+ * This is what lets a decision authored entirely in Studio actually route at
30
+ * runtime: the engine and the simulator branch on `edge.condition`, NOT on
31
+ * `node.config.conditions` (which is only a node-local branch list). Without
32
+ * the mirror, every out-edge stays unconditional and all branches fire.
33
+ */
34
+ function syncDecisionEdges(decisionId, conditions, edges) {
35
+ const branches = Array.isArray(conditions) ? conditions : [];
36
+ let bi = 0;
37
+ return edges.map((e) => {
38
+ if (e.source !== decisionId || e.type === 'fault' || e.type === 'back')
39
+ return e;
40
+ const branch = branches[bi++];
41
+ if (!branch || typeof branch !== 'object')
42
+ return e;
43
+ const expr = typeof branch.expression === 'string' ? branch.expression.trim() : '';
44
+ const label = typeof branch.label === 'string' ? branch.label.trim() : '';
45
+ const next = { ...e };
46
+ if (label)
47
+ next.label = label;
48
+ else
49
+ delete next.label;
50
+ if (expr && expr !== 'true') {
51
+ next.condition = expr;
52
+ delete next.isDefault;
53
+ }
54
+ else if (expr === 'true') {
55
+ next.isDefault = true;
56
+ delete next.condition;
57
+ }
58
+ else {
59
+ delete next.condition;
60
+ delete next.isDefault;
61
+ }
62
+ return next;
63
+ });
64
+ }
22
65
  function asConfig(node) {
23
66
  const c = node?.config;
24
67
  return c && typeof c === 'object' && !Array.isArray(c) ? c : {};
@@ -65,6 +108,16 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
65
108
  }, [configSchemas, node?.type]);
66
109
  const config = asConfig(node);
67
110
  const visibleFields = fields.filter((f) => isFieldVisible(f, node, fields));
111
+ // `{var}` interpolation source for the screen preview — the flow's declared
112
+ // variables and their defaults (the designer has no live run state).
113
+ const screenVars = React.useMemo(() => {
114
+ const decls = Array.isArray(draft.variables) ? draft.variables : [];
115
+ const out = {};
116
+ for (const v of decls)
117
+ if (v && typeof v.name === 'string')
118
+ out[v.name] = v.defaultValue;
119
+ return out;
120
+ }, [draft]);
68
121
  // Only fields stored under `config` "own" a config key; spec-structured
69
122
  // blocks (waitEventConfig, etc.) and top-level timeoutMs never suppress an
70
123
  // Advanced key.
@@ -100,9 +153,21 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
100
153
  onPatch({ nodes: spliceArray(nodes, index, { ...node, ...updates }) });
101
154
  };
102
155
  const hasExtras = extraJson.trim() !== '';
156
+ // Screen nodes (and the `user_task` alias) get a live end-user preview.
157
+ const isScreen = node.type === 'screen' || node.type === 'user_task';
103
158
  const setField = (path, value) => {
104
159
  const nextNode = setAtPath(node, path, value);
105
- onPatch({ nodes: spliceArray(nodes, index, nextNode) });
160
+ const patch = { nodes: spliceArray(nodes, index, nextNode) };
161
+ // Decision branches drive routing — mirror them onto the node's outgoing
162
+ // edges so the engine/simulator can actually branch (they read
163
+ // edge.condition, not node.config.conditions).
164
+ if (node.type === 'decision' && path.length === 2 && path[0] === 'config' && path[1] === 'conditions') {
165
+ const draftEdges = Array.isArray(draft.edges)
166
+ ? (draft.edges)
167
+ : [];
168
+ patch.edges = syncDecisionEdges(node.id, value, draftEdges);
169
+ }
170
+ onPatch(patch);
106
171
  };
107
172
  const commitAdvanced = () => {
108
173
  try {
@@ -133,7 +198,7 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
133
198
  const typeOptions = FLOW_NODE_TYPE_OPTIONS.includes(node.type)
134
199
  ? [...FLOW_NODE_TYPE_OPTIONS]
135
200
  : [...FLOW_NODE_TYPE_OPTIONS, node.type ?? ''].filter(Boolean);
136
- return (_jsxs(InspectorShell, { kindLabel: t('engine.inspector.flowNode.kind', locale), title: node.label || node.id, onClose: onClearSelection, closeLabel: t('engine.inspector.flowNode.close', locale), footer: _jsx(InspectorRemoveButton, { label: t('engine.inspector.flowNode.remove', locale), onClick: remove, disabled: readOnly }), children: [_jsx(InspectorTextField, { label: t('engine.inspector.flowNode.id', locale), value: node.id, onCommit: (v) => patchNode({ id: v }), disabled: readOnly, mono: true }), _jsx(InspectorTextField, { label: t('engine.inspector.flowNode.label', locale), value: node.label ?? '', onCommit: (v) => patchNode({ label: v }), disabled: readOnly }), _jsx(InspectorSelectField, { label: t('engine.inspector.flowNode.type', locale), value: node.type, options: typeOptions.map((v) => ({ value: v, label: v })), onCommit: (v) => patchNode({ type: v }), disabled: readOnly }), _jsx(InspectorTextField, { label: t('engine.inspector.flowNode.description', locale), value: node.description ?? '', onCommit: (v) => patchNode({ description: v || undefined }), disabled: readOnly }), fields.length === 0 ? (_jsx("p", { className: "pt-1 text-xs italic text-muted-foreground", children: t('engine.inspector.flowNode.noConfig', locale) })) : (visibleFields.length > 0 && (_jsxs("div", { className: "flex items-center gap-2 pt-1", children: [_jsx("span", { className: "text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", children: t('engine.inspector.flowNode.configuration', locale) }), _jsx("span", { className: "h-px flex-1 bg-border", "aria-hidden": true })] }))), visibleFields.map((field) => (_jsx(FlowNodeConfigField, { field: field, value: getFieldValue(node, field), onCommit: (v) => setField(field.path, v), disabled: readOnly, locale: locale, context: { draft, node } }, field.id))), hasExtras || advReveal ? (_jsxs("details", { className: "group rounded border bg-muted/20", open: advOpen, onToggle: (e) => setAdvOpen(e.target.open), children: [_jsx("summary", { className: "cursor-pointer select-none px-2 py-1.5 text-xs font-medium text-muted-foreground", children: t('engine.inspector.flowNode.advanced', locale) }), _jsxs("div", { className: "space-y-1 border-t p-2", children: [_jsx("p", { className: "text-[11px] leading-snug text-muted-foreground", children: t('engine.inspector.flowNode.advancedHint', locale) }), _jsx("textarea", { value: advText, onChange: (e) => setAdvText(e.target.value), onBlur: commitAdvanced, disabled: readOnly, rows: 6, placeholder: "{ }", className: "w-full rounded border bg-background px-2 py-1.5 font-mono text-xs" }), advError && _jsx("div", { className: "text-xs text-destructive", children: advError })] })] })) : (!readOnly && (_jsxs("button", { type: "button", onClick: () => {
201
+ return (_jsxs(InspectorShell, { kindLabel: t('engine.inspector.flowNode.kind', locale), title: node.label || node.id, onClose: onClearSelection, closeLabel: t('engine.inspector.flowNode.close', locale), footer: _jsx(InspectorRemoveButton, { label: t('engine.inspector.flowNode.remove', locale), onClick: remove, disabled: readOnly }), children: [_jsx(InspectorTextField, { label: t('engine.inspector.flowNode.id', locale), value: node.id, onCommit: (v) => patchNode({ id: v }), disabled: readOnly, mono: true }), _jsx(InspectorTextField, { label: t('engine.inspector.flowNode.label', locale), value: node.label ?? '', onCommit: (v) => patchNode({ label: v }), disabled: readOnly }), _jsx(InspectorSelectField, { label: t('engine.inspector.flowNode.type', locale), value: node.type, options: typeOptions.map((v) => ({ value: v, label: v })), onCommit: (v) => patchNode({ type: v }), disabled: readOnly }), _jsx(InspectorTextField, { label: t('engine.inspector.flowNode.description', locale), value: node.description ?? '', onCommit: (v) => patchNode({ description: v || undefined }), disabled: readOnly }), fields.length === 0 ? (_jsx("p", { className: "pt-1 text-xs italic text-muted-foreground", children: t('engine.inspector.flowNode.noConfig', locale) })) : (visibleFields.length > 0 && (_jsxs("div", { className: "flex items-center gap-2 pt-1", children: [_jsx("span", { className: "text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", children: t('engine.inspector.flowNode.configuration', locale) }), _jsx("span", { className: "h-px flex-1 bg-border", "aria-hidden": true })] }))), visibleFields.map((field) => (_jsx(FlowNodeConfigField, { field: field, value: getFieldValue(node, field), onCommit: (v) => setField(field.path, v), disabled: readOnly, locale: locale, context: { draft, node } }, field.id))), isScreen && _jsx(ScreenPreview, { node: node, variables: screenVars, className: "mt-1" }), hasExtras || advReveal ? (_jsxs("details", { className: "group rounded border bg-muted/20", open: advOpen, onToggle: (e) => setAdvOpen(e.target.open), children: [_jsx("summary", { className: "cursor-pointer select-none px-2 py-1.5 text-xs font-medium text-muted-foreground", children: t('engine.inspector.flowNode.advanced', locale) }), _jsxs("div", { className: "space-y-1 border-t p-2", children: [_jsx("p", { className: "text-[11px] leading-snug text-muted-foreground", children: t('engine.inspector.flowNode.advancedHint', locale) }), _jsx("textarea", { value: advText, onChange: (e) => setAdvText(e.target.value), onBlur: commitAdvanced, disabled: readOnly, rows: 6, placeholder: "{ }", className: "w-full rounded border bg-background px-2 py-1.5 font-mono text-xs" }), advError && _jsx("div", { className: "text-xs text-destructive", children: advError })] })] })) : (!readOnly && (_jsxs("button", { type: "button", onClick: () => {
137
202
  setAdvReveal(true);
138
203
  setAdvOpen(true);
139
204
  }, className: "inline-flex items-center gap-1 self-start text-[11px] text-muted-foreground transition-colors hover:text-foreground", children: [_jsx(Plus, { className: "h-3 w-3" }), t('engine.inspector.flowNode.advanced', locale)] })))] }));
@@ -21,7 +21,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
21
21
  import * as React from 'react';
22
22
  import { InspectorShell, InspectorTextField } from './_shared';
23
23
  import { Label } from '@object-ui/components';
24
- import { toFieldName } from '../previews/object-fields-io';
24
+ import { toFieldNameLoose } from '../previews/object-fields-io';
25
+ import { slugify } from '../createDerive';
25
26
  import { t } from '../i18n';
26
27
  export function ObjectDefaultInspector({ name, draft, onPatch, readOnly, locale, }) {
27
28
  const tr = React.useCallback((key) => t(key, locale), [locale]);
@@ -33,16 +34,16 @@ export function ObjectDefaultInspector({ name, draft, onPatch, readOnly, locale,
33
34
  const setLabel = (v) => {
34
35
  const patch = { label: v || undefined };
35
36
  if (createMode && !nameTouched.current)
36
- patch.name = toFieldName(v);
37
+ patch.name = slugify(v);
37
38
  onPatch(patch);
38
39
  };
39
40
  const setName = (v) => {
40
41
  nameTouched.current = true;
41
- onPatch({ name: toFieldName(v) });
42
+ onPatch({ name: toFieldNameLoose(v) });
42
43
  };
43
44
  const nameValue = createMode ? str('name') : name || str('name');
44
45
  const title = str('label') || name || tr('designer.object.kind');
45
- return (_jsx(InspectorShell, { kindLabel: tr('designer.object.kind'), title: title, onClose: () => { }, hideClose: true, children: _jsxs(Section, { title: tr('designer.object.section.basic'), hint: tr('designer.object.section.basicHint'), children: [_jsx(Field, { hint: tr('designer.object.nameHint'), children: _jsx(InspectorTextField, { label: tr('designer.object.name'), value: nameValue, onCommit: setName, disabled: readOnly || !createMode, mono: true, placeholder: tr('designer.object.namePlaceholder') }) }), _jsx(InspectorTextField, { label: tr('designer.object.label'), value: str('label'), onCommit: setLabel, disabled: readOnly, placeholder: tr('designer.object.labelPlaceholder') }), _jsx(InspectorTextField, { label: tr('designer.object.pluralLabel'), value: str('pluralLabel'), onCommit: (v) => onPatch({ pluralLabel: v || undefined }), disabled: readOnly, placeholder: tr('designer.object.pluralPlaceholder') }), _jsx(Field, { hint: tr('designer.object.iconHint'), children: _jsx(InspectorTextField, { label: tr('designer.object.icon'), value: str('icon'), onCommit: (v) => onPatch({ icon: v || undefined }), disabled: readOnly, mono: true, placeholder: "building" }) }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: tr('designer.object.description') }), _jsx("textarea", { value: str('description'), disabled: readOnly, rows: 2, placeholder: tr('designer.object.descriptionPlaceholder'), onChange: (e) => onPatch({ description: e.target.value || undefined }), className: "w-full text-sm rounded-md border bg-background px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary" })] })] }) }));
46
+ return (_jsx(InspectorShell, { kindLabel: tr('designer.object.kind'), title: title, onClose: () => { }, hideClose: true, children: _jsxs(Section, { title: tr('designer.object.section.basic'), hint: tr('designer.object.section.basicHint'), children: [_jsx(Field, { hint: tr('designer.object.nameHint'), children: _jsx(InspectorTextField, { label: tr('designer.object.name'), value: nameValue, onCommit: setName, disabled: readOnly || !createMode, mono: true, testId: "object-name-input", placeholder: tr('designer.object.namePlaceholder') }) }), _jsx(InspectorTextField, { label: tr('designer.object.label'), value: str('label'), onCommit: setLabel, disabled: readOnly, placeholder: tr('designer.object.labelPlaceholder') }), _jsx(InspectorTextField, { label: tr('designer.object.pluralLabel'), value: str('pluralLabel'), onCommit: (v) => onPatch({ pluralLabel: v || undefined }), disabled: readOnly, placeholder: tr('designer.object.pluralPlaceholder') }), _jsx(Field, { hint: tr('designer.object.iconHint'), children: _jsx(InspectorTextField, { label: tr('designer.object.icon'), value: str('icon'), onCommit: (v) => onPatch({ icon: v || undefined }), disabled: readOnly, mono: true, placeholder: "building" }) }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: tr('designer.object.description') }), _jsx("textarea", { value: str('description'), disabled: readOnly, rows: 2, placeholder: tr('designer.object.descriptionPlaceholder'), onChange: (e) => onPatch({ description: e.target.value || undefined }), className: "w-full text-sm rounded-md border bg-background px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary" })] })] }) }));
46
47
  }
47
48
  /* ─────────────── Sub-components ─────────────── */
48
49
  function Section({ title, hint, children, }) {
@@ -21,13 +21,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
21
21
  * auto-rewritten — callers should re-validate downstream.
22
22
  */
23
23
  import * as React from 'react';
24
+ import { slugify } from '../createDerive';
24
25
  import { useMetadataClient } from '../useMetadata';
25
26
  import { InspectorShell, InspectorReorderButtons, InspectorTextField, InspectorNumberField, InspectorSelectField, InspectorCheckboxField, InspectorRemoveButton, InspectorEmptyState, moveArray, } from './_shared';
26
27
  import { Button, Input, Label, Badge } from '@object-ui/components';
27
28
  import { Plus, X, ArrowUp, ArrowDown, Copy } from 'lucide-react';
28
29
  import { InspectorComboField } from './InspectorComboField';
29
30
  import { useObjectFields } from '../previews/useObjectFields';
30
- import { readFields, writeFields, toFieldName, indexOfField, } from '../previews/object-fields-io';
31
+ import { readFields, writeFields, toFieldNameLoose, indexOfField, } from '../previews/object-fields-io';
31
32
  import { FIELD_TYPE_META, TYPES_BY_CATEGORY, CATEGORY_LABEL_EN, CATEGORY_LABEL_ZH, } from '../previews/field-types';
32
33
  import { t, tFormat } from '../i18n';
33
34
  const isZh = (locale) => (locale ?? '').toLowerCase().startsWith('zh');
@@ -116,7 +117,7 @@ export function ObjectFieldInspector({ selection, draft, onPatch, onClearSelecti
116
117
  writeView({ shape: view.shape, entries: nextEntries });
117
118
  };
118
119
  const setKey = (rawNext) => {
119
- const nextName = toFieldName(rawNext);
120
+ const nextName = toFieldNameLoose(rawNext);
120
121
  if (!nextName || nextName === entry.name)
121
122
  return;
122
123
  // Disallow collision
@@ -127,6 +128,29 @@ export function ObjectFieldInspector({ selection, draft, onPatch, onClearSelecti
127
128
  writeView({ shape: view.shape, entries: nextEntries });
128
129
  onSelectionChange?.({ kind: 'field', id: nextName, label: String(def.label ?? nextName) });
129
130
  };
131
+ // Derive the API name from the label (on blur, so we use the complete
132
+ // string — not per keystroke, which would churn the field key) while the
133
+ // name is still an auto-generated default and the user hasn't customised it.
134
+ // Mirrors the object Name behaviour; slugify() returns '' for non-Latin
135
+ // labels, in which case the unique default name is kept.
136
+ const maybeDeriveName = (label) => {
137
+ if (readOnly)
138
+ return;
139
+ const base = type === 'select' ? 'status' : type;
140
+ const isAutoName = entry.name === base ||
141
+ (entry.name.startsWith(`${base}_`) && /^\d+$/.test(entry.name.slice(base.length + 1)));
142
+ if (!isAutoName)
143
+ return;
144
+ const derived = slugify(label);
145
+ if (!derived || derived === entry.name)
146
+ return;
147
+ if (view.entries.some((e, i) => i !== idx && e.name === derived))
148
+ return;
149
+ const nextEntries = [...view.entries];
150
+ nextEntries[idx] = { ...entry, name: derived };
151
+ writeView({ shape: view.shape, entries: nextEntries });
152
+ onSelectionChange?.({ kind: 'field', id: derived, label: String(def.label ?? derived) });
153
+ };
130
154
  const removeField = () => {
131
155
  const nextEntries = view.entries.filter((_, i) => i !== idx);
132
156
  writeView({ shape: view.shape, entries: nextEntries });
@@ -177,7 +201,7 @@ export function ObjectFieldInspector({ selection, draft, onPatch, onClearSelecti
177
201
  label: typeof def.label === 'string' ? def.label : entry.name,
178
202
  }), onClick: removeField, disabled: readOnly }));
179
203
  const typeMetaLabel = isZh(locale) ? typeMeta?.labelZh : typeMeta?.label;
180
- return (_jsxs(InspectorShell, { kindLabel: tr('designer.field.kind'), title: typeof def.label === 'string' && def.label ? def.label : entry.name, onClose: onClearSelection, closeLabel: tr('designer.field.close'), headerActions: headerActions, footer: footer, children: [_jsxs(Section, { title: tr('designer.field.section.basic'), children: [_jsx(InspectorTextField, { label: tr('designer.field.apiName'), value: entry.name, onCommit: setKey, disabled: readOnly, mono: true }), _jsx(InspectorTextField, { label: tr('designer.field.label'), value: typeof def.label === 'string' ? def.label : '', onCommit: (v) => patchDef({ label: v }), disabled: readOnly }), _jsx(InspectorSelectField, { label: tr('designer.field.type'), value: type, options: typeOptions, onCommit: (v) => patchDef({ type: v }), disabled: readOnly }), _jsxs("div", { className: "flex items-center gap-4 pt-1", children: [_jsx(InspectorCheckboxField, { label: tr('designer.field.required'), value: !!def.required, onCommit: (v) => patchDef({ required: v || undefined }), disabled: readOnly }), _jsx(InspectorCheckboxField, { label: tr('designer.field.unique'), value: !!def.unique, onCommit: (v) => patchDef({ unique: v || undefined }), disabled: readOnly })] }), _jsx(TextareaField, { label: tr('designer.field.description'), value: typeof def.description === 'string' ? def.description : '', onCommit: (v) => patchDef({ description: v || undefined }), disabled: readOnly, rows: 2 }), defaultValueKind(type) && (_jsx(DefaultValueField, { kind: defaultValueKind(type), value: def.defaultValue, options: options, onCommit: (v) => patchDef({ defaultValue: v }), disabled: readOnly, locale: locale })), _jsx(TextareaField, { label: tr('designer.field.helpText'), value: typeof def.inlineHelpText === 'string' ? def.inlineHelpText : '', onCommit: (v) => patchDef({ inlineHelpText: v || undefined }), disabled: readOnly, rows: 2, placeholder: tr('designer.field.helpTextPlaceholder') })] }), (isPicklist(type) || isLookup(type) || isComputed(type) || isNumeric(type) || isTexty(type)) && (_jsxs(Section, { title: tFormat('designer.field.section.options', locale, { type: typeMetaLabel ?? type }), children: [isPicklist(type) && (_jsx(OptionsEditor, { options: options, onChange: patchOptions, disabled: readOnly, locale: locale })), isLookup(type) && (_jsxs(_Fragment, { children: [_jsx(ObjectPicker, { label: tr('designer.field.relatedObject'), value: typeof def.reference === 'string' ? def.reference : '', options: objectOptions, onCommit: (v) => patchDef({ reference: v || undefined }), disabled: readOnly, placeholder: tr('designer.field.objectNamePlaceholder') }), _jsx(InspectorTextField, { label: tr('designer.field.relationshipName'), value: typeof def.relationshipName === 'string' ? def.relationshipName : '', onCommit: (v) => patchDef({ relationshipName: v || undefined }), disabled: readOnly, placeholder: tr('designer.field.relationshipNameHint') }), _jsx(LookupConfigFields, { def: def, patchDef: patchDef, hostFieldNames: view.entries.map((e) => e.name).filter((n) => n !== entry.name), readOnly: readOnly })] })), isComputed(type) && (_jsx(TextareaField, { label: tr('designer.field.formula'), value: typeof def.formula === 'string' ? def.formula : '', onCommit: (v) => patchDef({ formula: v || undefined }), disabled: readOnly, rows: 4, mono: true, placeholder: "record.amount * 0.2" })), isNumeric(type) && (_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(InspectorNumberField, { label: tr('designer.field.precision'), value: typeof def.precision === 'number' ? def.precision : undefined, onCommit: (v) => patchDef({ precision: v }), disabled: readOnly }), _jsx(InspectorNumberField, { label: tr('designer.field.scale'), value: typeof def.scale === 'number' ? def.scale : undefined, onCommit: (v) => patchDef({ scale: v }), disabled: readOnly }), _jsx(InspectorNumberField, { label: tr('designer.field.min'), value: typeof def.min === 'number' ? def.min : undefined, onCommit: (v) => patchDef({ min: v }), disabled: readOnly }), _jsx(InspectorNumberField, { label: tr('designer.field.max'), value: typeof def.max === 'number' ? def.max : undefined, onCommit: (v) => patchDef({ max: v }), disabled: readOnly })] })), isTexty(type) && (_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(InspectorNumberField, { label: tr('designer.field.minLength'), value: typeof def.minLength === 'number' ? def.minLength : undefined, onCommit: (v) => patchDef({ minLength: v }), disabled: readOnly, placeholder: "0" }), _jsx(InspectorNumberField, { label: tr('designer.field.maxLength'), value: typeof def.maxLength === 'number' ? def.maxLength : undefined, onCommit: (v) => patchDef({ maxLength: v }), disabled: readOnly, placeholder: "255" })] }))] })), _jsxs(Section, { title: tr('designer.field.section.advanced'), children: [_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(InspectorCheckboxField, { label: tr('designer.field.readonly'), value: !!def.readonly, onCommit: (v) => patchDef({ readonly: v || undefined }), disabled: readOnly }), _jsx(InspectorCheckboxField, { label: tr('designer.field.hidden'), value: !!def.hidden, onCommit: (v) => patchDef({ hidden: v || undefined }), disabled: readOnly }), _jsx(InspectorCheckboxField, { label: tr('designer.field.indexed'), value: !!def.indexed, onCommit: (v) => patchDef({ indexed: v || undefined }), disabled: readOnly }), _jsx(InspectorCheckboxField, { label: tr('designer.field.externalId'), value: !!def.externalId, onCommit: (v) => patchDef({ externalId: v || undefined }), disabled: readOnly }), _jsx(InspectorCheckboxField, { label: tr('designer.field.trackHistory'), value: !!def.trackHistory, onCommit: (v) => patchDef({ trackHistory: v || undefined }), disabled: readOnly })] }), _jsx(InspectorTextField, { label: tr('designer.field.placeholder'), value: typeof def.placeholder === 'string' ? def.placeholder : '', onCommit: (v) => patchDef({ placeholder: v || undefined }), disabled: readOnly }), _jsxs("div", { className: "space-y-1", children: [_jsx(InspectorTextField, { label: tr('designer.field.conditionalRequired'), value: typeof def.conditionalRequired === 'string' ? def.conditionalRequired : '', onCommit: (v) => patchDef({ conditionalRequired: v || undefined }), disabled: readOnly, mono: true, placeholder: "record.status == 'closed'" }), _jsx("p", { className: "text-[11px] text-muted-foreground/80 px-0.5 leading-snug", children: tr('designer.field.conditionalRequiredHint') })] }), fieldGroups.length > 0 && (_jsx(InspectorSelectField, { label: tr('designer.field.group'), value: typeof def.group === 'string' ? def.group : '', options: [
204
+ return (_jsxs(InspectorShell, { kindLabel: tr('designer.field.kind'), title: typeof def.label === 'string' && def.label ? def.label : entry.name, onClose: onClearSelection, closeLabel: tr('designer.field.close'), headerActions: headerActions, footer: footer, children: [_jsxs(Section, { title: tr('designer.field.section.basic'), children: [_jsx(InspectorTextField, { label: tr('designer.field.apiName'), value: entry.name, onCommit: setKey, disabled: readOnly, mono: true, testId: "field-apiname-input" }), _jsx(InspectorTextField, { label: tr('designer.field.label'), value: typeof def.label === 'string' ? def.label : '', onCommit: (v) => patchDef({ label: v }), onBlur: maybeDeriveName, disabled: readOnly, testId: "field-label-input" }), _jsx(InspectorSelectField, { label: tr('designer.field.type'), value: type, options: typeOptions, onCommit: (v) => patchDef({ type: v }), disabled: readOnly }), _jsxs("div", { className: "flex items-center gap-4 pt-1", children: [_jsx(InspectorCheckboxField, { label: tr('designer.field.required'), value: !!def.required, onCommit: (v) => patchDef({ required: v || undefined }), disabled: readOnly }), _jsx(InspectorCheckboxField, { label: tr('designer.field.unique'), value: !!def.unique, onCommit: (v) => patchDef({ unique: v || undefined }), disabled: readOnly })] }), _jsx(TextareaField, { label: tr('designer.field.description'), value: typeof def.description === 'string' ? def.description : '', onCommit: (v) => patchDef({ description: v || undefined }), disabled: readOnly, rows: 2 }), defaultValueKind(type) && (_jsx(DefaultValueField, { kind: defaultValueKind(type), value: def.defaultValue, options: options, onCommit: (v) => patchDef({ defaultValue: v }), disabled: readOnly, locale: locale })), _jsx(TextareaField, { label: tr('designer.field.helpText'), value: typeof def.inlineHelpText === 'string' ? def.inlineHelpText : '', onCommit: (v) => patchDef({ inlineHelpText: v || undefined }), disabled: readOnly, rows: 2, placeholder: tr('designer.field.helpTextPlaceholder') })] }), (isPicklist(type) || isLookup(type) || isComputed(type) || isNumeric(type) || isTexty(type)) && (_jsxs(Section, { title: tFormat('designer.field.section.options', locale, { type: typeMetaLabel ?? type }), children: [isPicklist(type) && (_jsx(OptionsEditor, { options: options, onChange: patchOptions, disabled: readOnly, locale: locale }, entry.name)), isLookup(type) && (_jsxs(_Fragment, { children: [_jsx(ObjectPicker, { label: tr('designer.field.relatedObject'), value: typeof def.reference === 'string' ? def.reference : '', options: objectOptions, onCommit: (v) => patchDef({ reference: v || undefined }), disabled: readOnly, placeholder: tr('designer.field.objectNamePlaceholder') }), _jsx(InspectorTextField, { label: tr('designer.field.relationshipName'), value: typeof def.relationshipName === 'string' ? def.relationshipName : '', onCommit: (v) => patchDef({ relationshipName: v || undefined }), disabled: readOnly, placeholder: tr('designer.field.relationshipNameHint') }), _jsx(LookupConfigFields, { def: def, patchDef: patchDef, hostFieldNames: view.entries.map((e) => e.name).filter((n) => n !== entry.name), readOnly: readOnly, locale: locale })] })), isComputed(type) && (_jsx(TextareaField, { label: tr('designer.field.formula'), value: typeof def.formula === 'string' ? def.formula : '', onCommit: (v) => patchDef({ formula: v || undefined }), disabled: readOnly, rows: 4, mono: true, placeholder: "record.amount * 0.2" })), isNumeric(type) && (_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(InspectorNumberField, { label: tr('designer.field.precision'), value: typeof def.precision === 'number' ? def.precision : undefined, onCommit: (v) => patchDef({ precision: v }), disabled: readOnly }), _jsx(InspectorNumberField, { label: tr('designer.field.scale'), value: typeof def.scale === 'number' ? def.scale : undefined, onCommit: (v) => patchDef({ scale: v }), disabled: readOnly }), _jsx(InspectorNumberField, { label: tr('designer.field.min'), value: typeof def.min === 'number' ? def.min : undefined, onCommit: (v) => patchDef({ min: v }), disabled: readOnly }), _jsx(InspectorNumberField, { label: tr('designer.field.max'), value: typeof def.max === 'number' ? def.max : undefined, onCommit: (v) => patchDef({ max: v }), disabled: readOnly })] })), isTexty(type) && (_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(InspectorNumberField, { label: tr('designer.field.minLength'), value: typeof def.minLength === 'number' ? def.minLength : undefined, onCommit: (v) => patchDef({ minLength: v }), disabled: readOnly, placeholder: "0" }), _jsx(InspectorNumberField, { label: tr('designer.field.maxLength'), value: typeof def.maxLength === 'number' ? def.maxLength : undefined, onCommit: (v) => patchDef({ maxLength: v }), disabled: readOnly, placeholder: "255" })] }))] })), _jsxs(Section, { title: tr('designer.field.section.advanced'), children: [_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(InspectorCheckboxField, { label: tr('designer.field.readonly'), value: !!def.readonly, onCommit: (v) => patchDef({ readonly: v || undefined }), disabled: readOnly }), _jsx(InspectorCheckboxField, { label: tr('designer.field.hidden'), value: !!def.hidden, onCommit: (v) => patchDef({ hidden: v || undefined }), disabled: readOnly }), _jsx(InspectorCheckboxField, { label: tr('designer.field.indexed'), value: !!def.indexed, onCommit: (v) => patchDef({ indexed: v || undefined }), disabled: readOnly }), _jsx(InspectorCheckboxField, { label: tr('designer.field.externalId'), value: !!def.externalId, onCommit: (v) => patchDef({ externalId: v || undefined }), disabled: readOnly }), _jsx(InspectorCheckboxField, { label: tr('designer.field.trackHistory'), value: !!def.trackHistory, onCommit: (v) => patchDef({ trackHistory: v || undefined }), disabled: readOnly })] }), _jsx(InspectorTextField, { label: tr('designer.field.placeholder'), value: typeof def.placeholder === 'string' ? def.placeholder : '', onCommit: (v) => patchDef({ placeholder: v || undefined }), disabled: readOnly }), _jsxs("div", { className: "space-y-1", children: [_jsx(InspectorTextField, { label: tr('designer.field.conditionalRequired'), value: typeof def.conditionalRequired === 'string' ? def.conditionalRequired : '', onCommit: (v) => patchDef({ conditionalRequired: v || undefined }), disabled: readOnly, mono: true, placeholder: "record.status == 'closed'" }), _jsx("p", { className: "text-[11px] text-muted-foreground/80 px-0.5 leading-snug", children: tr('designer.field.conditionalRequiredHint') })] }), fieldGroups.length > 0 && (_jsx(InspectorSelectField, { label: tr('designer.field.group'), value: typeof def.group === 'string' ? def.group : '', options: [
181
205
  { value: '', label: tr('designer.field.noGroup') },
182
206
  ...fieldGroups
183
207
  .filter((g) => typeof g.key === 'string')
@@ -223,15 +247,25 @@ function ObjectPicker({ label, value, options, onCommit, disabled, placeholder,
223
247
  return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: label }), _jsx(Input, { list: listId, value: value, onChange: (e) => onCommit(e.target.value), disabled: disabled, className: "h-8 text-sm font-mono", placeholder: placeholder ?? 'object_name' }), _jsx("datalist", { id: listId, children: options.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value))) })] }));
224
248
  }
225
249
  function OptionsEditor({ options, onChange, disabled, locale, }) {
250
+ // Local editing buffer. We keep a blank trailing row visible for input but
251
+ // only PERSIST rows whose `value` is non-empty — otherwise the blank row
252
+ // fails the spec identifier rule ("System identifier must be at least 2
253
+ // characters") and shows a confusing error mid-edit. The editor is remounted
254
+ // per field (key={entry.name}), so seeding from `options` once is correct.
255
+ const [rows, setRows] = React.useState(() => (options.length > 0 ? options : [{ value: '', label: '' }]));
256
+ const commit = (next) => {
257
+ setRows(next);
258
+ onChange(next.filter((o) => o.value.trim() !== ''));
259
+ };
226
260
  const update = (i, patch) => {
227
- const next = [...options];
261
+ const next = [...rows];
228
262
  next[i] = { ...next[i], ...patch };
229
- onChange(next);
263
+ commit(next);
230
264
  };
231
- const remove = (i) => onChange(options.filter((_, j) => j !== i));
232
- const move = (i, to) => onChange(moveArray(options, i, to));
233
- const add = () => onChange([...options, { value: '', label: '' }]);
234
- return (_jsxs("div", { className: "space-y-1.5", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: t('designer.field.picklistValues', locale) }), _jsx(Badge, { variant: "outline", className: "text-[10px]", children: options.length })] }), options.length === 0 ? (_jsx("div", { className: "text-[11px] italic text-muted-foreground px-1", children: t('designer.field.noValues', locale) })) : (_jsx("div", { className: "space-y-1", children: options.map((o, i) => (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Input, { value: o.value, onChange: (e) => update(i, { value: e.target.value }), placeholder: t('designer.field.optValue', locale), disabled: disabled, className: "h-7 text-xs font-mono flex-1" }), _jsx(Input, { value: o.label ?? '', onChange: (e) => update(i, { label: e.target.value }), placeholder: t('designer.field.optLabel', locale), disabled: disabled, className: "h-7 text-xs flex-1" }), _jsx("input", { type: "color", value: o.color ?? '#cccccc', onChange: (e) => update(i, { color: e.target.value }), disabled: disabled, className: "h-7 w-7 rounded border bg-background cursor-pointer p-0.5", title: t('designer.field.optColor', locale) }), _jsx(Button, { variant: "ghost", size: "sm", className: "h-7 w-7 p-0", onClick: () => move(i, i - 1), disabled: disabled || i === 0, "aria-label": t('designer.field.moveUp', locale), children: _jsx(ArrowUp, { className: "h-3 w-3" }) }), _jsx(Button, { variant: "ghost", size: "sm", className: "h-7 w-7 p-0", onClick: () => move(i, i + 1), disabled: disabled || i === options.length - 1, "aria-label": t('designer.field.moveDown', locale), children: _jsx(ArrowDown, { className: "h-3 w-3" }) }), _jsx(Button, { variant: "ghost", size: "sm", className: "h-7 w-7 p-0 text-destructive", onClick: () => remove(i), disabled: disabled, "aria-label": t('designer.field.removeValue', locale), children: _jsx(X, { className: "h-3 w-3" }) })] }, i))) })), !disabled && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-7 gap-1 text-xs", onClick: add, children: [_jsx(Plus, { className: "h-3 w-3" }), t('designer.field.addValue', locale)] }))] }));
265
+ const remove = (i) => commit(rows.filter((_, j) => j !== i));
266
+ const move = (i, to) => commit(moveArray(rows, i, to));
267
+ const add = () => commit([...rows, { value: '', label: '' }]);
268
+ return (_jsxs("div", { className: "space-y-1.5", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: t('designer.field.picklistValues', locale) }), _jsx(Badge, { variant: "outline", className: "text-[10px]", children: rows.length })] }), rows.length === 0 ? (_jsx("div", { className: "text-[11px] italic text-muted-foreground px-1", children: t('designer.field.noValues', locale) })) : (_jsx("div", { className: "space-y-1", children: rows.map((o, i) => (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Input, { value: o.value, onChange: (e) => update(i, { value: e.target.value }), placeholder: t('designer.field.optValue', locale), disabled: disabled, className: "h-7 text-xs font-mono flex-1" }), _jsx(Input, { value: o.label ?? '', onChange: (e) => update(i, { label: e.target.value }), placeholder: t('designer.field.optLabel', locale), disabled: disabled, className: "h-7 text-xs flex-1" }), _jsx("input", { type: "color", value: o.color ?? '#cccccc', onChange: (e) => update(i, { color: e.target.value }), disabled: disabled, className: "h-7 w-7 rounded border bg-background cursor-pointer p-0.5", title: t('designer.field.optColor', locale) }), _jsx(Button, { variant: "ghost", size: "sm", className: "h-7 w-7 p-0", onClick: () => move(i, i - 1), disabled: disabled || i === 0, "aria-label": t('designer.field.moveUp', locale), children: _jsx(ArrowUp, { className: "h-3 w-3" }) }), _jsx(Button, { variant: "ghost", size: "sm", className: "h-7 w-7 p-0", onClick: () => move(i, i + 1), disabled: disabled || i === rows.length - 1, "aria-label": t('designer.field.moveDown', locale), children: _jsx(ArrowDown, { className: "h-3 w-3" }) }), _jsx(Button, { variant: "ghost", size: "sm", className: "h-7 w-7 p-0 text-destructive", onClick: () => remove(i), disabled: disabled, "aria-label": t('designer.field.removeValue', locale), children: _jsx(X, { className: "h-3 w-3" }) })] }, i))) })), !disabled && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-7 gap-1 text-xs", onClick: add, children: [_jsx(Plus, { className: "h-3 w-3" }), t('designer.field.addValue', locale)] }))] }));
235
269
  }
236
270
  /* ─────────────── Lookup picker config (displayField / filters / dependent) ─────────────── */
237
271
  const LOOKUP_OPERATORS = [
@@ -266,7 +300,8 @@ function readDependsOn(def) {
266
300
  * dependent-lookup links to other fields on the same record (`dependsOn`).
267
301
  * Every field choice is picked from the referenced object's live schema.
268
302
  */
269
- function LookupConfigFields({ def, patchDef, hostFieldNames, readOnly, }) {
303
+ function LookupConfigFields({ def, patchDef, hostFieldNames, readOnly, locale, }) {
304
+ const tr = (key) => t(key, locale);
270
305
  const reference = typeof def.reference === 'string' ? def.reference : undefined;
271
306
  const { fields: targetFields, loading } = useObjectFields(reference);
272
307
  const fieldOptions = React.useMemo(() => targetFields.filter((f) => !f.hidden).map((f) => ({ value: f.name, label: f.label, hint: f.type })), [targetFields]);
@@ -296,8 +331,8 @@ function LookupConfigFields({ def, patchDef, hostFieldNames, readOnly, }) {
296
331
  const descriptionField = typeof def.descriptionField === 'string' ? def.descriptionField : '';
297
332
  const pageSize = typeof def.lookupPageSize === 'number' ? def.lookupPageSize : undefined;
298
333
  const allowCreate = def.allowCreate === true;
299
- const fieldPlaceholder = reference ? 'Select a field' : 'Set the target object first';
300
- return (_jsxs("div", { className: "space-y-2 border-t pt-2.5", children: [_jsx("div", { className: "text-[11px] font-medium text-muted-foreground", children: "Picker config" }), _jsx(InspectorComboField, { label: "Display field", value: displayField, onCommit: (v) => patchDef({ displayField: v || undefined }), options: fieldOptions, loading: loading, placeholder: fieldPlaceholder, searchPlaceholder: "Search fields\u2026", disabled: readOnly, mono: true }), _jsx(InspectorComboField, { label: "Description field", value: descriptionField, onCommit: (v) => patchDef({ descriptionField: v || undefined }), options: fieldOptions, loading: loading, placeholder: fieldPlaceholder, searchPlaceholder: "Search fields\u2026", disabled: readOnly, mono: true }), _jsxs("div", { className: "space-y-1.5 pt-1", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: "Selectable records" }), _jsx(Badge, { variant: "outline", className: "text-[10px]", children: filters.length })] }), !readOnly && (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "h-6 gap-1 px-1.5 text-[11px]", onClick: addFilter, children: [_jsx(Plus, { className: "h-3 w-3" }), " Add filter"] }))] }), filters.length === 0 ? (_jsxs("p", { className: "rounded-md border border-dashed bg-muted/30 px-3 py-2 text-center text-[11px] text-muted-foreground", children: ["No filter \u2014 every ", reference || 'related', " record is selectable."] })) : (filters.map((f, i) => (_jsxs("div", { className: "rounded-md border p-2 space-y-1.5", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("span", { className: "text-[11px] text-muted-foreground", children: ["Filter ", i + 1] }), !readOnly && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": "Remove filter", className: "h-6 w-6 p-0 text-muted-foreground hover:text-destructive", onClick: () => removeFilter(i), children: _jsx(X, { className: "h-3.5 w-3.5" }) }))] }), _jsx(InspectorComboField, { label: "Field", value: f.field ?? '', onCommit: (v) => patchFilter(i, { field: v }), options: fieldOptions, loading: loading, placeholder: fieldPlaceholder, searchPlaceholder: "Search fields\u2026", disabled: readOnly, mono: true }), _jsx(InspectorSelectField, { label: "Operator", value: f.operator ?? 'eq', options: LOOKUP_OPERATORS, onCommit: (v) => patchFilter(i, { operator: v }), disabled: readOnly }), _jsx(InspectorTextField, { label: "Value", value: valueToText(f.value), onCommit: (v) => patchFilter(i, { value: textToValue(f.operator, v) }), placeholder: f.operator === 'in' || f.operator === 'notIn' ? 'comma,separated,values' : 'value', disabled: readOnly, mono: true })] }, i))))] }), _jsxs("div", { className: "space-y-1.5 pt-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: "Depends on (same-record fields)" }), dependsOn.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1", children: dependsOn.map((n) => (_jsxs("span", { className: "inline-flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-[11px] font-mono", children: [n, !readOnly && (_jsx("button", { type: "button", className: "text-muted-foreground hover:text-foreground", onClick: () => removeDependsOn(n), "aria-label": `Remove ${n}`, children: "\u00D7" }))] }, n))) })), !readOnly && hostOptions.length > 0 && (_jsx(InspectorComboField, { value: "", onCommit: (v) => addDependsOn(v), options: hostOptions.filter((o) => !dependsOn.includes(o.value)), placeholder: "Add a field this lookup depends on\u2026", searchPlaceholder: "Search this object's fields\u2026", disabled: readOnly, allowCustom: false, mono: true }))] }), _jsxs("div", { className: "grid grid-cols-2 gap-2 pt-1", children: [_jsx(InspectorNumberField, { label: "Picker page size", value: pageSize, onCommit: (v) => patchDef({ lookupPageSize: v }), placeholder: "10", disabled: readOnly }), _jsx("div", { className: "flex items-end pb-1.5", children: _jsx(InspectorCheckboxField, { label: "Allow quick-create", value: allowCreate, onCommit: (v) => patchDef({ allowCreate: v || undefined }), disabled: readOnly }) })] })] }));
334
+ const fieldPlaceholder = reference ? tr('designer.field.lookup.selectField') : tr('designer.field.lookup.setTargetFirst');
335
+ return (_jsxs("div", { className: "space-y-2 border-t pt-2.5", children: [_jsx("div", { className: "text-[11px] font-medium text-muted-foreground", children: tr('designer.field.lookup.pickerConfig') }), _jsx(InspectorComboField, { label: tr('designer.field.lookup.displayField'), value: displayField, onCommit: (v) => patchDef({ displayField: v || undefined }), options: fieldOptions, loading: loading, placeholder: fieldPlaceholder, searchPlaceholder: tr('designer.field.lookup.searchFields'), disabled: readOnly, mono: true }), _jsx(InspectorComboField, { label: tr('designer.field.lookup.descriptionField'), value: descriptionField, onCommit: (v) => patchDef({ descriptionField: v || undefined }), options: fieldOptions, loading: loading, placeholder: fieldPlaceholder, searchPlaceholder: tr('designer.field.lookup.searchFields'), disabled: readOnly, mono: true }), _jsxs("div", { className: "space-y-1.5 pt-1", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: tr('designer.field.lookup.selectableRecords') }), _jsx(Badge, { variant: "outline", className: "text-[10px]", children: filters.length })] }), !readOnly && (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "h-6 gap-1 px-1.5 text-[11px]", onClick: addFilter, children: [_jsx(Plus, { className: "h-3 w-3" }), " ", tr('designer.field.lookup.addFilter')] }))] }), filters.length === 0 ? (_jsx("p", { className: "rounded-md border border-dashed bg-muted/30 px-3 py-2 text-center text-[11px] text-muted-foreground", children: tFormat('designer.field.lookup.noFilter', locale, { ref: reference || 'related' }) })) : (filters.map((f, i) => (_jsxs("div", { className: "rounded-md border p-2 space-y-1.5", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-[11px] text-muted-foreground", children: tFormat('designer.field.lookup.filterN', locale, { n: i + 1 }) }), !readOnly && (_jsx(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: () => removeFilter(i), children: _jsx(X, { className: "h-3.5 w-3.5" }) }))] }), _jsx(InspectorComboField, { label: tr('designer.field.lookup.filterField'), value: f.field ?? '', onCommit: (v) => patchFilter(i, { field: v }), options: fieldOptions, loading: loading, placeholder: fieldPlaceholder, searchPlaceholder: tr('designer.field.lookup.searchFields'), disabled: readOnly, mono: true }), _jsx(InspectorSelectField, { label: tr('designer.field.lookup.filterOperator'), value: f.operator ?? 'eq', options: LOOKUP_OPERATORS, onCommit: (v) => patchFilter(i, { operator: v }), disabled: readOnly }), _jsx(InspectorTextField, { label: tr('designer.field.lookup.filterValue'), value: valueToText(f.value), onCommit: (v) => patchFilter(i, { value: textToValue(f.operator, v) }), placeholder: f.operator === 'in' || f.operator === 'notIn' ? 'comma,separated,values' : 'value', disabled: readOnly, mono: true })] }, i))))] }), _jsxs("div", { className: "space-y-1.5 pt-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: tr('designer.field.lookup.dependsOn') }), dependsOn.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1", children: dependsOn.map((n) => (_jsxs("span", { className: "inline-flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-[11px] font-mono", children: [n, !readOnly && (_jsx("button", { type: "button", className: "text-muted-foreground hover:text-foreground", onClick: () => removeDependsOn(n), "aria-label": `Remove ${n}`, children: "\u00D7" }))] }, n))) })), !readOnly && hostOptions.length > 0 && (_jsx(InspectorComboField, { value: "", onCommit: (v) => addDependsOn(v), options: hostOptions.filter((o) => !dependsOn.includes(o.value)), placeholder: tr('designer.field.lookup.addDependsOn'), searchPlaceholder: tr('designer.field.lookup.searchHostFields'), disabled: readOnly, allowCustom: false, mono: true }))] }), _jsxs("div", { className: "grid grid-cols-2 gap-2 pt-1", children: [_jsx(InspectorNumberField, { label: tr('designer.field.lookup.pageSize'), value: pageSize, onCommit: (v) => patchDef({ lookupPageSize: v }), placeholder: "10", disabled: readOnly }), _jsx("div", { className: "flex items-end pb-1.5", children: _jsx(InspectorCheckboxField, { label: tr('designer.field.lookup.allowCreate'), value: allowCreate, onCommit: (v) => patchDef({ allowCreate: v || undefined }), disabled: readOnly }) })] })] }));
301
336
  }
302
337
  /* ─────────────── Hook: load object list for lookup picker ─────────────── */
303
338
  function useObjectOptions() {
@@ -55,4 +55,4 @@ export declare function DatasetNamesEditor({ label, emptyText, names, options, l
55
55
  readOnly?: boolean;
56
56
  onCommit: (next: string[]) => void;
57
57
  }): React.JSX.Element;
58
- export declare function ReportDefaultInspector({ draft, onPatch, readOnly, locale, datasetCatalogOverride, serverSchema, }: ReportDefaultInspectorProps): React.JSX.Element;
58
+ export declare function ReportDefaultInspector({ name, draft, onPatch, readOnly, locale, datasetCatalogOverride, serverSchema, }: ReportDefaultInspectorProps): React.JSX.Element;
@@ -31,6 +31,7 @@ import * as React from 'react';
31
31
  import { Badge, Label } from '@object-ui/components';
32
32
  import { InspectorShell, InspectorTextField, InspectorSelectField, appendArray, moveArray, spliceArray, } from './_shared';
33
33
  import { AddFieldPopover, FieldListRow } from '../previews/ViewColumnPanes';
34
+ import { toFieldName } from '../previews/object-fields-io';
34
35
  import { SchemaForm } from '../SchemaForm';
35
36
  import { useDatasetCatalog, useDatasetSemantics, } from '../previews/useDatasetCatalog';
36
37
  import { getReportForm, getReportSchema } from '../report-schema';
@@ -50,7 +51,14 @@ const REPORT_CURATED_FIELDS = new Set([
50
51
  'values',
51
52
  'rows',
52
53
  'columns', // matrix across-dimensions — dedicated list below
54
+ 'chart', // dedicated Chart panel below (type + dataset-aware X/Y pickers)
53
55
  ]);
56
+ /**
57
+ * Chart types offered in the curated Chart panel. A dataset-bound report plots
58
+ * one measure (yAxis) across one dimension (xAxis), so we surface the families
59
+ * that fit that shape; the renderer maps the rest. (`''` = no chart / table-only.)
60
+ */
61
+ const REPORT_CHART_TYPES = ['bar', 'column', 'line', 'area', 'pie', 'donut'];
54
62
  /** i18n keys for the spec `type` enum (falls back to the raw value). */
55
63
  const TYPE_LABEL_KEYS = {
56
64
  tabular: 'engine.inspector.report.type.tabular',
@@ -108,8 +116,17 @@ export function DatasetNamesEditor({ label, emptyText, names, options, loading,
108
116
  setOverIndex(null);
109
117
  } }, `${name}-${i}`))) })), !readOnly && (_jsx(AddFieldPopover, { fields: options, usedNames: used, loading: loading, error: error, onAdd: (f) => onCommit(appendArray(names, f.name)) }))] }));
110
118
  }
111
- export function ReportDefaultInspector({ draft, onPatch, readOnly, locale, datasetCatalogOverride, serverSchema, }) {
119
+ export function ReportDefaultInspector({ name, draft, onPatch, readOnly, locale, datasetCatalogOverride, serverSchema, }) {
112
120
  const tr = React.useCallback((key) => t(key, locale), [locale]);
121
+ // In create mode the host passes an empty `name` (the PK is assigned on
122
+ // first save). Mirror ObjectDefaultInspector: expose an editable Name that
123
+ // auto-derives a snake_case slug from the label until the author edits it
124
+ // directly. Without this, a report created through the canvas would save
125
+ // with an empty name and fail the snake_case identity rule (the create flow
126
+ // would dead-end exactly the way it did before report-create used the canvas).
127
+ const createMode = !name;
128
+ const nameTouched = React.useRef(false);
129
+ const nameValue = typeof draft.name === 'string' ? draft.name : '';
113
130
  const reportType = typeof draft.type === 'string' ? draft.type : 'tabular';
114
131
  const typeOptions = useTypeOptions(reportType, locale);
115
132
  const labelValue = typeof draft.label === 'string' ? draft.label : '';
@@ -143,6 +160,34 @@ export function ReportDefaultInspector({ draft, onPatch, readOnly, locale, datas
143
160
  type: d.type ?? 'text',
144
161
  hidden: false,
145
162
  })), [semantics.dimensions]);
163
+ // Embedded chart (ADR-0021) — edited via the dedicated panel below so authors
164
+ // pick the X dimension / Y measure from dropdowns sourced from the bound
165
+ // dataset (instead of free-typing field names), and the generic spec-form
166
+ // graft excludes `chart`. Patching merges into the chart object; clearing the
167
+ // type drops the chart entirely.
168
+ const chart = draft.chart && typeof draft.chart === 'object'
169
+ ? draft.chart
170
+ : {};
171
+ const chartType = typeof chart.type === 'string' ? chart.type : '';
172
+ const chartX = typeof chart.xAxis === 'string' ? chart.xAxis : '';
173
+ const chartY = typeof chart.yAxis === 'string' ? chart.yAxis : '';
174
+ const chartTitle = typeof chart.title === 'string' ? chart.title : '';
175
+ const commitChart = (patch) => {
176
+ const next = { ...chart, ...patch };
177
+ onPatch({ chart: next.type ? next : undefined });
178
+ };
179
+ const chartXOptions = React.useMemo(() => {
180
+ const opts = dimensionOptions.map((d) => ({ value: d.name, label: d.label || d.name }));
181
+ if (chartX && !opts.some((o) => o.value === chartX))
182
+ opts.push({ value: chartX, label: chartX });
183
+ return opts;
184
+ }, [dimensionOptions, chartX]);
185
+ const chartYOptions = React.useMemo(() => {
186
+ const opts = measureOptions.map((m) => ({ value: m.name, label: m.label || m.name }));
187
+ if (chartY && !opts.some((o) => o.value === chartY))
188
+ opts.push({ value: chartY, label: chartY });
189
+ return opts;
190
+ }, [measureOptions, chartY]);
146
191
  // A `joined` report carries its data on dataset-bound `blocks` (edited via
147
192
  // the spec form's repeater) — the top-level binding only applies otherwise.
148
193
  const datasetBound = reportType !== 'joined';
@@ -156,5 +201,18 @@ export function ReportDefaultInspector({ draft, onPatch, readOnly, locale, datas
156
201
  excludeFields: REPORT_CURATED_FIELDS,
157
202
  sectionTitle: t('engine.inspector.moreFields', locale),
158
203
  }), [serverSchema, locale]);
159
- return (_jsxs(InspectorShell, { kindLabel: tr('engine.inspector.report.kind'), title: String(labelValue || draft.name || tr('engine.inspector.report.kind')), onClose: () => { }, closeLabel: tr('engine.inspector.report.close'), hideClose: true, children: [_jsx(InspectorTextField, { label: tr('engine.inspector.report.label'), value: labelValue, onCommit: (v) => onPatch({ label: v }), placeholder: tr('engine.inspector.report.labelPlaceholder'), disabled: readOnly }), _jsx(InspectorSelectField, { label: tr('engine.inspector.report.type'), value: reportType, options: typeOptions, onCommit: (v) => onPatch({ type: v }), disabled: readOnly }), datasetBound && (_jsxs(_Fragment, { children: [catalog.datasets.length > 0 || datasetName ? (_jsx(InspectorSelectField, { label: tr('engine.inspector.report.dataset'), value: datasetName, options: datasetOptions, onCommit: (v) => onPatch({ dataset: v }), disabled: readOnly })) : (_jsx(InspectorTextField, { label: tr('engine.inspector.report.dataset'), value: datasetName, onCommit: (v) => onPatch({ dataset: v }), placeholder: tr('engine.inspector.report.datasetPlaceholder'), disabled: readOnly, mono: true })), _jsx(DatasetNamesEditor, { label: tr('engine.inspector.report.values'), emptyText: tr('engine.inspector.report.valuesEmpty'), names: values, options: measureOptions, loading: semantics.loading, error: semantics.error, readOnly: readOnly, onCommit: (next) => onPatch({ values: next }) }), _jsx(DatasetNamesEditor, { label: tr('engine.inspector.report.rows'), emptyText: tr('engine.inspector.report.rowsEmpty'), names: rows, options: dimensionOptions, loading: semantics.loading, error: semantics.error, readOnly: readOnly, onCommit: (next) => onPatch({ rows: next }) }), reportType === 'matrix' && (_jsx(DatasetNamesEditor, { label: tr('engine.inspector.report.columnsAcross'), emptyText: tr('engine.inspector.report.columnsAcrossEmpty'), names: columnsAcross, options: dimensionOptions, loading: semantics.loading, error: semantics.error, readOnly: readOnly, onCommit: (next) => onPatch({ columns: next }) }))] })), _jsx("div", { className: "border-t pt-3", children: schema ? (_jsx(SchemaForm, { schema: schema, form: form, value: draft, hiddenFields: ['type', 'label', 'name', 'dataset', 'values', 'rows', 'columns'], readOnly: readOnly, onChange: (next) => onPatch(next) })) : (_jsx("p", { className: "text-[11px] text-muted-foreground", children: tr('engine.inspector.report.noSchema') })) })] }));
204
+ return (_jsxs(InspectorShell, { kindLabel: tr('engine.inspector.report.kind'), title: String(labelValue || draft.name || tr('engine.inspector.report.kind')), onClose: () => { }, closeLabel: tr('engine.inspector.report.close'), hideClose: true, children: [createMode && (_jsx(InspectorTextField, { label: tr('engine.inspector.report.name'), value: nameValue, onCommit: (v) => {
205
+ nameTouched.current = true;
206
+ onPatch({ name: toFieldName(v) });
207
+ }, placeholder: tr('engine.inspector.report.namePlaceholder'), disabled: readOnly, mono: true })), _jsx(InspectorTextField, { label: tr('engine.inspector.report.label'), value: labelValue, onCommit: (v) => {
208
+ // Live-derive the snake_case name from the label until the author
209
+ // edits the Name field directly (create mode only).
210
+ const patch = { label: v };
211
+ if (createMode && !nameTouched.current)
212
+ patch.name = toFieldName(v);
213
+ onPatch(patch);
214
+ }, placeholder: tr('engine.inspector.report.labelPlaceholder'), disabled: readOnly }), _jsx(InspectorSelectField, { label: tr('engine.inspector.report.type'), value: reportType, options: typeOptions, onCommit: (v) => onPatch({ type: v }), disabled: readOnly }), datasetBound && (_jsxs(_Fragment, { children: [catalog.datasets.length > 0 || datasetName ? (_jsx(InspectorSelectField, { label: tr('engine.inspector.report.dataset'), value: datasetName, options: datasetOptions, onCommit: (v) => onPatch({ dataset: v }), disabled: readOnly })) : (_jsx(InspectorTextField, { label: tr('engine.inspector.report.dataset'), value: datasetName, onCommit: (v) => onPatch({ dataset: v }), placeholder: tr('engine.inspector.report.datasetPlaceholder'), disabled: readOnly, mono: true })), _jsx(DatasetNamesEditor, { label: tr('engine.inspector.report.values'), emptyText: tr('engine.inspector.report.valuesEmpty'), names: values, options: measureOptions, loading: semantics.loading, error: semantics.error, readOnly: readOnly, onCommit: (next) => onPatch({ values: next }) }), _jsx(DatasetNamesEditor, { label: tr('engine.inspector.report.rows'), emptyText: tr('engine.inspector.report.rowsEmpty'), names: rows, options: dimensionOptions, loading: semantics.loading, error: semantics.error, readOnly: readOnly, onCommit: (next) => onPatch({ rows: next }) }), reportType === 'matrix' && (_jsx(DatasetNamesEditor, { label: tr('engine.inspector.report.columnsAcross'), emptyText: tr('engine.inspector.report.columnsAcrossEmpty'), names: columnsAcross, options: dimensionOptions, loading: semantics.loading, error: semantics.error, readOnly: readOnly, onCommit: (next) => onPatch({ columns: next }) })), _jsxs("div", { className: "border-t pt-3 space-y-2", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: tr('engine.inspector.report.chart') }), _jsx(InspectorSelectField, { label: tr('engine.inspector.report.chartType'), value: chartType, options: [
215
+ { value: '', label: tr('engine.inspector.report.chartNone') },
216
+ ...REPORT_CHART_TYPES.map((tp) => ({ value: tp, label: tp })),
217
+ ], onCommit: (v) => commitChart({ type: v || undefined }), disabled: readOnly }), chartType ? (_jsxs(_Fragment, { children: [_jsx(InspectorTextField, { label: tr('engine.inspector.report.chartTitle'), value: chartTitle, onCommit: (v) => commitChart({ title: v || undefined }), disabled: readOnly }), _jsx(InspectorSelectField, { label: tr('engine.inspector.report.chartX'), value: chartX, options: chartXOptions, onCommit: (v) => commitChart({ xAxis: v }), disabled: readOnly }), _jsx(InspectorSelectField, { label: tr('engine.inspector.report.chartY'), value: chartY, options: chartYOptions, onCommit: (v) => commitChart({ yAxis: v }), disabled: readOnly })] })) : null] })] })), _jsx("div", { className: "border-t pt-3", children: schema ? (_jsx(SchemaForm, { schema: schema, form: form, value: draft, hiddenFields: ['type', 'label', 'name', 'dataset', 'values', 'rows', 'columns', 'chart'], readOnly: readOnly, onChange: (next) => onPatch(next) })) : (_jsx("p", { className: "text-[11px] text-muted-foreground", children: tr('engine.inspector.report.noSchema') })) })] }));
160
218
  }
@@ -54,13 +54,17 @@ export interface InspectorReorderButtonsProps {
54
54
  * `total - 1`) and the whole pair when `total <= 1`.
55
55
  */
56
56
  export declare function InspectorReorderButtons({ index, total, onMove, upLabel, downLabel, disabled, }: InspectorReorderButtonsProps): React.JSX.Element | null;
57
- export declare function InspectorTextField({ label, value, onCommit, placeholder, disabled, mono, }: {
57
+ export declare function InspectorTextField({ label, value, onCommit, onBlur, placeholder, disabled, mono, testId, }: {
58
58
  label: string;
59
59
  value: string;
60
60
  onCommit: (v: string) => void;
61
+ /** Fired on blur with the final value — e.g. to derive a dependent field. */
62
+ onBlur?: (v: string) => void;
61
63
  placeholder?: string;
62
64
  disabled?: boolean;
63
65
  mono?: boolean;
66
+ /** Stable hook for e2e/dogfood selectors. */
67
+ testId?: string;
64
68
  }): React.JSX.Element;
65
69
  export declare function InspectorNumberField({ label, value, onCommit, placeholder, disabled, }: {
66
70
  label: string;
@@ -18,8 +18,8 @@ export function InspectorReorderButtons({ index, total, onMove, upLabel = 'Move
18
18
  return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 w-7 p-0", onClick: () => canUp && onMove(index - 1), disabled: !canUp, "aria-label": upLabel, title: upLabel, children: _jsx(ArrowUp, { className: "h-4 w-4" }) }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 w-7 p-0", onClick: () => canDown && onMove(index + 1), disabled: !canDown, "aria-label": downLabel, title: downLabel, children: _jsx(ArrowDown, { className: "h-4 w-4" }) })] }));
19
19
  }
20
20
  /* ─────────────── Form atoms ─────────────── */
21
- export function InspectorTextField({ label, value, onCommit, placeholder, disabled, mono, }) {
22
- return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: label }), _jsx(Input, { value: value, onChange: (e) => onCommit(e.target.value), placeholder: placeholder, disabled: disabled, className: cn('h-8 text-sm', mono && 'font-mono') })] }));
21
+ export function InspectorTextField({ label, value, onCommit, onBlur, placeholder, disabled, mono, testId, }) {
22
+ return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: label }), _jsx(Input, { value: value, onChange: (e) => onCommit(e.target.value), onBlur: (e) => onBlur?.(e.target.value), placeholder: placeholder, disabled: disabled, "data-testid": testId, className: cn('h-8 text-sm', mono && 'font-mono') })] }));
23
23
  }
24
24
  export function InspectorNumberField({ label, value, onCommit, placeholder, disabled, }) {
25
25
  return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-xs text-muted-foreground", children: label }), _jsx(Input, { type: "number", value: value ?? '', onChange: (e) => {
@@ -0,0 +1,24 @@
1
+ export interface BuilderCondition {
2
+ id?: string;
3
+ field: string;
4
+ operator: string;
5
+ value?: unknown;
6
+ }
7
+ export interface BuilderGroup {
8
+ id?: string;
9
+ logic: 'and' | 'or';
10
+ conditions: BuilderCondition[];
11
+ }
12
+ export type FilterCondition = Record<string, any>;
13
+ /** Serialize the visual group → a spec FilterCondition (flat `$and`). */
14
+ export declare function groupToCondition(group: BuilderGroup | undefined): FilterCondition | undefined;
15
+ /**
16
+ * Parse a stored FilterCondition → the visual group. `representable: false` when
17
+ * the condition uses shapes the flat builder can't faithfully edit (nested
18
+ * `$and`/`$or`, multi-op objects, unmapped operators) — callers should then show
19
+ * the source editor instead.
20
+ */
21
+ export declare function conditionToGroup(cond: FilterCondition | undefined | null): {
22
+ group: BuilderGroup;
23
+ representable: boolean;
24
+ };
@@ -0,0 +1,97 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+ /**
3
+ * Bridge between the visual {@link FilterBuilder} (a flat `FilterGroup` of
4
+ * `{field, operator, value}` rows, camelCase operators) and the spec
5
+ * `FilterCondition` (Mongo-style `{ field: { $op: value } }`, conjoined with
6
+ * `$and`) stored on `dataset.filter` / `measure.filter`.
7
+ *
8
+ * Scope (deliberate): the visual editor supports the common case — a flat AND
9
+ * of simple `field op value` conditions. Anything it can't faithfully round-trip
10
+ * (nested groups, `$or`, multi-operator objects, unmapped operators) is reported
11
+ * as NOT representable so the caller can fall back to the source editor instead
12
+ * of silently corrupting the author's filter.
13
+ */
14
+ /** FilterBuilder camelCase operator → FilterCondition Mongo operator. */
15
+ const OP_TO_MONGO = {
16
+ equals: '$eq', notEquals: '$ne',
17
+ greaterThan: '$gt', greaterOrEqual: '$gte', lessThan: '$lt', lessOrEqual: '$lte',
18
+ after: '$gt', before: '$lt',
19
+ contains: '$contains', in: '$in', notIn: '$nin',
20
+ };
21
+ const MONGO_TO_OP = {
22
+ $eq: 'equals', $ne: 'notEquals',
23
+ $gt: 'greaterThan', $gte: 'greaterOrEqual', $lt: 'lessThan', $lte: 'lessOrEqual',
24
+ $contains: 'contains', $in: 'in', $nin: 'notIn',
25
+ };
26
+ /** Serialize the visual group → a spec FilterCondition (flat `$and`). */
27
+ export function groupToCondition(group) {
28
+ const conds = (group?.conditions ?? []).filter((c) => c && c.field);
29
+ const parts = [];
30
+ for (const c of conds) {
31
+ if (c.operator === 'isEmpty') {
32
+ parts.push({ [c.field]: { $exists: false } });
33
+ continue;
34
+ }
35
+ if (c.operator === 'isNotEmpty') {
36
+ parts.push({ [c.field]: { $exists: true } });
37
+ continue;
38
+ }
39
+ const mop = OP_TO_MONGO[c.operator];
40
+ if (!mop)
41
+ continue; // unmapped (e.g. notContains/between) — drop rather than emit a bad filter
42
+ parts.push({ [c.field]: { [mop]: c.value } });
43
+ }
44
+ if (parts.length === 0)
45
+ return undefined;
46
+ if (parts.length === 1)
47
+ return parts[0];
48
+ return { $and: parts };
49
+ }
50
+ /**
51
+ * Parse a stored FilterCondition → the visual group. `representable: false` when
52
+ * the condition uses shapes the flat builder can't faithfully edit (nested
53
+ * `$and`/`$or`, multi-op objects, unmapped operators) — callers should then show
54
+ * the source editor instead.
55
+ */
56
+ export function conditionToGroup(cond) {
57
+ const empty = { id: 'g', logic: 'and', conditions: [] };
58
+ if (cond == null)
59
+ return { group: empty, representable: true };
60
+ if (typeof cond !== 'object' || Array.isArray(cond))
61
+ return { group: empty, representable: false };
62
+ if ('$or' in cond)
63
+ return { group: empty, representable: false };
64
+ const list = Array.isArray(cond.$and) ? cond.$and : [cond];
65
+ const conditions = [];
66
+ for (let i = 0; i < list.length; i++) {
67
+ const c = list[i];
68
+ if (!c || typeof c !== 'object' || Array.isArray(c))
69
+ return { group: empty, representable: false };
70
+ if ('$and' in c || '$or' in c)
71
+ return { group: empty, representable: false };
72
+ const keys = Object.keys(c);
73
+ if (keys.length !== 1)
74
+ return { group: empty, representable: false };
75
+ const field = keys[0];
76
+ const v = c[field];
77
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
78
+ const opKeys = Object.keys(v);
79
+ if (opKeys.length !== 1)
80
+ return { group: empty, representable: false };
81
+ const mop = opKeys[0];
82
+ if (mop === '$exists') {
83
+ conditions.push({ id: `c${i}`, field, operator: v.$exists ? 'isNotEmpty' : 'isEmpty', value: '' });
84
+ }
85
+ else {
86
+ const op = MONGO_TO_OP[mop];
87
+ if (!op)
88
+ return { group: empty, representable: false };
89
+ conditions.push({ id: `c${i}`, field, operator: op, value: v[mop] });
90
+ }
91
+ }
92
+ else {
93
+ conditions.push({ id: `c${i}`, field, operator: 'equals', value: v }); // implicit equality
94
+ }
95
+ }
96
+ return { group: { id: 'g', logic: 'and', conditions }, representable: true };
97
+ }