@unboundcx/sdk 4.1.3 → 4.2.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/README.md +36 -0
- package/package.json +18 -5
- 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 +44 -0
- package/schemas/layouts/relatedList.js +48 -0
- package/schemas/layouts/section.js +104 -0
- package/schemas/layouts/selectDynamic.js +40 -0
- package/schemas/layouts/validate.js +18 -0
- package/services/layouts.js +142 -1
- package/services/liveQuery.js +26 -5
- package/services/objects.js +2 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Pure fn, no zod import — this runs before validation inside migrateV1toV2.
|
|
2
|
+
// Mirrors join.js's normalizeJoinSpec() (kept as a standalone duplicate here,
|
|
3
|
+
// not a re-export, so the migrations/ tier stays zod-free per the phase's
|
|
4
|
+
// "migrations run on plain objects" design — see SPEC-PHASE-1.md §2 A14-A18).
|
|
5
|
+
//
|
|
6
|
+
// legacyKind: 'table' | 'component' | 'kanbanChildRecords' — all three raw
|
|
7
|
+
// join shapes observed in the legacy layout builder collapse onto one
|
|
8
|
+
// canonical {childField, parentField} shape:
|
|
9
|
+
// - table / component: {column, value: "{{parentField}}"}
|
|
10
|
+
// - kanban child-records: {childField, parentField} (already un-templated)
|
|
11
|
+
//
|
|
12
|
+
// DEVIATION (Phase 5, SPEC-PHASE-5.md §2.1 step 3): returns
|
|
13
|
+
// {relationship, skipped} instead of a bare relationship object. `skipped`
|
|
14
|
+
// is true when `column` is missing/empty or `value` isn't a clean
|
|
15
|
+
// single-token "{{field}}" template — the spec's explicit "leave untouched"
|
|
16
|
+
// carve-out for joins that can't be synthesized 1:1. Both cases appear in
|
|
17
|
+
// real stored data (app1-api/src/services/layouts/examples.json:865,921 —
|
|
18
|
+
// two "Related Contacts"/"Deal Team" tables with `join.column === ""`), not
|
|
19
|
+
// just the hypothetical concatenated-template case the spec called out —
|
|
20
|
+
// an empty FK column can no more produce a valid JoinSpec.childField
|
|
21
|
+
// (min-length 1) than a non-trivial template can, so both are treated as
|
|
22
|
+
// the same carve-out.
|
|
23
|
+
const MUSTACHE_RE = /^\{\{\s*([\w.]+)\s*\}\}$/;
|
|
24
|
+
|
|
25
|
+
export function normalizeJoin(raw, legacyKind) {
|
|
26
|
+
if (!raw) return { relationship: undefined, skipped: false };
|
|
27
|
+
|
|
28
|
+
// Already canonical (or kanban child-records, which was never templated).
|
|
29
|
+
if (legacyKind === 'kanbanChildRecords' || (raw.childField && !raw.column)) {
|
|
30
|
+
if (!raw.childField) return { relationship: undefined, skipped: true };
|
|
31
|
+
return { relationship: { childField: raw.childField, parentField: raw.parentField || 'id' }, skipped: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!raw.column || typeof raw.value !== 'string') {
|
|
35
|
+
return { relationship: undefined, skipped: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const match = raw.value.match(MUSTACHE_RE);
|
|
39
|
+
if (!match) {
|
|
40
|
+
return { relationship: undefined, skipped: true };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { relationship: { childField: raw.column, parentField: match[1] }, skipped: false };
|
|
44
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { normalizeJoin } from './normalizeJoin.js';
|
|
2
|
+
|
|
3
|
+
// Pure fn, no zod import. Tolerates/normalizes per-mode kanban keys — the 3
|
|
4
|
+
// kanban modes (simple/related/child-records) are described inconsistently
|
|
5
|
+
// across TableEditor.svelte/KanbanEditor.svelte/ComponentEditor.svelte
|
|
6
|
+
// (maps.md §1.7); this fills in the structural keys KanbanConfigSpec
|
|
7
|
+
// (schemas/layouts/kanban.js) always expects (mode, summaries[], actions{})
|
|
8
|
+
// without dropping or rewriting anything mode-specific — additive only.
|
|
9
|
+
//
|
|
10
|
+
// `changes` (optional, Phase 5 addition) collects human-readable entries for
|
|
11
|
+
// the migration-preview tool / backfill-sweep log, same array threaded
|
|
12
|
+
// through migrateV1toV2.js's other normalizers.
|
|
13
|
+
export function normalizeKanban(raw, changes, sectionId) {
|
|
14
|
+
if (!raw) return raw;
|
|
15
|
+
|
|
16
|
+
const mode = raw.mode
|
|
17
|
+
|| (raw.childObject ? 'child-records' : (raw.configObject ? 'related' : 'simple'));
|
|
18
|
+
if (!raw.mode && changes) {
|
|
19
|
+
changes.push(`section "${sectionId}" kanban: mode inferred as "${mode}" (not explicitly set)`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const summaries = Array.isArray(raw.summaries) ? raw.summaries : [];
|
|
23
|
+
const actions = raw.actions && typeof raw.actions === 'object' ? raw.actions : {};
|
|
24
|
+
|
|
25
|
+
const next = {
|
|
26
|
+
...raw,
|
|
27
|
+
mode,
|
|
28
|
+
summaries,
|
|
29
|
+
actions: {
|
|
30
|
+
create: actions.create ?? false,
|
|
31
|
+
edit: actions.edit ?? true,
|
|
32
|
+
delete: actions.delete ?? false,
|
|
33
|
+
cardClick: actions.cardClick || 'modal',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// child-records mode's `join` is KanbanChildRecords's own JoinSpec-typed
|
|
38
|
+
// field (schemas/layouts/kanban.js) — canonical-only, same as
|
|
39
|
+
// TableSpec.join. Legacy child-records joins were never templated
|
|
40
|
+
// (kanbanChildRecords fast-path in normalizeJoin), so this only ever
|
|
41
|
+
// fills a missing default parentField; kept here (rather than assumed
|
|
42
|
+
// already-clean) because migrateV1toV2.js can't reach into mode-specific
|
|
43
|
+
// kanban keys itself without duplicating the mode-inference above.
|
|
44
|
+
if (mode === 'child-records' && raw.join) {
|
|
45
|
+
const { relationship, skipped } = normalizeJoin(raw.join, 'kanbanChildRecords');
|
|
46
|
+
if (relationship) {
|
|
47
|
+
next.join = relationship;
|
|
48
|
+
} else if (skipped && changes) {
|
|
49
|
+
changes.push(`section "${sectionId}" kanban: child-records join left as legacy shape (missing childField)`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Pure fn, no zod import. SPEC-PHASE-5.md §2.1 step 5: promotes qualifying
|
|
2
|
+
// TableSpec entries (already join-normalized by migrateV1toV2.js) to
|
|
3
|
+
// RelatedListSpec, additive alongside `tables[]` — NOT a replacement.
|
|
4
|
+
// `tables[]` stays fully intact so the section keeps validating and nothing
|
|
5
|
+
// is lost if a table doesn't qualify; `relatedLists[]` is a new, parallel
|
|
6
|
+
// key (schemas/layouts/section.js) for the Phase 4 engine's related-list
|
|
7
|
+
// component to read instead of re-deriving the same shape from `tables[]`
|
|
8
|
+
// itself. See section.js's comment at TableSection/TableKanbanSection for
|
|
9
|
+
// why this resolves relatedList.js's "Open Decision #5" as additive rather
|
|
10
|
+
// than folding tables[] away.
|
|
11
|
+
//
|
|
12
|
+
// Promotion criteria (ALL required, per spec):
|
|
13
|
+
// a. table.object differs from the layout's own objectName — a table
|
|
14
|
+
// entry matching the primary object is a plain table, not a related
|
|
15
|
+
// list of itself.
|
|
16
|
+
// b. table.join was normalized to a canonical relationship (i.e. NOT
|
|
17
|
+
// dropped by normalizeJoin's "leave untouched" carve-out).
|
|
18
|
+
// c. table.fields is non-empty — RelatedListSpec.columns.inline requires
|
|
19
|
+
// at least one column (min(1)); an empty fields[] can't produce a
|
|
20
|
+
// valid columns block. Not explicitly named in the spec's 3 criteria,
|
|
21
|
+
// but required by RelatedListSpec's actual zod shape.
|
|
22
|
+
// table.hideRowNumber / `table.header`-style overrides are NOT a gate (per
|
|
23
|
+
// spec: "keep columns: {inline: [...]} verbatim rather than failing the
|
|
24
|
+
// promotion") — this function always emits `columns: {inline: table.fields}`
|
|
25
|
+
// unconditionally, so criterion (c) is the only fields-related check.
|
|
26
|
+
export function promoteRelatedLists(section, objectName, changes) {
|
|
27
|
+
const relatedLists = [];
|
|
28
|
+
const createActions = [];
|
|
29
|
+
const promotedIds = new Set();
|
|
30
|
+
|
|
31
|
+
for (const table of section.tables || []) {
|
|
32
|
+
if (table.object === objectName) continue;
|
|
33
|
+
if (!table.join) continue;
|
|
34
|
+
if (!table.fields || table.fields.length === 0) continue;
|
|
35
|
+
|
|
36
|
+
relatedLists.push({
|
|
37
|
+
id: table.id,
|
|
38
|
+
object: table.object,
|
|
39
|
+
relationship: table.join,
|
|
40
|
+
columns: { inline: table.fields },
|
|
41
|
+
rowActions: {
|
|
42
|
+
open: 'modal',
|
|
43
|
+
quickEdit: table.actions?.edit ? 'modal' : undefined,
|
|
44
|
+
delete: !!table.actions?.delete,
|
|
45
|
+
custom: [],
|
|
46
|
+
},
|
|
47
|
+
defaultSort: table.orderBy,
|
|
48
|
+
filters: table.additionalWhere,
|
|
49
|
+
});
|
|
50
|
+
promotedIds.add(table.id);
|
|
51
|
+
changes.push(
|
|
52
|
+
`section "${section.id}" table "${table.id}": promoted to RelatedListSpec (object="${table.object}")`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (table.actions?.create) {
|
|
56
|
+
createActions.push({
|
|
57
|
+
id: `${table.id}-related-create`,
|
|
58
|
+
type: 'create',
|
|
59
|
+
target: table.object,
|
|
60
|
+
mode: 'modal',
|
|
61
|
+
placement: 'section',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { relatedLists, createActions, promotedIds };
|
|
67
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Not resolved here — just shape-checked. Resolution ({{field}} substitution
|
|
4
|
+
// against a record) stays a client/renderer concern (existing replaceVariables()
|
|
5
|
+
// in app1-client/src/utils/index.js), unchanged in this phase.
|
|
6
|
+
export const MustacheTemplate = z.string().min(1);
|
|
7
|
+
|
|
8
|
+
// Matches Section.svelte / TableEditor.svelte conditional-visibility operators
|
|
9
|
+
// (maps.md §1.5) — verified against Row.svelte:79-116's operator switch.
|
|
10
|
+
export const ConditionOperator = z.enum([
|
|
11
|
+
'eq', 'neq', 'contains', 'startsWith', 'endsWith',
|
|
12
|
+
'gt', 'gte', 'lt', 'lte', 'isNull', 'isNotNull',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export const ConditionSpec = z.object({
|
|
16
|
+
field: z.string().min(1),
|
|
17
|
+
operator: ConditionOperator.default('eq'),
|
|
18
|
+
value: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
|
|
19
|
+
}).refine(
|
|
20
|
+
(v) => ['isNull', 'isNotNull'].includes(v.operator) || v.value !== undefined,
|
|
21
|
+
{ message: 'value is required unless operator is isNull/isNotNull', path: ['value'] },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const OrderBySpec = z.object({
|
|
25
|
+
field: z.string().min(1),
|
|
26
|
+
direction: z.enum(['asc', 'desc']).default('asc'),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Multi-column ordering (2026-08-15): a single {field, direction} object
|
|
30
|
+
// (every stored layout to date) OR an ordered array of them. Consumers
|
|
31
|
+
// normalize via a "wrap in array if not one" step; the builder writes the
|
|
32
|
+
// array shape going forward.
|
|
33
|
+
export const OrderByListSpec = z.union([OrderBySpec, z.array(OrderBySpec)]);
|
|
34
|
+
|
|
35
|
+
export const HeaderSpec = z.object({
|
|
36
|
+
value: z.string().default(''),
|
|
37
|
+
collapsedValue: z.string().optional(),
|
|
38
|
+
hidden: z.boolean().default(false),
|
|
39
|
+
size: z.enum(['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl']).optional(),
|
|
40
|
+
weight: z.enum(['thin', 'light', 'normal', 'medium', 'semibold', 'bold', 'black']).optional(),
|
|
41
|
+
icon: z.string().optional(),
|
|
42
|
+
level: z.enum(['1', '2', '3', '4', '5', '6']).optional(),
|
|
43
|
+
collapsible: z.boolean().optional(),
|
|
44
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { JoinSpec } from './join.js';
|
|
3
|
+
import { OrderBySpec } from './primitives.js';
|
|
4
|
+
import { FormatType } from './format.js';
|
|
5
|
+
|
|
6
|
+
// New primitive, per V2-PLAN's exact definition. Wired into
|
|
7
|
+
// TableSection/TableKanbanSection as `relatedLists[]` (section.js) —
|
|
8
|
+
// additive alongside `tables[]`, not a replacement (SectionSpec.tables[]
|
|
9
|
+
// stays on the legacy-derived TableSpec/JoinSpec shape; RelatedListSpec is a
|
|
10
|
+
// parallel, promoted-subset view). Resolves Open Decision #5 (Phase 5,
|
|
11
|
+
// SPEC-PHASE-5.md §2.1 step 5, migrations/promoteRelatedLists.js) as
|
|
12
|
+
// additive: folding tables[] away entirely would break the old renderer's
|
|
13
|
+
// byte-identical read contract during the v1/v2 soak window.
|
|
14
|
+
const RelatedListInlineColumn = z.object({
|
|
15
|
+
field: z.string().min(1),
|
|
16
|
+
display: z.string().optional(),
|
|
17
|
+
formatType: FormatType.optional(),
|
|
18
|
+
format: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const RelatedListColumnsSpec = z.union([
|
|
22
|
+
z.object({ compactLayoutRef: z.string().min(1) }),
|
|
23
|
+
z.object({ inline: z.array(RelatedListInlineColumn).min(1) }),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const RowActionCustomSpec = z.object({
|
|
27
|
+
label: z.string().min(1),
|
|
28
|
+
icon: z.string().optional(),
|
|
29
|
+
action: z.enum(['open', 'edit', 'delete', 'custom']),
|
|
30
|
+
target: z.string().optional(), // custom: workflow/route id resolved by the Phase 4 action registry
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const RelatedListSpec = z.object({
|
|
34
|
+
id: z.string().min(1),
|
|
35
|
+
object: z.string().min(1),
|
|
36
|
+
relationship: JoinSpec, // ONE FK declaration — replaces the 3 join semantics for this primitive
|
|
37
|
+
columns: RelatedListColumnsSpec,
|
|
38
|
+
rowActions: z.object({
|
|
39
|
+
open: z.enum(['tab', 'modal', 'peek']).default('modal'),
|
|
40
|
+
quickEdit: z.enum(['inline', 'modal']).optional(), // 'modal' = opens this object's compact layout
|
|
41
|
+
delete: z.boolean().default(false),
|
|
42
|
+
custom: z.array(RowActionCustomSpec).default([]),
|
|
43
|
+
}).default({ open: 'modal', delete: false, custom: [] }),
|
|
44
|
+
emptyState: z.object({ message: z.string().optional(), icon: z.string().optional() }).optional(),
|
|
45
|
+
defaultSort: OrderBySpec.optional(),
|
|
46
|
+
filters: z.record(z.string()).optional(), // "<operator>::<term>" DSL — same as buildObjectQuery.js
|
|
47
|
+
pageSize: z.number().int().positive().max(200).default(25),
|
|
48
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
HeaderSpec,
|
|
4
|
+
ConditionSpec,
|
|
5
|
+
OrderByListSpec,
|
|
6
|
+
} from './primitives.js';
|
|
7
|
+
import { FieldSpec } from './field.js';
|
|
8
|
+
import { JoinSpec } from './join.js';
|
|
9
|
+
import { FormatType } from './format.js';
|
|
10
|
+
import { KanbanConfigSpec } from './kanban.js';
|
|
11
|
+
import { RelatedListSpec } from './relatedList.js';
|
|
12
|
+
|
|
13
|
+
const TableFieldSpec = z.object({
|
|
14
|
+
field: z.string().min(1),
|
|
15
|
+
display: z.string().default(''),
|
|
16
|
+
hidden: z.boolean().default(false),
|
|
17
|
+
sortable: z.boolean().default(true),
|
|
18
|
+
type: z.enum(['link']).optional(),
|
|
19
|
+
link: z.string().optional(),
|
|
20
|
+
linkField: z.string().optional(),
|
|
21
|
+
linkObject: z.string().optional(),
|
|
22
|
+
formatType: FormatType.optional(),
|
|
23
|
+
format: z.string().optional(),
|
|
24
|
+
relatedKey: z.string().optional(),
|
|
25
|
+
relatedObject: z.string().optional(),
|
|
26
|
+
}).refine((v) => v.type !== 'link' || (v.link && v.linkField && v.linkObject), {
|
|
27
|
+
message: 'type:"link" requires link, linkField, and linkObject',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const TableSpec = z.object({
|
|
31
|
+
id: z.string().min(1),
|
|
32
|
+
object: z.string().min(1),
|
|
33
|
+
join: JoinSpec.optional(), // absent = top-level list table (no parent record)
|
|
34
|
+
fields: z.array(TableFieldSpec).default([]),
|
|
35
|
+
header: HeaderSpec.partial().optional(),
|
|
36
|
+
actions: z.object({
|
|
37
|
+
edit: z.boolean().default(true),
|
|
38
|
+
create: z.boolean().default(false),
|
|
39
|
+
delete: z.boolean().default(false),
|
|
40
|
+
hideOnCreate: z.boolean().default(false),
|
|
41
|
+
cardClick: z.enum(['tab', 'modal']).default('tab'),
|
|
42
|
+
}).default({}),
|
|
43
|
+
orderBy: OrderByListSpec.optional(),
|
|
44
|
+
additionalWhere: z.record(z.string()).optional(),
|
|
45
|
+
hideOnCreate: z.boolean().default(false),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const RowSpec = z.object({
|
|
49
|
+
id: z.string().min(1),
|
|
50
|
+
columns: z.array(FieldSpec),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const BaseSection = z.object({
|
|
54
|
+
id: z.string().min(1),
|
|
55
|
+
header: HeaderSpec.default({}),
|
|
56
|
+
editable: z.boolean().default(true),
|
|
57
|
+
hideOnCreate: z.boolean().default(false),
|
|
58
|
+
autoCollapse: z.boolean().default(false),
|
|
59
|
+
showCollapse: z.boolean().default(false),
|
|
60
|
+
conditions: ConditionSpec.optional(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const ContentSection = BaseSection.extend({
|
|
64
|
+
type: z.literal('content'),
|
|
65
|
+
rows: z.array(RowSpec).default([]),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// `relatedLists` (Phase 5 addition, SPEC-PHASE-5.md §2.1 step 5): resolves
|
|
69
|
+
// relatedList.js's "Open Decision #5" as ADDITIVE, not a fold — `tables[]`
|
|
70
|
+
// stays the full, unmodified source of truth (old renderer + Phase 4's
|
|
71
|
+
// plain-table view both read it); `relatedLists[]` is a parallel,
|
|
72
|
+
// promoted-subset view for Phase 4's dedicated related-list component,
|
|
73
|
+
// populated by migrations/promoteRelatedLists.js for qualifying entries
|
|
74
|
+
// only. Never required, defaults empty.
|
|
75
|
+
const TableSection = BaseSection.extend({
|
|
76
|
+
type: z.literal('table'),
|
|
77
|
+
tableLayout: z.enum(['full-width', 'two-columns']).default('full-width'),
|
|
78
|
+
tables: z.array(TableSpec).min(1),
|
|
79
|
+
relatedLists: z.array(RelatedListSpec).default([]),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const KanbanSection = BaseSection.extend({
|
|
83
|
+
type: z.literal('kanban'),
|
|
84
|
+
kanban: KanbanConfigSpec,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const TableKanbanSection = BaseSection.extend({
|
|
88
|
+
type: z.literal('table-kanban'),
|
|
89
|
+
defaultView: z.enum(['table', 'kanban']).default('table'),
|
|
90
|
+
tableLayout: z.enum(['full-width', 'two-columns']).default('full-width'),
|
|
91
|
+
tables: z.array(TableSpec).min(1),
|
|
92
|
+
kanban: KanbanConfigSpec,
|
|
93
|
+
relatedLists: z.array(RelatedListSpec).default([]),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Note: this schema has no branch for legacy single-table
|
|
97
|
+
// section.object/fields/join/actions (no tables[]) — those docs must pass
|
|
98
|
+
// through migrateV1toV2 (which auto-wraps them into tables:[{...}], per
|
|
99
|
+
// V2-PLAN §6) before they'll validate. This is deliberate: it gives Phase 3's
|
|
100
|
+
// "delete TableEditor's legacy code path" work a schema-level forcing
|
|
101
|
+
// function.
|
|
102
|
+
export const SectionSpec = z.discriminatedUnion('type', [
|
|
103
|
+
ContentSection, TableSection, KanbanSection, TableKanbanSection,
|
|
104
|
+
]);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { MustacheTemplate } from './primitives.js';
|
|
3
|
+
|
|
4
|
+
// Canonicalizes away the `url` string / `queryConfig` object duplication and
|
|
5
|
+
// the `response` vs `responseMapping` dual mapping (maps.md §1.9). Design
|
|
6
|
+
// decision: v2 canonical docs store `queryConfig` only — `url` is never
|
|
7
|
+
// authored or persisted; the client's shared selectDynamic resolver (Phase 4,
|
|
8
|
+
// src/lib/layout-engine/) regenerates the request URL from `queryConfig` at
|
|
9
|
+
// render time, replacing the 3x-duplicated buildSelectDynamicUrl logic
|
|
10
|
+
// (Column.svelte:196-245, ModernField.svelte:222-269, WorkflowField.svelte:174-214).
|
|
11
|
+
// This is a schema-level decision Phase 4 must honor; flagged in Risks.
|
|
12
|
+
export const ResponseMappingSpec = z.object({
|
|
13
|
+
name: z.string().min(1),
|
|
14
|
+
value: z.string().min(1),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const SelectDynamicQueryConfig = z.object({
|
|
18
|
+
object: z.string().min(1),
|
|
19
|
+
select: z.array(z.string()).default(['id', 'name']),
|
|
20
|
+
searchField: z.string().default('name'),
|
|
21
|
+
searchOperator: z.enum(['contains', 'startsWith', 'endsWith', 'eq']).default('contains'),
|
|
22
|
+
valueField: z.string().default('id'), // canonical name for legacy valueColumn
|
|
23
|
+
displayTemplate: MustacheTemplate.default('{{name}}'),
|
|
24
|
+
limit: z.number().int().positive().max(200).default(25),
|
|
25
|
+
additionalWhere: z.record(z.string()).optional(), // "<operator>::<term>" DSL, same as buildObjectQuery.js
|
|
26
|
+
orderByField: z.string().optional(),
|
|
27
|
+
orderByDirection: z.enum(['asc', 'desc']).default('asc'),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const SelectDynamicSpec = z.object({
|
|
31
|
+
queryConfig: SelectDynamicQueryConfig,
|
|
32
|
+
responseMapping: ResponseMappingSpec.default({ name: 'name', value: 'id' }),
|
|
33
|
+
multiple: z.boolean().default(false),
|
|
34
|
+
clearable: z.boolean().default(true),
|
|
35
|
+
searchable: z.boolean().default(true),
|
|
36
|
+
preloadOptions: z.boolean().default(false),
|
|
37
|
+
initialSearchQuery: z.string().optional(),
|
|
38
|
+
initialLoadLimit: z.number().int().positive().optional(),
|
|
39
|
+
fetchOptions: z.object({ credentials: z.string().optional() }).optional(),
|
|
40
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { LayoutDoc } from './layoutDoc.js';
|
|
2
|
+
import { CompactLayoutDoc } from './compact.js';
|
|
3
|
+
|
|
4
|
+
// type: explicit override; falls back to doc.type. Compact docs (type:'compact')
|
|
5
|
+
// validate against CompactLayoutDoc; everything else against LayoutDoc.
|
|
6
|
+
export function validateLayoutDoc(rawDoc, { type } = {}) {
|
|
7
|
+
const docType = type || rawDoc?.type;
|
|
8
|
+
const schema = docType === 'compact' ? CompactLayoutDoc : LayoutDoc;
|
|
9
|
+
const result = schema.safeParse(rawDoc);
|
|
10
|
+
if (result.success) {
|
|
11
|
+
return { valid: true, errors: [], data: result.data };
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
valid: false,
|
|
15
|
+
errors: result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
|
|
16
|
+
data: null,
|
|
17
|
+
};
|
|
18
|
+
}
|
package/services/layouts.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import * as layoutSchemas from '../schemas/layouts/index.js';
|
|
2
|
+
|
|
1
3
|
export class LayoutsService {
|
|
2
4
|
constructor(sdk) {
|
|
3
5
|
this.sdk = sdk;
|
|
6
|
+
this.schema = layoutSchemas; // sdk.layouts.schema.{LayoutDoc, validateLayoutDoc, migrateLayoutSchema, ...}
|
|
7
|
+
this.assignments = new LayoutAssignmentsService(sdk); // sdk.layouts.assignments.{list,create,update,delete}
|
|
4
8
|
}
|
|
5
9
|
|
|
6
10
|
async get(objectName, id, query = {}) {
|
|
@@ -86,10 +90,147 @@ export class LayoutsService {
|
|
|
86
90
|
};
|
|
87
91
|
|
|
88
92
|
const result = await this.sdk._fetch(
|
|
89
|
-
'/layouts/
|
|
93
|
+
'/layouts/selectDynamic/search',
|
|
90
94
|
'GET',
|
|
91
95
|
params,
|
|
92
96
|
);
|
|
93
97
|
return result;
|
|
94
98
|
}
|
|
99
|
+
|
|
100
|
+
async resolve({ object, kind, recordId, recordTypeId, asUser } = {}) {
|
|
101
|
+
this.sdk.validateParams(
|
|
102
|
+
{ object, kind },
|
|
103
|
+
{
|
|
104
|
+
object: { type: 'string', required: true },
|
|
105
|
+
kind: { type: 'string', required: true },
|
|
106
|
+
recordId: { type: 'string', required: false },
|
|
107
|
+
recordTypeId: { type: 'string', required: false },
|
|
108
|
+
asUser: { type: 'string', required: false },
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const params = {
|
|
113
|
+
query: { object, kind, recordId, recordTypeId, asUser },
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const result = await this.sdk._fetch('/layouts/resolve', 'GET', params);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async getVersions(layoutId) {
|
|
121
|
+
this.sdk.validateParams(
|
|
122
|
+
{ layoutId },
|
|
123
|
+
{ layoutId: { type: 'string', required: true } },
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const result = await this.sdk._fetch(`/layouts/${layoutId}/versions`, 'GET', {});
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async getForEdit(layoutId) {
|
|
131
|
+
this.sdk.validateParams(
|
|
132
|
+
{ layoutId },
|
|
133
|
+
{ layoutId: { type: 'string', required: true } },
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const result = await this.sdk._fetch(`/layouts/${layoutId}/edit`, 'GET', {});
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async publish(layoutId, { changeNote } = {}) {
|
|
141
|
+
this.sdk.validateParams(
|
|
142
|
+
{ layoutId },
|
|
143
|
+
{ layoutId: { type: 'string', required: true } },
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const params = {
|
|
147
|
+
body: { changeNote },
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const result = await this.sdk._fetch(`/layouts/${layoutId}/publish`, 'POST', params);
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async rollback(layoutId, version) {
|
|
155
|
+
this.sdk.validateParams(
|
|
156
|
+
{ layoutId, version },
|
|
157
|
+
{
|
|
158
|
+
layoutId: { type: 'string', required: true },
|
|
159
|
+
version: { type: 'number', required: true },
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const result = await this.sdk._fetch(
|
|
164
|
+
`/layouts/${layoutId}/versions/${version}/rollback`,
|
|
165
|
+
'POST',
|
|
166
|
+
{},
|
|
167
|
+
);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export class LayoutAssignmentsService {
|
|
173
|
+
constructor(sdk) {
|
|
174
|
+
this.sdk = sdk;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async list({ objectName, kind } = {}) {
|
|
178
|
+
this.sdk.validateParams(
|
|
179
|
+
{ objectName, kind },
|
|
180
|
+
{
|
|
181
|
+
objectName: { type: 'string', required: true },
|
|
182
|
+
kind: { type: 'string', required: true },
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const params = {
|
|
187
|
+
query: { objectName, kind },
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result = await this.sdk._fetch('/layouts/assignments', 'GET', params);
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async create({ objectName, kind, recordTypeId, audienceType, audienceId, layoutId, priority } = {}) {
|
|
195
|
+
this.sdk.validateParams(
|
|
196
|
+
{ objectName, kind, audienceType, layoutId },
|
|
197
|
+
{
|
|
198
|
+
objectName: { type: 'string', required: true },
|
|
199
|
+
kind: { type: 'string', required: true },
|
|
200
|
+
audienceType: { type: 'string', required: true },
|
|
201
|
+
layoutId: { type: 'string', required: true },
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const params = {
|
|
206
|
+
body: { objectName, kind, recordTypeId, audienceType, audienceId, layoutId, priority },
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const result = await this.sdk._fetch('/layouts/assignments', 'POST', params);
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async update(id, updates) {
|
|
214
|
+
this.sdk.validateParams(
|
|
215
|
+
{ id },
|
|
216
|
+
{ id: { type: 'string', required: true } },
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const params = {
|
|
220
|
+
body: updates,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const result = await this.sdk._fetch(`/layouts/assignments/${id}`, 'PUT', params);
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async delete(id) {
|
|
228
|
+
this.sdk.validateParams(
|
|
229
|
+
{ id },
|
|
230
|
+
{ id: { type: 'string', required: true } },
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const result = await this.sdk._fetch(`/layouts/assignments/${id}`, 'DELETE', {});
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
95
236
|
}
|
package/services/liveQuery.js
CHANGED
|
@@ -117,15 +117,20 @@ class LiveQueryHandle {
|
|
|
117
117
|
filter,
|
|
118
118
|
fields,
|
|
119
119
|
recordTypeId,
|
|
120
|
+
uoql,
|
|
120
121
|
onEvent,
|
|
121
122
|
onStateChange,
|
|
122
123
|
}) {
|
|
123
124
|
this.manager = manager;
|
|
124
125
|
this.socket = socket;
|
|
125
|
-
|
|
126
|
+
// Internal bookkeeping/log labels need an object-name-shaped string even
|
|
127
|
+
// in uoql mode - use the literal 'uoql' there (no single object name
|
|
128
|
+
// exists yet, that's resolved server-side by analyze()).
|
|
129
|
+
this.object = uoql !== undefined ? 'uoql' : object;
|
|
126
130
|
this.filter = filter;
|
|
127
131
|
this.fields = fields;
|
|
128
132
|
this.recordTypeId = recordTypeId;
|
|
133
|
+
this.uoql = uoql;
|
|
129
134
|
this.onEvent = onEvent;
|
|
130
135
|
this.onStateChange = onStateChange;
|
|
131
136
|
|
|
@@ -136,7 +141,10 @@ class LiveQueryHandle {
|
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
async _subscribe() {
|
|
139
|
-
const payload =
|
|
144
|
+
const payload =
|
|
145
|
+
this.uoql !== undefined
|
|
146
|
+
? { uoql: this.uoql }
|
|
147
|
+
: { objectName: this.object, filter: this.filter, fields: this.fields };
|
|
140
148
|
if (this.recordTypeId !== undefined) payload.recordTypeId = this.recordTypeId;
|
|
141
149
|
|
|
142
150
|
const ack = await new Promise((resolve, reject) => {
|
|
@@ -249,6 +257,11 @@ class LiveQueryHandle {
|
|
|
249
257
|
* this account; falls back to `sdk.socket` if the sdk instance holds one.
|
|
250
258
|
* - object, filter, fields, recordTypeId: subscribe-time query, same shape
|
|
251
259
|
* as `sdk.objects.query`.
|
|
260
|
+
* - uoql: subscribe-time query as a UOQL string instead - mutually exclusive
|
|
261
|
+
* with object/filter/fields/recordTypeId (throws synchronously if both, or
|
|
262
|
+
* neither, are given). Sent to the server as `{ uoql }`; the server runs
|
|
263
|
+
* uoql analyze() to resolve it to a fine or coarse subscription. Also
|
|
264
|
+
* re-sent verbatim on reconnect resubscribe.
|
|
252
265
|
* - onEvent(frame): called for every 'enter'|'change'|'leave'|'refresh'|
|
|
253
266
|
* 'resync'|'revoked' frame (resync frames are also synthesized locally on
|
|
254
267
|
* seq gaps and on reconnect).
|
|
@@ -257,7 +270,7 @@ class LiveQueryHandle {
|
|
|
257
270
|
* Resolves to { subscriptionId, mode, unsubscribe() }.
|
|
258
271
|
*/
|
|
259
272
|
export async function liveQuery(sdk, args = {}) {
|
|
260
|
-
const { socket: providedSocket, object, filter, fields, recordTypeId, onEvent, onStateChange } = args;
|
|
273
|
+
const { socket: providedSocket, object, filter, fields, recordTypeId, uoql, onEvent, onStateChange } = args;
|
|
261
274
|
|
|
262
275
|
const socket = providedSocket || sdk.socket;
|
|
263
276
|
if (!socket || typeof socket.emit !== 'function' || typeof socket.on !== 'function') {
|
|
@@ -267,8 +280,15 @@ export async function liveQuery(sdk, args = {}) {
|
|
|
267
280
|
'deviation from the locked liveQuery contract, see plan Phase 3',
|
|
268
281
|
);
|
|
269
282
|
}
|
|
270
|
-
|
|
271
|
-
|
|
283
|
+
|
|
284
|
+
const hasObjectForm = object !== undefined || filter !== undefined || fields !== undefined || recordTypeId !== undefined;
|
|
285
|
+
if (uoql !== undefined && hasObjectForm) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
'liveQuery :: init :: uoql is mutually exclusive with object/filter/fields/recordTypeId',
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (uoql === undefined && !object) {
|
|
291
|
+
throw new Error('liveQuery :: init :: either uoql or object is required');
|
|
272
292
|
}
|
|
273
293
|
|
|
274
294
|
const manager = getSocketManager(socket);
|
|
@@ -279,6 +299,7 @@ export async function liveQuery(sdk, args = {}) {
|
|
|
279
299
|
filter,
|
|
280
300
|
fields,
|
|
281
301
|
recordTypeId,
|
|
302
|
+
uoql,
|
|
282
303
|
onEvent,
|
|
283
304
|
onStateChange,
|
|
284
305
|
});
|
package/services/objects.js
CHANGED
|
@@ -30,6 +30,8 @@ export class ObjectsService {
|
|
|
30
30
|
* re-subscribe, revoked teardown).
|
|
31
31
|
*
|
|
32
32
|
* sdk.objects.liveQuery({ socket, object, filter, fields, recordTypeId, onEvent, onStateChange })
|
|
33
|
+
* sdk.objects.liveQuery({ socket, uoql, onEvent, onStateChange }) // uoql is mutually
|
|
34
|
+
* exclusive with object/filter/fields/recordTypeId
|
|
33
35
|
* -> Promise<{ subscriptionId, mode, unsubscribe() }>
|
|
34
36
|
*/
|
|
35
37
|
liveQuery(args) {
|