@unboundcx/sdk 4.1.3 → 4.4.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/package.json +16 -3
- package/schemas/layouts/SCHEMA.md +5100 -0
- package/schemas/layouts/action.js +17 -0
- package/schemas/layouts/compact.js +11 -0
- package/schemas/layouts/field.js +103 -0
- package/schemas/layouts/format.js +23 -0
- package/schemas/layouts/index.js +16 -0
- package/schemas/layouts/join.js +33 -0
- package/schemas/layouts/kanban.js +94 -0
- package/schemas/layouts/layoutDoc.js +35 -0
- package/schemas/layouts/migrations/deriveActions.js +70 -0
- package/schemas/layouts/migrations/deriveCompactFields.js +19 -0
- package/schemas/layouts/migrations/index.js +43 -0
- package/schemas/layouts/migrations/migrateV1toV2.js +162 -0
- package/schemas/layouts/migrations/normalizeJoin.js +44 -0
- package/schemas/layouts/migrations/normalizeKanban.js +54 -0
- package/schemas/layouts/migrations/promoteRelatedLists.js +67 -0
- package/schemas/layouts/primitives.js +38 -0
- package/schemas/layouts/relatedList.js +48 -0
- package/schemas/layouts/section.js +100 -0
- package/schemas/layouts/selectDynamic.js +40 -0
- package/schemas/layouts/validate.js +18 -0
- package/services/layouts.js +142 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ConditionSpec, MustacheTemplate } from './primitives.js';
|
|
3
|
+
|
|
4
|
+
// New primitive, per V2-PLAN's exact definition.
|
|
5
|
+
export const ActionSpec = z.object({
|
|
6
|
+
id: z.string().min(1),
|
|
7
|
+
type: z.enum(['create', 'edit', 'delete', 'custom']),
|
|
8
|
+
label: z.string().optional(),
|
|
9
|
+
target: z.string().optional(), // object name (create/edit/delete) or custom-action registry key
|
|
10
|
+
mode: z.enum(['modal', 'tab', 'inline']).default('modal'),
|
|
11
|
+
layout: z.string().optional(), // compactLayoutId, used when mode:'modal' for create/edit
|
|
12
|
+
placement: z.enum(['header', 'section', 'row', 'card']),
|
|
13
|
+
visibility: ConditionSpec.optional(),
|
|
14
|
+
prefill: z.record(MustacheTemplate).optional(), // e.g. {relatedId: '{{id}}'} — create-with-prefilled-relationship
|
|
15
|
+
}).refine((v) => v.type !== 'custom' || !!v.target, {
|
|
16
|
+
message: 'custom action requires target', path: ['target'],
|
|
17
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { FieldSpec } from './field.js';
|
|
3
|
+
|
|
4
|
+
// Per V2-PLAN §1.4: "just { fields: FieldSpec[] } — capped list, no sections."
|
|
5
|
+
// Reused everywhere a short field summary is needed: kanban cardFields,
|
|
6
|
+
// hover cards, modal create/edit forms.
|
|
7
|
+
export const CompactLayoutDoc = z.object({
|
|
8
|
+
schemaVersion: z.literal(2).default(2),
|
|
9
|
+
objectName: z.string().min(1),
|
|
10
|
+
fields: z.array(FieldSpec).min(1).max(12),
|
|
11
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ConditionSpec } from './primitives.js';
|
|
3
|
+
import { MustacheTemplate } from './primitives.js';
|
|
4
|
+
import { FormatType } from './format.js';
|
|
5
|
+
import { JoinSpec } from './join.js';
|
|
6
|
+
import { SelectDynamicSpec } from './selectDynamic.js';
|
|
7
|
+
|
|
8
|
+
const SecurityConfigSpec = z.object({
|
|
9
|
+
editWithoutValue: z.boolean().default(false),
|
|
10
|
+
readOnly: z.boolean().default(false),
|
|
11
|
+
showChars: z.number().int().min(0).optional(),
|
|
12
|
+
hideLength: z.boolean().default(false),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const LinkSpec = z.object({
|
|
16
|
+
path: MustacheTemplate,
|
|
17
|
+
tabName: z.string().optional(),
|
|
18
|
+
tabIcon: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// DEVIATION from spec's literal DISPLAY_FIELD_TYPES list (see SPEC-PHASE-1.md
|
|
22
|
+
// A6): added 'select'/'selectDynamic'. Verified against real stored data
|
|
23
|
+
// (app1-api/src/services/layouts/examples.json) — display.fieldType commonly
|
|
24
|
+
// mirrors edit.fieldType for select-backed fields (5 occurrences of
|
|
25
|
+
// display.fieldType:'selectDynamic' in the two-doc fixture); the literal spec
|
|
26
|
+
// list omitted them, which would warn on effectively every select field in
|
|
27
|
+
// production data — a false-positive, not the genuine defect this schema is
|
|
28
|
+
// meant to surface. Flagged for reviewer per task instructions.
|
|
29
|
+
const DISPLAY_FIELD_TYPES = [
|
|
30
|
+
'input', 'textArea', 'code', 'readOnly', 'spacer', 'composite',
|
|
31
|
+
'select', 'selectDynamic',
|
|
32
|
+
'securityBlurHover', 'securityBlurAlways', 'securityLastX', 'securityFirstX', 'securityFirstLastX',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const FieldDisplaySpec = z.object({
|
|
36
|
+
label: z.string().default(''),
|
|
37
|
+
value: z.string().min(1), // dot-path ok, e.g. "company.name"
|
|
38
|
+
fieldType: z.enum(DISPLAY_FIELD_TYPES),
|
|
39
|
+
formatType: FormatType.optional(),
|
|
40
|
+
format: z.string().optional(),
|
|
41
|
+
relatedKey: z.string().optional(),
|
|
42
|
+
relatedObject: z.string().optional(),
|
|
43
|
+
code: z.object({
|
|
44
|
+
language: z.string().default('plaintext'),
|
|
45
|
+
width: z.string().optional(),
|
|
46
|
+
height: z.string().optional(),
|
|
47
|
+
}).optional(),
|
|
48
|
+
securityConfig: SecurityConfigSpec.optional(),
|
|
49
|
+
link: LinkSpec.optional(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const SelectStaticSpec = z.object({
|
|
53
|
+
options: z.array(z.object({ name: z.string(), value: z.string() })).optional(),
|
|
54
|
+
optionSource: z.string().optional(), // e.g. 'local.timezones', resolved by client static-option registry
|
|
55
|
+
}).refine((v) => (v.options && v.options.length > 0) || v.optionSource, {
|
|
56
|
+
message: 'select requires options[] or optionSource',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const EditEntrySpec = z.object({
|
|
60
|
+
field: z.string().min(1),
|
|
61
|
+
label: z.string().optional(),
|
|
62
|
+
required: z.boolean().default(false),
|
|
63
|
+
editableOnCreateOnly: z.boolean().default(false),
|
|
64
|
+
hiddenOnCreate: z.boolean().default(false),
|
|
65
|
+
fieldType: z.enum(['input', 'select', 'selectDynamic', 'textArea', 'code', 'spacer']),
|
|
66
|
+
fieldTypeSub: z.enum(['text', 'email', 'tel', 'url', 'number', 'date', 'datetime-local', 'time']).optional(),
|
|
67
|
+
placeholder: z.string().optional(),
|
|
68
|
+
textArea: z.object({ rows: z.number().int().positive().default(4) }).optional(),
|
|
69
|
+
code: z.object({ language: z.string().default('plaintext') }).optional(),
|
|
70
|
+
select: z.union([SelectStaticSpec, SelectDynamicSpec]).optional(),
|
|
71
|
+
}).superRefine((v, ctx) => {
|
|
72
|
+
if (v.fieldType === 'select' && !v.select) {
|
|
73
|
+
ctx.addIssue({ code: 'custom', message: 'select fieldType requires select config', path: ['select'] });
|
|
74
|
+
}
|
|
75
|
+
if (v.fieldType === 'selectDynamic' && !(v.select && 'queryConfig' in v.select)) {
|
|
76
|
+
ctx.addIssue({ code: 'custom', message: 'selectDynamic fieldType requires SelectDynamicSpec (queryConfig)', path: ['select'] });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// `component` stays z.string() (open), not a closed enum — see ASSUMPTION in
|
|
81
|
+
// §7 re: Open Decision #4 (component roadmap not yet settled).
|
|
82
|
+
export const FieldSpec = z.object({
|
|
83
|
+
id: z.string().min(1),
|
|
84
|
+
type: z.enum(['content', 'component', 'empty']),
|
|
85
|
+
object: z.string().optional(),
|
|
86
|
+
hideOnCreate: z.boolean().default(false),
|
|
87
|
+
conditions: ConditionSpec.optional(),
|
|
88
|
+
join: JoinSpec.optional(), // only meaningful when type:'component'
|
|
89
|
+
component: z.string().optional(),
|
|
90
|
+
componentConfig: z.record(z.unknown()).optional(), // NOT type-checked here — see Risks §7
|
|
91
|
+
display: FieldDisplaySpec.optional(),
|
|
92
|
+
edit: z.union([EditEntrySpec, z.array(EditEntrySpec)]).optional(), // array = composite (defect #7 contract)
|
|
93
|
+
}).superRefine((v, ctx) => {
|
|
94
|
+
if (v.type === 'content' && !v.object) {
|
|
95
|
+
ctx.addIssue({ code: 'custom', message: 'content field requires object', path: ['object'] });
|
|
96
|
+
}
|
|
97
|
+
if (v.type === 'component' && !v.component) {
|
|
98
|
+
ctx.addIssue({ code: 'custom', message: 'component field requires component', path: ['component'] });
|
|
99
|
+
}
|
|
100
|
+
if (v.display?.fieldType === 'composite' && !Array.isArray(v.edit)) {
|
|
101
|
+
ctx.addIssue({ code: 'custom', message: 'composite fieldType requires edit as an array', path: ['edit'] });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Canonical set of display-formatting behaviors, unified across the three
|
|
4
|
+
// duplicated call sites (Column.svelte, ModernField.svelte,
|
|
5
|
+
// SectionTableRowColumn.svelte) plus formatDisplayValue.js's switch — see
|
|
6
|
+
// maps.md §1.8. 'user' renders a linked-user chip (relatedKey/relatedObject
|
|
7
|
+
// pair on the owning FieldSpec/TableFieldSpec resolves who to show).
|
|
8
|
+
export const FormatType = z.enum([
|
|
9
|
+
'timestamp', 'phone', 'currency', 'number', 'percentage', 'boolean', 'user',
|
|
10
|
+
'securityBlurHover', 'securityBlurAlways', 'securityLastX', 'securityFirstX', 'securityFirstLastX',
|
|
11
|
+
'none',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
// `format` is the free-form, formatType-specific option string (e.g. a
|
|
15
|
+
// date-fns pattern for 'timestamp', a comma/rounded mode for 'number') —
|
|
16
|
+
// deliberately left as z.string() rather than a per-type union: the shapes
|
|
17
|
+
// are too varied (date pattern vs. numeric mode vs. a related-object field
|
|
18
|
+
// name for 'user') to usefully close without duplicating
|
|
19
|
+
// formatDisplayValue.js's switch here. Resolution stays a client concern.
|
|
20
|
+
export const FormatSpec = z.object({
|
|
21
|
+
formatType: FormatType,
|
|
22
|
+
format: z.string().optional(),
|
|
23
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './primitives.js';
|
|
2
|
+
export * from './format.js';
|
|
3
|
+
export * from './join.js';
|
|
4
|
+
export * from './selectDynamic.js';
|
|
5
|
+
export * from './field.js';
|
|
6
|
+
export * from './kanban.js';
|
|
7
|
+
export * from './relatedList.js';
|
|
8
|
+
export * from './action.js';
|
|
9
|
+
export * from './section.js';
|
|
10
|
+
export * from './compact.js';
|
|
11
|
+
export * from './layoutDoc.js';
|
|
12
|
+
export { validateLayoutDoc } from './validate.js';
|
|
13
|
+
export {
|
|
14
|
+
migrateLayoutSchema, migrateToLatest, MIGRATIONS, CURRENT_SCHEMA_VERSION,
|
|
15
|
+
} from './migrations/index.js';
|
|
16
|
+
export { migrateV1toV2 } from './migrations/migrateV1toV2.js';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Today's shape is {column, value} reused by three consumers with different
|
|
4
|
+
// meanings (maps.md §2.6, confirmed): table/component joins use `column` =
|
|
5
|
+
// child FK, `value` = Mustache template against the *parent* record (e.g.
|
|
6
|
+
// "{{id}}"); kanban child-records joins use {childField, parentField} where
|
|
7
|
+
// `parentField` names the field on the parent record directly (no template
|
|
8
|
+
// wrapper). Canonical shape unifies on the kanban naming (it's already
|
|
9
|
+
// un-templated and clearer) — table/component's `value:"{{x}}"` form is
|
|
10
|
+
// normalized to `parentField: "x"` by normalizeJoinSpec below.
|
|
11
|
+
export const JoinSpec = z.object({
|
|
12
|
+
childField: z.string().min(1)
|
|
13
|
+
.describe('FK column on the child/related object that stores the parent id (legacy: table/component join.column, kanban join.childField)'),
|
|
14
|
+
parentField: z.string().min(1).default('id')
|
|
15
|
+
.describe('Field read off the parent record to match (legacy: kanban join.parentField; table/component join.value as "{{parentField}}")'),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const MUSTACHE_RE = /^\{\{\s*([\w.]+)\s*\}\}$/;
|
|
19
|
+
|
|
20
|
+
// legacyKind: 'table' | 'component' | 'kanbanChildRecords' — all three raw
|
|
21
|
+
// shapes observed in maps.md §1.6/§1.7/§1.10 map onto one JoinSpec.
|
|
22
|
+
export function normalizeJoinSpec(raw, legacyKind) {
|
|
23
|
+
if (!raw) return undefined;
|
|
24
|
+
if (legacyKind === 'kanbanChildRecords') {
|
|
25
|
+
return { childField: raw.childField, parentField: raw.parentField || 'id' };
|
|
26
|
+
}
|
|
27
|
+
// table / component: {column, value: "{{field}}"}
|
|
28
|
+
const match = typeof raw.value === 'string' ? raw.value.match(MUSTACHE_RE) : null;
|
|
29
|
+
return {
|
|
30
|
+
childField: raw.column,
|
|
31
|
+
parentField: match ? match[1] : (raw.value || 'id'),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { JoinSpec } from './join.js';
|
|
3
|
+
import { FormatType } from './format.js';
|
|
4
|
+
|
|
5
|
+
// Reconciles the 3-modes-described-3-different-ways problem (maps.md §1.7)
|
|
6
|
+
// into one discriminated union, and replaces ad hoc cardFields[] with
|
|
7
|
+
// compactLayoutId (inline cardFields[] still tolerated, per V2-PLAN §1.4:
|
|
8
|
+
// "with inline override still allowed").
|
|
9
|
+
const KanbanCardFieldRef = z.object({
|
|
10
|
+
field: z.string().min(1),
|
|
11
|
+
display: z.string().optional(),
|
|
12
|
+
formatType: FormatType.optional(),
|
|
13
|
+
format: z.string().optional(),
|
|
14
|
+
type: z.enum(['link']).optional(),
|
|
15
|
+
linkObject: z.string().optional(),
|
|
16
|
+
linkField: z.string().optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const KanbanCardSourceSpec = z.object({
|
|
20
|
+
compactLayoutId: z.string().optional(), // canonical, v2-preferred
|
|
21
|
+
cardFields: z.array(KanbanCardFieldRef).optional(), // legacy inline fallback, tolerated indefinitely
|
|
22
|
+
}).refine((v) => v.compactLayoutId || (v.cardFields && v.cardFields.length > 0), {
|
|
23
|
+
message: 'kanban requires compactLayoutId or a non-empty cardFields[]',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const KanbanSummarySpec = z.object({
|
|
27
|
+
type: z.enum(['count', 'sum', 'avg', 'min', 'max']),
|
|
28
|
+
field: z.string().optional(),
|
|
29
|
+
enabled: z.boolean().default(true),
|
|
30
|
+
formatType: z.enum(['currency', 'number']).optional(),
|
|
31
|
+
format: z.string().optional(),
|
|
32
|
+
}).refine((v) => v.type === 'count' || !!v.field, {
|
|
33
|
+
message: 'field is required unless type is "count"', path: ['field'],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const KanbanActionsSpec = z.object({
|
|
37
|
+
create: z.boolean().default(false),
|
|
38
|
+
edit: z.boolean().default(true),
|
|
39
|
+
delete: z.boolean().default(false),
|
|
40
|
+
cardClick: z.enum(['tab', 'modal', 'none']).default('modal'),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const KanbanShared = z.object({
|
|
44
|
+
summaries: z.array(KanbanSummarySpec).default([]),
|
|
45
|
+
actions: KanbanActionsSpec.default({}),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const KanbanSimple = KanbanCardSourceSpec.and(KanbanShared).and(z.object({
|
|
49
|
+
mode: z.literal('simple'),
|
|
50
|
+
columnField: z.string().min(1),
|
|
51
|
+
columnSort: z.enum(['asc', 'desc']).default('asc'),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const KanbanRelated = KanbanCardSourceSpec.and(KanbanShared).and(z.object({
|
|
55
|
+
mode: z.literal('related'),
|
|
56
|
+
configObject: z.string().min(1),
|
|
57
|
+
configObjectField: z.string().min(1),
|
|
58
|
+
stagesObject: z.string().min(1),
|
|
59
|
+
stagesColumnField: z.string().min(1),
|
|
60
|
+
stagesOrderField: z.string().optional(),
|
|
61
|
+
stagesSortDirection: z.enum(['asc', 'desc']).default('asc'),
|
|
62
|
+
relationship: z.object({
|
|
63
|
+
configToStages: z.object({ field: z.string().min(1), relatedField: z.string().min(1) }),
|
|
64
|
+
mainToConfig: z.object({ field: z.string().min(1), relatedField: z.string().min(1) }),
|
|
65
|
+
}),
|
|
66
|
+
stageMapping: z.object({ mainField: z.string().min(1), stageField: z.string().min(1) }),
|
|
67
|
+
autoSelect: z.object({
|
|
68
|
+
enabled: z.boolean().default(false),
|
|
69
|
+
field: z.string().optional(),
|
|
70
|
+
value: z.string().optional(),
|
|
71
|
+
}).default({ enabled: false }),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
const KanbanChildRecords = KanbanCardSourceSpec.and(KanbanShared).and(z.object({
|
|
75
|
+
mode: z.literal('child-records'),
|
|
76
|
+
childObject: z.string().min(1),
|
|
77
|
+
join: JoinSpec,
|
|
78
|
+
stageMapping: z.object({
|
|
79
|
+
field: z.string().min(1),
|
|
80
|
+
lookupObject: z.string().optional(),
|
|
81
|
+
lookupDisplayField: z.string().optional(),
|
|
82
|
+
lookupOrderField: z.string().optional(),
|
|
83
|
+
filterByParent: z.boolean().default(false),
|
|
84
|
+
stageParentField: z.string().optional(),
|
|
85
|
+
parentRecordField: z.string().optional(),
|
|
86
|
+
}),
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
// z.union (not discriminatedUnion) because each branch is itself a .and()
|
|
90
|
+
// intersection — zod's discriminatedUnion requires each member to be a bare
|
|
91
|
+
// ZodObject with the literal at the top level, which intersections aren't.
|
|
92
|
+
// Runtime cost is negligible (3 candidates, small objects); revisit if this
|
|
93
|
+
// list grows.
|
|
94
|
+
export const KanbanConfigSpec = z.union([KanbanSimple, KanbanRelated, KanbanChildRecords]);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { MustacheTemplate } from './primitives.js';
|
|
3
|
+
import { SectionSpec } from './section.js';
|
|
4
|
+
import { ActionSpec } from './action.js';
|
|
5
|
+
|
|
6
|
+
const ToggleSpec = z.object({
|
|
7
|
+
enabled: z.boolean().default(false),
|
|
8
|
+
hideOnCreate: z.boolean().default(false),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const LayoutDoc = z.object({
|
|
12
|
+
schemaVersion: z.literal(2).default(2),
|
|
13
|
+
objectName: z.string().min(1), // canonical key — legacy `object` normalized away by migrateV1toV2
|
|
14
|
+
type: z.enum(['list', 'detail']), // 'compact' docs validate against CompactLayoutDoc instead
|
|
15
|
+
tabName: MustacheTemplate.default('{{objectName}}'),
|
|
16
|
+
tabIcon: z.string().default('fa-database'),
|
|
17
|
+
sections: z.array(SectionSpec).default([]),
|
|
18
|
+
feeds: ToggleSpec.optional(),
|
|
19
|
+
notes: ToggleSpec.optional(),
|
|
20
|
+
aiInsights: ToggleSpec.optional(),
|
|
21
|
+
aiGoals: ToggleSpec.optional(),
|
|
22
|
+
googleDrive: z.object({
|
|
23
|
+
enabled: z.boolean().default(false),
|
|
24
|
+
hideOnCreate: z.boolean().default(false),
|
|
25
|
+
sharedDriveId: z.string().optional(),
|
|
26
|
+
folderPath: z.string().optional(),
|
|
27
|
+
}).optional(),
|
|
28
|
+
filterPanel: z.object({
|
|
29
|
+
enabled: z.boolean().default(true),
|
|
30
|
+
defaultCollapsed: z.boolean().default(false),
|
|
31
|
+
}).optional(),
|
|
32
|
+
// v2 canonical: array of ActionSpec, replaces v1's bespoke {create?, edit?}
|
|
33
|
+
// object — see migrations/deriveActions.js for the additive v1→v2 mapping.
|
|
34
|
+
actions: z.array(ActionSpec).default([]),
|
|
35
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Pure fn, no zod import. Maps legacy `layout.actions{create,edit}` (a
|
|
2
|
+
// bespoke boolean-flag object — real stored docs mostly have this as `{}`)
|
|
3
|
+
// plus per-table action booleans (TableSpec.actions.{create,edit,delete})
|
|
4
|
+
// into the v2-canonical ActionSpec[] shape (schemas/layouts/action.js).
|
|
5
|
+
// Additive only: if `doc.actions` is already an array (already v2-shaped),
|
|
6
|
+
// it's passed through unchanged rather than re-derived.
|
|
7
|
+
//
|
|
8
|
+
// `skipTableIds` (optional Set, Phase 5 addition): table ids already
|
|
9
|
+
// promoted to RelatedListSpec by promoteRelatedLists.js. A promoted table's
|
|
10
|
+
// create/delete already surface via the promoted section-placed ActionSpec
|
|
11
|
+
// (create) and RelatedListSpec.rowActions.delete (delete) — deriving a
|
|
12
|
+
// second, row-placed ActionSpec here for the same table would double the
|
|
13
|
+
// affordance. See migrateV1toV2.js for how the two derivations are composed.
|
|
14
|
+
export function deriveActions(doc, { skipTableIds } = {}) {
|
|
15
|
+
if (Array.isArray(doc.actions)) {
|
|
16
|
+
return doc.actions;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const legacy = doc.actions || {};
|
|
20
|
+
const objectName = doc.objectName || doc.object;
|
|
21
|
+
const skip = skipTableIds || new Set();
|
|
22
|
+
const derived = [];
|
|
23
|
+
|
|
24
|
+
if (legacy.create) {
|
|
25
|
+
derived.push({
|
|
26
|
+
id: 'header-create',
|
|
27
|
+
type: 'create',
|
|
28
|
+
target: objectName,
|
|
29
|
+
mode: 'modal',
|
|
30
|
+
placement: 'header',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (legacy.edit) {
|
|
34
|
+
derived.push({
|
|
35
|
+
id: 'header-edit',
|
|
36
|
+
type: 'edit',
|
|
37
|
+
target: objectName,
|
|
38
|
+
mode: 'modal',
|
|
39
|
+
placement: 'header',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Per-table create/delete booleans surface as row-placed ActionSpecs so
|
|
44
|
+
// Phase 4's action registry has one uniform ActionSpec[] to read,
|
|
45
|
+
// regardless of where the boolean originally lived. (Per-table `edit` is
|
|
46
|
+
// deliberately not derived: it's the default row-click behavior already,
|
|
47
|
+
// not a distinct affordance — see TableSpec.actions.edit default:true.)
|
|
48
|
+
for (const section of doc.sections || []) {
|
|
49
|
+
const tables = section.tables || (section.object ? [section] : []);
|
|
50
|
+
for (const table of tables) {
|
|
51
|
+
if (!table.actions) continue;
|
|
52
|
+
const tableId = table.id || section.id;
|
|
53
|
+
if (skip.has(tableId)) continue;
|
|
54
|
+
if (table.actions.create) {
|
|
55
|
+
derived.push({
|
|
56
|
+
id: `${tableId}-create`, type: 'create', target: table.object,
|
|
57
|
+
mode: 'modal', placement: 'row',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (table.actions.delete) {
|
|
61
|
+
derived.push({
|
|
62
|
+
id: `${tableId}-delete`, type: 'delete', target: table.object,
|
|
63
|
+
mode: 'modal', placement: 'row',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return derived;
|
|
70
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Pure fn, no zod import. Flattens a detail-shaped `sections[]` doc into
|
|
2
|
+
// CompactLayoutDoc.fields[] when `type==='compact'` — only invoked by
|
|
3
|
+
// migrateV1toV2 when a compact-typed legacy doc has `sections` but no
|
|
4
|
+
// `fields` yet (see migrateV1toV2.js). CompactLayoutDoc caps at 12 fields
|
|
5
|
+
// (schemas/layouts/compact.js), so this stops collecting once it hits that.
|
|
6
|
+
export function deriveCompactFields(doc) {
|
|
7
|
+
const fields = [];
|
|
8
|
+
|
|
9
|
+
for (const section of doc.sections || []) {
|
|
10
|
+
for (const row of section.rows || []) {
|
|
11
|
+
for (const column of row.columns || []) {
|
|
12
|
+
fields.push(column);
|
|
13
|
+
if (fields.length >= 12) return fields;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return fields;
|
|
19
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { migrateV1toV2 } from './migrateV1toV2.js';
|
|
2
|
+
|
|
3
|
+
export const CURRENT_SCHEMA_VERSION = 2;
|
|
4
|
+
|
|
5
|
+
// v0 -> v1: no-op placeholder. No genuinely un-versioned document has ever
|
|
6
|
+
// existed in production (schemaVersion was introduced alongside v2 itself),
|
|
7
|
+
// but the step is defined so migrateToLatest's version-walking loop has no
|
|
8
|
+
// gap for the `doc.schemaVersion` absent/0 default case — see
|
|
9
|
+
// SPEC-PHASE-5.md §2.1's registry sample.
|
|
10
|
+
function v0ToV1(json) {
|
|
11
|
+
return { json, schemaVersion: 1, changes: [] };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const MIGRATIONS = { 1: v0ToV1, 2: migrateV1toV2 };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs every migration step from `fromVersion` (exclusive) up to
|
|
18
|
+
* CURRENT_SCHEMA_VERSION (inclusive), threading `changes[]` through.
|
|
19
|
+
* @param {object} json
|
|
20
|
+
* @param {number} fromVersion
|
|
21
|
+
* @param {{ objectName?: string, layoutKind?: 'list'|'detail'|'compact' }} [ctx]
|
|
22
|
+
* @returns {{ json: object, schemaVersion: number, changes: string[] }}
|
|
23
|
+
*/
|
|
24
|
+
export function migrateToLatest(json, fromVersion, ctx) {
|
|
25
|
+
let out = { json, schemaVersion: fromVersion || 0, changes: [] };
|
|
26
|
+
for (let v = (fromVersion || 0) + 1; v <= CURRENT_SCHEMA_VERSION; v++) {
|
|
27
|
+
const step = MIGRATIONS[v];
|
|
28
|
+
if (!step) continue;
|
|
29
|
+
const result = step(out.json, ctx);
|
|
30
|
+
out = { json: result.json, schemaVersion: v, changes: [...out.changes, ...result.changes] };
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Convenience wrapper: migrate a stored doc, reading its own schemaVersion
|
|
36
|
+
// (public API — announced in CHANGELOG.md 4.2.0 as
|
|
37
|
+
// `sdk.layouts.schema.migrateLayoutSchema(doc)`). Docs that predate the
|
|
38
|
+
// schemaVersion column entirely default to 0 (equivalent in practice to 1 —
|
|
39
|
+
// the v0->v1 step is a no-op — since either way only migrateV1toV2 does
|
|
40
|
+
// real work).
|
|
41
|
+
export function migrateLayoutSchema(doc, ctx = {}) {
|
|
42
|
+
return migrateToLatest(doc, doc?.schemaVersion ?? 0, ctx);
|
|
43
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { normalizeJoin } from './normalizeJoin.js';
|
|
2
|
+
import { normalizeKanban } from './normalizeKanban.js';
|
|
3
|
+
import { deriveActions } from './deriveActions.js';
|
|
4
|
+
import { deriveCompactFields } from './deriveCompactFields.js';
|
|
5
|
+
import { promoteRelatedLists } from './promoteRelatedLists.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} doc
|
|
9
|
+
* @param {string[]} changes
|
|
10
|
+
* @returns {{ rest: object, objectName: string }}
|
|
11
|
+
*/
|
|
12
|
+
// SPEC-PHASE-5.md §2.1 step 1. `object`/`objectName` are written together,
|
|
13
|
+
// same value, by loadLayoutFromJson (client, builder) — canonicalize to
|
|
14
|
+
// `objectName`, drop `object`. On drift (both present, disagree), keep
|
|
15
|
+
// objectName's value per `edit/[id]/+page.svelte`'s own source-of-truth
|
|
16
|
+
// convention, and record the drift for backfill-sweep review rather than
|
|
17
|
+
// silently picking one.
|
|
18
|
+
function canonicalizeObjectName(doc, changes) {
|
|
19
|
+
const { object, objectName, ...rest } = doc;
|
|
20
|
+
if (object && objectName && object !== objectName) {
|
|
21
|
+
changes.push(`objectName: drift detected (object="${object}" vs objectName="${objectName}") — kept objectName`);
|
|
22
|
+
} else if (object && !objectName) {
|
|
23
|
+
changes.push('objectName: canonicalized from legacy "object" key');
|
|
24
|
+
}
|
|
25
|
+
return { rest, objectName: objectName || object };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// TableSpec.join (schemas/layouts/section.js) is canonical-JoinSpec-only —
|
|
29
|
+
// unlike the raw document's other legacy keys, there's no dual
|
|
30
|
+
// legacy-shape + relationship pair for a table's join, so a successful
|
|
31
|
+
// normalization REPLACES `join` in place. A skipped (carve-out) join is
|
|
32
|
+
// dropped entirely rather than left as a legacy shape that would fail
|
|
33
|
+
// JoinSpec validation — TableSpec.join is `.optional()` precisely for this
|
|
34
|
+
// ("absent = top-level list table, no parent record").
|
|
35
|
+
function normalizeTableJoin(table, changes, sectionId) {
|
|
36
|
+
if (!table.join) return table;
|
|
37
|
+
const { relationship, skipped } = normalizeJoin(table.join, 'table');
|
|
38
|
+
if (skipped) {
|
|
39
|
+
changes.push(`section "${sectionId}" table "${table.id}": join left as legacy shape (non-trivial template or missing column)`);
|
|
40
|
+
const { join, ...rest } = table;
|
|
41
|
+
return rest;
|
|
42
|
+
}
|
|
43
|
+
changes.push(`section "${sectionId}" table "${table.id}": join normalized to relationship`);
|
|
44
|
+
return { ...table, join: relationship };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// SPEC-PHASE-5.md §2.1 step 2. Wraps a legacy flat single-table section
|
|
48
|
+
// (section.object/fields/join/actions/orderBy/additionalWhere, no
|
|
49
|
+
// tables[]) into `section.tables = [{...}]`, field-for-field — does NOT
|
|
50
|
+
// reuse sectionOperations.js:initializeTablesArray (client-side), which
|
|
51
|
+
// builds a *blank* table config and would discard the existing values (see
|
|
52
|
+
// spec §2.1 step 2 and the sibling app1-client work item fixing that
|
|
53
|
+
// function for the same reason). `header` is carried into the new table
|
|
54
|
+
// (multi-table sections can give each table its own sub-header) but also
|
|
55
|
+
// LEFT at the section level, since BaseSection.header is required and this
|
|
56
|
+
// same value is what the section's own title already reads today.
|
|
57
|
+
function wrapLegacyTable(section, changes) {
|
|
58
|
+
const id = section.id ? `${section.id}-table` : 'legacy-table';
|
|
59
|
+
changes.push(`section "${section.id}": legacy single-table shape wrapped into tables[]`);
|
|
60
|
+
const { object, fields, join, actions, orderBy, additionalWhere, ...rest } = section;
|
|
61
|
+
const table = normalizeTableJoin({
|
|
62
|
+
id, object, fields: fields || [], join, actions: actions || {}, orderBy, additionalWhere,
|
|
63
|
+
header: section.header,
|
|
64
|
+
}, changes, section.id);
|
|
65
|
+
return { ...rest, tables: [table] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// FieldSpec.join (component-typed fields) is likewise canonical-only — see
|
|
69
|
+
// normalizeTableJoin above. DEVIATION from SPEC-PHASE-5.md's literal step
|
|
70
|
+
// list, which doesn't call out component fields separately: real stored
|
|
71
|
+
// data (examples.json's company-detail "Related Engagements" section) has
|
|
72
|
+
// `field.join = {column, value}` on a type:'component' field, using the
|
|
73
|
+
// same 3-consumer semantics table row as component joins generally. Added
|
|
74
|
+
// per the same reasoning Phase 1 already documented for this exact gap.
|
|
75
|
+
function normalizeRows(rows, changes) {
|
|
76
|
+
if (!Array.isArray(rows)) return rows;
|
|
77
|
+
return rows.map((row) => ({
|
|
78
|
+
...row,
|
|
79
|
+
columns: (row.columns || []).map((column) => {
|
|
80
|
+
if (column.type !== 'component' || !column.join) return column;
|
|
81
|
+
const { relationship, skipped } = normalizeJoin(column.join, 'component');
|
|
82
|
+
if (skipped) {
|
|
83
|
+
changes.push(`field "${column.id}": join left as legacy shape (non-trivial template or missing column)`);
|
|
84
|
+
return column;
|
|
85
|
+
}
|
|
86
|
+
changes.push(`field "${column.id}": component join normalized to relationship`);
|
|
87
|
+
return { ...column, join: relationship };
|
|
88
|
+
}),
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// DEVIATION (Phase 5, found reading real fixture data, not called out in
|
|
93
|
+
// SPEC-PHASE-5.md): a section can carry `type: "table"` while also having
|
|
94
|
+
// a `kanban` block, `tables[]`, and `defaultView` — i.e. it's actually a
|
|
95
|
+
// table-kanban hybrid, but the builder never updated `type` when the
|
|
96
|
+
// kanban toggle was added (examples.json's company-detail "Opportunities"
|
|
97
|
+
// section, id "section4"). SectionSpec's discriminated union has no
|
|
98
|
+
// `kanban` key on the `type:'table'` branch (TableSection), so leaving the
|
|
99
|
+
// tag wrong means a `.safeParse()` silently drops the kanban config from
|
|
100
|
+
// validated output. Retag before validation ever sees it.
|
|
101
|
+
function normalizeSectionType(section, changes) {
|
|
102
|
+
if (section.type === 'table' && section.kanban && (section.tables || section.object)) {
|
|
103
|
+
changes.push(`section "${section.id}": type normalized "table" -> "table-kanban" (kanban block present alongside tables)`);
|
|
104
|
+
return 'table-kanban';
|
|
105
|
+
}
|
|
106
|
+
return section.type;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {object} layoutJson - raw stored layout.layout / layoutVersions.layoutJson (schemaVersion 0/1)
|
|
111
|
+
* @param {{ objectName?: string, layoutKind?: 'list'|'detail'|'compact' }} [ctx]
|
|
112
|
+
* @returns {{ json: object, schemaVersion: 2, changes: string[] }}
|
|
113
|
+
*/
|
|
114
|
+
export function migrateV1toV2(layoutJson, ctx = {}) {
|
|
115
|
+
const changes = [];
|
|
116
|
+
const { rest, objectName: docObjectName } = canonicalizeObjectName(layoutJson, changes);
|
|
117
|
+
const objectName = docObjectName || ctx.objectName;
|
|
118
|
+
|
|
119
|
+
const promotedCreateActions = [];
|
|
120
|
+
const allPromotedIds = new Set();
|
|
121
|
+
|
|
122
|
+
const sections = (rest.sections || []).map((section) => {
|
|
123
|
+
let next = { ...section, type: normalizeSectionType(section, changes), rows: normalizeRows(section.rows, changes) };
|
|
124
|
+
|
|
125
|
+
if (next.type === 'table' && !section.tables && section.object) {
|
|
126
|
+
next = wrapLegacyTable(next, changes);
|
|
127
|
+
} else if (section.tables) {
|
|
128
|
+
next.tables = section.tables.map((t) => normalizeTableJoin(t, changes, section.id));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (section.kanban) {
|
|
132
|
+
next.kanban = normalizeKanban(section.kanban, changes, section.id);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (next.tables && next.tables.length) {
|
|
136
|
+
const { relatedLists, createActions, promotedIds } = promoteRelatedLists(next, objectName, changes);
|
|
137
|
+
if (relatedLists.length) next.relatedLists = relatedLists;
|
|
138
|
+
promotedCreateActions.push(...createActions);
|
|
139
|
+
promotedIds.forEach((id) => allPromotedIds.add(id));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return next;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const migrated = {
|
|
146
|
+
...rest,
|
|
147
|
+
schemaVersion: 2,
|
|
148
|
+
objectName,
|
|
149
|
+
sections,
|
|
150
|
+
actions: [
|
|
151
|
+
...deriveActions({ ...rest, objectName, sections }, { skipTableIds: allPromotedIds }),
|
|
152
|
+
...promotedCreateActions,
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (rest.type === 'compact' && rest.sections && !rest.fields) {
|
|
157
|
+
migrated.fields = deriveCompactFields(rest);
|
|
158
|
+
changes.push('compact: derived fields[] from sections[] rows/columns');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { json: migrated, schemaVersion: 2, changes };
|
|
162
|
+
}
|