@svgrid/enterprise 2.6.0 → 2.6.1

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.
@@ -25,11 +25,11 @@ export { crudSuiteScreens, addCrudSuite, crudAppFromSchemas, listScreen, formScr
25
25
  export { sanitizeStudioProject, buildStudioBugReport, type SanitizeResult, type BugReport, type BugReportInput, type StudioEnv, } from './bug-report.js';
26
26
  export { generateValue, generateRows } from './sample-data.js';
27
27
  export { studioThemes, defaultStudioTheme, getStudioTheme, resolveThemeTokens, resolveThemeTokensFor, isDarkTheme, themeStyleString, type StudioTheme } from './themes.js';
28
- export { createProject, defaultScreenFor, defaultBlockConfig, gridColumns, entityOf, blockPalette, addBlock, addBlockAt, addComponentBlock, removeBlock, duplicateBlock, moveBlock, reorderBlock, updateBlock, addEntity, removeEntity, updateEntity, addScreen, addFreestandingScreen, addScreenAction, removeScreenAction, enableScreenCode, disableScreenCode, setScreenRenderGrid, screenLayoutOf, buildDockLayout, reconcileDock, syncDockPanes, setScreenLayout, isPaneLayout, PANE_LAYOUTS, buildCanvasLayout, canvasRectOf, setCanvasRect, CANVAS_COLS, CANVAS_ROW_PX, CANVAS_GAP_PX, type CanvasRect, applyGridPreset, gridPresetLabel, GRID_PRESETS, setScreenDock, reseedScreenDock, setDockPaneTitle, dockPaneTitleOf, dockPaneIds, setLayoutOpts, gridOpts, stackOpts, splitOpts, dockOpts, canvasOpts, type ScreenLayout, type GridPreset, type LayoutOpts, type GridLayoutOpts, type StackLayoutOpts, type SplitLayoutOpts, type DockLayoutOpts, type CanvasLayoutOpts, setHandlerBody, setScreenHandlersSource, setHandlerSteps, addHandlerStep, updateHandlerStep, moveHandlerStep, stepsToCode, compileStep, compileHandlerSteps, compileValue, compileCondition, compileTriggerStep, compileTriggerSteps, triggersOf, setTrigger, TRIGGER_EVENTS, type TriggerStep, type TriggerEvent, type EntityTriggers, setJob, type ScheduledJob, type ScheduledJobKind, setTenancy, tenantField, isTenantScoped, type TenancyConfig, clickSlot, ssrScreenShape, ssrEligible, withSsrDefaults, setScreenRenderMode, eventSlot, rowSelectSlot, changeSlot, FORM_SUBMIT, GRID_EVENTS, type GridEventDef, addStateVar, updateStateVar, removeStateVar, stateInitExpr, stateTsType, type ActionStep, type ActionStepType, type StateVar, type StateVarType, type LogicValue, type LogicOp, type Condition, type FieldValue, setComponentName, setComponentBinding, componentHasBindings, componentHandleName, ON_LOAD, removeScreen, updateScreen, duplicateScreen, reorderScreen, insertBlock, setDataSource, setDeployTarget, setAuth, setDataLayer, seedUsers, defaultEntitySource, flattenBlocks, TAB_CHILD_KINDS, CONTAINER_CHILD_KINDS, CONTAINER_DISPLAY_KINDS, addTab, removeTab, renameTab, addTabBlock, addTabComponent, removeTabBlock, addAccordionSection, removeAccordionSection, renameAccordionSection, setAccordionMultiple, addAccordionBlock, addAccordionComponent, removeAccordionBlock, setEntityDataSource, entityDataSource, setTheme, setThemePreset, setShell, sanitizeProject, screenFromTemplate, addScreenFromTemplate, serializeProject, parseProject, validateProject, isProjectValid, blockColumns, blockStyleCss, blockClassName, sanitizeClassName, mergeBlockStyle, type BlockStyle, roleCanScreen, roleCanAction, CRUD_ACTIONS, type CrudAction, type RoleAccess, type AccessControl, type AuthConfig, type OAuthProvider, type SeedUser, type I18nConfig, type DeployTarget, type StudioProject, type Screen, type ScreenNav, type Block, type BlockKind, type BlockConfig, type GridConfig, type GridColumnConfig, type ColumnFormat, type ColumnCellType, type GridExportConfig, type GridFilterUi, type TreeDataConfig, type SchedulerViewConfig, type SchedulerViewMode, type RowLink, type RowAction, type RowActionKind, type ActionConfig, type ComponentConfig, type ComponentBinding, type FormatOp, type FormatRule, type GridEditing, type GridDensity, type GridAlign, type PagerPosition, type FormConfig, type ChartConfig, type KpiConfig, type KpiFormat, type GaugeConfig, type TreeConfig, type TabsConfig, type StudioTab, type AccordionConfig, type AccordionSection, type DashboardConfig, type MasterDetailConfig, type LookupConfig, type PivotConfig, type FilterPanelConfig, type RecordConfig, type BoardConfig, type CalendarConfig, type DetailConfig, type DetailRelated, type Reduce, type DataSourceKind, type EntityDataSource, type MemorySource, type RestSource, type RestAdapterConfig, type SqlSource, type SupabaseSource, type PgliteSource, type RestMethod, type ParamLocation, type ParamType, type RequestParam, type SqlDialectKind, type ProjectTheme, type ShellConfig, type ShellStyle, type ScreenTemplate, type PaletteItem, type ProjectIssue, } from './project.js';
28
+ export { createProject, defaultScreenFor, defaultBlockConfig, gridColumns, entityOf, blockPalette, addBlock, addBlockAt, addComponentBlock, removeBlock, duplicateBlock, moveBlock, reorderBlock, updateBlock, addEntity, removeEntity, updateEntity, formPlan, setEntityForm, setFormColumns, addFormSection, updateFormSection, removeFormSection, moveFormSection, moveFormField, moveFormFields, setFieldConditions, updateEntityField, setFieldInput, setFieldHidden, formControlsFor, formControlSettings, suggestFormSections, addScreen, addFreestandingScreen, addScreenAction, removeScreenAction, enableScreenCode, disableScreenCode, setScreenRenderGrid, screenLayoutOf, buildDockLayout, reconcileDock, syncDockPanes, setScreenLayout, isPaneLayout, PANE_LAYOUTS, buildCanvasLayout, canvasRectOf, setCanvasRect, CANVAS_COLS, CANVAS_ROW_PX, CANVAS_GAP_PX, type CanvasRect, applyGridPreset, gridPresetLabel, GRID_PRESETS, setScreenDock, reseedScreenDock, setDockPaneTitle, dockPaneTitleOf, dockPaneIds, setLayoutOpts, gridOpts, stackOpts, splitOpts, dockOpts, canvasOpts, type ScreenLayout, type GridPreset, type LayoutOpts, type GridLayoutOpts, type StackLayoutOpts, type SplitLayoutOpts, type DockLayoutOpts, type CanvasLayoutOpts, setHandlerBody, setScreenHandlersSource, setHandlerSteps, addHandlerStep, updateHandlerStep, moveHandlerStep, stepsToCode, compileStep, compileHandlerSteps, compileValue, compileCondition, compileTriggerStep, compileTriggerSteps, triggersOf, setTrigger, TRIGGER_EVENTS, type TriggerStep, type TriggerEvent, type EntityTriggers, setJob, type ScheduledJob, type ScheduledJobKind, setTenancy, tenantField, isTenantScoped, type TenancyConfig, clickSlot, ssrScreenShape, ssrEligible, withSsrDefaults, setScreenRenderMode, eventSlot, rowSelectSlot, changeSlot, FORM_SUBMIT, GRID_EVENTS, type GridEventDef, addStateVar, updateStateVar, removeStateVar, stateInitExpr, stateTsType, type ActionStep, type ActionStepType, type StateVar, type StateVarType, type LogicValue, type LogicOp, type Condition, type FieldValue, setComponentName, setComponentBinding, componentHasBindings, componentHandleName, ON_LOAD, removeScreen, updateScreen, duplicateScreen, reorderScreen, insertBlock, setDataSource, setDeployTarget, setAuth, setDataLayer, seedUsers, defaultEntitySource, flattenBlocks, TAB_CHILD_KINDS, CONTAINER_CHILD_KINDS, CONTAINER_DISPLAY_KINDS, addTab, removeTab, renameTab, addTabBlock, addTabComponent, removeTabBlock, addAccordionSection, removeAccordionSection, renameAccordionSection, setAccordionMultiple, addAccordionBlock, addAccordionComponent, removeAccordionBlock, setEntityDataSource, entityDataSource, setTheme, setThemePreset, setShell, sanitizeProject, screenFromTemplate, addScreenFromTemplate, serializeProject, parseProject, validateProject, isProjectValid, blockColumns, blockStyleCss, blockClassName, sanitizeClassName, mergeBlockStyle, type BlockStyle, roleCanScreen, roleCanAction, CRUD_ACTIONS, type CrudAction, type RoleAccess, type AccessControl, type AuthConfig, type OAuthProvider, type SeedUser, type I18nConfig, type DeployTarget, type StudioProject, type Screen, type ScreenNav, type Block, type BlockKind, type BlockConfig, type GridConfig, type GridColumnConfig, type ColumnFormat, type ColumnCellType, type GridExportConfig, type GridFilterUi, type TreeDataConfig, type SchedulerViewConfig, type SchedulerViewMode, type RowLink, type RowAction, type RowActionKind, type ActionConfig, type ComponentConfig, type ComponentBinding, type FormatOp, type FormatRule, type GridEditing, type GridDensity, type GridAlign, type PagerPosition, type FormConfig, type ChartConfig, type KpiConfig, type KpiFormat, type GaugeConfig, type TreeConfig, type TabsConfig, type StudioTab, type AccordionConfig, type AccordionSection, type DashboardConfig, type MasterDetailConfig, type LookupConfig, type PivotConfig, type FilterPanelConfig, type RecordConfig, type BoardConfig, type CalendarConfig, type DetailConfig, type DetailRelated, type Reduce, type DataSourceKind, type EntityDataSource, type MemorySource, type RestSource, type RestAdapterConfig, type SqlSource, type SupabaseSource, type PgliteSource, type RestMethod, type ParamLocation, type ParamType, type RequestParam, type SqlDialectKind, type ProjectTheme, type ShellConfig, type ShellStyle, type ScreenTemplate, type PaletteItem, type ProjectIssue, } from './project.js';
29
29
  export { verifyScaffold, summarizeVerify, type VerifyResult, type VerifyIssue, type VerifySeverity, } from './verify.js';
30
30
  export { UI_COMPONENT_REGISTRY, uiComponentSpec, gridPropSurface, gridApiSettableProps, GRID_CURATED_PROPS, STANDARD_UI_EVENTS, type UiPropType, type UiPropGroup, type UiComponentEvent, type UiComponentProp, type UiComponentSpec, } from './ui-components.js';
31
31
  export { buildCopilotMessages, projectFromModelText, type CopilotMessages } from './copilot-core.js';
32
32
  export { resolveDeployTarget, deployCommands, missingEnvKeys, type DeployProvider, type DeployResolution, type DeployPlanCommands, } from './deploy-cli.js';
33
33
  export { runStudioAdd, runStudioAddApp, resolveSchema, resolveSchemas, type StudioIO, type AddOptions, type AddResult, type AddAppResult, } from './cli.js';
34
- export { resolveIdField, schemaToColumns, schemaToFormFields, linkRelationLabels, pickLabelField, type EntitySchema, type EntityField, type EntityFieldType, } from '../schema.js';
34
+ export { resolveIdField, schemaToColumns, schemaToFormFields, isFieldHidden, linkRelationLabels, pickLabelField, type EntitySchema, type EntityField, type EntityFieldType, } from '../schema.js';
35
35
  export { checkLicenseKey, type LicenseInfo, type LicenseStatus } from '../license-core.js';
@@ -25,14 +25,17 @@ export { crudSuiteScreens, addCrudSuite, crudAppFromSchemas, listScreen, formScr
25
25
  export { sanitizeStudioProject, buildStudioBugReport, } from './bug-report.js';
26
26
  export { generateValue, generateRows } from './sample-data.js';
27
27
  export { studioThemes, defaultStudioTheme, getStudioTheme, resolveThemeTokens, resolveThemeTokensFor, isDarkTheme, themeStyleString } from './themes.js';
28
- export { createProject, defaultScreenFor, defaultBlockConfig, gridColumns, entityOf, blockPalette, addBlock, addBlockAt, addComponentBlock, removeBlock, duplicateBlock, moveBlock, reorderBlock, updateBlock, addEntity, removeEntity, updateEntity, addScreen, addFreestandingScreen, addScreenAction, removeScreenAction, enableScreenCode, disableScreenCode, setScreenRenderGrid, screenLayoutOf, buildDockLayout, reconcileDock, syncDockPanes, setScreenLayout, isPaneLayout, PANE_LAYOUTS, buildCanvasLayout, canvasRectOf, setCanvasRect, CANVAS_COLS, CANVAS_ROW_PX, CANVAS_GAP_PX, applyGridPreset, gridPresetLabel, GRID_PRESETS, setScreenDock, reseedScreenDock, setDockPaneTitle, dockPaneTitleOf, dockPaneIds, setLayoutOpts, gridOpts, stackOpts, splitOpts, dockOpts, canvasOpts, setHandlerBody, setScreenHandlersSource, setHandlerSteps, addHandlerStep, updateHandlerStep, moveHandlerStep, stepsToCode, compileStep, compileHandlerSteps, compileValue, compileCondition, compileTriggerStep, compileTriggerSteps, triggersOf, setTrigger, TRIGGER_EVENTS, setJob, setTenancy, tenantField, isTenantScoped, clickSlot, ssrScreenShape, ssrEligible, withSsrDefaults, setScreenRenderMode, eventSlot, rowSelectSlot, changeSlot, FORM_SUBMIT, GRID_EVENTS, addStateVar, updateStateVar, removeStateVar, stateInitExpr, stateTsType, setComponentName, setComponentBinding, componentHasBindings, componentHandleName, ON_LOAD, removeScreen, updateScreen, duplicateScreen, reorderScreen, insertBlock, setDataSource, setDeployTarget, setAuth, setDataLayer, seedUsers, defaultEntitySource, flattenBlocks, TAB_CHILD_KINDS, CONTAINER_CHILD_KINDS, CONTAINER_DISPLAY_KINDS, addTab, removeTab, renameTab, addTabBlock, addTabComponent, removeTabBlock, addAccordionSection, removeAccordionSection, renameAccordionSection, setAccordionMultiple, addAccordionBlock, addAccordionComponent, removeAccordionBlock, setEntityDataSource, entityDataSource, setTheme, setThemePreset, setShell, sanitizeProject, screenFromTemplate, addScreenFromTemplate, serializeProject, parseProject, validateProject, isProjectValid, blockColumns, blockStyleCss, blockClassName, sanitizeClassName, mergeBlockStyle, roleCanScreen, roleCanAction, CRUD_ACTIONS, } from './project.js';
28
+ export { createProject, defaultScreenFor, defaultBlockConfig, gridColumns, entityOf, blockPalette, addBlock, addBlockAt, addComponentBlock, removeBlock, duplicateBlock, moveBlock, reorderBlock, updateBlock, addEntity, removeEntity, updateEntity,
29
+ // Form layout: the operations a form builder drives, on the entity's own
30
+ // `form` so a built form travels with the entity rather than with one block.
31
+ formPlan, setEntityForm, setFormColumns, addFormSection, updateFormSection, removeFormSection, moveFormSection, moveFormField, moveFormFields, setFieldConditions, updateEntityField, setFieldInput, setFieldHidden, formControlsFor, formControlSettings, suggestFormSections, addScreen, addFreestandingScreen, addScreenAction, removeScreenAction, enableScreenCode, disableScreenCode, setScreenRenderGrid, screenLayoutOf, buildDockLayout, reconcileDock, syncDockPanes, setScreenLayout, isPaneLayout, PANE_LAYOUTS, buildCanvasLayout, canvasRectOf, setCanvasRect, CANVAS_COLS, CANVAS_ROW_PX, CANVAS_GAP_PX, applyGridPreset, gridPresetLabel, GRID_PRESETS, setScreenDock, reseedScreenDock, setDockPaneTitle, dockPaneTitleOf, dockPaneIds, setLayoutOpts, gridOpts, stackOpts, splitOpts, dockOpts, canvasOpts, setHandlerBody, setScreenHandlersSource, setHandlerSteps, addHandlerStep, updateHandlerStep, moveHandlerStep, stepsToCode, compileStep, compileHandlerSteps, compileValue, compileCondition, compileTriggerStep, compileTriggerSteps, triggersOf, setTrigger, TRIGGER_EVENTS, setJob, setTenancy, tenantField, isTenantScoped, clickSlot, ssrScreenShape, ssrEligible, withSsrDefaults, setScreenRenderMode, eventSlot, rowSelectSlot, changeSlot, FORM_SUBMIT, GRID_EVENTS, addStateVar, updateStateVar, removeStateVar, stateInitExpr, stateTsType, setComponentName, setComponentBinding, componentHasBindings, componentHandleName, ON_LOAD, removeScreen, updateScreen, duplicateScreen, reorderScreen, insertBlock, setDataSource, setDeployTarget, setAuth, setDataLayer, seedUsers, defaultEntitySource, flattenBlocks, TAB_CHILD_KINDS, CONTAINER_CHILD_KINDS, CONTAINER_DISPLAY_KINDS, addTab, removeTab, renameTab, addTabBlock, addTabComponent, removeTabBlock, addAccordionSection, removeAccordionSection, renameAccordionSection, setAccordionMultiple, addAccordionBlock, addAccordionComponent, removeAccordionBlock, setEntityDataSource, entityDataSource, setTheme, setThemePreset, setShell, sanitizeProject, screenFromTemplate, addScreenFromTemplate, serializeProject, parseProject, validateProject, isProjectValid, blockColumns, blockStyleCss, blockClassName, sanitizeClassName, mergeBlockStyle, roleCanScreen, roleCanAction, CRUD_ACTIONS, } from './project.js';
29
32
  export { verifyScaffold, summarizeVerify, } from './verify.js';
30
33
  export { UI_COMPONENT_REGISTRY, uiComponentSpec, gridPropSurface, gridApiSettableProps, GRID_CURATED_PROPS, STANDARD_UI_EVENTS, } from './ui-components.js';
31
34
  export { buildCopilotMessages, projectFromModelText } from './copilot-core.js';
32
35
  export { resolveDeployTarget, deployCommands, missingEnvKeys, } from './deploy-cli.js';
33
36
  export { runStudioAdd, runStudioAddApp, resolveSchema, resolveSchemas, } from './cli.js';
34
37
  // Re-export the schema surface so consumers get everything from one entry.
35
- export { resolveIdField, schemaToColumns, schemaToFormFields, linkRelationLabels, pickLabelField, } from '../schema.js';
38
+ export { resolveIdField, schemaToColumns, schemaToFormFields, isFieldHidden, linkRelationLabels, pickLabelField, } from '../schema.js';
36
39
  // Pure license classification, so the Node/MCP generator can soft-gate with the
37
40
  // same rules as the browser (see license-core.ts). No DOM, safe in Node.
38
41
  export { checkLicenseKey } from '../license-core.js';
@@ -10,7 +10,7 @@
10
10
  * Block-config unions are defined here (not imported from `sources/`) to keep the
11
11
  * module resolvable under node16.
12
12
  */
13
- import type { EntitySchema, FormSection } from '../schema.js';
13
+ import { type EntityField, type EntityFieldType, type EntitySchema, type FormLayout, type FormSection, type StudioEditorType } from '../schema.js';
14
14
  import type { ChartType, DockManagerState } from '@svgrid/grid';
15
15
  export type Reduce = 'sum' | 'avg' | 'count' | 'min' | 'max';
16
16
  export type DataSourceKind = 'memory' | 'sql' | 'supabase' | 'rest' | 'pglite';
@@ -317,9 +317,37 @@ export type ActionConfig = {
317
317
  };
318
318
  /** Legacy standalone edit-form block. Editing is now a Grid property; kept so old
319
319
  * `studio.config.json` files still parse. Not offered in the palette. */
320
+ /**
321
+ * A standalone form for creating a record: a "New ticket" page, an intake
322
+ * screen, a signup. Blank on load, submits, and creates a row.
323
+ *
324
+ * Deliberately create-only, because the other two directions are already taken:
325
+ * a grid with `editing: 'form'` edits an existing row in a popup, and a
326
+ * `record` block with `editable` edits whatever row is selected. What no block
327
+ * covered was a form that stands on its own with no grid behind it.
328
+ *
329
+ * Its fields, order, sections and conditions come from the entity's own
330
+ * `EntitySchema.form` - the same layout the form builder edits - so a form
331
+ * designed once looks the same wherever it is placed.
332
+ */
320
333
  export type FormConfig = {
321
334
  kind: 'form';
322
335
  presentation: Presentation;
336
+ /** Heading above the form. Defaults to "New <entity>". */
337
+ title?: string;
338
+ /** Submit button text. Defaults to "Create". */
339
+ submitLabel?: string;
340
+ /** After a successful create: blank the form for another entry (default), or
341
+ * go to another screen. */
342
+ afterSave?: 'reset' | 'navigate';
343
+ /** Screen id to open when `afterSave` is 'navigate'. */
344
+ navigateTo?: string;
345
+ /**
346
+ * How wide the form draws. Inline forms fill their block by default, which is
347
+ * right for a narrow column and too wide to read across a full page - this
348
+ * caps it. Maps to `SvGridEditPanel`'s `formSize`.
349
+ */
350
+ width?: 'sm' | 'md' | 'lg';
323
351
  };
324
352
  /** A chart, optionally drilling into `drillScreen` (filtered by the clicked category). */
325
353
  export type ChartConfig = {
@@ -1068,8 +1096,9 @@ export type PaletteItem = {
1068
1096
  label: string;
1069
1097
  needs?: 'measure' | 'child';
1070
1098
  };
1071
- /** The designer's block palette, in menu order. (Editing is a Grid property, so
1072
- * there's no standalone form block.) */
1099
+ /** The designer's block palette, in menu order. A grid owns edit-in-popup and a
1100
+ * record panel edits the selected row, so the standalone Form block is the one
1101
+ * that creates: a form on its own page, with no grid behind it. */
1073
1102
  export declare const blockPalette: ReadonlyArray<PaletteItem>;
1074
1103
  export declare function entityOf(project: StudioProject, name: string | undefined): EntitySchema | undefined;
1075
1104
  /** Sensible grid columns for an entity: every non-grid-hidden field, shown. */
@@ -1151,6 +1180,98 @@ export declare function updateEntity(project: StudioProject, name: string, schem
1151
1180
  export declare function addEntity(project: StudioProject, schema: EntitySchema): StudioProject;
1152
1181
  /** Remove an entity and every screen bound to it. */
1153
1182
  export declare function removeEntity(project: StudioProject, name: string): StudioProject;
1183
+ /**
1184
+ * The sections as a builder should show them, plus the fields no section claims.
1185
+ *
1186
+ * Sections name their fields by string, so a rename, a delete, or hiding a field
1187
+ * from the form can leave a name behind. Those are dropped here rather than
1188
+ * drawn as ghosts, and a field in no section comes back in `unassigned` - which
1189
+ * is where the form itself puts it too, in a trailing untitled group.
1190
+ */
1191
+ export declare function formPlan(schema: EntitySchema, override?: ReadonlyArray<FormSection>): {
1192
+ sections: FormSection[];
1193
+ unassigned: string[];
1194
+ };
1195
+ /** Replace an entity's form layout. `undefined` drops it back to the default. */
1196
+ export declare function setEntityForm(project: StudioProject, entityName: string, form: FormLayout | undefined): StudioProject;
1197
+ /** Set the form's column count, or clear it back to the default. */
1198
+ export declare function setFormColumns(project: StudioProject, entityName: string, columns: 1 | 2 | 3 | undefined): StudioProject;
1199
+ /** Append a section. New sections start empty - fields move in afterwards. */
1200
+ export declare function addFormSection(project: StudioProject, entityName: string, title?: string): StudioProject;
1201
+ /** Patch one section. An out-of-range index is a no-op rather than an error. */
1202
+ export declare function updateFormSection(project: StudioProject, entityName: string, index: number, patch: Partial<FormSection>): StudioProject;
1203
+ /**
1204
+ * Remove a section. Its fields stay in the form - they fall back to the trailing
1205
+ * untitled group, so deleting a heading never deletes the questions under it.
1206
+ */
1207
+ export declare function removeFormSection(project: StudioProject, entityName: string, index: number): StudioProject;
1208
+ /** Reorder the sections themselves. */
1209
+ export declare function moveFormSection(project: StudioProject, entityName: string, from: number, to: number): StudioProject;
1210
+ /**
1211
+ * Move a field into a section at a position, or out of every section when
1212
+ * `toSection` is null. The field is pulled out of wherever it was first, so it
1213
+ * can never end up in two sections - which would render the control twice.
1214
+ */
1215
+ export declare function moveFormField(project: StudioProject, entityName: string, field: string, toSection: number | null, toIndex?: number): StudioProject;
1216
+ /**
1217
+ * Move several fields at once, as one contiguous block in the order given.
1218
+ *
1219
+ * Not a loop of {@link moveFormField}: each single move both shifts the target
1220
+ * indices and re-resolves the plan, so a loop lands the group scattered or
1221
+ * reversed. Strip every named field first, then splice the whole group at the
1222
+ * index computed against the stripped target.
1223
+ *
1224
+ * Unknown and hidden-from-form names are dropped, duplicates keep their first
1225
+ * position, and an input that resolves to nothing returns the project
1226
+ * unchanged (same reference), so an undo stack never records a no-op.
1227
+ */
1228
+ export declare function moveFormFields(project: StudioProject, entityName: string, fields: ReadonlyArray<string>, toSection: number | null, toIndex?: number): StudioProject;
1229
+ /**
1230
+ * Set (or clear) a field's value-driven conditions. Each is a `PredicateExpr`,
1231
+ * so it stays data all the way into the generated app - see `EntityField.when`.
1232
+ */
1233
+ export declare function setFieldConditions(project: StudioProject, entityName: string, field: string, when: EntityField['when'] | undefined): StudioProject;
1234
+ /**
1235
+ * Patch one field of one entity. The form builder edits an entity that is not
1236
+ * necessarily the selected screen's, so it needs the project-level door rather
1237
+ * than the schema-level `updateField`.
1238
+ *
1239
+ * Deliberately shallow: `type` changes belong to the schema designer, which also
1240
+ * has to clean up `options` / `relation` when the type moves.
1241
+ */
1242
+ export declare function updateEntityField(project: StudioProject, entityName: string, field: string, patch: Partial<EntityField>): StudioProject;
1243
+ /**
1244
+ * Patch a field's form presentation (`EntityField.input`). Merges rather than
1245
+ * replaces, and a key set to `undefined` or `''` is removed - so clearing a
1246
+ * placeholder leaves no empty string behind, and emptying the last key drops
1247
+ * `input` itself rather than persisting `input: {}`.
1248
+ */
1249
+ export declare function setFieldInput(project: StudioProject, entityName: string, field: string, patch: Partial<NonNullable<EntityField['input']>>): StudioProject;
1250
+ /**
1251
+ * Show or hide a field on one surface, keeping the other surface's answer.
1252
+ * `hidden: true` means both, so it is expanded before one half is changed -
1253
+ * otherwise hiding a field from the form would silently un-hide its column.
1254
+ */
1255
+ export declare function setFieldHidden(project: StudioProject, entityName: string, field: string, surface: 'grid' | 'form', hidden: boolean): StudioProject;
1256
+ /**
1257
+ * The form controls worth offering for a field type. A form builder that listed
1258
+ * every editor for every type would be a wall of mostly-wrong choices - a date
1259
+ * field has no business rendering as a colour picker. The first entry is what
1260
+ * the field renders as when `input.editorType` is unset.
1261
+ */
1262
+ export declare function formControlsFor(type: EntityFieldType): StudioEditorType[];
1263
+ /**
1264
+ * Which extra settings a control actually uses, so a builder can show those and
1265
+ * nothing else. Picking "mask" and then having nowhere to type the pattern - or
1266
+ * "slider" with no way to set its range - is a dead end, and listing all of them
1267
+ * against every control is a wall of mostly-irrelevant boxes.
1268
+ *
1269
+ * `range` is min/max, which live on the field itself rather than under `input`
1270
+ * (they validate as well as bound the control).
1271
+ */
1272
+ export type ControlSetting = 'range' | 'step' | 'precision' | 'affix' | 'mask';
1273
+ export declare function formControlSettings(control: StudioEditorType | undefined, type: EntityFieldType): ControlSetting[];
1274
+ export declare function suggestFormSections(schema: EntitySchema): FormSection[];
1154
1275
  export declare function addScreen(project: StudioProject, entity: string): StudioProject;
1155
1276
  export declare function removeScreen(project: StudioProject, screenId: string): StudioProject;
1156
1277
  export declare function updateScreen(project: StudioProject, screenId: string, patch: Partial<Pick<Screen, 'title' | 'route' | 'entity' | 'nav' | 'actions' | 'className' | 'renderMode'>>): StudioProject;
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Studio project model - the declarative spine the visual designer edits and the
3
+ * codegen emits. A project is a multi-entity app: `entities` (the data model) +
4
+ * `screens` (one per entity by default), where a screen is an ordered list of
5
+ * data-bound `blocks` (grid, form, chart, dashboard, KPI, master-detail, lookup).
6
+ *
7
+ * Pure + node-safe (lives in the `./studio` subtree - `.js` imports, no Svelte,
8
+ * no `sources/`), mirroring the immutable-ops style of `schema-designer.ts`:
9
+ * every mutation returns a NEW project so Svelte reactivity + undo stay simple.
10
+ * Block-config unions are defined here (not imported from `sources/`) to keep the
11
+ * module resolvable under node16.
12
+ */
13
+ import { schemaToFormFields } from '../schema.js';
1
14
  import { collectColumnRefs } from '../expressions/parse.js';
2
15
  import { uiComponentSpec } from './ui-components.js';
3
16
  /** Block kinds allowed inside a Tabs / Accordion container: the controller-free,
@@ -401,10 +414,12 @@ export function setJob(project, id, job) {
401
414
  const next = job ? [...rest, { ...job, id }] : rest;
402
415
  return { ...project, jobs: next.length ? next : undefined };
403
416
  }
404
- /** The designer's block palette, in menu order. (Editing is a Grid property, so
405
- * there's no standalone form block.) */
417
+ /** The designer's block palette, in menu order. A grid owns edit-in-popup and a
418
+ * record panel edits the selected row, so the standalone Form block is the one
419
+ * that creates: a form on its own page, with no grid behind it. */
406
420
  export const blockPalette = [
407
421
  { kind: 'grid', label: 'Grid' },
422
+ { kind: 'form', label: 'Form' },
408
423
  { kind: 'chart', label: 'Chart', needs: 'measure' },
409
424
  { kind: 'pivot', label: 'Pivot', needs: 'measure' },
410
425
  { kind: 'dashboard', label: 'Dashboard' },
@@ -463,7 +478,8 @@ export function defaultBlockConfig(kind, entity) {
463
478
  case 'grid':
464
479
  return { kind, columns: gridColumns(entity), pageSize: 10, selectable: true, sortable: true, filterable: false, editing: 'form', formPresentation: 'modal', density: 'normal', striped: false, cellSelection: false, rowSummaries: false, paginated: true, paginationPosition: 'bottom', pageSizeOptions: [10, 25, 50, 100] };
465
480
  case 'form':
466
- return { kind, presentation: 'modal' };
481
+ // Inline: a create form belongs on the page, not floating over it.
482
+ return { kind, presentation: 'inline', afterSave: 'reset' };
467
483
  case 'chart': {
468
484
  const measure = pickMeasure(entity);
469
485
  return { kind, dimension: pickDimension(entity), measure, reduce: measure ? 'sum' : 'count', type: 'bar' };
@@ -839,6 +855,365 @@ export function removeEntity(project, name) {
839
855
  screens: project.screens.filter((s) => s.entity !== name),
840
856
  };
841
857
  }
858
+ // ---- Form layout ----------------------------------------------------------
859
+ //
860
+ // The arrangement lives on the entity (`EntitySchema.form`) rather than on the
861
+ // block that happens to render it, so a form built once travels with the entity:
862
+ // it round-trips through `studio.config.json`, generates into the app, and draws
863
+ // the same in the edit panel and in a server-rendered form. These are the
864
+ // operations a builder UI drives; keeping them here - pure, on the project -
865
+ // means the designer and an MCP tool cannot disagree about what "move a field"
866
+ // means.
867
+ /**
868
+ * The fields a form actually renders, in schema order. Arranging anything else
869
+ * would be a lie: a field hidden from the form cannot be put in a section and
870
+ * then appear there.
871
+ */
872
+ function formFieldNames(schema) {
873
+ return schemaToFormFields(schema).map((f) => f.field);
874
+ }
875
+ /**
876
+ * The sections as a builder should show them, plus the fields no section claims.
877
+ *
878
+ * Sections name their fields by string, so a rename, a delete, or hiding a field
879
+ * from the form can leave a name behind. Those are dropped here rather than
880
+ * drawn as ghosts, and a field in no section comes back in `unassigned` - which
881
+ * is where the form itself puts it too, in a trailing untitled group.
882
+ */
883
+ export function formPlan(schema, override) {
884
+ const inForm = formFieldNames(schema);
885
+ const known = new Set(inForm);
886
+ const source = override ?? schema.form?.sections ?? [];
887
+ const seen = new Set();
888
+ const sections = source.map((section) => {
889
+ // A field named twice belongs to the first section that claimed it: two
890
+ // copies of one control in a form is never what anybody meant. Claiming as
891
+ // we go also catches a name repeated inside a single section.
892
+ const fields = section.fields.filter((f) => {
893
+ if (!known.has(f) || seen.has(f))
894
+ return false;
895
+ seen.add(f);
896
+ return true;
897
+ });
898
+ return { ...section, fields };
899
+ });
900
+ return { sections, unassigned: inForm.filter((f) => !seen.has(f)) };
901
+ }
902
+ /** Replace an entity's form layout. `undefined` drops it back to the default. */
903
+ export function setEntityForm(project, entityName, form) {
904
+ return mapEntity(project, entityName, (schema) => {
905
+ const next = { ...schema };
906
+ // An empty layout is deleted rather than stored, so a config file does not
907
+ // carry `form: {}` around and a reader can tell "no layout" from "one column".
908
+ if (form && (form.columns !== undefined || form.sections?.length))
909
+ next.form = form;
910
+ else
911
+ delete next.form;
912
+ return next;
913
+ });
914
+ }
915
+ /** Set the form's column count, or clear it back to the default. */
916
+ export function setFormColumns(project, entityName, columns) {
917
+ const schema = entityOf(project, entityName);
918
+ if (!schema)
919
+ return project;
920
+ return setEntityForm(project, entityName, { ...schema.form, columns });
921
+ }
922
+ /** Append a section. New sections start empty - fields move in afterwards. */
923
+ export function addFormSection(project, entityName, title = 'Section') {
924
+ const schema = entityOf(project, entityName);
925
+ if (!schema)
926
+ return project;
927
+ return setEntityForm(project, entityName, { ...schema.form, sections: [...(schema.form?.sections ?? []), { title, fields: [] }] });
928
+ }
929
+ /** Patch one section. An out-of-range index is a no-op rather than an error. */
930
+ export function updateFormSection(project, entityName, index, patch) {
931
+ const schema = entityOf(project, entityName);
932
+ const sections = schema?.form?.sections;
933
+ if (!sections?.[index])
934
+ return project;
935
+ const next = sections.map((s, i) => {
936
+ if (i !== index)
937
+ return s;
938
+ const merged = { ...s, ...patch };
939
+ // A cleared title / description is dropped rather than stored blank, so the
940
+ // form renders an untitled group instead of an empty heading.
941
+ if (!merged.title)
942
+ delete merged.title;
943
+ if (!merged.description)
944
+ delete merged.description;
945
+ if (!merged.visibleWhen)
946
+ delete merged.visibleWhen;
947
+ if (!merged.columns)
948
+ delete merged.columns;
949
+ if (!merged.collapsible) {
950
+ delete merged.collapsible;
951
+ delete merged.collapsed;
952
+ }
953
+ if (!merged.collapsed)
954
+ delete merged.collapsed;
955
+ return merged;
956
+ });
957
+ return setEntityForm(project, entityName, { ...schema.form, sections: next });
958
+ }
959
+ /**
960
+ * Remove a section. Its fields stay in the form - they fall back to the trailing
961
+ * untitled group, so deleting a heading never deletes the questions under it.
962
+ */
963
+ export function removeFormSection(project, entityName, index) {
964
+ const schema = entityOf(project, entityName);
965
+ const sections = schema?.form?.sections;
966
+ if (!sections?.[index])
967
+ return project;
968
+ return setEntityForm(project, entityName, { ...schema.form, sections: sections.filter((_, i) => i !== index) });
969
+ }
970
+ /** Reorder the sections themselves. */
971
+ export function moveFormSection(project, entityName, from, to) {
972
+ const schema = entityOf(project, entityName);
973
+ const sections = schema?.form?.sections;
974
+ if (!sections?.[from] || to < 0 || to >= sections.length || from === to)
975
+ return project;
976
+ const next = [...sections];
977
+ next.splice(to, 0, next.splice(from, 1)[0]);
978
+ return setEntityForm(project, entityName, { ...schema.form, sections: next });
979
+ }
980
+ /**
981
+ * Move a field into a section at a position, or out of every section when
982
+ * `toSection` is null. The field is pulled out of wherever it was first, so it
983
+ * can never end up in two sections - which would render the control twice.
984
+ */
985
+ export function moveFormField(project, entityName, field, toSection, toIndex = Number.MAX_SAFE_INTEGER) {
986
+ const schema = entityOf(project, entityName);
987
+ // Only a field the form renders can be placed - see `formPlan`.
988
+ if (!schema || !formFieldNames(schema).includes(field))
989
+ return project;
990
+ const sections = schema.form?.sections ?? [];
991
+ if (toSection !== null && !sections[toSection])
992
+ return project;
993
+ const stripped = sections.map((s) => ({ ...s, fields: s.fields.filter((f) => f !== field) }));
994
+ if (toSection === null)
995
+ return setEntityForm(project, entityName, { ...schema.form, sections: stripped });
996
+ const target = stripped[toSection];
997
+ const at = Math.max(0, Math.min(toIndex, target.fields.length));
998
+ const fields = [...target.fields];
999
+ fields.splice(at, 0, field);
1000
+ return setEntityForm(project, entityName, {
1001
+ ...schema.form,
1002
+ sections: stripped.map((s, i) => (i === toSection ? { ...s, fields } : s)),
1003
+ });
1004
+ }
1005
+ /**
1006
+ * Move several fields at once, as one contiguous block in the order given.
1007
+ *
1008
+ * Not a loop of {@link moveFormField}: each single move both shifts the target
1009
+ * indices and re-resolves the plan, so a loop lands the group scattered or
1010
+ * reversed. Strip every named field first, then splice the whole group at the
1011
+ * index computed against the stripped target.
1012
+ *
1013
+ * Unknown and hidden-from-form names are dropped, duplicates keep their first
1014
+ * position, and an input that resolves to nothing returns the project
1015
+ * unchanged (same reference), so an undo stack never records a no-op.
1016
+ */
1017
+ export function moveFormFields(project, entityName, fields, toSection, toIndex = Number.MAX_SAFE_INTEGER) {
1018
+ const schema = entityOf(project, entityName);
1019
+ if (!schema)
1020
+ return project;
1021
+ const movable = new Set(formFieldNames(schema));
1022
+ const group = [];
1023
+ for (const f of fields)
1024
+ if (movable.has(f) && !group.includes(f))
1025
+ group.push(f);
1026
+ if (!group.length)
1027
+ return project;
1028
+ const sections = schema.form?.sections ?? [];
1029
+ if (toSection !== null && !sections[toSection])
1030
+ return project;
1031
+ const moving = new Set(group);
1032
+ const stripped = sections.map((s) => ({ ...s, fields: s.fields.filter((f) => !moving.has(f)) }));
1033
+ if (toSection === null)
1034
+ return setEntityForm(project, entityName, { ...schema.form, sections: stripped });
1035
+ const target = stripped[toSection];
1036
+ const at = Math.max(0, Math.min(toIndex, target.fields.length));
1037
+ const next = [...target.fields];
1038
+ next.splice(at, 0, ...group);
1039
+ return setEntityForm(project, entityName, {
1040
+ ...schema.form,
1041
+ sections: stripped.map((s, i) => (i === toSection ? { ...s, fields: next } : s)),
1042
+ });
1043
+ }
1044
+ /**
1045
+ * Set (or clear) a field's value-driven conditions. Each is a `PredicateExpr`,
1046
+ * so it stays data all the way into the generated app - see `EntityField.when`.
1047
+ */
1048
+ export function setFieldConditions(project, entityName, field, when) {
1049
+ return mapEntity(project, entityName, (schema) => ({
1050
+ ...schema,
1051
+ fields: schema.fields.map((f) => {
1052
+ if (f.field !== field)
1053
+ return f;
1054
+ const next = { ...f };
1055
+ if (when?.visible || when?.disabled || when?.required) {
1056
+ next.when = {
1057
+ ...(when.visible && { visible: when.visible }),
1058
+ ...(when.disabled && { disabled: when.disabled }),
1059
+ ...(when.required && { required: when.required }),
1060
+ };
1061
+ }
1062
+ else {
1063
+ delete next.when;
1064
+ }
1065
+ return next;
1066
+ }),
1067
+ }));
1068
+ }
1069
+ /**
1070
+ * Patch one field of one entity. The form builder edits an entity that is not
1071
+ * necessarily the selected screen's, so it needs the project-level door rather
1072
+ * than the schema-level `updateField`.
1073
+ *
1074
+ * Deliberately shallow: `type` changes belong to the schema designer, which also
1075
+ * has to clean up `options` / `relation` when the type moves.
1076
+ */
1077
+ export function updateEntityField(project, entityName, field, patch) {
1078
+ return mapEntity(project, entityName, (schema) => ({
1079
+ ...schema,
1080
+ fields: schema.fields.map((f) => (f.field === field ? { ...f, ...patch } : f)),
1081
+ }));
1082
+ }
1083
+ /**
1084
+ * Patch a field's form presentation (`EntityField.input`). Merges rather than
1085
+ * replaces, and a key set to `undefined` or `''` is removed - so clearing a
1086
+ * placeholder leaves no empty string behind, and emptying the last key drops
1087
+ * `input` itself rather than persisting `input: {}`.
1088
+ */
1089
+ export function setFieldInput(project, entityName, field, patch) {
1090
+ return mapEntity(project, entityName, (schema) => ({
1091
+ ...schema,
1092
+ fields: schema.fields.map((f) => {
1093
+ if (f.field !== field)
1094
+ return f;
1095
+ const input = { ...f.input };
1096
+ for (const [key, value] of Object.entries(patch)) {
1097
+ if (value === undefined || value === '')
1098
+ delete input[key];
1099
+ else
1100
+ input[key] = value;
1101
+ }
1102
+ const next = { ...f };
1103
+ if (Object.keys(input).length)
1104
+ next.input = input;
1105
+ else
1106
+ delete next.input;
1107
+ return next;
1108
+ }),
1109
+ }));
1110
+ }
1111
+ /**
1112
+ * Show or hide a field on one surface, keeping the other surface's answer.
1113
+ * `hidden: true` means both, so it is expanded before one half is changed -
1114
+ * otherwise hiding a field from the form would silently un-hide its column.
1115
+ */
1116
+ export function setFieldHidden(project, entityName, field, surface, hidden) {
1117
+ return mapEntity(project, entityName, (schema) => ({
1118
+ ...schema,
1119
+ fields: schema.fields.map((f) => {
1120
+ if (f.field !== field)
1121
+ return f;
1122
+ const current = f.hidden === true ? { grid: true, form: true } : { ...(f.hidden || {}) };
1123
+ const merged = { ...current, [surface]: hidden };
1124
+ const next = { ...f };
1125
+ if (merged.grid && merged.form)
1126
+ next.hidden = true;
1127
+ else if (merged.grid || merged.form)
1128
+ next.hidden = { ...(merged.grid && { grid: true }), ...(merged.form && { form: true }) };
1129
+ else
1130
+ delete next.hidden;
1131
+ return next;
1132
+ }),
1133
+ }));
1134
+ }
1135
+ /**
1136
+ * The form controls worth offering for a field type. A form builder that listed
1137
+ * every editor for every type would be a wall of mostly-wrong choices - a date
1138
+ * field has no business rendering as a colour picker. The first entry is what
1139
+ * the field renders as when `input.editorType` is unset.
1140
+ */
1141
+ export function formControlsFor(type) {
1142
+ switch (type) {
1143
+ case 'number': return ['number', 'slider', 'rating', 'text'];
1144
+ case 'boolean': return ['checkbox'];
1145
+ case 'date':
1146
+ case 'dateString': return ['date', 'date-native'];
1147
+ case 'datetime': return ['datetime', 'datetime-native', 'time'];
1148
+ case 'enum': return ['select', 'rich-select', 'list', 'chips', 'autocomplete'];
1149
+ case 'relation': return ['select', 'autocomplete'];
1150
+ case 'json': return ['textarea', 'text'];
1151
+ default: return ['text', 'textarea', 'password', 'phone', 'country', 'mask', 'color', 'autocomplete', 'chips'];
1152
+ }
1153
+ }
1154
+ export function formControlSettings(control, type) {
1155
+ const kind = control ?? formControlsFor(type)[0];
1156
+ if (kind === 'mask')
1157
+ return ['mask'];
1158
+ if (kind === 'slider' || kind === 'rating')
1159
+ return ['range', 'step'];
1160
+ if (kind === 'number' || (type === 'number' && kind !== 'text'))
1161
+ return ['range', 'step', 'precision', 'affix'];
1162
+ return [];
1163
+ }
1164
+ /**
1165
+ * A first pass at sectioning an unarranged form, by what the field names say
1166
+ * they are. Studio's promise is a working app in a few clicks, and an entity of
1167
+ * fifteen fields in one flat list is not that.
1168
+ *
1169
+ * A guess, offered once: it returns sections for a builder to drop in and then
1170
+ * edit, and it never invents a group for a form small enough not to need one.
1171
+ */
1172
+ const SECTION_HINTS = [
1173
+ { title: 'Contact', match: /^(email|phone|mobile|fax|website|url|contact)/i },
1174
+ { title: 'Address', match: /(address|street|city|state|province|zip|postcode|postal|country|region)/i },
1175
+ { title: 'Money', match: /(amount|price|cost|total|revenue|mrr|arr|salary|budget|value|balance|fee|tax|discount)/i },
1176
+ { title: 'Dates', match: /(date|_at$|At$|deadline|due|expires|starts|ends)/i },
1177
+ { title: 'Notes', match: /(note|notes|comment|comments|description|summary|remark)/i },
1178
+ ];
1179
+ export function suggestFormSections(schema) {
1180
+ const names = formFieldNames(schema);
1181
+ // Below this a flat form reads fine, and a heading over two fields is noise.
1182
+ if (names.length < 6)
1183
+ return [];
1184
+ const buckets = new Map();
1185
+ const rest = [];
1186
+ for (const name of names) {
1187
+ const hint = SECTION_HINTS.find((h) => h.match.test(name));
1188
+ if (!hint) {
1189
+ rest.push(name);
1190
+ continue;
1191
+ }
1192
+ const bucket = buckets.get(hint.title);
1193
+ if (bucket)
1194
+ bucket.push(name);
1195
+ else
1196
+ buckets.set(hint.title, [name]);
1197
+ }
1198
+ // A bucket of one is not a group; give it back to Details rather than crown it.
1199
+ const sections = [];
1200
+ for (const [title, fields] of buckets) {
1201
+ if (fields.length > 1)
1202
+ sections.push({ title, fields });
1203
+ else
1204
+ rest.push(...fields);
1205
+ }
1206
+ if (!sections.length)
1207
+ return [];
1208
+ // Whatever matched nothing leads, under the entity's own name for a heading.
1209
+ return rest.length ? [{ title: 'Details', fields: rest }, ...sections] : sections;
1210
+ }
1211
+ /** Apply a change to one entity's schema, leaving the project alone if it is unknown. */
1212
+ function mapEntity(project, entityName, fn) {
1213
+ if (!entityOf(project, entityName))
1214
+ return project;
1215
+ return { ...project, entities: project.entities.map((e) => (e.name === entityName ? fn(e) : e)) };
1216
+ }
842
1217
  /** Append a screen, making its id AND route unique against the existing set. */
843
1218
  function appendScreen(project, base, keyBase) {
844
1219
  const ids = new Set(project.screens.map((s) => s.id));
@@ -1707,7 +2082,21 @@ export function validateProject(project) {
1707
2082
  for (const b of flattenBlocks(s.blocks)) {
1708
2083
  const at = (message, level = 'warning') => ({ level, message, screen: s.id, block: b.id });
1709
2084
  const c = b.config;
1710
- if (c.kind === 'master-detail') {
2085
+ if (c.kind === 'form') {
2086
+ // A form on a screen with no entity has nothing to create.
2087
+ if (!s.entity)
2088
+ issues.push(at('A Form block needs a screen bound to an entity.', 'error'));
2089
+ // "Go to a screen" that names none, or names a screen that has since been
2090
+ // removed, leaves the user on a form that appears to do nothing on save.
2091
+ if (c.afterSave === 'navigate') {
2092
+ if (!c.navigateTo)
2093
+ issues.push(at('The form is set to open a screen after saving, but no screen is chosen.'));
2094
+ else if (!project.screens.some((x) => x.id === c.navigateTo)) {
2095
+ issues.push(at(`The form opens a missing screen "${c.navigateTo}" after saving.`, 'error'));
2096
+ }
2097
+ }
2098
+ }
2099
+ else if (c.kind === 'master-detail') {
1711
2100
  if (!c.childEntity || !c.foreignKey)
1712
2101
  issues.push(at('Master/detail needs a child entity + foreign key.'));
1713
2102
  else if (!entityOf(project, c.childEntity))