@pilotiq/pilotiq 0.5.0 → 0.6.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +10 -0
- package/dist/Column.d.ts +36 -0
- package/dist/Column.d.ts.map +1 -1
- package/dist/Column.js +24 -0
- package/dist/Column.js.map +1 -1
- package/dist/RenderHook.d.ts +2 -2
- package/dist/RenderHook.d.ts.map +1 -1
- package/dist/RenderHook.js +8 -0
- package/dist/RenderHook.js.map +1 -1
- package/dist/applyPageHooks.d.ts.map +1 -1
- package/dist/applyPageHooks.js +76 -0
- package/dist/applyPageHooks.js.map +1 -1
- package/dist/elements/dispatchForm.d.ts +14 -6
- package/dist/elements/dispatchForm.d.ts.map +1 -1
- package/dist/elements/dispatchForm.js +28 -8
- package/dist/elements/dispatchForm.js.map +1 -1
- package/dist/fields/TextField.d.ts +10 -0
- package/dist/fields/TextField.d.ts.map +1 -1
- package/dist/fields/TextField.js +11 -0
- package/dist/fields/TextField.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +32 -4
- package/dist/pageData.js.map +1 -1
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +25 -4
- package/dist/react/SchemaRenderer.js.map +1 -1
- package/dist/react/cells/EditableCell.d.ts.map +1 -1
- package/dist/react/cells/EditableCell.js +6 -1
- package/dist/react/cells/EditableCell.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +35 -0
- package/dist/routes.js.map +1 -1
- package/dist/schema/SlotComponent.d.ts +49 -0
- package/dist/schema/SlotComponent.d.ts.map +1 -0
- package/dist/schema/SlotComponent.js +65 -0
- package/dist/schema/SlotComponent.js.map +1 -0
- package/dist/schema/Wizard.d.ts +37 -0
- package/dist/schema/Wizard.d.ts.map +1 -1
- package/dist/schema/Wizard.js +21 -0
- package/dist/schema/Wizard.js.map +1 -1
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +1 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/slot-components/index.d.ts +2 -0
- package/dist/slot-components/index.d.ts.map +1 -0
- package/dist/slot-components/index.js +6 -0
- package/dist/slot-components/index.js.map +1 -0
- package/dist/slot-components/registry.d.ts +41 -0
- package/dist/slot-components/registry.d.ts.map +1 -0
- package/dist/slot-components/registry.js +17 -0
- package/dist/slot-components/registry.js.map +1 -0
- package/package.json +5 -1
- package/src/Column.test.ts +23 -0
- package/src/Column.ts +44 -0
- package/src/RenderHook.ts +16 -0
- package/src/applyPageHooks.test.ts +167 -2
- package/src/applyPageHooks.ts +88 -0
- package/src/elements/dispatchForm.test.ts +23 -1
- package/src/elements/dispatchForm.ts +33 -9
- package/src/fields/TextField.test.ts +45 -0
- package/src/fields/TextField.ts +13 -0
- package/src/index.ts +1 -0
- package/src/pageData.test.ts +83 -0
- package/src/pageData.ts +37 -4
- package/src/react/SchemaRenderer.tsx +43 -4
- package/src/react/cells/EditableCell.tsx +5 -1
- package/src/routes.test.ts +141 -0
- package/src/routes.ts +32 -0
- package/src/schema/SlotComponent.test.ts +77 -0
- package/src/schema/SlotComponent.ts +71 -0
- package/src/schema/Wizard.ts +45 -0
- package/src/schema/containers.test.ts +28 -0
- package/src/schema/index.ts +1 -0
- package/src/slot-components/index.ts +10 -0
- package/src/slot-components/registry.ts +56 -0
|
@@ -9,13 +9,16 @@ import { Form } from './elements/Form.js'
|
|
|
9
9
|
import { TextField } from './fields/TextField.js'
|
|
10
10
|
import { Heading } from './schema/Heading.js'
|
|
11
11
|
import { Alert } from './schema/Alert.js'
|
|
12
|
-
import {
|
|
12
|
+
import { Action } from './actions/Action.js'
|
|
13
|
+
import { resourceIndexData, resourceCreateData, resourceViewData, resourceEditData, customPageData } from './pageData.js'
|
|
13
14
|
import { Page } from './Page.js'
|
|
14
15
|
|
|
15
|
-
const heading = (content: string): ElementMeta =>
|
|
16
|
+
const heading = (content: string, children?: ElementMeta[]): ElementMeta =>
|
|
17
|
+
children ? { type: 'heading', content, children } : { type: 'heading', content }
|
|
16
18
|
const table = (rows = 0): ElementMeta => ({ type: 'table', rows })
|
|
17
19
|
const form = (id = 'f'): ElementMeta => ({ type: 'form', formId: id })
|
|
18
20
|
const tabs = (children: ElementMeta[] = []): ElementMeta => ({ type: 'listTabs', children })
|
|
21
|
+
const action = (label: string): ElementMeta => ({ type: 'action', label })
|
|
19
22
|
|
|
20
23
|
describe('pageHooksFor()', () => {
|
|
21
24
|
it('returns universal page bounds for non-resource roles', () => {
|
|
@@ -36,6 +39,24 @@ describe('pageHooksFor()', () => {
|
|
|
36
39
|
assert.ok(names.includes('panels::resource.pages.create-record.form.before'))
|
|
37
40
|
assert.ok(names.includes('panels::resource.pages.create-record.form.after'))
|
|
38
41
|
})
|
|
42
|
+
|
|
43
|
+
it('extends with header.actions.before/.after for the four resource roles', () => {
|
|
44
|
+
assert.ok(pageHooksFor('list').includes('panels::resource.pages.list-records.header.actions.before'))
|
|
45
|
+
assert.ok(pageHooksFor('list').includes('panels::resource.pages.list-records.header.actions.after'))
|
|
46
|
+
assert.ok(pageHooksFor('create').includes('panels::resource.pages.create-record.header.actions.before'))
|
|
47
|
+
assert.ok(pageHooksFor('create').includes('panels::resource.pages.create-record.header.actions.after'))
|
|
48
|
+
assert.ok(pageHooksFor('edit').includes('panels::resource.pages.edit-record.header.actions.before'))
|
|
49
|
+
assert.ok(pageHooksFor('edit').includes('panels::resource.pages.edit-record.header.actions.after'))
|
|
50
|
+
assert.ok(pageHooksFor('view').includes('panels::resource.pages.view-record.header.actions.before'))
|
|
51
|
+
assert.ok(pageHooksFor('view').includes('panels::resource.pages.view-record.header.actions.after'))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('does not include header.actions slots on non-resource roles', () => {
|
|
55
|
+
const names = pageHooksFor('dashboard')
|
|
56
|
+
assert.ok(!names.some(n => n.includes('header.actions')))
|
|
57
|
+
const search = pageHooksFor('search')
|
|
58
|
+
assert.ok(!search.some(n => n.includes('header.actions')))
|
|
59
|
+
})
|
|
39
60
|
})
|
|
40
61
|
|
|
41
62
|
describe('applyPageHooks()', () => {
|
|
@@ -172,6 +193,107 @@ describe('applyPageHooks()', () => {
|
|
|
172
193
|
assert.deepEqual(contents, ['outer-top', 'inner-top', 'detail', 'inner-bottom', 'outer-bottom'])
|
|
173
194
|
})
|
|
174
195
|
|
|
196
|
+
it('appends list-records.header.actions.before/.after into the first heading children', () => {
|
|
197
|
+
const out = applyPageHooks(
|
|
198
|
+
[heading('Posts', [action('Create')]), table()],
|
|
199
|
+
{
|
|
200
|
+
'panels::resource.pages.list-records.header.actions.before': [action('Plugin Pre')],
|
|
201
|
+
'panels::resource.pages.list-records.header.actions.after': [action('Plugin Post')],
|
|
202
|
+
},
|
|
203
|
+
'list',
|
|
204
|
+
)
|
|
205
|
+
const updated = out[0] as ElementMeta
|
|
206
|
+
assert.equal(updated.type, 'heading')
|
|
207
|
+
assert.equal(updated.children?.length, 3)
|
|
208
|
+
assert.equal(updated.children?.[0]?.['label'], 'Plugin Pre')
|
|
209
|
+
assert.equal(updated.children?.[1]?.['label'], 'Create')
|
|
210
|
+
assert.equal(updated.children?.[2]?.['label'], 'Plugin Post')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('header.actions.before fires on create-record role around the heading children', () => {
|
|
214
|
+
const out = applyPageHooks(
|
|
215
|
+
[heading('Create Post', [action('Save')]), form()],
|
|
216
|
+
{
|
|
217
|
+
'panels::resource.pages.create-record.header.actions.before': [action('Agents')],
|
|
218
|
+
},
|
|
219
|
+
'create',
|
|
220
|
+
)
|
|
221
|
+
const updated = out[0] as ElementMeta
|
|
222
|
+
assert.equal(updated.children?.length, 2)
|
|
223
|
+
assert.equal(updated.children?.[0]?.['label'], 'Agents')
|
|
224
|
+
assert.equal(updated.children?.[1]?.['label'], 'Save')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('header.actions.after fires on edit-record role appended after existing children', () => {
|
|
228
|
+
const out = applyPageHooks(
|
|
229
|
+
[heading('Edit Post', [action('Save'), action('Delete')]), form()],
|
|
230
|
+
{
|
|
231
|
+
'panels::resource.pages.edit-record.header.actions.after': [action('Agents')],
|
|
232
|
+
},
|
|
233
|
+
'edit',
|
|
234
|
+
)
|
|
235
|
+
const updated = out[0] as ElementMeta
|
|
236
|
+
assert.equal(updated.children?.length, 3)
|
|
237
|
+
assert.equal(updated.children?.[2]?.['label'], 'Agents')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('header.actions.before/.after fires on view-record role', () => {
|
|
241
|
+
const out = applyPageHooks(
|
|
242
|
+
[heading('View Post', [action('Edit')])],
|
|
243
|
+
{
|
|
244
|
+
'panels::resource.pages.view-record.header.actions.before': [action('Pre')],
|
|
245
|
+
'panels::resource.pages.view-record.header.actions.after': [action('Post')],
|
|
246
|
+
},
|
|
247
|
+
'view',
|
|
248
|
+
)
|
|
249
|
+
const updated = out[0] as ElementMeta
|
|
250
|
+
assert.deepEqual(updated.children?.map(c => c['label']), ['Pre', 'Edit', 'Post'])
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('drops header.actions hooks silently when no heading anchor exists', () => {
|
|
254
|
+
const out = applyPageHooks(
|
|
255
|
+
[table()],
|
|
256
|
+
{ 'panels::resource.pages.list-records.header.actions.before': [action('lost')] },
|
|
257
|
+
'list',
|
|
258
|
+
)
|
|
259
|
+
assert.equal(out.length, 1)
|
|
260
|
+
assert.equal(out[0]?.type, 'table')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('only the first top-level heading receives header.actions splice', () => {
|
|
264
|
+
const out = applyPageHooks(
|
|
265
|
+
[heading('First'), heading('Second')],
|
|
266
|
+
{ 'panels::resource.pages.list-records.header.actions.before': [action('Once')] },
|
|
267
|
+
'list',
|
|
268
|
+
)
|
|
269
|
+
assert.equal((out[0] as ElementMeta).children?.length, 1)
|
|
270
|
+
assert.equal((out[1] as ElementMeta).children, undefined)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('non-action contributions land in heading.children at meta level (renderer filters at paint)', () => {
|
|
274
|
+
const out = applyPageHooks(
|
|
275
|
+
[heading('Posts', [action('Create')])],
|
|
276
|
+
{
|
|
277
|
+
'panels::resource.pages.list-records.header.actions.before': [heading('non-action')],
|
|
278
|
+
},
|
|
279
|
+
'list',
|
|
280
|
+
)
|
|
281
|
+
const updated = out[0] as ElementMeta
|
|
282
|
+
assert.equal(updated.children?.length, 2)
|
|
283
|
+
assert.equal(updated.children?.[0]?.type, 'heading')
|
|
284
|
+
assert.equal(updated.children?.[1]?.['label'], 'Create')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('does not splice header.actions on non-resource roles', () => {
|
|
288
|
+
const out = applyPageHooks(
|
|
289
|
+
[heading('Hello', [action('Built-in')])],
|
|
290
|
+
{ 'panels::resource.pages.list-records.header.actions.before': [action('Lost')] },
|
|
291
|
+
'dashboard',
|
|
292
|
+
)
|
|
293
|
+
assert.equal((out[0] as ElementMeta).children?.length, 1)
|
|
294
|
+
assert.equal((out[0] as ElementMeta).children?.[0]?.['label'], 'Built-in')
|
|
295
|
+
})
|
|
296
|
+
|
|
175
297
|
it('does not splice list-records.* on a non-list role', () => {
|
|
176
298
|
const out = applyPageHooks(
|
|
177
299
|
[table()],
|
|
@@ -274,6 +396,49 @@ describe('Per-builder render-hook integration', () => {
|
|
|
274
396
|
assert.equal(schema[0]?.['content'], 'Page banner')
|
|
275
397
|
})
|
|
276
398
|
|
|
399
|
+
it('resourceIndexData splices header.actions.before into the page heading children', async () => {
|
|
400
|
+
const panel = Pilotiq.make('admin')
|
|
401
|
+
.path('/admin')
|
|
402
|
+
.resources([Articles])
|
|
403
|
+
.renderHook(
|
|
404
|
+
'panels::resource.pages.list-records.header.actions.before',
|
|
405
|
+
() => [Action.make('agents').label('Agents')],
|
|
406
|
+
)
|
|
407
|
+
const data = await resourceIndexData(panel, 'articles')
|
|
408
|
+
const schema = data?.['schemaData'] as ElementMeta[]
|
|
409
|
+
const headingMeta = schema.find(m => m.type === 'heading')
|
|
410
|
+
assert.ok(headingMeta, 'expected page heading')
|
|
411
|
+
const labels = (headingMeta.children ?? []).map(c => c['label'])
|
|
412
|
+
assert.ok(labels.includes('Agents'))
|
|
413
|
+
// Plugin chip appears at the start (before the resource's built-in
|
|
414
|
+
// "New Article" / equivalents).
|
|
415
|
+
assert.equal(labels[0], 'Agents')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('resourceEditData scoped header.actions hook only fires for matching Resource', async () => {
|
|
419
|
+
class OtherResource extends Resource {
|
|
420
|
+
static override label = 'Other'
|
|
421
|
+
static override slug = 'other'
|
|
422
|
+
static override form(form: Form): Form { return form.schema([TextField.make('name')]) }
|
|
423
|
+
}
|
|
424
|
+
const panel = Pilotiq.make('admin')
|
|
425
|
+
.path('/admin')
|
|
426
|
+
.resources([Articles, OtherResource])
|
|
427
|
+
.renderHook(
|
|
428
|
+
'panels::resource.pages.edit-record.header.actions.after',
|
|
429
|
+
() => [Action.make('agents').label('Agents')],
|
|
430
|
+
{ resource: Articles },
|
|
431
|
+
)
|
|
432
|
+
const onArticles = await resourceEditData(panel, 'articles', 'rec-1')
|
|
433
|
+
const onOther = await resourceEditData(panel, 'other', 'rec-2')
|
|
434
|
+
const findAgents = (sd: ElementMeta[] | undefined): boolean => {
|
|
435
|
+
const h = (sd ?? []).find(m => m.type === 'heading')
|
|
436
|
+
return Boolean((h?.children ?? []).some(c => c['label'] === 'Agents'))
|
|
437
|
+
}
|
|
438
|
+
assert.ok(findAgents(onArticles?.['schemaData'] as ElementMeta[]))
|
|
439
|
+
assert.equal(findAgents(onOther?.['schemaData'] as ElementMeta[]), false)
|
|
440
|
+
})
|
|
441
|
+
|
|
277
442
|
it('scoped hook only fires when the matching resource is active', async () => {
|
|
278
443
|
class OtherResource extends Resource {
|
|
279
444
|
static override label = 'Other'
|
package/src/applyPageHooks.ts
CHANGED
|
@@ -55,24 +55,32 @@ export function pageHooksFor(role: PageRole): readonly RenderHookName[] {
|
|
|
55
55
|
'panels::resource.pages.list-records.table.before',
|
|
56
56
|
'panels::resource.pages.list-records.table.after',
|
|
57
57
|
'panels::resource.pages.list-records.tabs.end',
|
|
58
|
+
'panels::resource.pages.list-records.header.actions.before',
|
|
59
|
+
'panels::resource.pages.list-records.header.actions.after',
|
|
58
60
|
]
|
|
59
61
|
case 'create':
|
|
60
62
|
return [
|
|
61
63
|
...PAGE_BOUNDS,
|
|
62
64
|
'panels::resource.pages.create-record.form.before',
|
|
63
65
|
'panels::resource.pages.create-record.form.after',
|
|
66
|
+
'panels::resource.pages.create-record.header.actions.before',
|
|
67
|
+
'panels::resource.pages.create-record.header.actions.after',
|
|
64
68
|
]
|
|
65
69
|
case 'edit':
|
|
66
70
|
return [
|
|
67
71
|
...PAGE_BOUNDS,
|
|
68
72
|
'panels::resource.pages.edit-record.form.before',
|
|
69
73
|
'panels::resource.pages.edit-record.form.after',
|
|
74
|
+
'panels::resource.pages.edit-record.header.actions.before',
|
|
75
|
+
'panels::resource.pages.edit-record.header.actions.after',
|
|
70
76
|
]
|
|
71
77
|
case 'view':
|
|
72
78
|
return [
|
|
73
79
|
...PAGE_BOUNDS,
|
|
74
80
|
'panels::resource.pages.view-record.start',
|
|
75
81
|
'panels::resource.pages.view-record.end',
|
|
82
|
+
'panels::resource.pages.view-record.header.actions.before',
|
|
83
|
+
'panels::resource.pages.view-record.header.actions.after',
|
|
76
84
|
]
|
|
77
85
|
case 'search':
|
|
78
86
|
return [
|
|
@@ -85,6 +93,40 @@ export function pageHooksFor(role: PageRole): readonly RenderHookName[] {
|
|
|
85
93
|
}
|
|
86
94
|
}
|
|
87
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Map a resource page role to its `header.actions.before / .after`
|
|
98
|
+
* slot pair. Returns `undefined` for non-resource roles where the
|
|
99
|
+
* concept doesn't apply.
|
|
100
|
+
*/
|
|
101
|
+
function headerActionSlotsFor(
|
|
102
|
+
role: PageRole,
|
|
103
|
+
): readonly [RenderHookName, RenderHookName] | undefined {
|
|
104
|
+
switch (role) {
|
|
105
|
+
case 'list':
|
|
106
|
+
return [
|
|
107
|
+
'panels::resource.pages.list-records.header.actions.before',
|
|
108
|
+
'panels::resource.pages.list-records.header.actions.after',
|
|
109
|
+
]
|
|
110
|
+
case 'create':
|
|
111
|
+
return [
|
|
112
|
+
'panels::resource.pages.create-record.header.actions.before',
|
|
113
|
+
'panels::resource.pages.create-record.header.actions.after',
|
|
114
|
+
]
|
|
115
|
+
case 'edit':
|
|
116
|
+
return [
|
|
117
|
+
'panels::resource.pages.edit-record.header.actions.before',
|
|
118
|
+
'panels::resource.pages.edit-record.header.actions.after',
|
|
119
|
+
]
|
|
120
|
+
case 'view':
|
|
121
|
+
return [
|
|
122
|
+
'panels::resource.pages.view-record.header.actions.before',
|
|
123
|
+
'panels::resource.pages.view-record.header.actions.after',
|
|
124
|
+
]
|
|
125
|
+
default:
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
88
130
|
/**
|
|
89
131
|
* Splice a `RenderHookMap` into a resolved page schema at the
|
|
90
132
|
* role-specific positions. Returns a new `ElementMeta[]`; the input
|
|
@@ -167,6 +209,20 @@ export function applyPageHooks(
|
|
|
167
209
|
)
|
|
168
210
|
}
|
|
169
211
|
|
|
212
|
+
// Resource-page header actions — splice into the first top-level
|
|
213
|
+
// Heading's children alongside built-in actions. Slot is action-only
|
|
214
|
+
// by convention (the heading renderer filters children to action /
|
|
215
|
+
// actionGroup); non-action contributions are silently skipped at
|
|
216
|
+
// render time.
|
|
217
|
+
const headerSlots = headerActionSlotsFor(role)
|
|
218
|
+
if (headerSlots) {
|
|
219
|
+
result = spliceHeadingActions(
|
|
220
|
+
result,
|
|
221
|
+
hooks[headerSlots[0]],
|
|
222
|
+
hooks[headerSlots[1]],
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
170
226
|
// Universal page.start / page.end wrap everything else.
|
|
171
227
|
result = wrap(
|
|
172
228
|
result,
|
|
@@ -205,6 +261,38 @@ function spliceAroundType(
|
|
|
205
261
|
return result
|
|
206
262
|
}
|
|
207
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Splice `before` / `after` into the first top-level `'heading'` meta's
|
|
266
|
+
* `children` array — the slot the heading renderer uses to mount the
|
|
267
|
+
* page-title's right-aligned action chips. No-op when neither slot has
|
|
268
|
+
* content; drops silently when no heading anchor exists (custom
|
|
269
|
+
* headers carry their own action layout).
|
|
270
|
+
*/
|
|
271
|
+
function spliceHeadingActions(
|
|
272
|
+
metas: ElementMeta[],
|
|
273
|
+
before: ElementMeta[] | undefined,
|
|
274
|
+
after: ElementMeta[] | undefined,
|
|
275
|
+
): ElementMeta[] {
|
|
276
|
+
const hasBefore = before && before.length > 0
|
|
277
|
+
const hasAfter = after && after.length > 0
|
|
278
|
+
if (!hasBefore && !hasAfter) return metas
|
|
279
|
+
let mutated = false
|
|
280
|
+
return metas.map(m => {
|
|
281
|
+
if (!mutated && m.type === 'heading') {
|
|
282
|
+
mutated = true
|
|
283
|
+
return {
|
|
284
|
+
...m,
|
|
285
|
+
children: [
|
|
286
|
+
...(hasBefore ? before : []),
|
|
287
|
+
...(m.children ?? []),
|
|
288
|
+
...(hasAfter ? after : []),
|
|
289
|
+
],
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return m
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
208
296
|
/** Append `toAppend` into the first top-level meta whose `type`
|
|
209
297
|
* matches, on its `children` array. No-op when the type isn't found. */
|
|
210
298
|
function appendIntoChildrenOfType(
|
|
@@ -2,7 +2,7 @@ import { describe, it } from 'node:test'
|
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
|
|
4
4
|
import { Form } from './Form.js'
|
|
5
|
-
import { applyStateUpdate, coerceFormValues, dispatchFormSubmit, findForms, findWizardStepFields, selectForm, selectFormById } from './dispatchForm.js'
|
|
5
|
+
import { applyStateUpdate, coerceFormValues, dispatchFormSubmit, findForms, findWizardStep, findWizardStepFields, selectForm, selectFormById } from './dispatchForm.js'
|
|
6
6
|
import { Wizard, Step } from '../schema/Wizard.js'
|
|
7
7
|
import { TextField } from '../fields/TextField.js'
|
|
8
8
|
import { NumberField } from '../fields/NumberField.js'
|
|
@@ -453,3 +453,25 @@ describe('findWizardStepFields (Plan #8)', () => {
|
|
|
453
453
|
assert.equal((fields![0] as TextField).name, 'nested')
|
|
454
454
|
})
|
|
455
455
|
})
|
|
456
|
+
|
|
457
|
+
describe('findWizardStep (Plan #8)', () => {
|
|
458
|
+
it('returns the live Step instance for the requested index', () => {
|
|
459
|
+
const stepA = Step.make('a').schema([TextField.make('email')])
|
|
460
|
+
const stepB = Step.make('b').schema([TextField.make('name')])
|
|
461
|
+
const form = Form.make().schema([Wizard.make().steps([stepA, stepB])])
|
|
462
|
+
const found = findWizardStep(form.getChildren()!, 1)
|
|
463
|
+
assert.equal(found, stepB)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('returns undefined when no Wizard descendant exists', () => {
|
|
467
|
+
const form = Form.make().schema([TextField.make('plain')])
|
|
468
|
+
assert.equal(findWizardStep(form.getChildren()!, 0), undefined)
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('returns undefined when the step index is out of range', () => {
|
|
472
|
+
const form = Form.make().schema([
|
|
473
|
+
Wizard.make().steps([Step.make('only').schema([])]),
|
|
474
|
+
])
|
|
475
|
+
assert.equal(findWizardStep(form.getChildren()!, 5), undefined)
|
|
476
|
+
})
|
|
477
|
+
})
|
|
@@ -397,6 +397,16 @@ export function coerceFormValues(
|
|
|
397
397
|
break
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
+
// `TextField.trim()` — strips leading/trailing whitespace from the
|
|
401
|
+
// submitted value. Runs BEFORE stripCharacters so a value like
|
|
402
|
+
// `' (415) 555-1212 '` first trims, then has the listed mask
|
|
403
|
+
// characters removed. Skipped for non-strings.
|
|
404
|
+
const trimmer = (field as { getTrim?: () => boolean }).getTrim
|
|
405
|
+
if (typeof trimmer === 'function' && trimmer.call(field)) {
|
|
406
|
+
const cur = out[name]
|
|
407
|
+
if (typeof cur === 'string') out[name] = cur.trim()
|
|
408
|
+
}
|
|
409
|
+
|
|
400
410
|
// `TextField.stripCharacters([…])` — applies after type-specific
|
|
401
411
|
// coercion so the persisted value never carries the listed
|
|
402
412
|
// characters. Duck-typed: any Field whose `getStripCharacters?`
|
|
@@ -767,17 +777,18 @@ export function findForms(elements: ReadonlyArray<Element>): Form[] {
|
|
|
767
777
|
}
|
|
768
778
|
|
|
769
779
|
/**
|
|
770
|
-
* Plan #8 — locate the
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
780
|
+
* Plan #8 — locate the Wizard step Element at the given index inside the
|
|
781
|
+
* form's tree. Returns the live Step instance so callers can read both
|
|
782
|
+
* its children (`step.getChildren()`) and any hooks attached to it
|
|
783
|
+
* (`getBeforeValidation / getAfterValidation`). Walks structurally
|
|
784
|
+
* (`getType() === 'wizard'/'step'`) to stay robust to Vite SSR
|
|
785
|
+
* module-cache duplication. `undefined` when the form has no Wizard
|
|
786
|
+
* descendant or the step index is out of range.
|
|
776
787
|
*/
|
|
777
|
-
export function
|
|
788
|
+
export function findWizardStep(
|
|
778
789
|
formChildren: ReadonlyArray<Element>,
|
|
779
790
|
stepIndex: number,
|
|
780
|
-
): Element
|
|
791
|
+
): Element | undefined {
|
|
781
792
|
let wizard: Element | undefined
|
|
782
793
|
const walk = (els: ReadonlyArray<Element>): void => {
|
|
783
794
|
for (const el of els) {
|
|
@@ -790,7 +801,20 @@ export function findWizardStepFields(
|
|
|
790
801
|
walk(formChildren)
|
|
791
802
|
if (!wizard) return undefined
|
|
792
803
|
const steps = (wizard.getChildren() ?? []).filter(c => c.getType() === 'step')
|
|
793
|
-
|
|
804
|
+
return steps[stepIndex]
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Sibling helper: returns just the children of the Wizard step at the
|
|
809
|
+
* given index. Thin wrapper over `findWizardStep` for callers that only
|
|
810
|
+
* need to validate the step's fields without touching the Step instance
|
|
811
|
+
* itself. `undefined` when the step is missing.
|
|
812
|
+
*/
|
|
813
|
+
export function findWizardStepFields(
|
|
814
|
+
formChildren: ReadonlyArray<Element>,
|
|
815
|
+
stepIndex: number,
|
|
816
|
+
): Element[] | undefined {
|
|
817
|
+
const step = findWizardStep(formChildren, stepIndex)
|
|
794
818
|
if (!step) return undefined
|
|
795
819
|
return step.getChildren() ?? []
|
|
796
820
|
}
|
|
@@ -91,6 +91,51 @@ describe('TextField rich affordances (audit gap #3)', () => {
|
|
|
91
91
|
})
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
+
describe('trim', () => {
|
|
95
|
+
it('emits the flag only when set', () => {
|
|
96
|
+
const a = TextField.make('x').trim().toMeta()
|
|
97
|
+
assert.equal(a['trim'], true)
|
|
98
|
+
|
|
99
|
+
const b = TextField.make('x').toMeta()
|
|
100
|
+
assert.equal(b['trim'], undefined)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('disarms with explicit false', () => {
|
|
104
|
+
const a = TextField.make('x').trim(false).toMeta()
|
|
105
|
+
assert.equal(a['trim'], undefined)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('strips leading and trailing whitespace during coerce', () => {
|
|
109
|
+
const f = TextField.make('email').trim()
|
|
110
|
+
const out = coerceFormValues([f], { email: ' user@example.com\n' })
|
|
111
|
+
assert.equal(out['email'], 'user@example.com')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('coerce no-ops when not configured', () => {
|
|
115
|
+
const f = TextField.make('plain')
|
|
116
|
+
const out = coerceFormValues([f], { plain: ' spaced ' })
|
|
117
|
+
assert.equal(out['plain'], ' spaced ')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('coerce skips non-string values', () => {
|
|
121
|
+
const f = TextField.make('x').trim()
|
|
122
|
+
const out = coerceFormValues([f], { x: 42 as unknown as string })
|
|
123
|
+
assert.equal(out['x'], 42)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('runs before stripCharacters when both are set', () => {
|
|
127
|
+
const f = TextField.make('phone').trim().stripCharacters('()- ')
|
|
128
|
+
const out = coerceFormValues([f], { phone: ' (415) 555-1212 ' })
|
|
129
|
+
assert.equal(out['phone'], '4155551212')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('preserves empty strings', () => {
|
|
133
|
+
const f = TextField.make('x').trim()
|
|
134
|
+
const out = coerceFormValues([f], { x: ' ' })
|
|
135
|
+
assert.equal(out['x'], '')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
94
139
|
describe('inputMode + autocapitalize', () => {
|
|
95
140
|
it('emits each attribute when set', () => {
|
|
96
141
|
const a = TextField.make('q').inputMode('search').autocapitalize('off').toMeta()
|
package/src/fields/TextField.ts
CHANGED
|
@@ -38,6 +38,7 @@ export class TextField extends Field {
|
|
|
38
38
|
private _mask?: string
|
|
39
39
|
private _datalist?: string[]
|
|
40
40
|
private _stripCharacters?: string[]
|
|
41
|
+
private _trim = false
|
|
41
42
|
private _inputMode?: TextInputMode
|
|
42
43
|
private _autocapitalize?: TextAutocapitalize
|
|
43
44
|
private _prefixAction?: Action
|
|
@@ -117,6 +118,17 @@ export class TextField extends Field {
|
|
|
117
118
|
|
|
118
119
|
getStripCharacters(): string[] | undefined { return this._stripCharacters }
|
|
119
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Strip leading and trailing whitespace from the submitted value
|
|
123
|
+
* before validation runs. Mirrors Laravel's `TrimStrings` middleware
|
|
124
|
+
* — server-side authority, so a tampered client still gets trimmed
|
|
125
|
+
* values. Composes with `stripCharacters()` (trim runs first). Empty
|
|
126
|
+
* strings remain empty; non-string values pass through.
|
|
127
|
+
*/
|
|
128
|
+
trim(v: boolean = true): this { this._trim = v; return this }
|
|
129
|
+
|
|
130
|
+
getTrim(): boolean { return this._trim }
|
|
131
|
+
|
|
120
132
|
/**
|
|
121
133
|
* Set the HTML `inputmode` attribute — drives the virtual-keyboard
|
|
122
134
|
* layout on mobile. Distinct from `type=` (a `text` field with
|
|
@@ -157,6 +169,7 @@ export class TextField extends Field {
|
|
|
157
169
|
...(this._mask !== undefined ? { mask: this._mask } : {}),
|
|
158
170
|
...(this._datalist !== undefined ? { datalist: this._datalist } : {}),
|
|
159
171
|
...(this._stripCharacters!== undefined ? { stripCharacters:this._stripCharacters} : {}),
|
|
172
|
+
...(this._trim ? { trim: true } : {}),
|
|
160
173
|
...(this._inputMode !== undefined ? { inputMode: this._inputMode } : {}),
|
|
161
174
|
...(this._autocapitalize !== undefined ? { autocapitalize: this._autocapitalize } : {}),
|
|
162
175
|
}
|
package/src/index.ts
CHANGED
|
@@ -172,6 +172,7 @@ export { MetaTag, type MetaTagAttrs } from './schema/MetaTag.js'
|
|
|
172
172
|
export { LinkTag, type LinkTagAttrs } from './schema/LinkTag.js'
|
|
173
173
|
export { ScriptTag, type ScriptTagAttrs } from './schema/ScriptTag.js'
|
|
174
174
|
export { StyleTag } from './schema/StyleTag.js'
|
|
175
|
+
export { SlotComponent } from './schema/SlotComponent.js'
|
|
175
176
|
|
|
176
177
|
// Plan #16 — read-only entry primitives for `Resource.detail()`.
|
|
177
178
|
export {
|
package/src/pageData.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
applyFillPipeline,
|
|
9
9
|
formCreateOptionData,
|
|
10
10
|
formStateData,
|
|
11
|
+
formWizardData,
|
|
11
12
|
mentionResolveData,
|
|
12
13
|
panelInfo,
|
|
13
14
|
resolveActiveTab,
|
|
@@ -29,6 +30,7 @@ import { TextField } from './fields/TextField.js'
|
|
|
29
30
|
import { SelectField } from './fields/SelectField.js'
|
|
30
31
|
import { ToggleField } from './fields/ToggleField.js'
|
|
31
32
|
import { Section } from './schema/Section.js'
|
|
33
|
+
import { Wizard, Step } from './schema/Wizard.js'
|
|
32
34
|
import { Repeater } from './fields/RepeaterField.js'
|
|
33
35
|
import { Builder } from './fields/BuilderField.js'
|
|
34
36
|
import { Block } from './schema/Block.js'
|
|
@@ -1098,6 +1100,87 @@ describe('mentionResolveData (async mention items)', () => {
|
|
|
1098
1100
|
})
|
|
1099
1101
|
})
|
|
1100
1102
|
|
|
1103
|
+
describe('formWizardData — Step.beforeValidation / afterValidation hooks', () => {
|
|
1104
|
+
function panelWithWizard(steps: Step[]) {
|
|
1105
|
+
class TestPage extends Page {
|
|
1106
|
+
static override slug = 'demo'
|
|
1107
|
+
static override schema() {
|
|
1108
|
+
return [Form.make().formId('the-form').schema([Wizard.make().steps(steps)])]
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const dispatch = (panel: ReturnType<typeof Pilotiq.make>, body: { formId: string; step: number; values: Record<string, unknown> }) =>
|
|
1115
|
+
formWizardData(panel, { kind: 'page', pageSlug: 'demo' }, body)
|
|
1116
|
+
|
|
1117
|
+
it('returns ok:true when no hooks are set and validation passes', async () => {
|
|
1118
|
+
const panel = panelWithWizard([Step.make('a').schema([TextField.make('x')])])
|
|
1119
|
+
const result = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
|
|
1120
|
+
assert.deepEqual(result, { ok: true })
|
|
1121
|
+
})
|
|
1122
|
+
|
|
1123
|
+
it('runs beforeValidation before validators and lets it mutate values in place', async () => {
|
|
1124
|
+
const seen: string[] = []
|
|
1125
|
+
const panel = panelWithWizard([
|
|
1126
|
+
Step.make('a').schema([TextField.make('email').required()])
|
|
1127
|
+
.beforeValidation((values) => {
|
|
1128
|
+
seen.push('before')
|
|
1129
|
+
values['email'] = 'auto@example.com'
|
|
1130
|
+
}),
|
|
1131
|
+
])
|
|
1132
|
+
const result = await dispatch(panel, { formId: 'the-form', step: 0, values: {} })
|
|
1133
|
+
assert.deepEqual(result, { ok: true })
|
|
1134
|
+
assert.deepEqual(seen, ['before'])
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
it('throwing from beforeValidation halts with 422 under the _step key', async () => {
|
|
1138
|
+
const panel = panelWithWizard([
|
|
1139
|
+
Step.make('a').schema([TextField.make('x')])
|
|
1140
|
+
.beforeValidation(async () => { throw new Error('email already in use') }),
|
|
1141
|
+
])
|
|
1142
|
+
const result = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
|
|
1143
|
+
assert.equal((result as { ok: false; status: number }).ok, false)
|
|
1144
|
+
assert.equal((result as { ok: false; status: number }).status, 422)
|
|
1145
|
+
assert.deepEqual((result as { errors: Record<string, string[]> }).errors, { _step: ['email already in use'] })
|
|
1146
|
+
})
|
|
1147
|
+
|
|
1148
|
+
it('runs afterValidation only when validators pass', async () => {
|
|
1149
|
+
let afterRan = false
|
|
1150
|
+
const panel = panelWithWizard([
|
|
1151
|
+
Step.make('a').schema([TextField.make('x').required()])
|
|
1152
|
+
.afterValidation(() => { afterRan = true }),
|
|
1153
|
+
])
|
|
1154
|
+
// Failing field validators short-circuit before afterValidation fires.
|
|
1155
|
+
const failed = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: '' } })
|
|
1156
|
+
assert.equal((failed as { ok: false }).ok, false)
|
|
1157
|
+
assert.equal(afterRan, false)
|
|
1158
|
+
// Passing values let afterValidation run.
|
|
1159
|
+
const passed = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
|
|
1160
|
+
assert.deepEqual(passed, { ok: true })
|
|
1161
|
+
assert.equal(afterRan, true)
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1164
|
+
it('throwing from afterValidation halts with 422 under the _step key', async () => {
|
|
1165
|
+
const panel = panelWithWizard([
|
|
1166
|
+
Step.make('a').schema([TextField.make('x')])
|
|
1167
|
+
.afterValidation(() => { throw new Error('cross-field invariant failed') }),
|
|
1168
|
+
])
|
|
1169
|
+
const result = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
|
|
1170
|
+
assert.equal((result as { ok: false; status: number }).status, 422)
|
|
1171
|
+
assert.deepEqual((result as { errors: Record<string, string[]> }).errors, { _step: ['cross-field invariant failed'] })
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
it('non-Error throws still produce a usable message', async () => {
|
|
1175
|
+
const panel = panelWithWizard([
|
|
1176
|
+
Step.make('a').schema([TextField.make('x')])
|
|
1177
|
+
.beforeValidation(() => { throw 'plain string failure' as unknown as Error }),
|
|
1178
|
+
])
|
|
1179
|
+
const result = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
|
|
1180
|
+
assert.deepEqual((result as { errors: Record<string, string[]> }).errors, { _step: ['plain string failure'] })
|
|
1181
|
+
})
|
|
1182
|
+
})
|
|
1183
|
+
|
|
1101
1184
|
describe('tagRichTextMentionUrls — nested Repeater + Builder rows', () => {
|
|
1102
1185
|
it('stamps a Repeater template field via the form-level URL', () => {
|
|
1103
1186
|
const inner = new FakeRichTextField('body', true)
|