@pilotiq/pilotiq 0.4.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.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +11 -1
  3. package/dist/Column.d.ts +36 -0
  4. package/dist/Column.d.ts.map +1 -1
  5. package/dist/Column.js +24 -0
  6. package/dist/Column.js.map +1 -1
  7. package/dist/RenderHook.d.ts +2 -2
  8. package/dist/RenderHook.d.ts.map +1 -1
  9. package/dist/RenderHook.js +8 -0
  10. package/dist/RenderHook.js.map +1 -1
  11. package/dist/applyPageHooks.d.ts.map +1 -1
  12. package/dist/applyPageHooks.js +76 -0
  13. package/dist/applyPageHooks.js.map +1 -1
  14. package/dist/elements/dispatchForm.d.ts +14 -6
  15. package/dist/elements/dispatchForm.d.ts.map +1 -1
  16. package/dist/elements/dispatchForm.js +28 -8
  17. package/dist/elements/dispatchForm.js.map +1 -1
  18. package/dist/fields/TextField.d.ts +10 -0
  19. package/dist/fields/TextField.d.ts.map +1 -1
  20. package/dist/fields/TextField.js +11 -0
  21. package/dist/fields/TextField.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/pageData.d.ts.map +1 -1
  27. package/dist/pageData.js +32 -4
  28. package/dist/pageData.js.map +1 -1
  29. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  30. package/dist/react/SchemaRenderer.js +25 -4
  31. package/dist/react/SchemaRenderer.js.map +1 -1
  32. package/dist/react/cells/EditableCell.d.ts.map +1 -1
  33. package/dist/react/cells/EditableCell.js +6 -1
  34. package/dist/react/cells/EditableCell.js.map +1 -1
  35. package/dist/routes.d.ts.map +1 -1
  36. package/dist/routes.js +35 -0
  37. package/dist/routes.js.map +1 -1
  38. package/dist/schema/SlotComponent.d.ts +49 -0
  39. package/dist/schema/SlotComponent.d.ts.map +1 -0
  40. package/dist/schema/SlotComponent.js +65 -0
  41. package/dist/schema/SlotComponent.js.map +1 -0
  42. package/dist/schema/Wizard.d.ts +37 -0
  43. package/dist/schema/Wizard.d.ts.map +1 -1
  44. package/dist/schema/Wizard.js +21 -0
  45. package/dist/schema/Wizard.js.map +1 -1
  46. package/dist/schema/index.d.ts +1 -0
  47. package/dist/schema/index.d.ts.map +1 -1
  48. package/dist/schema/index.js +1 -0
  49. package/dist/schema/index.js.map +1 -1
  50. package/dist/slot-components/index.d.ts +2 -0
  51. package/dist/slot-components/index.d.ts.map +1 -0
  52. package/dist/slot-components/index.js +6 -0
  53. package/dist/slot-components/index.js.map +1 -0
  54. package/dist/slot-components/registry.d.ts +41 -0
  55. package/dist/slot-components/registry.d.ts.map +1 -0
  56. package/dist/slot-components/registry.js +17 -0
  57. package/dist/slot-components/registry.js.map +1 -0
  58. package/package.json +5 -1
  59. package/src/Column.test.ts +23 -0
  60. package/src/Column.ts +44 -0
  61. package/src/RenderHook.ts +16 -0
  62. package/src/applyPageHooks.test.ts +167 -2
  63. package/src/applyPageHooks.ts +88 -0
  64. package/src/elements/dispatchForm.test.ts +23 -1
  65. package/src/elements/dispatchForm.ts +33 -9
  66. package/src/fields/TextField.test.ts +45 -0
  67. package/src/fields/TextField.ts +13 -0
  68. package/src/index.ts +1 -0
  69. package/src/pageData.test.ts +83 -0
  70. package/src/pageData.ts +37 -4
  71. package/src/react/SchemaRenderer.tsx +43 -4
  72. package/src/react/cells/EditableCell.tsx +5 -1
  73. package/src/routes.test.ts +141 -0
  74. package/src/routes.ts +32 -0
  75. package/src/schema/SlotComponent.test.ts +77 -0
  76. package/src/schema/SlotComponent.ts +71 -0
  77. package/src/schema/Wizard.ts +45 -0
  78. package/src/schema/containers.test.ts +28 -0
  79. package/src/schema/index.ts +1 -0
  80. package/src/slot-components/index.ts +10 -0
  81. 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 { resourceIndexData, resourceCreateData, resourceViewData, customPageData } from './pageData.js'
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 => ({ type: 'heading', content })
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'
@@ -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 children of a Wizard step at the given index inside
771
- * the form's tree. Returns `undefined` when the form has no Wizard
772
- * descendant or the step index is out of range. Step children are returned
773
- * as a fresh Element[] so callers can pass them straight to
774
- * `validateSchema`. Walks structurally (`getType() === 'wizard'/'step'`)
775
- * to stay robust to Vite SSR module-cache duplication.
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 findWizardStepFields(
788
+ export function findWizardStep(
778
789
  formChildren: ReadonlyArray<Element>,
779
790
  stepIndex: number,
780
- ): Element[] | undefined {
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
- const step = steps[stepIndex]
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()
@@ -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 {
@@ -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)