@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.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +10 -0
  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
package/src/pageData.ts CHANGED
@@ -23,7 +23,7 @@ import { isServerDataElement, type ServerDataElement } from './schema/ServerData
23
23
  import { Form } from './elements/Form.js'
24
24
  import { Table } from './elements/Table.js'
25
25
  import { Column } from './Column.js'
26
- import { applyStateUpdate, coerceFormValues, findForms, findWizardStepFields, loadRelationRows, selectFormById } from './elements/dispatchForm.js'
26
+ import { applyStateUpdate, coerceFormValues, findForms, findWizardStep, loadRelationRows, selectFormById } from './elements/dispatchForm.js'
27
27
  import { isRepeaterField, RepeaterField } from './fields/RepeaterField.js'
28
28
  import { isBuilderField, BuilderField } from './fields/BuilderField.js'
29
29
  import { SelectField } from './fields/SelectField.js'
@@ -3732,16 +3732,49 @@ export async function formWizardData(
3732
3732
  if (!form) return { ok: false, status: 404, error: `Form "${body.formId}" not found on page` }
3733
3733
 
3734
3734
  const formChildren = form.getChildren() ?? []
3735
- const stepFields = findWizardStepFields(formChildren, body.step)
3736
- if (!stepFields) return { ok: false, status: 404, error: `Step ${body.step} not found on form "${body.formId}"` }
3735
+ const step = findWizardStep(formChildren, body.step)
3736
+ if (!step) return { ok: false, status: 404, error: `Step ${body.step} not found on form "${body.formId}"` }
3737
+
3738
+ // Step.beforeValidation — runs before validators. May mutate `body.values`
3739
+ // in place (the validator reads from the same object), or throw to halt
3740
+ // with a 422 stamped under the reserved `_step` key.
3741
+ type StepHook = (values: Record<string, unknown>, ctx: { record?: unknown; user?: unknown }) => void | Promise<void>
3742
+ const stepHooks = step as {
3743
+ getBeforeValidation?: () => StepHook | undefined
3744
+ getAfterValidation?: () => StepHook | undefined
3745
+ }
3746
+ const beforeHook = stepHooks.getBeforeValidation?.call(step)
3747
+ if (beforeHook) {
3748
+ try { await beforeHook(body.values, { record, user }) }
3749
+ catch (err) {
3750
+ return { ok: false, status: 422, errors: { _step: [stepHookErrorMessage(err)] } }
3751
+ }
3752
+ }
3737
3753
 
3738
- const errors = await validateSchema(stepFields, body.values, record)
3754
+ const errors = await validateSchema(step.getChildren() ?? [], body.values, record)
3739
3755
  if (Object.keys(errors).length > 0) {
3740
3756
  return { ok: false, status: 422, errors }
3741
3757
  }
3758
+
3759
+ // Step.afterValidation — fires only when validators pass. Same throw →
3760
+ // 422 contract as beforeValidation.
3761
+ const afterHook = stepHooks.getAfterValidation?.call(step)
3762
+ if (afterHook) {
3763
+ try { await afterHook(body.values, { record, user }) }
3764
+ catch (err) {
3765
+ return { ok: false, status: 422, errors: { _step: [stepHookErrorMessage(err)] } }
3766
+ }
3767
+ }
3768
+
3742
3769
  return { ok: true }
3743
3770
  }
3744
3771
 
3772
+ function stepHookErrorMessage(err: unknown): string {
3773
+ if (err instanceof Error && err.message) return err.message
3774
+ if (typeof err === 'string' && err.length > 0) return err
3775
+ return 'Step validation failed'
3776
+ }
3777
+
3745
3778
  // ─── SelectField inline-create-option data builder ───────────
3746
3779
 
3747
3780
  export interface FormCreateOptionRequest {
@@ -104,6 +104,7 @@ import { StatsOverviewRenderer } from './widgets/StatsOverviewRenderer.js'
104
104
  import { TableWidgetRenderer } from './widgets/TableWidgetRenderer.js'
105
105
  import { ViewRenderer } from './widgets/ViewRenderer.js'
106
106
  import { getEntryComponent } from '../entries/registry.js'
107
+ import { getSlotComponent } from '../slot-components/registry.js'
107
108
  import { getWidgetRenderer } from './widgetRegistry.js'
108
109
 
109
110
  /** Resolve an icon name through the user-extensible registry. Returns
@@ -1205,6 +1206,14 @@ function renderActionLike(
1205
1206
  index: number,
1206
1207
  opts: RenderActionOptions = {},
1207
1208
  ): React.ReactNode {
1209
+ if (el.type === 'slotComponent') {
1210
+ // Plugin-contributed React mount — render through the main element
1211
+ // dispatcher, which looks up the registered component and forwards
1212
+ // its serialised props bag. Keeps every action-row slot (heading
1213
+ // children, alert footer, empty-state footer, table-toolbar bulk
1214
+ // strip) usable as a plugin extension point.
1215
+ return renderElement(el, index)
1216
+ }
1208
1217
  if (el.type === 'actionGroup') {
1209
1218
  return <ActionGroupTrigger key={index} el={el} ids={opts.ids ?? []} />
1210
1219
  }
@@ -2949,7 +2958,7 @@ function renderElement(el: ElementMeta, index: number): React.ReactNode {
2949
2958
  const level = (el['level'] as number) ?? 1
2950
2959
  const content = String(el['content'] ?? '')
2951
2960
  const description = el['description'] ? String(el['description']) : undefined
2952
- const headerActions = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup')
2961
+ const headerActions = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent')
2953
2962
  const Tag = level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3'
2954
2963
  const sizes = { 1: 'text-2xl', 2: 'text-xl', 3: 'text-lg' } as const
2955
2964
  const titleBlock = (
@@ -2976,7 +2985,7 @@ function renderElement(el: ElementMeta, index: number): React.ReactNode {
2976
2985
  }
2977
2986
 
2978
2987
  case 'alert': {
2979
- const footer = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup')
2988
+ const footer = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent')
2980
2989
  return (
2981
2990
  <AlertRenderer
2982
2991
  key={index}
@@ -2998,7 +3007,7 @@ function renderElement(el: ElementMeta, index: number): React.ReactNode {
2998
3007
  const iconName = el['icon'] ? String(el['icon']) : undefined
2999
3008
  const contained = el['contained'] !== false
3000
3009
  const Icon = iconName ? resolveIcon(iconName) : undefined
3001
- const footer = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup')
3010
+ const footer = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent')
3002
3011
  const wrapper = contained
3003
3012
  ? 'rounded-lg border border-border bg-card text-card-foreground py-12 px-6'
3004
3013
  : 'py-8'
@@ -3201,6 +3210,36 @@ function renderElement(el: ElementMeta, index: number): React.ReactNode {
3201
3210
  case 'view':
3202
3211
  return <ViewRenderer key={index} meta={el} />
3203
3212
 
3213
+ case 'slotComponent': {
3214
+ const componentName = String(el['component'] ?? '')
3215
+ if (!componentName) {
3216
+ return (
3217
+ <div
3218
+ key={index}
3219
+ className="rounded-md border border-amber-500/40 bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200"
3220
+ role="alert"
3221
+ >
3222
+ SlotComponent without a registered <code className="font-mono">component</code> name.
3223
+ </div>
3224
+ )
3225
+ }
3226
+ const Component = getSlotComponent(componentName)
3227
+ if (!Component) {
3228
+ return (
3229
+ <div
3230
+ key={index}
3231
+ className="rounded-md border border-amber-500/40 bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200"
3232
+ role="alert"
3233
+ >
3234
+ No slot component registered for <code className="font-mono">{componentName}</code>.
3235
+ Call <code className="font-mono">registerSlotComponents({'{ '}{componentName}{' }'})</code> at app boot.
3236
+ </div>
3237
+ )
3238
+ }
3239
+ const props = (el['props'] ?? {}) as Record<string, unknown>
3240
+ return <Component key={index} {...props} />
3241
+ }
3242
+
3204
3243
  default: {
3205
3244
  // Plan #15 Phase C — server-data widget elements registered by
3206
3245
  // adapter packages (`@pilotiq/recharts` for `'chart'`, future
@@ -5281,7 +5320,7 @@ function TableRendererBody({ el }: { el: ElementMeta }) {
5281
5320
  const columns = children.filter(c => c.type === 'column')
5282
5321
  // Actions and ActionGroups share placement — both show up in the
5283
5322
  // header/bulk/row toolbars depending on their `placement` field.
5284
- const actionLike = children.filter(c => c.type === 'action' || c.type === 'actionGroup')
5323
+ const actionLike = children.filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent')
5285
5324
  const filters = children.filter(c => c.type === 'filter')
5286
5325
  const hasRecordUrl = Boolean(el['recordUrl'])
5287
5326
  const hasRecordClasses = Boolean(el['recordClasses'])
@@ -81,11 +81,15 @@ async function patchCell(url: string, value: unknown): Promise<PatchResult> {
81
81
  }
82
82
 
83
83
  /** Pull the first error message out of a `{ errors: { value: [...] } }`
84
- * response. Falls back to a generic "Couldn't save" string. */
84
+ * response. Also reads the reserved `_cell` key surfaced by
85
+ * `Column.beforeStateUpdated / afterStateUpdated` halts. Falls back to
86
+ * a generic "Couldn't save" string. */
85
87
  function firstErrorMessage(result: PatchResultErrors | PatchResultError): string {
86
88
  if ('errors' in result) {
87
89
  const fieldErrs = result.errors['value']
88
90
  if (fieldErrs && fieldErrs.length > 0) return fieldErrs[0]!
91
+ const cellErrs = result.errors['_cell']
92
+ if (cellErrs && cellErrs.length > 0) return cellErrs[0]!
89
93
  }
90
94
  if ('error' in result) return result.error
91
95
  return "Couldn't save"
@@ -1608,6 +1608,147 @@ describe('Editable cell columns — _cell route', () => {
1608
1608
  assert.equal(body.ok, false)
1609
1609
  assert.match(body.error, /database is on fire/)
1610
1610
  })
1611
+
1612
+ describe('beforeStateUpdated / afterStateUpdated hooks', () => {
1613
+ it('runs beforeStateUpdated before the DB update and afterStateUpdated after', async () => {
1614
+ const order: string[] = []
1615
+ const { M, calls } = makeUpdatableModel([{ id: '1', title: 'old' }])
1616
+ class Posts extends Resource {
1617
+ static override label = 'Posts'
1618
+ static override slug = 'posts'
1619
+ static override model = M as any
1620
+ static override table(t: Table): Table {
1621
+ return t.columns([
1622
+ Column.make('id'),
1623
+ TextInputColumn.make('title')
1624
+ .beforeStateUpdated((value, { record }) => {
1625
+ order.push(`before:${value}:${(record as { title: string }).title}`)
1626
+ })
1627
+ .afterStateUpdated((value) => {
1628
+ order.push(`after:${value}`)
1629
+ }),
1630
+ ])
1631
+ }
1632
+ }
1633
+ registerPilotiqRoutes(router, panelWith(Posts))
1634
+ const route = router.list().find(r => r.path === '/admin/posts/:id/_cell/:column' && r.method === 'POST')!
1635
+ const { res } = await callHandlerCapturing(route.handler, fakeReq({
1636
+ params: { id: '1', column: 'title' },
1637
+ body: { value: 'new' },
1638
+ }))
1639
+ assert.equal(res.statusCode, 200)
1640
+ assert.deepEqual(calls.update, [{ id: '1', data: { title: 'new' } }])
1641
+ assert.deepEqual(order, ['before:new:old', 'after:new'])
1642
+ })
1643
+
1644
+ it('throwing from beforeStateUpdated halts before the DB update with 422 _cell', async () => {
1645
+ const { M, calls } = makeUpdatableModel([{ id: '1', title: 'a' }])
1646
+ class Posts extends Resource {
1647
+ static override label = 'Posts'
1648
+ static override slug = 'posts'
1649
+ static override model = M as any
1650
+ static override table(t: Table): Table {
1651
+ return t.columns([
1652
+ Column.make('id'),
1653
+ TextInputColumn.make('title').beforeStateUpdated(() => {
1654
+ throw new Error('locked while review is pending')
1655
+ }),
1656
+ ])
1657
+ }
1658
+ }
1659
+ registerPilotiqRoutes(router, panelWith(Posts))
1660
+ const route = router.list().find(r => r.path === '/admin/posts/:id/_cell/:column' && r.method === 'POST')!
1661
+ const { res } = await callHandlerCapturing(route.handler, fakeReq({
1662
+ params: { id: '1', column: 'title' },
1663
+ body: { value: 'new' },
1664
+ }))
1665
+ assert.equal(res.statusCode, 422)
1666
+ const body = res.sentBody as { ok: boolean; errors: { _cell: string[] } }
1667
+ assert.equal(body.ok, false)
1668
+ assert.deepEqual(body.errors, { _cell: ['locked while review is pending'] })
1669
+ assert.equal(calls.update.length, 0)
1670
+ })
1671
+
1672
+ it('throwing from afterStateUpdated returns 422 _cell but the row is already updated', async () => {
1673
+ const { M, calls } = makeUpdatableModel([{ id: '1', title: 'a' }])
1674
+ class Posts extends Resource {
1675
+ static override label = 'Posts'
1676
+ static override slug = 'posts'
1677
+ static override model = M as any
1678
+ static override table(t: Table): Table {
1679
+ return t.columns([
1680
+ Column.make('id'),
1681
+ TextInputColumn.make('title').afterStateUpdated(async () => {
1682
+ throw new Error('broadcast queue down')
1683
+ }),
1684
+ ])
1685
+ }
1686
+ }
1687
+ registerPilotiqRoutes(router, panelWith(Posts))
1688
+ const route = router.list().find(r => r.path === '/admin/posts/:id/_cell/:column' && r.method === 'POST')!
1689
+ const { res } = await callHandlerCapturing(route.handler, fakeReq({
1690
+ params: { id: '1', column: 'title' },
1691
+ body: { value: 'new' },
1692
+ }))
1693
+ assert.equal(res.statusCode, 422)
1694
+ const body = res.sentBody as { ok: boolean; errors: { _cell: string[] } }
1695
+ assert.deepEqual(body.errors, { _cell: ['broadcast queue down'] })
1696
+ assert.deepEqual(calls.update, [{ id: '1', data: { title: 'new' } }])
1697
+ })
1698
+
1699
+ it('hooks do not run when validators fail', async () => {
1700
+ let beforeRan = false
1701
+ const { M, calls } = makeUpdatableModel([{ id: '1', title: 'a' }])
1702
+ class Posts extends Resource {
1703
+ static override label = 'Posts'
1704
+ static override slug = 'posts'
1705
+ static override model = M as any
1706
+ static override table(t: Table): Table {
1707
+ return t.columns([
1708
+ Column.make('id'),
1709
+ TextInputColumn.make('title')
1710
+ .validate(minLength(3))
1711
+ .beforeStateUpdated(() => { beforeRan = true }),
1712
+ ])
1713
+ }
1714
+ }
1715
+ registerPilotiqRoutes(router, panelWith(Posts))
1716
+ const route = router.list().find(r => r.path === '/admin/posts/:id/_cell/:column' && r.method === 'POST')!
1717
+ const { res } = await callHandlerCapturing(route.handler, fakeReq({
1718
+ params: { id: '1', column: 'title' },
1719
+ body: { value: 'ab' },
1720
+ }))
1721
+ assert.equal(res.statusCode, 422)
1722
+ assert.equal(beforeRan, false)
1723
+ assert.equal(calls.update.length, 0)
1724
+ })
1725
+
1726
+ it('non-Error throws still produce a usable _cell message', async () => {
1727
+ const { M } = makeUpdatableModel([{ id: '1', title: 'a' }])
1728
+ class Posts extends Resource {
1729
+ static override label = 'Posts'
1730
+ static override slug = 'posts'
1731
+ static override model = M as any
1732
+ static override table(t: Table): Table {
1733
+ return t.columns([
1734
+ Column.make('id'),
1735
+ TextInputColumn.make('title').beforeStateUpdated(() => {
1736
+ throw 'plain string failure' as unknown as Error
1737
+ }),
1738
+ ])
1739
+ }
1740
+ }
1741
+ registerPilotiqRoutes(router, panelWith(Posts))
1742
+ const route = router.list().find(r => r.path === '/admin/posts/:id/_cell/:column' && r.method === 'POST')!
1743
+ const { res } = await callHandlerCapturing(route.handler, fakeReq({
1744
+ params: { id: '1', column: 'title' },
1745
+ body: { value: 'new' },
1746
+ }))
1747
+ assert.equal(res.statusCode, 422)
1748
+ const body = res.sentBody as { ok: boolean; errors: { _cell: string[] } }
1749
+ assert.deepEqual(body.errors, { _cell: ['plain string failure'] })
1750
+ })
1751
+ })
1611
1752
  })
1612
1753
 
1613
1754
  describe('persistFiltersInSession — list-page filter restore', () => {
package/src/routes.ts CHANGED
@@ -161,6 +161,15 @@ function forbidden(res: AppResponse, json: boolean): unknown {
161
161
  return res.send('Forbidden')
162
162
  }
163
163
 
164
+ /** Extract a user-facing message from a thrown value inside an editable
165
+ * column's beforeStateUpdated / afterStateUpdated hook. Stamped under
166
+ * the reserved `_cell` key in the 422 response. */
167
+ function cellHookErrorMessage(err: unknown): string {
168
+ if (err instanceof Error && err.message) return err.message
169
+ if (typeof err === 'string' && err.length > 0) return err
170
+ return 'Update halted'
171
+ }
172
+
164
173
  /** Run a `canX(...)` predicate, treating throws as `false`. The predicate
165
174
  * is user-authored and we want a flaky check to fail closed (deny) rather
166
175
  * than 500 the page. */
@@ -1114,6 +1123,17 @@ export function registerPilotiqRoutes(
1114
1123
  return res.json({ ok: false, errors: { value: errors } })
1115
1124
  }
1116
1125
 
1126
+ // beforeStateUpdated — runs after validators pass, before the
1127
+ // DB write. Throwing halts with 422 under `_cell`.
1128
+ const beforeHook = col.getBeforeStateUpdated()
1129
+ if (beforeHook) {
1130
+ try { await beforeHook(value, { record: record as Record<string, unknown>, user }) }
1131
+ catch (err) {
1132
+ res.status(422)
1133
+ return res.json({ ok: false, errors: { _cell: [cellHookErrorMessage(err)] } })
1134
+ }
1135
+ }
1136
+
1117
1137
  try {
1118
1138
  await R.model!.update(id, { [col.name]: value })
1119
1139
  } catch (err) {
@@ -1124,6 +1144,18 @@ export function registerPilotiqRoutes(
1124
1144
  })
1125
1145
  }
1126
1146
 
1147
+ // afterStateUpdated — runs only on a confirmed write. Throwing
1148
+ // surfaces the error to the user; the DB row is already
1149
+ // updated (the hook is for follow-up effects, not rollback).
1150
+ const afterHook = col.getAfterStateUpdated()
1151
+ if (afterHook) {
1152
+ try { await afterHook(value, { record: record as Record<string, unknown>, user }) }
1153
+ catch (err) {
1154
+ res.status(422)
1155
+ return res.json({ ok: false, errors: { _cell: [cellHookErrorMessage(err)] } })
1156
+ }
1157
+ }
1158
+
1127
1159
  return res.json({ ok: true, value, notifications: [] })
1128
1160
  })
1129
1161
  }
@@ -0,0 +1,77 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { SlotComponent } from './SlotComponent.js'
5
+
6
+ describe('SlotComponent schema primitive', () => {
7
+ it('emits component name and no props by default', () => {
8
+ const meta = SlotComponent.make('BookmarkButton').toMeta()
9
+ assert.equal(meta['type'], 'slotComponent')
10
+ assert.equal(meta['component'], 'BookmarkButton')
11
+ assert.equal('props' in meta, false)
12
+ })
13
+
14
+ it('ships .props({...}) verbatim under meta.props', () => {
15
+ const meta = SlotComponent.make('BookmarkButton')
16
+ .props({ basePath: '/admin', recordId: '42' })
17
+ .toMeta()
18
+ assert.deepEqual(meta['props'], { basePath: '/admin', recordId: '42' })
19
+ })
20
+
21
+ it('successive .props() calls merge shallowly', () => {
22
+ const meta = SlotComponent.make('X')
23
+ .props({ a: 1, b: 2 })
24
+ .props({ b: 3, c: 4 })
25
+ .toMeta()
26
+ assert.deepEqual(meta['props'], { a: 1, b: 3, c: 4 })
27
+ })
28
+
29
+ it('exposes the component name via getComponentName()', () => {
30
+ const el = SlotComponent.make('BookmarkButton')
31
+ assert.equal(el.getComponentName(), 'BookmarkButton')
32
+ })
33
+
34
+ it('inherits Element.visible() / hidden() / columnSpan', () => {
35
+ const el = SlotComponent.make('X').visible(false).columnSpan(2)
36
+ assert.equal(el.hasVisibilityRule(), true)
37
+ assert.deepEqual(el.getLayoutPositioning(), { columnSpan: 2 })
38
+ })
39
+
40
+ it('getType returns slotComponent (matches wire shape discriminator)', () => {
41
+ assert.equal(SlotComponent.make('X').getType(), 'slotComponent')
42
+ })
43
+ })
44
+
45
+ describe('Slot component runtime registry', () => {
46
+ it('registers, retrieves, and resets', async () => {
47
+ const { registerSlotComponents, getSlotComponent, _resetSlotComponentRegistryForTests } =
48
+ await import('../slot-components/registry.js')
49
+ _resetSlotComponentRegistryForTests()
50
+ const Stub = (() => null) as unknown as Parameters<typeof registerSlotComponents>[0][string]
51
+ registerSlotComponents({ Stub })
52
+ assert.equal(getSlotComponent('Stub'), Stub)
53
+ assert.equal(getSlotComponent('Missing'), undefined)
54
+ _resetSlotComponentRegistryForTests()
55
+ assert.equal(getSlotComponent('Stub'), undefined)
56
+ })
57
+
58
+ it('skips falsy values during register', async () => {
59
+ const { registerSlotComponents, getSlotComponent, _resetSlotComponentRegistryForTests } =
60
+ await import('../slot-components/registry.js')
61
+ _resetSlotComponentRegistryForTests()
62
+ registerSlotComponents({ Bad: undefined as never })
63
+ assert.equal(getSlotComponent('Bad'), undefined)
64
+ })
65
+
66
+ it('multiple registrations merge into the same registry', async () => {
67
+ const { registerSlotComponents, getSlotComponent, _resetSlotComponentRegistryForTests } =
68
+ await import('../slot-components/registry.js')
69
+ _resetSlotComponentRegistryForTests()
70
+ const A = (() => null) as never
71
+ const B = (() => null) as never
72
+ registerSlotComponents({ A })
73
+ registerSlotComponents({ B })
74
+ assert.equal(getSlotComponent('A'), A)
75
+ assert.equal(getSlotComponent('B'), B)
76
+ })
77
+ })
@@ -0,0 +1,71 @@
1
+ import { Element } from './Element.js'
2
+
3
+ /**
4
+ * Escape-hatch schema element that hands rendering off to a user-supplied
5
+ * React component registered via `registerSlotComponents()`. Distinct from
6
+ * `ComponentEntry` (which is record-bound and label-shelled for infolists)
7
+ * and from `View` (which is widget-shaped with `getData`/polling). Use for
8
+ * plugin-contributed UI that needs to mount inline at any schema position
9
+ * — toolbars, header chips, sidebar contributions, anywhere `Action` /
10
+ * `ActionGroup` would otherwise live.
11
+ *
12
+ * Wire shape ships only the registered name + a serialisable `props` bag.
13
+ * The `_components.ts` build-time manifest doesn't see runtime-registered
14
+ * components (they live in plugin packages, not `cfg.resources / globals
15
+ * / pages` statics), so the lookup happens at render via the runtime
16
+ * registry — same model as `registerEntryComponents` and
17
+ * `registerWidgetComponents`.
18
+ *
19
+ * @example
20
+ * // bootstrap/providers.ts
21
+ * registerSlotComponents({ BookmarkButton })
22
+ *
23
+ * // A plugin / app contributes the chip into the resource-edit header:
24
+ * panel.renderHook(
25
+ * 'panels::resource.pages.edit-record.header.actions.before',
26
+ * (ctx) => [
27
+ * SlotComponent.make('BookmarkButton').props({
28
+ * basePath: ctx.basePath,
29
+ * resourceSlug: ctx.resource?.getSlug(),
30
+ * recordId: ctx.recordId,
31
+ * }),
32
+ * ],
33
+ * )
34
+ */
35
+ export class SlotComponent extends Element {
36
+ protected _componentName: string
37
+ protected _props?: Record<string, unknown>
38
+
39
+ protected constructor(componentName: string) {
40
+ super()
41
+ this._componentName = componentName
42
+ }
43
+
44
+ static make(componentName: string): SlotComponent {
45
+ return new SlotComponent(componentName)
46
+ }
47
+
48
+ /**
49
+ * Static props passed verbatim to the registered component. Must be
50
+ * JSON-serialisable — they ride through `viewProps` to the client.
51
+ * Successive calls merge shallowly with the previous bag.
52
+ */
53
+ props(props: Record<string, unknown>): this {
54
+ this._props = { ...(this._props ?? {}), ...props }
55
+ return this
56
+ }
57
+
58
+ getComponentName(): string {
59
+ return this._componentName
60
+ }
61
+
62
+ override getType(): string { return 'slotComponent' }
63
+
64
+ override toMeta(): Record<string, unknown> {
65
+ return {
66
+ type: 'slotComponent' as const,
67
+ component: this._componentName,
68
+ ...(this._props ? { props: this._props } : {}),
69
+ }
70
+ }
71
+ }
@@ -1,5 +1,26 @@
1
1
  import { Element } from './Element.js'
2
2
 
3
+ /**
4
+ * Context handed to `Step.beforeValidation` / `afterValidation` hooks.
5
+ * Mirrors the shape used by Form lifecycle hooks (record + user; values
6
+ * are passed positionally so handlers can mutate them in place).
7
+ */
8
+ export interface StepValidationContext {
9
+ record?: unknown
10
+ user?: unknown
11
+ }
12
+
13
+ /**
14
+ * Hook signature for `Step.beforeValidation` / `afterValidation`.
15
+ * Throwing aborts the wizard advance with a 422 stamped under the
16
+ * reserved `_step` error key — surface a user-facing message via the
17
+ * thrown Error's `.message`.
18
+ */
19
+ export type StepValidationHook = (
20
+ values: Record<string, unknown>,
21
+ ctx: StepValidationContext,
22
+ ) => void | Promise<void>
23
+
3
24
  /**
4
25
  * Single step inside a `Wizard`. Holds a label, optional icon/description,
5
26
  * and a list of child Elements. Children resolve unconditionally on every
@@ -9,6 +30,8 @@ import { Element } from './Element.js'
9
30
  export class Step extends Element {
10
31
  private _icon?: string
11
32
  private _description?: string
33
+ private _beforeValidation?: StepValidationHook
34
+ private _afterValidation?: StepValidationHook
12
35
 
13
36
  private constructor(private _label: string) {
14
37
  super()
@@ -30,6 +53,28 @@ export class Step extends Element {
30
53
  return this
31
54
  }
32
55
 
56
+ /**
57
+ * Runs on the server BEFORE the step's fields are validated when the
58
+ * user clicks Next. Use to mutate `values` in place (e.g. stamp a
59
+ * computed field needed by validators) or run an async availability
60
+ * check that should block advance. Throw an Error to halt — the
61
+ * thrown message lands under the reserved `_step` key in the 422
62
+ * response so the renderer can show it next to the Next button.
63
+ */
64
+ beforeValidation(fn: StepValidationHook): this { this._beforeValidation = fn; return this }
65
+
66
+ /**
67
+ * Runs on the server AFTER the step's validators pass and before the
68
+ * 200 advance response. Use for cross-field invariants, side-effects
69
+ * that should fire only on confirmed advance, or computed-field stamps
70
+ * that downstream steps will read. Throw to halt — same `_step` key
71
+ * convention as `beforeValidation`.
72
+ */
73
+ afterValidation(fn: StepValidationHook): this { this._afterValidation = fn; return this }
74
+
75
+ getBeforeValidation(): StepValidationHook | undefined { return this._beforeValidation }
76
+ getAfterValidation(): StepValidationHook | undefined { return this._afterValidation }
77
+
33
78
  getType(): string { return 'step' }
34
79
 
35
80
  toMeta(): Record<string, unknown> {
@@ -399,6 +399,34 @@ describe('Wizard / Step (Plan #8)', () => {
399
399
  assert.equal(result[0]!['persist'], false)
400
400
  })
401
401
 
402
+ describe('Step.beforeValidation / afterValidation', () => {
403
+ it('accessors return undefined when no hooks are set', () => {
404
+ const step = Step.make('x').schema([])
405
+ assert.equal(step.getBeforeValidation(), undefined)
406
+ assert.equal(step.getAfterValidation(), undefined)
407
+ })
408
+
409
+ it('accessors return the registered hook function', () => {
410
+ const before = async () => {}
411
+ const after = () => {}
412
+ const step = Step.make('x').schema([]).beforeValidation(before).afterValidation(after)
413
+ assert.equal(step.getBeforeValidation(), before)
414
+ assert.equal(step.getAfterValidation(), after)
415
+ })
416
+
417
+ it('hooks are not serialized into the wire shape', async () => {
418
+ const tree = [
419
+ Wizard.make().steps([
420
+ Step.make('a').schema([]).beforeValidation(() => {}).afterValidation(() => {}),
421
+ ]),
422
+ ]
423
+ const result = await resolveSchema(tree)
424
+ const step = result[0]!.children![0]!
425
+ assert.equal(step['beforeValidation'], undefined)
426
+ assert.equal(step['afterValidation'], undefined)
427
+ })
428
+ })
429
+
402
430
  it('all step children resolve so cross-step $get works', async () => {
403
431
  // Step 2 has a Section that hides based on a value entered in Step 0;
404
432
  // both steps must be resolved on every cycle for the predicate to fire.
@@ -28,6 +28,7 @@ export { Group } from './Group.js'
28
28
  export { Fieldset } from './Fieldset.js'
29
29
  export { Split, type SplitFrom } from './Split.js'
30
30
  export { Wizard, Step } from './Wizard.js'
31
+ export { SlotComponent } from './SlotComponent.js'
31
32
  export {
32
33
  resolveSchema,
33
34
  registerResolver,
@@ -0,0 +1,10 @@
1
+ // Slot-component runtime registry — opt-in component registration for
2
+ // `SlotComponent` schema elements. Imported by panel `bootstrap/providers.ts`
3
+ // or by a plugin's `register(panel)` step (e.g. `@pilotiq-pro/ai`'s
4
+ // resource-header agents dropdown).
5
+ export {
6
+ registerSlotComponents,
7
+ getSlotComponent,
8
+ type SlotComponent,
9
+ type SlotComponentProps,
10
+ } from './registry.js'