@object-ui/app-shell 11.4.0 → 11.5.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 (64) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +9 -0
  3. package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
  4. package/dist/console/AppContent.js +145 -26
  5. package/dist/console/ConsoleShell.js +8 -1
  6. package/dist/context/CommandPaletteProvider.js +2 -1
  7. package/dist/hooks/useObjectActions.js +16 -4
  8. package/dist/layout/AppHeader.js +13 -5
  9. package/dist/layout/AppSidebar.js +10 -4
  10. package/dist/observability/sentry.d.ts +5 -0
  11. package/dist/observability/sentry.js +6 -1
  12. package/dist/preview/DraftChangesPanel.d.ts +29 -1
  13. package/dist/preview/DraftChangesPanel.js +141 -14
  14. package/dist/urlParams.d.ts +68 -0
  15. package/dist/urlParams.js +76 -0
  16. package/dist/utils/appRoute.d.ts +15 -0
  17. package/dist/utils/appRoute.js +22 -0
  18. package/dist/utils/index.d.ts +1 -1
  19. package/dist/utils/index.js +1 -1
  20. package/dist/utils/pageTabsUrlSync.d.ts +32 -0
  21. package/dist/utils/pageTabsUrlSync.js +43 -0
  22. package/dist/utils/recordFormNavigation.d.ts +40 -0
  23. package/dist/utils/recordFormNavigation.js +30 -0
  24. package/dist/views/InterfaceListPage.d.ts +1 -0
  25. package/dist/views/InterfaceListPage.js +1 -1
  26. package/dist/views/ObjectDataPage.d.ts +29 -0
  27. package/dist/views/ObjectDataPage.js +227 -0
  28. package/dist/views/ObjectView.js +4 -3
  29. package/dist/views/RecordDetailView.js +61 -20
  30. package/dist/views/RelatedRecordActionsBridge.d.ts +10 -1
  31. package/dist/views/RelatedRecordActionsBridge.js +49 -16
  32. package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
  33. package/dist/views/metadata-admin/i18n.js +214 -4
  34. package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
  35. package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
  36. package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
  37. package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
  38. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +35 -19
  39. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
  40. package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
  41. package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
  42. package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
  43. package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
  44. package/dist/views/metadata-admin/nav-selection.js +81 -0
  45. package/dist/views/metadata-admin/previews/AppNavCanvas.js +9 -1
  46. package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
  47. package/dist/views/studio-design/BuilderLanding.d.ts +1 -1
  48. package/dist/views/studio-design/BuilderLanding.js +12 -19
  49. package/dist/views/studio-design/ObjectFormDesigner.d.ts +5 -3
  50. package/dist/views/studio-design/ObjectFormDesigner.js +17 -12
  51. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +1 -1
  52. package/dist/views/studio-design/ObjectSettingsPanel.js +4 -3
  53. package/dist/views/studio-design/ObjectValidationsPanel.js +6 -4
  54. package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
  55. package/dist/views/studio-design/PackageIdInput.js +40 -0
  56. package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
  57. package/dist/views/studio-design/StudioDesignSurface.js +227 -57
  58. package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
  59. package/dist/views/studio-design/packageSurfaces.js +34 -0
  60. package/dist/views/studio-design/packages-io.d.ts +11 -0
  61. package/dist/views/studio-design/packages-io.js +12 -0
  62. package/dist/views/studio-design/skeletons.d.ts +16 -0
  63. package/dist/views/studio-design/skeletons.js +51 -0
  64. package/package.json +38 -38
@@ -56,12 +56,12 @@ export function resolveRefKind(ref, sibling) {
56
56
  if (!ref)
57
57
  return undefined;
58
58
  if (ref.kind)
59
- return { kind: ref.kind, objectSource: ref.objectSource };
59
+ return { kind: ref.kind, objectSource: ref.objectSource, connectorSource: ref.connectorSource };
60
60
  if (ref.kindFrom && ref.map) {
61
61
  const disc = sibling(ref.kindFrom);
62
62
  const k = typeof disc === 'string' ? ref.map[disc] : undefined;
63
63
  if (k)
64
- return { kind: k, objectSource: ref.objectSource };
64
+ return { kind: k, objectSource: ref.objectSource, connectorSource: ref.connectorSource };
65
65
  }
66
66
  return undefined;
67
67
  }
@@ -86,6 +86,31 @@ function resolveObjectName(kind, objectSource, ctx) {
86
86
  // A sibling config key on the same node (CRUD nodes carry their own objectName).
87
87
  return configString(ctx.node, src);
88
88
  }
89
+ /**
90
+ * Resolve the chosen connector name for a `connector-action` reference — read
91
+ * from the sibling key on this node's `connectorConfig` block (default
92
+ * `connectorId`), which is where the connector picker writes it.
93
+ */
94
+ export function resolveConnectorName(kind, connectorSource, ctx) {
95
+ if (kind !== 'connector-action')
96
+ return undefined;
97
+ const cc = ctx.node?.connectorConfig;
98
+ if (!cc || typeof cc !== 'object' || Array.isArray(cc))
99
+ return undefined;
100
+ const v = cc[connectorSource || 'connectorId'];
101
+ return typeof v === 'string' && v ? v : undefined;
102
+ }
103
+ /** A connector descriptor's action list → combobox options (exported for test). */
104
+ export function connectorActionsToOptions(actions) {
105
+ if (!Array.isArray(actions))
106
+ return [];
107
+ return actions
108
+ .filter((a) => !!a && typeof a.key === 'string' && !!a.key)
109
+ .map((a) => ({
110
+ value: a.key,
111
+ label: typeof a.label === 'string' && a.label && a.label !== a.key ? `${a.label} (${a.key})` : a.key,
112
+ }));
113
+ }
89
114
  /**
90
115
  * Fetch a metadata type's items as combobox options. `type === undefined`
91
116
  * disables the fetch (returns empty), so the hook can be called
@@ -127,6 +152,45 @@ function useMetadataListOptions(type) {
127
152
  }, [client, type]);
128
153
  return state;
129
154
  }
155
+ /**
156
+ * Fetch a connector's actions as combobox options from the runtime connector
157
+ * descriptors (`GET /api/v1/automation/connectors`, each `{ name, actions:
158
+ * [{key,label}] }`). `connectorName === undefined` disables the fetch (so the
159
+ * hook is safe to call unconditionally). Degrades to empty on any failure.
160
+ */
161
+ function useConnectorActionOptions(connectorName) {
162
+ const [state, setState] = React.useState({
163
+ options: [],
164
+ loading: !!connectorName,
165
+ });
166
+ React.useEffect(() => {
167
+ if (!connectorName) {
168
+ setState({ options: [], loading: false });
169
+ return;
170
+ }
171
+ let cancelled = false;
172
+ setState((s) => ({ ...s, loading: true }));
173
+ fetch('/api/v1/automation/connectors', { credentials: 'include', headers: { Accept: 'application/json' } })
174
+ .then((r) => (r.ok ? r.json() : null))
175
+ .then((payload) => {
176
+ if (cancelled)
177
+ return;
178
+ const connectors = payload?.data?.connectors ?? payload?.connectors ?? [];
179
+ const conn = Array.isArray(connectors)
180
+ ? connectors.find((c) => c?.name === connectorName)
181
+ : undefined;
182
+ setState({ options: connectorActionsToOptions(conn?.actions), loading: false });
183
+ })
184
+ .catch(() => {
185
+ if (!cancelled)
186
+ setState({ options: [], loading: false });
187
+ });
188
+ return () => {
189
+ cancelled = true;
190
+ };
191
+ }, [connectorName]);
192
+ return state;
193
+ }
130
194
  /**
131
195
  * The bare reference combobox — suggestions for `resolved.kind`, always
132
196
  * free-text editable. Hooks are called unconditionally (kind-gated args) so the
@@ -139,8 +203,11 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, disabled,
139
203
  // object-field: resolve the target object, then its field catalog.
140
204
  const objectName = resolved ? resolveObjectName(resolved.kind, resolved.objectSource, ctx) : undefined;
141
205
  const { fields: objectFields } = useObjectFields(kind === 'object-field' ? objectName : undefined);
206
+ // connector-action: resolve the chosen connector, then its action catalog.
207
+ const connectorName = resolved ? resolveConnectorName(resolved.kind, resolved.connectorSource, ctx) : undefined;
208
+ const { options: connectorActionOptions } = useConnectorActionOptions(kind === 'connector-action' ? connectorName : undefined);
142
209
  // Flat metadata-list kinds (object / flow / role / user / team / …).
143
- const listType = kind && kind !== 'object-field' && kind !== 'node' ? KIND_TO_META_TYPE[kind] : undefined;
210
+ const listType = kind && kind !== 'object-field' && kind !== 'node' && kind !== 'connector-action' ? KIND_TO_META_TYPE[kind] : undefined;
144
211
  const { options: listOptions } = useMetadataListOptions(listType);
145
212
  const options = React.useMemo(() => {
146
213
  if (kind === 'object-field') {
@@ -149,6 +216,8 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, disabled,
149
216
  label: f.label && f.label !== f.name ? `${f.label} (${f.name})` : f.name,
150
217
  }));
151
218
  }
219
+ if (kind === 'connector-action')
220
+ return connectorActionOptions;
152
221
  if (kind === 'node') {
153
222
  const nodes = Array.isArray(ctx.draft.nodes) ? ctx.draft.nodes : [];
154
223
  const currentId = typeof ctx.node?.id === 'string' ? ctx.node.id : undefined;
@@ -163,11 +232,13 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, disabled,
163
232
  if (listType)
164
233
  return listOptions;
165
234
  return [];
166
- }, [kind, listType, objectFields, listOptions, ctx.draft, ctx.node]);
235
+ }, [kind, listType, objectFields, connectorActionOptions, listOptions, ctx.draft, ctx.node]);
167
236
  // For an object-field whose object can't be resolved, tell the author why the
168
237
  // suggestions are empty — but still let them type a value.
169
238
  const unresolvedObject = kind === 'object-field' && !objectName;
170
- return (_jsxs("div", { className: "w-full space-y-1", children: [_jsx(Input, { list: options.length ? listId : undefined, value: value != null ? String(value) : '', onChange: (e) => onCommit(e.target.value), onBlur: onBlur, placeholder: placeholder, disabled: disabled, className: "h-8 text-sm" }), options.length > 0 && (_jsx("datalist", { id: listId, children: options.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value))) })), showHint && kind === 'object-field' && objectName && (_jsxs("p", { className: "text-[11px] leading-snug text-muted-foreground", children: ["Fields of ", objectName, "."] })), showHint && unresolvedObject && (_jsx("p", { className: "text-[11px] leading-snug text-muted-foreground", children: "Set the flow\u2019s trigger object (on the Start node) to list fields." }))] }));
239
+ // Same for a connector-action with no connector chosen yet.
240
+ const unresolvedConnector = kind === 'connector-action' && !connectorName;
241
+ return (_jsxs("div", { className: "w-full space-y-1", children: [_jsx(Input, { list: options.length ? listId : undefined, value: value != null ? String(value) : '', onChange: (e) => onCommit(e.target.value), onBlur: onBlur, placeholder: placeholder, disabled: disabled, className: "h-8 text-sm" }), options.length > 0 && (_jsx("datalist", { id: listId, children: options.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value))) })), showHint && kind === 'object-field' && objectName && (_jsxs("p", { className: "text-[11px] leading-snug text-muted-foreground", children: ["Fields of ", objectName, "."] })), showHint && unresolvedObject && (_jsx("p", { className: "text-[11px] leading-snug text-muted-foreground", children: "Set the flow\u2019s trigger object (on the Start node) to list fields." })), showHint && kind === 'connector-action' && connectorName && (_jsxs("p", { className: "text-[11px] leading-snug text-muted-foreground", children: ["Actions of ", connectorName, "."] })), showHint && unresolvedConnector && (_jsx("p", { className: "text-[11px] leading-snug text-muted-foreground", children: "Choose a Connector above to list its actions." }))] }));
171
242
  }
172
243
  /**
173
244
  * Inspector field wrapper: a labelled reference combobox. A polymorphic ref is
@@ -21,7 +21,6 @@ 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';
25
24
  import { useMetadataClient } from '../useMetadata';
26
25
  import { InspectorShell, InspectorReorderButtons, InspectorTextField, InspectorNumberField, InspectorSelectField, InspectorCheckboxField, InspectorRemoveButton, InspectorEmptyState, moveArray, } from './_shared';
27
26
  import { Button, Input, Label, Badge } from '@object-ui/components';
@@ -128,28 +127,33 @@ export function ObjectFieldInspector({ selection, draft, onPatch, onClearSelecti
128
127
  writeView({ shape: view.shape, entries: nextEntries });
129
128
  onSelectionChange?.({ kind: 'field', id: nextName, label: String(def.label ?? nextName) });
130
129
  };
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) => {
130
+ // Derive the API name from the label live, per keystroke with
131
+ // toFieldNameLoose (prefix-stable, unlike slugify which trims trailing
132
+ // underscores and would fight mid-word typing) while the name is still
133
+ // an auto-generated default and the user hasn't customised it. Mirrors the
134
+ // object/app Name behaviour. toFieldNameLoose returns '' for non-Latin
135
+ // labels, in which case the unique default name is kept. Pure — the
136
+ // caller applies the label and (if any) the derived name in one write,
137
+ // since two separate writeView() calls from the same stale `entry` closure
138
+ // would have the second clobber the first.
139
+ const deriveNameFor = (label) => {
137
140
  if (readOnly)
138
- return;
141
+ return null;
139
142
  const base = type === 'select' ? 'status' : type;
140
143
  const isAutoName = entry.name === base ||
141
- (entry.name.startsWith(`${base}_`) && /^\d+$/.test(entry.name.slice(base.length + 1)));
144
+ (entry.name.startsWith(`${base}_`) && /^\d+$/.test(entry.name.slice(base.length + 1))) ||
145
+ // Freshly added fields are named by nextFieldName() as `field_<N>`
146
+ // (StudioDesignSurface.tsx), independent of the field's type — match
147
+ // that scheme too, or a type-typed rename right after add never derives.
148
+ /^field_\d+$/.test(entry.name);
142
149
  if (!isAutoName)
143
- return;
144
- const derived = slugify(label);
150
+ return null;
151
+ const derived = toFieldNameLoose(label);
145
152
  if (!derived || derived === entry.name)
146
- return;
153
+ return null;
147
154
  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) });
155
+ return null;
156
+ return derived;
153
157
  };
154
158
  const removeField = () => {
155
159
  const nextEntries = view.entries.filter((_, i) => i !== idx);
@@ -201,7 +205,19 @@ export function ObjectFieldInspector({ selection, draft, onPatch, onClearSelecti
201
205
  label: typeof def.label === 'string' ? def.label : entry.name,
202
206
  }), onClick: removeField, disabled: readOnly }));
203
207
  const typeMetaLabel = isZh(locale) ? typeMeta?.labelZh : typeMeta?.label;
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: [
208
+ 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) => {
209
+ const derivedName = deriveNameFor(v);
210
+ const nextEntries = [...view.entries];
211
+ nextEntries[idx] = {
212
+ ...entry,
213
+ name: derivedName ?? entry.name,
214
+ def: { ...def, label: v },
215
+ };
216
+ writeView({ shape: view.shape, entries: nextEntries });
217
+ if (derivedName) {
218
+ onSelectionChange?.({ kind: 'field', id: derivedName, label: v });
219
+ }
220
+ }, 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: [
205
221
  { value: '', label: tr('designer.field.noGroup') },
206
222
  ...fieldGroups
207
223
  .filter((g) => typeof g.key === 'string')
@@ -265,7 +281,7 @@ function OptionsEditor({ options, onChange, disabled, locale, }) {
265
281
  const remove = (i) => commit(rows.filter((_, j) => j !== i));
266
282
  const move = (i, to) => commit(moveArray(rows, i, to));
267
283
  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)] }))] }));
284
+ 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.5", children: rows.map((o, i) => (_jsxs("div", { className: "rounded-md border border-border/60 p-1.5 space-y-1", children: [_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 min-w-0 flex-1 text-xs font-mono" }), _jsx(Input, { value: o.label ?? '', onChange: (e) => update(i, { label: e.target.value }), placeholder: t('designer.field.optLabel', locale), disabled: disabled, className: "h-7 min-w-0 flex-1 text-xs" })] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("input", { type: "color", value: o.color ?? '#cccccc', onChange: (e) => update(i, { color: e.target.value }), disabled: disabled, className: "h-6 w-6 rounded border bg-background cursor-pointer p-0.5", title: t('designer.field.optColor', locale) }), _jsx("span", { className: "flex-1" }), _jsx(Button, { variant: "ghost", size: "sm", className: "h-6 w-6 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-6 w-6 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-6 w-6 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)] }))] }));
269
285
  }
270
286
  /* ─────────────── Lookup picker config (displayField / filters / dependent) ─────────────── */
271
287
  const LOOKUP_OPERATORS = [
@@ -42,7 +42,7 @@ export type FlowConfigFieldKind = 'text' | 'expression' | 'number' | 'boolean' |
42
42
  * Kinds that have no catalog in the current tenant simply degrade to a plain
43
43
  * text box — the control is always an editable combobox, never a hard dropdown.
44
44
  */
45
- export type ReferenceKind = 'object' | 'object-field' | 'flow' | 'role' | 'node' | 'user' | 'team' | 'queue' | 'department' | 'connector' | 'email-template';
45
+ export type ReferenceKind = 'object' | 'object-field' | 'flow' | 'role' | 'node' | 'user' | 'team' | 'queue' | 'department' | 'connector' | 'connector-action' | 'email-template';
46
46
  export interface FlowReferenceSpec {
47
47
  /**
48
48
  * Concrete reference kind. Omit when the kind is *polymorphic* — chosen at
@@ -57,6 +57,13 @@ export interface FlowReferenceSpec {
57
57
  * the object name (e.g. CRUD nodes resolve from their own `objectName`).
58
58
  */
59
59
  objectSource?: string;
60
+ /**
61
+ * For `connector-action` only: the sibling key (on this node's
62
+ * `connectorConfig` block) holding the chosen connector's name. Defaults to
63
+ * `'connectorId'`. The picker lists THAT connector's actions (from the runtime
64
+ * connector descriptors); with no connector chosen it degrades to free text.
65
+ */
66
+ connectorSource?: string;
60
67
  /**
61
68
  * Polymorphic reference: the kind is selected at render time by the value of
62
69
  * a sibling field/column named `kindFrom`, looked up in {@link map}. A value
@@ -347,9 +347,10 @@ const FLOW_NODE_CONFIG = {
347
347
  ],
348
348
  connector_action: [
349
349
  at('connectorConfig', 'connectorId', 'Connector', 'reference', { ref: { kind: 'connector' }, placeholder: 'slack · email · salesforce' }),
350
- // actionId is polymorphic on the chosen connector and has no flat catalog
350
+ // actionId is polymorphic on the chosen connector: the picker lists THAT
351
+ // connector's actions (runtime descriptors), degrading to free text if none.
351
352
  // (a deliberate open extension point) — stays free text.
352
- at('connectorConfig', 'actionId', 'Action', 'text', { placeholder: 'sendMessage · send' }),
353
+ at('connectorConfig', 'actionId', 'Action', 'reference', { ref: { kind: 'connector-action', connectorSource: 'connectorId' }, placeholder: 'sendMessage · send' }),
353
354
  at('connectorConfig', 'input', 'Input', 'keyValue', { help: 'Mapped inputs for the connector action.' }),
354
355
  { id: 'timeoutMs', path: ['timeoutMs'], label: 'Timeout (ms)', kind: 'number', placeholder: '30000' },
355
356
  ],
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Nav-item target resolution (#2245) — the pure logic behind
3
+ * AppNavInspector's type + per-type target editing.
4
+ *
5
+ * The nav contract is a discriminated union on `type`; each type carries its
6
+ * own typed target field. `object` items additionally have FOUR mutually
7
+ * exclusive landing modes matching resolveHref's precedence
8
+ * (`recordId` → `filters` → `viewName` → bare default). The mode is NEVER
9
+ * persisted — it is derived from which fields are present — and switching
10
+ * type/mode explicitly clears the other target fields plus the legacy
11
+ * off-spec keys (`path` / `kind` / aliases), so every edit normalizes the
12
+ * item to spec shape ("edit is the migration").
13
+ */
14
+ /** Nav item types the inspector offers (spec union minus separator/action). */
15
+ export declare const NAV_ITEM_TYPES: readonly ["object", "page", "dashboard", "report", "url", "group"];
16
+ export type NavItemType = (typeof NAV_ITEM_TYPES)[number];
17
+ /** Landing modes for `type: 'object'`, in resolveHref precedence order. */
18
+ export declare const OBJECT_TARGET_MODES: readonly ["default", "view", "record", "filters"];
19
+ export type ObjectTargetMode = (typeof OBJECT_TARGET_MODES)[number];
20
+ /**
21
+ * Per-type target descriptor: which field the picker writes and which
22
+ * metadata list feeds its options (`client.list(metaType)`); free-text
23
+ * types have no metaType.
24
+ */
25
+ export declare const NAV_TYPE_TARGETS: Record<NavItemType, {
26
+ targetKey?: string;
27
+ metaType?: string;
28
+ }>;
29
+ /**
30
+ * Infer the effective type of a (possibly legacy) nav node for display:
31
+ * spec `type` wins; else legacy `kind`; else the presence of typed or
32
+ * legacy target fields; else children ⇒ group; else null (unset).
33
+ */
34
+ export declare function inferNavItemType(node: Record<string, unknown>): NavItemType | null;
35
+ /**
36
+ * Derive an object item's landing mode from field presence, following
37
+ * resolveHref's precedence — the mode is a projection, never stored.
38
+ */
39
+ export declare function deriveObjectTargetMode(node: Record<string, unknown>): ObjectTargetMode;
40
+ /**
41
+ * The patch that clears everything EXCEPT the fields the given type+mode
42
+ * legitimately owns. Always includes the legacy keys. Spread this before
43
+ * the fields being set so a type/mode switch leaves no stale target behind.
44
+ */
45
+ export declare function clearedTargetPatch(keep?: ReadonlyArray<string>): Record<string, undefined>;
46
+ /** Fields each object landing mode owns (besides objectName). */
47
+ export declare const OBJECT_MODE_FIELDS: Record<ObjectTargetMode, ReadonlyArray<string>>;
48
+ /**
49
+ * Ensure a spec-valid snake_case `id`. Existing ids are kept; otherwise one
50
+ * is derived from the target/label and uniqued against sibling ids.
51
+ */
52
+ export declare function ensureNavId(node: Record<string, unknown>, siblings: ReadonlyArray<Record<string, unknown>>, seed?: string): string;
@@ -0,0 +1,149 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+ /**
3
+ * Nav-item target resolution (#2245) — the pure logic behind
4
+ * AppNavInspector's type + per-type target editing.
5
+ *
6
+ * The nav contract is a discriminated union on `type`; each type carries its
7
+ * own typed target field. `object` items additionally have FOUR mutually
8
+ * exclusive landing modes matching resolveHref's precedence
9
+ * (`recordId` → `filters` → `viewName` → bare default). The mode is NEVER
10
+ * persisted — it is derived from which fields are present — and switching
11
+ * type/mode explicitly clears the other target fields plus the legacy
12
+ * off-spec keys (`path` / `kind` / aliases), so every edit normalizes the
13
+ * item to spec shape ("edit is the migration").
14
+ */
15
+ /** Nav item types the inspector offers (spec union minus separator/action). */
16
+ export const NAV_ITEM_TYPES = ['object', 'page', 'dashboard', 'report', 'url', 'group'];
17
+ /** Landing modes for `type: 'object'`, in resolveHref precedence order. */
18
+ export const OBJECT_TARGET_MODES = ['default', 'view', 'record', 'filters'];
19
+ /**
20
+ * Per-type target descriptor: which field the picker writes and which
21
+ * metadata list feeds its options (`client.list(metaType)`); free-text
22
+ * types have no metaType.
23
+ */
24
+ export const NAV_TYPE_TARGETS = {
25
+ object: { targetKey: 'objectName', metaType: 'object' },
26
+ page: { targetKey: 'pageName', metaType: 'page' },
27
+ dashboard: { targetKey: 'dashboardName', metaType: 'dashboard' },
28
+ report: { targetKey: 'reportName', metaType: 'report' },
29
+ url: { targetKey: 'url' },
30
+ group: {},
31
+ };
32
+ /** Typed target fields across the whole union. */
33
+ const TYPED_TARGET_FIELDS = [
34
+ 'objectName',
35
+ 'viewName',
36
+ 'recordId',
37
+ 'recordMode',
38
+ 'filters',
39
+ 'pageName',
40
+ 'dashboardName',
41
+ 'reportName',
42
+ 'url',
43
+ 'target',
44
+ 'params',
45
+ ];
46
+ /**
47
+ * Legacy / off-spec keys that runtime resolution ignores and save-time
48
+ * validation rejects. Cleared on EVERY inspector edit so stale keys never
49
+ * hijack behavior or fail validation (`navigation.0: Invalid input`).
50
+ */
51
+ const LEGACY_KEYS = ['path', 'kind', 'href', 'route', 'object', 'page', 'dashboard', 'report'];
52
+ /**
53
+ * Map a legacy `kind` value (the old inspector's vocabulary) to the spec
54
+ * type; `link` was never a spec member — it maps to `url`.
55
+ */
56
+ const LEGACY_KIND_TO_TYPE = {
57
+ object: 'object',
58
+ page: 'page',
59
+ dashboard: 'dashboard',
60
+ report: 'report',
61
+ link: 'url',
62
+ url: 'url',
63
+ group: 'group',
64
+ };
65
+ /**
66
+ * Infer the effective type of a (possibly legacy) nav node for display:
67
+ * spec `type` wins; else legacy `kind`; else the presence of typed or
68
+ * legacy target fields; else children ⇒ group; else null (unset).
69
+ */
70
+ export function inferNavItemType(node) {
71
+ const t = node.type;
72
+ if (typeof t === 'string' && NAV_ITEM_TYPES.includes(t)) {
73
+ return t;
74
+ }
75
+ const kind = node.kind;
76
+ if (typeof kind === 'string' && LEGACY_KIND_TO_TYPE[kind])
77
+ return LEGACY_KIND_TO_TYPE[kind];
78
+ if (node.objectName || node.object)
79
+ return 'object';
80
+ if (node.pageName || node.page)
81
+ return 'page';
82
+ if (node.dashboardName || node.dashboard)
83
+ return 'dashboard';
84
+ if (node.reportName || node.report)
85
+ return 'report';
86
+ if (node.url || node.href)
87
+ return 'url';
88
+ if (Array.isArray(node.children) && node.children.length > 0)
89
+ return 'group';
90
+ const path = node.path;
91
+ if (typeof path === 'string' && /^https?:/i.test(path))
92
+ return 'url';
93
+ return null;
94
+ }
95
+ /**
96
+ * Derive an object item's landing mode from field presence, following
97
+ * resolveHref's precedence — the mode is a projection, never stored.
98
+ */
99
+ export function deriveObjectTargetMode(node) {
100
+ if (node.recordId)
101
+ return 'record';
102
+ const filters = node.filters;
103
+ if (filters && typeof filters === 'object' && !Array.isArray(filters))
104
+ return 'filters';
105
+ if (node.viewName)
106
+ return 'view';
107
+ return 'default';
108
+ }
109
+ /**
110
+ * The patch that clears everything EXCEPT the fields the given type+mode
111
+ * legitimately owns. Always includes the legacy keys. Spread this before
112
+ * the fields being set so a type/mode switch leaves no stale target behind.
113
+ */
114
+ export function clearedTargetPatch(keep = []) {
115
+ const patch = {};
116
+ for (const key of [...TYPED_TARGET_FIELDS, ...LEGACY_KEYS]) {
117
+ if (!keep.includes(key))
118
+ patch[key] = undefined;
119
+ }
120
+ return patch;
121
+ }
122
+ /** Fields each object landing mode owns (besides objectName). */
123
+ export const OBJECT_MODE_FIELDS = {
124
+ default: ['objectName'],
125
+ view: ['objectName', 'viewName'],
126
+ record: ['objectName', 'recordId', 'recordMode'],
127
+ filters: ['objectName', 'filters'],
128
+ };
129
+ /**
130
+ * Ensure a spec-valid snake_case `id`. Existing ids are kept; otherwise one
131
+ * is derived from the target/label and uniqued against sibling ids.
132
+ */
133
+ export function ensureNavId(node, siblings, seed) {
134
+ const existing = node.id;
135
+ if (typeof existing === 'string' && existing)
136
+ return existing;
137
+ const raw = (seed ?? String(node.objectName ?? node.label ?? 'item'))
138
+ .toLowerCase()
139
+ .replace(/[^a-z0-9]+/g, '_')
140
+ .replace(/^_+|_+$/g, '') || 'item';
141
+ const base = `nav_${raw}`;
142
+ const taken = new Set(siblings.map((s) => (typeof s.id === 'string' ? s.id : '')).filter(Boolean));
143
+ if (!taken.has(base))
144
+ return base;
145
+ let n = 2;
146
+ while (taken.has(`${base}_${n}`))
147
+ n++;
148
+ return `${base}_${n}`;
149
+ }
@@ -0,0 +1,20 @@
1
+ /** Search param carrying the designer's selected element. */
2
+ export declare const DESIGNER_SEL_PARAM = "sel";
3
+ /** Parse a `sel` param value; returns the nav item id for `nav:<id>`. */
4
+ export declare function parseNavSelParam(value: string | null | undefined): string | null;
5
+ export declare function formatNavSelParam(navId: string): string;
6
+ /**
7
+ * Locate a nav item by its `id` across all accepted root keys, returning
8
+ * the positional selection id the canvas/inspector pair uses
9
+ * (`<rootKey>[i]` / `<rootKey>[i].children[j]`), or null when absent.
10
+ */
11
+ export declare function findNavPositionById(draft: Record<string, unknown>, navId: string): {
12
+ selectionId: string;
13
+ label?: string;
14
+ } | null;
15
+ /**
16
+ * Read the nav item `id` at a positional selection id (the inverse of
17
+ * {@link findNavPositionById}); null when the path is invalid or the node
18
+ * has no id.
19
+ */
20
+ export declare function navIdAtPosition(draft: Record<string, unknown>, positionalId: string): string | null;
@@ -0,0 +1,81 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+ /**
3
+ * Nav-item deep-link selection (#2272).
4
+ *
5
+ * The designer's internal selection ids are POSITIONAL (`navigation[2]`,
6
+ * `nav[0].children[1]`) — cheap for canvas/inspector wiring but unstable:
7
+ * they drift on reorder and mean nothing outside one editing session. The
8
+ * EXTERNAL contract is the nav item's spec-required snake_case `id`,
9
+ * carried in the URL as `?sel=nav:<id>`. These helpers translate between
10
+ * the two at the designer boundary; positions never leave component state.
11
+ */
12
+ import { APP_NAV_ROOT_KEYS } from './inspectors/AppNavInspector';
13
+ /** Search param carrying the designer's selected element. */
14
+ export const DESIGNER_SEL_PARAM = 'sel';
15
+ /** Parse a `sel` param value; returns the nav item id for `nav:<id>`. */
16
+ export function parseNavSelParam(value) {
17
+ if (!value || !value.startsWith('nav:'))
18
+ return null;
19
+ const id = value.slice(4);
20
+ return id.length > 0 ? id : null;
21
+ }
22
+ export function formatNavSelParam(navId) {
23
+ return `nav:${navId}`;
24
+ }
25
+ /**
26
+ * Locate a nav item by its `id` across all accepted root keys, returning
27
+ * the positional selection id the canvas/inspector pair uses
28
+ * (`<rootKey>[i]` / `<rootKey>[i].children[j]`), or null when absent.
29
+ */
30
+ export function findNavPositionById(draft, navId) {
31
+ const walk = (nodes, prefix) => {
32
+ for (let i = 0; i < nodes.length; i++) {
33
+ const node = nodes[i];
34
+ if (!node || typeof node !== 'object')
35
+ continue;
36
+ const pos = `${prefix}[${i}]`;
37
+ if (node.id === navId) {
38
+ const label = node.label ?? node.title ?? node.name;
39
+ return { selectionId: pos, label: typeof label === 'string' ? label : undefined };
40
+ }
41
+ if (Array.isArray(node.children)) {
42
+ const hit = walk(node.children, `${pos}.children`);
43
+ if (hit)
44
+ return hit;
45
+ }
46
+ }
47
+ return null;
48
+ };
49
+ for (const rootKey of APP_NAV_ROOT_KEYS) {
50
+ const arr = draft[rootKey];
51
+ if (!Array.isArray(arr))
52
+ continue;
53
+ const hit = walk(arr, rootKey);
54
+ if (hit)
55
+ return hit;
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * Read the nav item `id` at a positional selection id (the inverse of
61
+ * {@link findNavPositionById}); null when the path is invalid or the node
62
+ * has no id.
63
+ */
64
+ export function navIdAtPosition(draft, positionalId) {
65
+ const segs = positionalId.split('.');
66
+ let node;
67
+ for (let s = 0; s < segs.length; s++) {
68
+ const m = /^([a-zA-Z_]\w*)\[(\d+)\]$/.exec(segs[s]);
69
+ if (!m)
70
+ return null;
71
+ const key = m[1];
72
+ const index = Number(m[2]);
73
+ const arr = s === 0 ? draft[key] : node?.[key];
74
+ if (!Array.isArray(arr))
75
+ return null;
76
+ node = arr[index];
77
+ if (!node || typeof node !== 'object')
78
+ return null;
79
+ }
80
+ return typeof node?.id === 'string' && node.id ? node.id : null;
81
+ }
@@ -117,7 +117,15 @@ export function AppNavCanvas({ draft, rootKey, onPatch, selection, onSelectionCh
117
117
  if (!onPatch)
118
118
  return;
119
119
  const newLabel = t('engine.appNav.newItem', locale);
120
- const newItem = { label: newLabel, path: '' };
120
+ // Spec invariants from birth (#2245): a snake_case `id` and a `type`
121
+ // (object is the 80% case per the app-composition guide) — never the
122
+ // old `{label, path:''}` placeholder that failed save validation. The
123
+ // item completes once the inspector's object picker fills `objectName`.
124
+ const taken = new Set(items.map((it) => (typeof it.id === 'string' ? it.id : '')).filter(Boolean));
125
+ let navId = `nav_item_${items.length + 1}`;
126
+ for (let n = items.length + 2; taken.has(navId); n++)
127
+ navId = `nav_item_${n}`;
128
+ const newItem = { id: navId, type: 'object', label: newLabel };
121
129
  const next = appendArray(items, newItem);
122
130
  setItems(next);
123
131
  onSelectionChange?.({
@@ -80,8 +80,10 @@ export function AppPreview({ name, draft, editing, selection, onSelectionChange,
80
80
  }
81
81
  return { rootKey: null, navItems: [] };
82
82
  }, [draft]);
83
- // For Add we need a root key even when empty — default to "nav".
84
- const addRootKey = rootKey ?? 'nav';
83
+ // For Add we need a root key even when empty — default to `navigation`,
84
+ // the only root key the spec (AppSchema) actually accepts; `nav` /
85
+ // `tabs` / `items` are read-back tolerances, not write targets (#2245).
86
+ const addRootKey = rootKey ?? 'navigation';
85
87
  const designMode = !!(editing && onSelectionChange);
86
88
  const canEdit = designMode && !!onPatch;
87
89
  const selectedId = selection && selection.kind === 'nav' ? selection.id : null;