@pilotiq/pilotiq 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +19 -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/RightSidebarContext.d.ts.map +1 -1
  30. package/dist/react/RightSidebarContext.js +35 -15
  31. package/dist/react/RightSidebarContext.js.map +1 -1
  32. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  33. package/dist/react/SchemaRenderer.js +25 -4
  34. package/dist/react/SchemaRenderer.js.map +1 -1
  35. package/dist/react/cells/EditableCell.d.ts.map +1 -1
  36. package/dist/react/cells/EditableCell.js +6 -1
  37. package/dist/react/cells/EditableCell.js.map +1 -1
  38. package/dist/routes.d.ts.map +1 -1
  39. package/dist/routes.js +41 -2
  40. package/dist/routes.js.map +1 -1
  41. package/dist/schema/SlotComponent.d.ts +49 -0
  42. package/dist/schema/SlotComponent.d.ts.map +1 -0
  43. package/dist/schema/SlotComponent.js +65 -0
  44. package/dist/schema/SlotComponent.js.map +1 -0
  45. package/dist/schema/Wizard.d.ts +37 -0
  46. package/dist/schema/Wizard.d.ts.map +1 -1
  47. package/dist/schema/Wizard.js +21 -0
  48. package/dist/schema/Wizard.js.map +1 -1
  49. package/dist/schema/index.d.ts +1 -0
  50. package/dist/schema/index.d.ts.map +1 -1
  51. package/dist/schema/index.js +1 -0
  52. package/dist/schema/index.js.map +1 -1
  53. package/dist/slot-components/index.d.ts +2 -0
  54. package/dist/slot-components/index.d.ts.map +1 -0
  55. package/dist/slot-components/index.js +6 -0
  56. package/dist/slot-components/index.js.map +1 -0
  57. package/dist/slot-components/registry.d.ts +41 -0
  58. package/dist/slot-components/registry.d.ts.map +1 -0
  59. package/dist/slot-components/registry.js +17 -0
  60. package/dist/slot-components/registry.js.map +1 -0
  61. package/package.json +5 -1
  62. package/src/Column.test.ts +23 -0
  63. package/src/Column.ts +44 -0
  64. package/src/RenderHook.ts +16 -0
  65. package/src/applyPageHooks.test.ts +167 -2
  66. package/src/applyPageHooks.ts +88 -0
  67. package/src/elements/dispatchForm.test.ts +23 -1
  68. package/src/elements/dispatchForm.ts +33 -9
  69. package/src/fields/TextField.test.ts +45 -0
  70. package/src/fields/TextField.ts +13 -0
  71. package/src/index.ts +1 -0
  72. package/src/pageData.test.ts +83 -0
  73. package/src/pageData.ts +37 -4
  74. package/src/react/RightSidebarContext.tsx +34 -11
  75. package/src/react/SchemaRenderer.tsx +43 -4
  76. package/src/react/cells/EditableCell.tsx +5 -1
  77. package/src/routes.test.ts +141 -0
  78. package/src/routes.ts +38 -2
  79. package/src/schema/SlotComponent.test.ts +77 -0
  80. package/src/schema/SlotComponent.ts +71 -0
  81. package/src/schema/Wizard.ts +45 -0
  82. package/src/schema/containers.test.ts +28 -0
  83. package/src/schema/index.ts +1 -0
  84. package/src/slot-components/index.ts +10 -0
  85. 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 {
@@ -93,20 +93,43 @@ export function RightSidebarProvider({ meta, basePath, children }: RightSidebarP
93
93
 
94
94
  const fallbackId = meta.panels[0]?.id ?? null
95
95
 
96
- const [open, setOpenState] = useState<boolean>(() => readBool(openKey, false))
97
- const [activeId, setActiveIdState] = useState<string | null>(() => {
98
- const stored = readString(activeKey)
99
- if (stored && meta.panels.some(p => p.id === stored)) return stored
100
- return fallbackId
101
- })
102
- const [width, setWidthState] = useState<number>(() => {
103
- const stored = readString(widthKey)
104
- return clampPanelWidth(stored ?? meta.defaultWidth, {
96
+ // SSR-safety: initialise to closed / fallback / default-width so the
97
+ // server-rendered tree matches a fresh client (no localStorage). The
98
+ // useEffect below rehydrates from localStorage AFTER mount, avoiding a
99
+ // hydration mismatch warning every time a returning user reloads with
100
+ // the panel previously open. Brief closed→open flash is acceptable and
101
+ // identical to first-visit behaviour.
102
+ const [open, setOpenState] = useState<boolean>(false)
103
+ const [activeId, setActiveIdState] = useState<string | null>(fallbackId)
104
+ const [width, setWidthState] = useState<number>(() =>
105
+ clampPanelWidth(meta.defaultWidth, {
105
106
  min: meta.minWidth,
106
107
  max: meta.maxWidth,
107
108
  defaultWidth: meta.defaultWidth,
108
- })
109
- })
109
+ }),
110
+ )
111
+
112
+ useEffect(() => {
113
+ if (typeof window === 'undefined') return
114
+ const storedOpen = readBool(openKey, false)
115
+ setOpenState(storedOpen)
116
+ const storedActive = readString(activeKey)
117
+ if (storedActive && meta.panels.some(p => p.id === storedActive)) {
118
+ setActiveIdState(storedActive)
119
+ }
120
+ const storedWidth = readString(widthKey)
121
+ if (storedWidth !== null) {
122
+ setWidthState(clampPanelWidth(storedWidth, {
123
+ min: meta.minWidth,
124
+ max: meta.maxWidth,
125
+ defaultWidth: meta.defaultWidth,
126
+ }))
127
+ }
128
+ // Run once on mount per basePath. Width / activeId / open keys are
129
+ // basePath-derived, so the dependency list is effectively static for
130
+ // a given panel — no stale-closure risk on subsequent renders.
131
+ // eslint-disable-next-line react-hooks/exhaustive-deps
132
+ }, [basePath])
110
133
 
111
134
  // Re-validate `activeId` when the contribution set changes (e.g.,
112
135
  // canAccess gating flipped after a route nav). If the stored id has
@@ -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. */
@@ -547,7 +556,10 @@ async function handleUploadRequest(
547
556
  }
548
557
  }
549
558
 
550
- // Server-side resize via @rudderjs/image (optional peer dep)
559
+ // Server-side resize via @rudderjs/image (optional peer dep). Variable-
560
+ // string `import(name)` keeps Vite's static import-analysis from trying
561
+ // to pre-resolve the module on host apps that don't have @rudderjs/image
562
+ // installed — same pattern as `notifications/database.ts` for `@rudderjs/orm`.
551
563
  const resizeWidthStr = typeof body['resize_width'] === 'string' ? body['resize_width'] : ''
552
564
  const resizeHeightStr = typeof body['resize_height'] === 'string' ? body['resize_height'] : ''
553
565
  let uploadFile: File = file
@@ -556,8 +568,9 @@ async function handleUploadRequest(
556
568
  const h = Number(resizeHeightStr)
557
569
  if (Number.isFinite(w) && w > 0 && Number.isFinite(h) && h > 0) {
558
570
  try {
571
+ const imageModuleName = '@rudderjs/image'
559
572
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
560
- const pkg = await import('@rudderjs/image' as string) as { image: (input: unknown) => { resize(w: number, h: number): { format(f: string): { toBuffer(): Promise<Buffer> } } } }
573
+ const pkg = await import(/* @vite-ignore */ imageModuleName) as { image: (input: unknown) => { resize(w: number, h: number): { format(f: string): { toBuffer(): Promise<Buffer> } } } }
561
574
  const buf = await pkg.image(file).resize(w, h).format('webp').toBuffer()
562
575
  const baseName = file.name.replace(/\.[^.]+$/, '')
563
576
  uploadFile = new File([buf.buffer as ArrayBuffer], `${baseName}.webp`, { type: 'image/webp' })
@@ -1114,6 +1127,17 @@ export function registerPilotiqRoutes(
1114
1127
  return res.json({ ok: false, errors: { value: errors } })
1115
1128
  }
1116
1129
 
1130
+ // beforeStateUpdated — runs after validators pass, before the
1131
+ // DB write. Throwing halts with 422 under `_cell`.
1132
+ const beforeHook = col.getBeforeStateUpdated()
1133
+ if (beforeHook) {
1134
+ try { await beforeHook(value, { record: record as Record<string, unknown>, user }) }
1135
+ catch (err) {
1136
+ res.status(422)
1137
+ return res.json({ ok: false, errors: { _cell: [cellHookErrorMessage(err)] } })
1138
+ }
1139
+ }
1140
+
1117
1141
  try {
1118
1142
  await R.model!.update(id, { [col.name]: value })
1119
1143
  } catch (err) {
@@ -1124,6 +1148,18 @@ export function registerPilotiqRoutes(
1124
1148
  })
1125
1149
  }
1126
1150
 
1151
+ // afterStateUpdated — runs only on a confirmed write. Throwing
1152
+ // surfaces the error to the user; the DB row is already
1153
+ // updated (the hook is for follow-up effects, not rollback).
1154
+ const afterHook = col.getAfterStateUpdated()
1155
+ if (afterHook) {
1156
+ try { await afterHook(value, { record: record as Record<string, unknown>, user }) }
1157
+ catch (err) {
1158
+ res.status(422)
1159
+ return res.json({ ok: false, errors: { _cell: [cellHookErrorMessage(err)] } })
1160
+ }
1161
+ }
1162
+
1127
1163
  return res.json({ ok: true, value, notifications: [] })
1128
1164
  })
1129
1165
  }
@@ -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> {