@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.
- package/CHANGELOG.md +178 -0
- package/README.md +9 -0
- package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
- package/dist/console/AppContent.js +145 -26
- package/dist/console/ConsoleShell.js +8 -1
- package/dist/context/CommandPaletteProvider.js +2 -1
- package/dist/hooks/useObjectActions.js +16 -4
- package/dist/layout/AppHeader.js +13 -5
- package/dist/layout/AppSidebar.js +10 -4
- package/dist/observability/sentry.d.ts +5 -0
- package/dist/observability/sentry.js +6 -1
- package/dist/preview/DraftChangesPanel.d.ts +29 -1
- package/dist/preview/DraftChangesPanel.js +141 -14
- package/dist/urlParams.d.ts +68 -0
- package/dist/urlParams.js +76 -0
- package/dist/utils/appRoute.d.ts +15 -0
- package/dist/utils/appRoute.js +22 -0
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/pageTabsUrlSync.d.ts +32 -0
- package/dist/utils/pageTabsUrlSync.js +43 -0
- package/dist/utils/recordFormNavigation.d.ts +40 -0
- package/dist/utils/recordFormNavigation.js +30 -0
- package/dist/views/InterfaceListPage.d.ts +1 -0
- package/dist/views/InterfaceListPage.js +1 -1
- package/dist/views/ObjectDataPage.d.ts +29 -0
- package/dist/views/ObjectDataPage.js +227 -0
- package/dist/views/ObjectView.js +4 -3
- package/dist/views/RecordDetailView.js +61 -20
- package/dist/views/RelatedRecordActionsBridge.d.ts +10 -1
- package/dist/views/RelatedRecordActionsBridge.js +49 -16
- package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
- package/dist/views/metadata-admin/i18n.js +214 -4
- package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
- package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
- package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
- package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
- package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +35 -19
- package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
- package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
- package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
- package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
- package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
- package/dist/views/metadata-admin/nav-selection.js +81 -0
- package/dist/views/metadata-admin/previews/AppNavCanvas.js +9 -1
- package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
- package/dist/views/studio-design/BuilderLanding.d.ts +1 -1
- package/dist/views/studio-design/BuilderLanding.js +12 -19
- package/dist/views/studio-design/ObjectFormDesigner.d.ts +5 -3
- package/dist/views/studio-design/ObjectFormDesigner.js +17 -12
- package/dist/views/studio-design/ObjectSettingsPanel.d.ts +1 -1
- package/dist/views/studio-design/ObjectSettingsPanel.js +4 -3
- package/dist/views/studio-design/ObjectValidationsPanel.js +6 -4
- package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
- package/dist/views/studio-design/PackageIdInput.js +40 -0
- package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
- package/dist/views/studio-design/StudioDesignSurface.js +227 -57
- package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
- package/dist/views/studio-design/packageSurfaces.js +34 -0
- package/dist/views/studio-design/packages-io.d.ts +11 -0
- package/dist/views/studio-design/packages-io.js +12 -0
- package/dist/views/studio-design/skeletons.d.ts +16 -0
- package/dist/views/studio-design/skeletons.js +51 -0
- 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
|
-
|
|
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
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
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 =
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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', '
|
|
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
|
-
|
|
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
|
|
84
|
-
|
|
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;
|