@svgrid/enterprise 2.3.0 → 2.3.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.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +6885 -3504
- package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
- package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
- package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
- package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
- package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
- package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
- package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
- package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
- package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
- package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +5660 -1747
- package/package.json +8 -5
- package/src/SvGridBoard.svelte +4 -1
- package/src/SvGridScheduler.svelte +114 -90
- package/src/ai-export-pdf.dom.test.ts +77 -77
- package/src/ai-export-xlsx.dom.test.ts +90 -90
- package/src/ai-export.dom.test.ts +114 -114
- package/src/export-ooxml.ts +4 -0
- package/src/export-xls.ts +3 -0
- package/src/expressions/evaluate.ts +9 -0
- package/src/import.test.ts +1 -1
- package/src/import.ts +1 -1
- package/src/index.ts +12 -0
- package/src/pivot.test.ts +0 -1
- package/src/schema-designer.ts +1 -1
- package/src/studio/emit-project.test.ts +298 -7
- package/src/studio/emit-project.ts +483 -29
- package/src/studio/emit-schema.ts +20 -10
- package/src/studio/index.ts +34 -0
- package/src/studio/init-flow.test.ts +239 -0
- package/src/studio/init-flow.ts +358 -0
- package/src/studio/project.test.ts +0 -1
- package/src/studio/project.ts +109 -2
- package/src/studio/samples/datasets.test.ts +84 -0
- package/src/studio/samples/datasets.ts +340 -0
- package/src/studio/samples/live-data.test.ts +1 -1
- package/src/studio/samples/samples.test.ts +268 -268
- package/src/studio/samples/shared.ts +7 -150
- package/src/studio/screen-suites.test.ts +226 -0
- package/src/studio/screen-suites.ts +445 -0
- package/src/studio/ui-components-surface.test.ts +2 -2
- package/src/studio/ui-components.generated.ts +1643 -106
- package/src/studio/ui-components.ts +742 -641
- package/src/sveltekit/index.ts +1 -0
- package/src/sveltekit/sql-source.ts +1 -1
- package/src/sveltekit/transport-scope.test.ts +125 -0
- package/src/sveltekit/transport.ts +76 -2
|
@@ -8,9 +8,14 @@
|
|
|
8
8
|
* parse/serialize and generates a runnable app with no special-casing.
|
|
9
9
|
*/
|
|
10
10
|
import type { EntitySchema } from '../../schema.js'
|
|
11
|
-
import type { ChartType } from '@svgrid/grid'
|
|
12
11
|
import { generateValue } from '../sample-data.js'
|
|
13
|
-
import {
|
|
12
|
+
import { gridConfig, type GridOpts } from '../screen-suites.js'
|
|
13
|
+
import { sanitizeProject, screenFromTemplate, buildDockLayout, type Block, type GridConfig, type Screen, type ScreenTemplate, type StudioProject, type EntityDataSource, type ShellStyle, type SchedulerViewConfig, type SchedulerViewMode, type AuthConfig, type AccessControl } from '../project.js'
|
|
14
|
+
|
|
15
|
+
// The generic screen factories live in `../screen-suites.js` (they are public API
|
|
16
|
+
// now - the guided CRUD suite builds on them). Re-exported here so every sample
|
|
17
|
+
// app keeps importing its whole toolkit from one place.
|
|
18
|
+
export { dashScreen, detailScreen, formScreen, gridConfig, listScreen, statusPills, type GridOpts, type Tile } from '../screen-suites.js'
|
|
14
19
|
|
|
15
20
|
type Row = Record<string, unknown>
|
|
16
21
|
|
|
@@ -72,135 +77,6 @@ export type SampleApp = {
|
|
|
72
77
|
build: () => StudioProject
|
|
73
78
|
}
|
|
74
79
|
|
|
75
|
-
/** Rich-grid options an enterprise screen layers onto the default grid: status
|
|
76
|
-
* pills / thresholds (formatRules), a totals row, row actions, row-click drill,
|
|
77
|
-
* and density. */
|
|
78
|
-
export type GridOpts = {
|
|
79
|
-
format?: FormatRule[]
|
|
80
|
-
rowActions?: RowAction[]
|
|
81
|
-
rowLink?: RowLink
|
|
82
|
-
summaries?: boolean
|
|
83
|
-
density?: GridDensity
|
|
84
|
-
pageSize?: number
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** A dashboard tile: KPI card (with trend/target/format), chart, gauge, pivot,
|
|
88
|
-
* tree, a rich grid, or a tabbed group of display tiles. */
|
|
89
|
-
export type Tile =
|
|
90
|
-
| { kpi: string; measure?: string; reduce: Reduce; format?: KpiFormat; trendField?: string; trendReduce?: Reduce; target?: number; span?: 1 | 2 | 3 }
|
|
91
|
-
| { chart: string; measure?: string; reduce?: Reduce; type?: ChartType; span?: 1 | 2 | 3 }
|
|
92
|
-
| { gauge: string; measure?: string; reduce: Reduce; min?: number; max?: number; unit?: string; span?: 1 | 2 | 3 }
|
|
93
|
-
| { pivot: { rows: string[]; cols: string[]; measure?: string; aggregate?: Reduce }; span?: 1 | 2 | 3 }
|
|
94
|
-
| { tree: { labelField: string; parentField: string }; span?: 1 | 2 | 3 }
|
|
95
|
-
| ({ grid: true; span?: 1 | 2 | 3 } & GridOpts)
|
|
96
|
-
| { filter: string[]; span?: 1 | 2 | 3 }
|
|
97
|
-
| { tabs: { label: string; tiles: Tile[] }[]; span?: 1 | 2 | 3 }
|
|
98
|
-
|
|
99
|
-
/** The default grid config with enterprise options merged on. */
|
|
100
|
-
function gridConfig(entity: EntitySchema, opts: GridOpts): BlockConfig {
|
|
101
|
-
const base = defaultBlockConfig('grid', entity) as GridConfig
|
|
102
|
-
return {
|
|
103
|
-
...base,
|
|
104
|
-
...(opts.format ? { formatRules: opts.format } : {}),
|
|
105
|
-
...(opts.rowActions ? { rowActions: opts.rowActions } : {}),
|
|
106
|
-
...(opts.rowLink ? { rowLink: opts.rowLink } : {}),
|
|
107
|
-
...(opts.summaries ? { rowSummaries: true } : {}),
|
|
108
|
-
...(opts.density ? { density: opts.density } : {}),
|
|
109
|
-
...(opts.pageSize ? { pageSize: opts.pageSize } : {}),
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** One dashboard tile -> a Block. Recurses for `tabs` (whose children must be
|
|
114
|
-
* display blocks: chart / kpi / gauge / pivot / tree). `nextId` yields ids unique
|
|
115
|
-
* across the WHOLE screen (top-level tiles AND every nested tab child) - a per-level
|
|
116
|
-
* index would collide (top-level `blk-1` vs each tab's first child `blk-1`), which
|
|
117
|
-
* makes a keyed each throw `each_key_duplicate`. */
|
|
118
|
-
function tileBlock(entity: EntitySchema, t: Tile, nextId: () => string): Block {
|
|
119
|
-
const id = nextId()
|
|
120
|
-
if ('kpi' in t) {
|
|
121
|
-
const config: BlockConfig = {
|
|
122
|
-
kind: 'kpi', label: t.kpi, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce,
|
|
123
|
-
...(t.format ? { format: t.format } : {}), ...(t.trendField ? { trendField: t.trendField } : {}),
|
|
124
|
-
...(t.trendReduce ? { trendReduce: t.trendReduce } : {}), ...(t.target != null ? { target: t.target } : {}),
|
|
125
|
-
}
|
|
126
|
-
return { id, span: t.span ?? 1, config }
|
|
127
|
-
}
|
|
128
|
-
if ('chart' in t) {
|
|
129
|
-
return { id, span: t.span ?? 2, config: { kind: 'chart', dimension: t.chart, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce ?? 'sum', type: t.type ?? 'bar' } }
|
|
130
|
-
}
|
|
131
|
-
if ('gauge' in t) {
|
|
132
|
-
return { id, span: t.span ?? 1, config: { kind: 'gauge', label: t.gauge, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce, min: t.min ?? 0, max: t.max ?? 100, ...(t.unit ? { unit: t.unit } : {}) } }
|
|
133
|
-
}
|
|
134
|
-
if ('pivot' in t) {
|
|
135
|
-
return { id, span: t.span ?? 3, config: { kind: 'pivot', rows: t.pivot.rows, cols: t.pivot.cols, ...(t.pivot.measure ? { measure: t.pivot.measure } : {}), aggregate: t.pivot.aggregate ?? 'sum' } }
|
|
136
|
-
}
|
|
137
|
-
if ('tree' in t) {
|
|
138
|
-
return { id, span: t.span ?? 1, config: { kind: 'tree', labelField: t.tree.labelField, parentField: t.tree.parentField } }
|
|
139
|
-
}
|
|
140
|
-
if ('filter' in t) {
|
|
141
|
-
return { id, span: t.span ?? 3, config: { kind: 'filter', fields: t.filter } }
|
|
142
|
-
}
|
|
143
|
-
if ('tabs' in t) {
|
|
144
|
-
return { id, span: t.span ?? 3, config: { kind: 'tabs', tabs: t.tabs.map((tab) => ({ label: tab.label, blocks: tab.tiles.map((tt) => tileBlock(entity, tt, nextId)) })) } }
|
|
145
|
-
}
|
|
146
|
-
return { id, span: t.span ?? 3, config: gridConfig(entity, t) }
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Compose a dashboard screen from explicit tiles (KPI cards + gauges + charts +
|
|
150
|
-
* pivots + trees + a rich grid). */
|
|
151
|
-
export function dashScreen(
|
|
152
|
-
entity: EntitySchema,
|
|
153
|
-
meta: { id: string; title: string; order: number },
|
|
154
|
-
tiles: Tile[],
|
|
155
|
-
): Screen {
|
|
156
|
-
let n = 0
|
|
157
|
-
const nextId = () => `blk-${++n}`
|
|
158
|
-
const blocks = tiles.map((t) => tileBlock(entity, t, nextId))
|
|
159
|
-
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** FormatRule[] that color-code an enum field's cells by its option colors -
|
|
163
|
-
* turns a plain status column into enterprise status pills. */
|
|
164
|
-
export function statusPills(entity: EntitySchema, field: string): FormatRule[] {
|
|
165
|
-
const f = entity.fields.find((x) => x.field === field)
|
|
166
|
-
if (!f?.options) return []
|
|
167
|
-
return f.options
|
|
168
|
-
.filter((o) => o.color)
|
|
169
|
-
.map((o) => ({ field, op: 'eq' as const, value: o.value as string | number, background: `color-mix(in srgb, ${o.color} 18%, transparent)`, color: o.color, bold: true }))
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** A list screen: an optional faceted filter panel + a rich grid (status pills,
|
|
173
|
-
* totals, row actions, drill-through). */
|
|
174
|
-
export function listScreen(
|
|
175
|
-
entity: EntitySchema,
|
|
176
|
-
meta: { id: string; title: string; order: number },
|
|
177
|
-
opts: { filter?: string[]; grid?: GridOpts } = {},
|
|
178
|
-
): Screen {
|
|
179
|
-
const blocks: Block[] = []
|
|
180
|
-
if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
|
|
181
|
-
blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, opts.grid ?? {}) })
|
|
182
|
-
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* A form-showcase screen: a grid to pick a row + an editable record panel that
|
|
187
|
-
* renders the full field form (phone / rating / tags / mask / slider editors)
|
|
188
|
-
* inline. Lets a user see and try the rich editors on load, not just on edit.
|
|
189
|
-
*/
|
|
190
|
-
export function formScreen(
|
|
191
|
-
entity: EntitySchema,
|
|
192
|
-
meta: { id: string; title: string; order: number },
|
|
193
|
-
fields?: string[],
|
|
194
|
-
grid?: GridOpts,
|
|
195
|
-
filter?: string[],
|
|
196
|
-
): Screen {
|
|
197
|
-
const blocks: Block[] = []
|
|
198
|
-
if (filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: filter } })
|
|
199
|
-
blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, grid ?? {}) })
|
|
200
|
-
blocks.push({ id: 'record-1', span: 3, config: { kind: 'record', editable: true, ...(fields ? { fields } : {}) } })
|
|
201
|
-
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
202
|
-
}
|
|
203
|
-
|
|
204
80
|
/** A Kanban board screen: an optional filter panel + a board grouping rows by an
|
|
205
81
|
* enum field into draggable columns (the signature "pipeline" view). */
|
|
206
82
|
export function boardScreen(
|
|
@@ -277,25 +153,6 @@ export function workspaceScreen(
|
|
|
277
153
|
return { ...base, dock: buildDockLayout(base) }
|
|
278
154
|
}
|
|
279
155
|
|
|
280
|
-
/** A record detail-page screen: a full record view (header + status pill + metric
|
|
281
|
-
* tiles + tabbed Overview / related timelines) - the signature view for
|
|
282
|
-
* relation-heavy records (a customer / deal / patient / trip page). */
|
|
283
|
-
export function detailScreen(
|
|
284
|
-
entity: EntitySchema,
|
|
285
|
-
meta: { id: string; title: string; order: number },
|
|
286
|
-
opts: { titleField: string; subtitleField?: string; statusField?: string; metricFields?: string[]; sections?: { label: string; fields: string[] }[]; related?: DetailRelated[] },
|
|
287
|
-
): Screen {
|
|
288
|
-
const blocks: Block[] = [{ id: 'detail-1', span: 3, config: {
|
|
289
|
-
kind: 'detail', titleField: opts.titleField,
|
|
290
|
-
...(opts.subtitleField ? { subtitleField: opts.subtitleField } : {}),
|
|
291
|
-
...(opts.statusField ? { statusField: opts.statusField } : {}),
|
|
292
|
-
...(opts.metricFields?.length ? { metricFields: opts.metricFields } : {}),
|
|
293
|
-
...(opts.sections?.length ? { sections: opts.sections } : {}),
|
|
294
|
-
...(opts.related?.length ? { related: opts.related } : {}),
|
|
295
|
-
} }]
|
|
296
|
-
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
297
|
-
}
|
|
298
|
-
|
|
299
156
|
/** Build a screen from a template, then override its id / route / title / nav.
|
|
300
157
|
* `linkScreen` wires a master-detail parent row to drill into a detail screen. */
|
|
301
158
|
export function screen(
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { EntitySchema } from '../schema.js'
|
|
3
|
+
import { crudSuiteScreens, addCrudSuite, crudAppFromSchemas } from './screen-suites.js'
|
|
4
|
+
import {
|
|
5
|
+
createProject,
|
|
6
|
+
parseProject,
|
|
7
|
+
serializeProject,
|
|
8
|
+
validateProject,
|
|
9
|
+
type DetailConfig,
|
|
10
|
+
type GridConfig,
|
|
11
|
+
type Screen,
|
|
12
|
+
} from './project.js'
|
|
13
|
+
|
|
14
|
+
const customers: EntitySchema = {
|
|
15
|
+
name: 'customers',
|
|
16
|
+
label: 'Customer',
|
|
17
|
+
idField: 'id',
|
|
18
|
+
fields: [
|
|
19
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
20
|
+
{ field: 'name', type: 'text', label: 'Customer' },
|
|
21
|
+
{ field: 'city', type: 'text' },
|
|
22
|
+
{ field: 'tier', type: 'enum', options: [
|
|
23
|
+
{ value: 'free', label: 'Free', color: '#64748b' },
|
|
24
|
+
{ value: 'pro', label: 'Pro', color: '#2563eb' },
|
|
25
|
+
] },
|
|
26
|
+
{ field: 'spend', type: 'number' },
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const orders: EntitySchema = {
|
|
31
|
+
name: 'orders',
|
|
32
|
+
label: 'Order',
|
|
33
|
+
idField: 'id',
|
|
34
|
+
fields: [
|
|
35
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
36
|
+
{ field: 'reference', type: 'text' },
|
|
37
|
+
{ field: 'customer_id', type: 'relation', relation: { entity: 'customers', labelField: 'name' } },
|
|
38
|
+
{ field: 'total', type: 'number' },
|
|
39
|
+
{ field: 'status', type: 'enum', options: [{ value: 'paid', label: 'Paid', color: '#16a34a' }] },
|
|
40
|
+
{ field: 'placed', type: 'dateString' },
|
|
41
|
+
],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const gridOf = (screen: Screen): GridConfig =>
|
|
45
|
+
screen.blocks.find((b) => b.config.kind === 'grid')!.config as GridConfig
|
|
46
|
+
const detailOf = (screen: Screen): DetailConfig =>
|
|
47
|
+
screen.blocks.find((b) => b.config.kind === 'detail')!.config as DetailConfig
|
|
48
|
+
|
|
49
|
+
describe('crudSuiteScreens', () => {
|
|
50
|
+
it('defaults to list + form, and adds a detail page when the entity has children', () => {
|
|
51
|
+
// `customers` is referenced by `orders`, so it earns a detail page.
|
|
52
|
+
expect(crudSuiteScreens(customers, [customers, orders]).map((s) => s.id))
|
|
53
|
+
.toEqual(['customers', 'customers-manage', 'customers-detail'])
|
|
54
|
+
// `orders` has no children pointing at it - list + form only.
|
|
55
|
+
expect(crudSuiteScreens(orders, [customers, orders]).map((s) => s.id))
|
|
56
|
+
.toEqual(['orders', 'orders-manage'])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('honours an explicit screen selection', () => {
|
|
60
|
+
const screens = crudSuiteScreens(customers, [customers, orders], { screens: ['dashboard', 'list'] })
|
|
61
|
+
expect(screens.map((s) => s.id)).toEqual(['customers-overview', 'customers'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('puts status pills and a totals row on the list grid', () => {
|
|
65
|
+
const [list] = crudSuiteScreens(customers, [customers], { screens: ['list'] })
|
|
66
|
+
const grid = gridOf(list!)
|
|
67
|
+
expect(grid.formatRules?.map((r) => r.value)).toEqual(['free', 'pro'])
|
|
68
|
+
expect(grid.rowSummaries).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('seeds a faceted filter panel, and drops it when asked', () => {
|
|
72
|
+
const [withFacets] = crudSuiteScreens(customers, [customers], { screens: ['list'] })
|
|
73
|
+
expect(withFacets!.blocks[0]!.config.kind).toBe('filter')
|
|
74
|
+
const [plain] = crudSuiteScreens(customers, [customers], { screens: ['list'], filter: false })
|
|
75
|
+
expect(plain!.blocks.every((b) => b.config.kind !== 'filter')).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('editing modes', () => {
|
|
79
|
+
it("'form' (the default) edits through a popup form", () => {
|
|
80
|
+
const [list] = crudSuiteScreens(customers, [customers], { screens: ['list'] })
|
|
81
|
+
expect(gridOf(list!).editing).toBe('form')
|
|
82
|
+
expect(gridOf(list!).rowLink).toBeUndefined()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("'inline' edits in the grid", () => {
|
|
86
|
+
const [list] = crudSuiteScreens(customers, [customers], { screens: ['list'], editing: 'inline' })
|
|
87
|
+
expect(gridOf(list!).editing).toBe('inline')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("'detail' makes the list read-only and drills into the detail screen", () => {
|
|
91
|
+
const screens = crudSuiteScreens(customers, [customers, orders], { editing: 'detail' })
|
|
92
|
+
const list = screens.find((s) => s.id === 'customers')!
|
|
93
|
+
const detail = screens.find((s) => s.id === 'customers-detail')!
|
|
94
|
+
expect(gridOf(list).editing).toBe('none')
|
|
95
|
+
expect(gridOf(list).rowLink).toEqual({ screen: detail.id, targetField: 'id' })
|
|
96
|
+
// A drill target is reached by clicking a row, so it stays out of the nav.
|
|
97
|
+
expect(detail.nav?.show).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('builds related-record tabs from every relation pointing at the entity', () => {
|
|
102
|
+
const [detail] = crudSuiteScreens(customers, [customers, orders], { screens: ['detail'] })
|
|
103
|
+
expect(detailOf(detail!).related).toEqual([
|
|
104
|
+
{ entity: 'orders', foreignKey: 'customer_id', label: 'Order', titleField: 'reference', statusField: 'status', dateField: 'placed' },
|
|
105
|
+
])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('treats a self-referential FK as a child collection (a hierarchy)', () => {
|
|
109
|
+
const folders: EntitySchema = {
|
|
110
|
+
name: 'folders',
|
|
111
|
+
idField: 'id',
|
|
112
|
+
fields: [
|
|
113
|
+
{ field: 'id', type: 'text', primaryKey: true },
|
|
114
|
+
{ field: 'name', type: 'text' },
|
|
115
|
+
{ field: 'parent_id', type: 'relation', relation: { entity: 'folders', labelField: 'name' } },
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
const [detail] = crudSuiteScreens(folders, [folders], { screens: ['detail'] })
|
|
119
|
+
expect(detailOf(detail!).related?.[0]).toMatchObject({ entity: 'folders', foreignKey: 'parent_id' })
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('survives an entity with nothing but a primary key', () => {
|
|
123
|
+
const bare: EntitySchema = { name: 'flags', idField: 'id', fields: [{ field: 'id', type: 'text', primaryKey: true }] }
|
|
124
|
+
const screens = crudSuiteScreens(bare, [bare])
|
|
125
|
+
expect(screens.map((s) => s.id)).toEqual(['flags', 'flags-manage'])
|
|
126
|
+
expect(gridOf(screens[0]!).rowSummaries).toBeFalsy()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('picks non-colliding ids AND routes, keeping drill-through pointed at the real screen', () => {
|
|
130
|
+
const screens = crudSuiteScreens(customers, [customers, orders], {
|
|
131
|
+
editing: 'detail',
|
|
132
|
+
taken: { ids: new Set(['customers', 'customers-detail']), routes: new Set(['customers']) },
|
|
133
|
+
})
|
|
134
|
+
const ids = screens.map((s) => s.id)
|
|
135
|
+
expect(ids).toEqual(['customers-2', 'customers-manage', 'customers-detail-2'])
|
|
136
|
+
expect(screens.map((s) => s.route)).toEqual(['customers-2', 'customers-manage', 'customers-detail'])
|
|
137
|
+
// The rowLink must name the RENAMED detail screen, not the original id.
|
|
138
|
+
expect(gridOf(screens[0]!).rowLink?.screen).toBe('customers-detail-2')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('addCrudSuite', () => {
|
|
143
|
+
it('appends a suite without disturbing the existing screens', () => {
|
|
144
|
+
const project = createProject([customers, orders])
|
|
145
|
+
const next = addCrudSuite(project, 'orders')
|
|
146
|
+
expect(next.screens.slice(0, 2).map((s) => s.id)).toEqual(['customers', 'orders'])
|
|
147
|
+
// 'orders' is taken by the default screen, so the suite claims fresh ids.
|
|
148
|
+
expect(next.screens.slice(2).map((s) => s.id)).toEqual(['orders-2', 'orders-manage'])
|
|
149
|
+
expect(project.screens).toHaveLength(2) // immutable
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('ignores an unknown entity', () => {
|
|
153
|
+
const project = createProject([customers])
|
|
154
|
+
expect(addCrudSuite(project, 'nope')).toBe(project)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('crudAppFromSchemas', () => {
|
|
159
|
+
it('leads with an overview dashboard over the most-referenced entity', () => {
|
|
160
|
+
const app = crudAppFromSchemas([orders, customers])
|
|
161
|
+
const first = app.screens[0]!
|
|
162
|
+
expect(first.title).toBe('Overview')
|
|
163
|
+
expect(first.entity).toBe('customers') // orders point at customers
|
|
164
|
+
expect(first.nav?.order).toBe(0)
|
|
165
|
+
expect(first.blocks.some((b) => b.config.kind === 'kpi')).toBe(true)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('can skip the dashboard', () => {
|
|
169
|
+
const app = crudAppFromSchemas([customers, orders], { overviewDashboard: false })
|
|
170
|
+
expect(app.screens[0]!.id).toBe('customers')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('generates a suite per entity with unique ids, routes and ordered nav', () => {
|
|
174
|
+
const app = crudAppFromSchemas([customers, orders])
|
|
175
|
+
const ids = app.screens.map((s) => s.id)
|
|
176
|
+
const routes = app.screens.map((s) => s.route)
|
|
177
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
178
|
+
expect(new Set(routes).size).toBe(routes.length)
|
|
179
|
+
const orders2 = app.screens.map((s) => s.nav?.order).filter((n): n is number => n != null)
|
|
180
|
+
expect([...orders2].sort((a, b) => a - b)).toEqual(orders2)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('binds sources per entity and merges seed rows into memory bindings', () => {
|
|
184
|
+
const app = crudAppFromSchemas([customers], { seed: { customers: [{ id: 'c1', name: 'Acme' }] } })
|
|
185
|
+
expect(app.dataSources?.customers).toEqual({ kind: 'memory', seed: [{ id: 'c1', name: 'Acme' }] })
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('carries seed into a PGlite binding and leaves SQL bindings alone', () => {
|
|
189
|
+
const app = crudAppFromSchemas([customers, orders], {
|
|
190
|
+
dataSource: 'sql',
|
|
191
|
+
sources: { customers: { kind: 'pglite', table: 'customers' }, orders: { kind: 'sql', table: 'orders', dialect: 'postgres' } },
|
|
192
|
+
seed: { customers: [{ id: 'c1' }], orders: [{ id: 'o1' }] },
|
|
193
|
+
})
|
|
194
|
+
expect(app.dataSources?.customers).toEqual({ kind: 'pglite', table: 'customers', seed: [{ id: 'c1' }] })
|
|
195
|
+
expect(app.dataSources?.orders).toEqual({ kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
196
|
+
expect(app.dataSource).toBe('sql')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('applies per-entity overrides', () => {
|
|
200
|
+
const app = crudAppFromSchemas([customers, orders], {
|
|
201
|
+
overviewDashboard: false,
|
|
202
|
+
perEntity: { orders: { screens: ['list'] } },
|
|
203
|
+
})
|
|
204
|
+
expect(app.screens.filter((s) => s.entity === 'orders').map((s) => s.id)).toEqual(['orders'])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('produces a valid project that round-trips through parse/serialize', () => {
|
|
208
|
+
const app = crudAppFromSchemas([customers, orders], { title: 'Sales desk' })
|
|
209
|
+
expect(validateProject(app).filter((i) => i.level === 'error')).toEqual([])
|
|
210
|
+
const round = parseProject(serializeProject(app))
|
|
211
|
+
expect(round).toEqual(app)
|
|
212
|
+
expect(round.title).toBe('Sales desk')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('every rowLink names a screen that exists', () => {
|
|
216
|
+
const app = crudAppFromSchemas([customers, orders], { perEntity: { customers: { editing: 'detail' } } })
|
|
217
|
+
const ids = new Set(app.screens.map((s) => s.id))
|
|
218
|
+
for (const screen of app.screens) {
|
|
219
|
+
for (const block of screen.blocks) {
|
|
220
|
+
if (block.config.kind !== 'grid') continue
|
|
221
|
+
const link = (block.config as GridConfig).rowLink
|
|
222
|
+
if (link) expect(ids.has(link.screen), `dangling rowLink -> ${link.screen}`).toBe(true)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
})
|