@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +19 -0
- package/dist/Column.d.ts +36 -0
- package/dist/Column.d.ts.map +1 -1
- package/dist/Column.js +24 -0
- package/dist/Column.js.map +1 -1
- package/dist/RenderHook.d.ts +2 -2
- package/dist/RenderHook.d.ts.map +1 -1
- package/dist/RenderHook.js +8 -0
- package/dist/RenderHook.js.map +1 -1
- package/dist/applyPageHooks.d.ts.map +1 -1
- package/dist/applyPageHooks.js +76 -0
- package/dist/applyPageHooks.js.map +1 -1
- package/dist/elements/dispatchForm.d.ts +14 -6
- package/dist/elements/dispatchForm.d.ts.map +1 -1
- package/dist/elements/dispatchForm.js +28 -8
- package/dist/elements/dispatchForm.js.map +1 -1
- package/dist/fields/TextField.d.ts +10 -0
- package/dist/fields/TextField.d.ts.map +1 -1
- package/dist/fields/TextField.js +11 -0
- package/dist/fields/TextField.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +32 -4
- package/dist/pageData.js.map +1 -1
- package/dist/react/RightSidebarContext.d.ts.map +1 -1
- package/dist/react/RightSidebarContext.js +35 -15
- package/dist/react/RightSidebarContext.js.map +1 -1
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +25 -4
- package/dist/react/SchemaRenderer.js.map +1 -1
- package/dist/react/cells/EditableCell.d.ts.map +1 -1
- package/dist/react/cells/EditableCell.js +6 -1
- package/dist/react/cells/EditableCell.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +41 -2
- package/dist/routes.js.map +1 -1
- package/dist/schema/SlotComponent.d.ts +49 -0
- package/dist/schema/SlotComponent.d.ts.map +1 -0
- package/dist/schema/SlotComponent.js +65 -0
- package/dist/schema/SlotComponent.js.map +1 -0
- package/dist/schema/Wizard.d.ts +37 -0
- package/dist/schema/Wizard.d.ts.map +1 -1
- package/dist/schema/Wizard.js +21 -0
- package/dist/schema/Wizard.js.map +1 -1
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +1 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/slot-components/index.d.ts +2 -0
- package/dist/slot-components/index.d.ts.map +1 -0
- package/dist/slot-components/index.js +6 -0
- package/dist/slot-components/index.js.map +1 -0
- package/dist/slot-components/registry.d.ts +41 -0
- package/dist/slot-components/registry.d.ts.map +1 -0
- package/dist/slot-components/registry.js +17 -0
- package/dist/slot-components/registry.js.map +1 -0
- package/package.json +5 -1
- package/src/Column.test.ts +23 -0
- package/src/Column.ts +44 -0
- package/src/RenderHook.ts +16 -0
- package/src/applyPageHooks.test.ts +167 -2
- package/src/applyPageHooks.ts +88 -0
- package/src/elements/dispatchForm.test.ts +23 -1
- package/src/elements/dispatchForm.ts +33 -9
- package/src/fields/TextField.test.ts +45 -0
- package/src/fields/TextField.ts +13 -0
- package/src/index.ts +1 -0
- package/src/pageData.test.ts +83 -0
- package/src/pageData.ts +37 -4
- package/src/react/RightSidebarContext.tsx +34 -11
- package/src/react/SchemaRenderer.tsx +43 -4
- package/src/react/cells/EditableCell.tsx +5 -1
- package/src/routes.test.ts +141 -0
- package/src/routes.ts +38 -2
- package/src/schema/SlotComponent.test.ts +77 -0
- package/src/schema/SlotComponent.ts +71 -0
- package/src/schema/Wizard.ts +45 -0
- package/src/schema/containers.test.ts +28 -0
- package/src/schema/index.ts +1 -0
- package/src/slot-components/index.ts +10 -0
- package/src/slot-components/registry.ts +56 -0
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,
|
|
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
|
|
3736
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const [
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
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"
|
package/src/routes.test.ts
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/src/schema/Wizard.ts
CHANGED
|
@@ -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> {
|