@pilotiq/pilotiq 0.6.2 → 0.7.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 (197) hide show
  1. package/.turbo/turbo-build.log +6 -2
  2. package/CHANGELOG.md +608 -0
  3. package/CLAUDE.md +6 -5
  4. package/dist/Column.d.ts +35 -0
  5. package/dist/Column.d.ts.map +1 -1
  6. package/dist/Column.js +41 -0
  7. package/dist/Column.js.map +1 -1
  8. package/dist/Page.d.ts +13 -4
  9. package/dist/Page.d.ts.map +1 -1
  10. package/dist/Page.js +9 -2
  11. package/dist/Page.js.map +1 -1
  12. package/dist/Pilotiq.d.ts +84 -0
  13. package/dist/Pilotiq.d.ts.map +1 -1
  14. package/dist/Pilotiq.js +66 -0
  15. package/dist/Pilotiq.js.map +1 -1
  16. package/dist/Resource.d.ts +26 -0
  17. package/dist/Resource.d.ts.map +1 -1
  18. package/dist/Resource.js +9 -0
  19. package/dist/Resource.js.map +1 -1
  20. package/dist/actions/exportFactory.js +1 -1
  21. package/dist/actions/exportFactory.js.map +1 -1
  22. package/dist/columns/SelectColumn.d.ts +32 -5
  23. package/dist/columns/SelectColumn.d.ts.map +1 -1
  24. package/dist/columns/SelectColumn.js +37 -7
  25. package/dist/columns/SelectColumn.js.map +1 -1
  26. package/dist/defaultPages.d.ts.map +1 -1
  27. package/dist/defaultPages.js +3 -0
  28. package/dist/defaultPages.js.map +1 -1
  29. package/dist/elements/Form.d.ts +17 -0
  30. package/dist/elements/Form.d.ts.map +1 -1
  31. package/dist/elements/Form.js +17 -0
  32. package/dist/elements/Form.js.map +1 -1
  33. package/dist/elements/Table.d.ts +26 -0
  34. package/dist/elements/Table.d.ts.map +1 -1
  35. package/dist/elements/Table.js +15 -1
  36. package/dist/elements/Table.js.map +1 -1
  37. package/dist/elements/TableGroup.d.ts +84 -0
  38. package/dist/elements/TableGroup.d.ts.map +1 -1
  39. package/dist/elements/TableGroup.js +103 -0
  40. package/dist/elements/TableGroup.js.map +1 -1
  41. package/dist/elements/dispatchForm.d.ts.map +1 -1
  42. package/dist/elements/dispatchForm.js +36 -6
  43. package/dist/elements/dispatchForm.js.map +1 -1
  44. package/dist/elements/dispatchTable.d.ts +12 -0
  45. package/dist/elements/dispatchTable.d.ts.map +1 -1
  46. package/dist/elements/dispatchTable.js +103 -28
  47. package/dist/elements/dispatchTable.js.map +1 -1
  48. package/dist/fields/Field.d.ts +7 -2
  49. package/dist/fields/Field.d.ts.map +1 -1
  50. package/dist/fields/Field.js +8 -3
  51. package/dist/fields/Field.js.map +1 -1
  52. package/dist/fields/RepeaterField.d.ts +65 -0
  53. package/dist/fields/RepeaterField.d.ts.map +1 -1
  54. package/dist/fields/RepeaterField.js +48 -0
  55. package/dist/fields/RepeaterField.js.map +1 -1
  56. package/dist/orm/modelDefaults.d.ts.map +1 -1
  57. package/dist/orm/modelDefaults.js +19 -0
  58. package/dist/orm/modelDefaults.js.map +1 -1
  59. package/dist/pageData.d.ts +20 -0
  60. package/dist/pageData.d.ts.map +1 -1
  61. package/dist/pageData.js +242 -34
  62. package/dist/pageData.js.map +1 -1
  63. package/dist/react/AppShell.d.ts +17 -1
  64. package/dist/react/AppShell.d.ts.map +1 -1
  65. package/dist/react/AppShell.js +34 -3
  66. package/dist/react/AppShell.js.map +1 -1
  67. package/dist/react/PendingSuggestionApplierRegistry.d.ts +34 -0
  68. package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -0
  69. package/dist/react/PendingSuggestionApplierRegistry.js +51 -0
  70. package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -0
  71. package/dist/react/PendingSuggestionOverlayRegistry.d.ts +46 -0
  72. package/dist/react/PendingSuggestionOverlayRegistry.d.ts.map +1 -0
  73. package/dist/react/PendingSuggestionOverlayRegistry.js +16 -0
  74. package/dist/react/PendingSuggestionOverlayRegistry.js.map +1 -0
  75. package/dist/react/PendingSuggestionsContext.d.ts +153 -0
  76. package/dist/react/PendingSuggestionsContext.d.ts.map +1 -0
  77. package/dist/react/PendingSuggestionsContext.js +46 -0
  78. package/dist/react/PendingSuggestionsContext.js.map +1 -0
  79. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  80. package/dist/react/SchemaRenderer.js +312 -39
  81. package/dist/react/SchemaRenderer.js.map +1 -1
  82. package/dist/react/cells/EditableCell.d.ts +8 -0
  83. package/dist/react/cells/EditableCell.d.ts.map +1 -1
  84. package/dist/react/cells/EditableCell.js +6 -2
  85. package/dist/react/cells/EditableCell.js.map +1 -1
  86. package/dist/react/fields/CheckboxListInput.d.ts.map +1 -1
  87. package/dist/react/fields/CheckboxListInput.js +29 -2
  88. package/dist/react/fields/CheckboxListInput.js.map +1 -1
  89. package/dist/react/fields/ColorInput.d.ts.map +1 -1
  90. package/dist/react/fields/ColorInput.js +28 -2
  91. package/dist/react/fields/ColorInput.js.map +1 -1
  92. package/dist/react/fields/DateTimeInput.d.ts.map +1 -1
  93. package/dist/react/fields/DateTimeInput.js +28 -2
  94. package/dist/react/fields/DateTimeInput.js.map +1 -1
  95. package/dist/react/fields/FieldShell.d.ts.map +1 -1
  96. package/dist/react/fields/FieldShell.js +161 -3
  97. package/dist/react/fields/FieldShell.js.map +1 -1
  98. package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
  99. package/dist/react/fields/FileUploadInput.js +27 -2
  100. package/dist/react/fields/FileUploadInput.js.map +1 -1
  101. package/dist/react/fields/KeyValueInput.d.ts.map +1 -1
  102. package/dist/react/fields/KeyValueInput.js +33 -2
  103. package/dist/react/fields/KeyValueInput.js.map +1 -1
  104. package/dist/react/fields/RadioInput.d.ts.map +1 -1
  105. package/dist/react/fields/RadioInput.js +28 -2
  106. package/dist/react/fields/RadioInput.js.map +1 -1
  107. package/dist/react/fields/SelectFieldInput.d.ts.map +1 -1
  108. package/dist/react/fields/SelectFieldInput.js +31 -2
  109. package/dist/react/fields/SelectFieldInput.js.map +1 -1
  110. package/dist/react/fields/SliderInput.d.ts.map +1 -1
  111. package/dist/react/fields/SliderInput.js +26 -2
  112. package/dist/react/fields/SliderInput.js.map +1 -1
  113. package/dist/react/fields/TagsInput.d.ts.map +1 -1
  114. package/dist/react/fields/TagsInput.js +26 -2
  115. package/dist/react/fields/TagsInput.js.map +1 -1
  116. package/dist/react/fields/ToggleFieldInput.d.ts.map +1 -1
  117. package/dist/react/fields/ToggleFieldInput.js +29 -2
  118. package/dist/react/fields/ToggleFieldInput.js.map +1 -1
  119. package/dist/react/index.d.ts +3 -0
  120. package/dist/react/index.d.ts.map +1 -1
  121. package/dist/react/index.js +3 -0
  122. package/dist/react/index.js.map +1 -1
  123. package/dist/routes.d.ts.map +1 -1
  124. package/dist/routes.js +55 -2
  125. package/dist/routes.js.map +1 -1
  126. package/dist/schema/Section.d.ts +16 -0
  127. package/dist/schema/Section.d.ts.map +1 -1
  128. package/dist/schema/Section.js +16 -0
  129. package/dist/schema/Section.js.map +1 -1
  130. package/dist/schema/Wizard.d.ts +45 -0
  131. package/dist/schema/Wizard.d.ts.map +1 -1
  132. package/dist/schema/Wizard.js +50 -0
  133. package/dist/schema/Wizard.js.map +1 -1
  134. package/dist/schema/resolveSchema.d.ts +8 -0
  135. package/dist/schema/resolveSchema.d.ts.map +1 -1
  136. package/dist/schema/resolveSchema.js +70 -1
  137. package/dist/schema/resolveSchema.js.map +1 -1
  138. package/dist/sessionFilters.d.ts.map +1 -1
  139. package/dist/sessionFilters.js +12 -1
  140. package/dist/sessionFilters.js.map +1 -1
  141. package/dist/styles/file-upload.css +13 -0
  142. package/dist/vite.d.ts.map +1 -1
  143. package/dist/vite.js +9 -2
  144. package/dist/vite.js.map +1 -1
  145. package/package.json +6 -4
  146. package/src/Column.test.ts +36 -0
  147. package/src/Column.ts +54 -0
  148. package/src/Page.ts +13 -4
  149. package/src/Pilotiq.ts +109 -0
  150. package/src/Resource.ts +29 -0
  151. package/src/actions/exportFactory.ts +1 -1
  152. package/src/columns/SelectColumn.ts +46 -8
  153. package/src/columns/editableColumns.test.ts +45 -0
  154. package/src/defaultPages.ts +3 -0
  155. package/src/elements/Form.ts +19 -0
  156. package/src/elements/Table.ts +35 -1
  157. package/src/elements/TableGroup.test.ts +111 -0
  158. package/src/elements/TableGroup.ts +135 -0
  159. package/src/elements/dispatchForm.ts +34 -7
  160. package/src/elements/dispatchTable.test.ts +267 -0
  161. package/src/elements/dispatchTable.ts +111 -32
  162. package/src/fields/Field.test.ts +15 -0
  163. package/src/fields/Field.ts +8 -3
  164. package/src/fields/RepeaterField.ts +104 -0
  165. package/src/fields/RepeaterRelationship.test.ts +173 -0
  166. package/src/nestedRelationManagerData.test.ts +21 -0
  167. package/src/orm/modelDefaults.ts +21 -0
  168. package/src/pageData.ts +267 -47
  169. package/src/react/AppShell.tsx +55 -4
  170. package/src/react/PendingSuggestionApplierRegistry.ts +80 -0
  171. package/src/react/PendingSuggestionOverlayRegistry.ts +54 -0
  172. package/src/react/PendingSuggestionsContext.tsx +172 -0
  173. package/src/react/SchemaRenderer.tsx +504 -95
  174. package/src/react/cells/EditableCell.tsx +11 -2
  175. package/src/react/fields/CheckboxListInput.tsx +23 -2
  176. package/src/react/fields/ColorInput.tsx +22 -2
  177. package/src/react/fields/DateTimeInput.tsx +22 -2
  178. package/src/react/fields/FieldShell.tsx +167 -3
  179. package/src/react/fields/FileUploadInput.tsx +21 -2
  180. package/src/react/fields/KeyValueInput.tsx +32 -2
  181. package/src/react/fields/RadioInput.tsx +23 -2
  182. package/src/react/fields/SelectFieldInput.tsx +25 -2
  183. package/src/react/fields/SliderInput.tsx +20 -2
  184. package/src/react/fields/TagsInput.tsx +20 -2
  185. package/src/react/fields/ToggleFieldInput.tsx +23 -2
  186. package/src/react/index.ts +18 -0
  187. package/src/relationManagerData.test.ts +451 -2
  188. package/src/routes.ts +58 -2
  189. package/src/schema/Section.ts +17 -0
  190. package/src/schema/Wizard.ts +67 -0
  191. package/src/schema/containers.test.ts +90 -0
  192. package/src/schema/resolveSchema.test.ts +50 -0
  193. package/src/schema/resolveSchema.ts +79 -1
  194. package/src/sessionFilters.test.ts +23 -0
  195. package/src/sessionFilters.ts +11 -1
  196. package/src/styles/file-upload.css +13 -0
  197. package/src/vite.ts +9 -2
@@ -1,4 +1,19 @@
1
1
  import { Element } from './Element.js'
2
+ import type { Action } from '../actions/Action.js'
3
+
4
+ /**
5
+ * Customizer for the wizard's built-in nav buttons (`submitAction`,
6
+ * `nextAction`, `previousAction`). Pass an `Action` to replace the
7
+ * default outright, or a function that receives the framework-built
8
+ * default and returns a customized clone.
9
+ *
10
+ * Only chrome carries through to the renderer (label / icon / color /
11
+ * size / outlined / iconOnly / tooltip / disabled rules). The button's
12
+ * click behavior stays hardwired to advance / recede / submit-form —
13
+ * dispatch overrides (`.handler()`, `.action()`, `.href()`) are
14
+ * intentionally ignored so the wizard chrome never breaks navigation.
15
+ */
16
+ export type WizardActionCustomizer = Action | ((defaultAction: Action) => Action)
2
17
 
3
18
  /**
4
19
  * Context handed to `Step.beforeValidation` / `afterValidation` hooks.
@@ -108,6 +123,10 @@ export class Wizard extends Element {
108
123
  private _skippable = false
109
124
  private _startOnStep = 0
110
125
  private _persist = true
126
+ private _persistStepInQueryString: string | undefined = undefined
127
+ private _submitAction?: WizardActionCustomizer
128
+ private _nextAction?: WizardActionCustomizer
129
+ private _previousAction?: WizardActionCustomizer
111
130
 
112
131
  private constructor() { super() }
113
132
 
@@ -140,6 +159,51 @@ export class Wizard extends Element {
140
159
  */
141
160
  persist(v: boolean): this { this._persist = v; return this }
142
161
 
162
+ /**
163
+ * Sync the active step index to the URL as `?<key>=N` (1-based for
164
+ * human-friendly URLs). When set, the URL value wins over localStorage
165
+ * on initial mount, so deep-linking to a specific step works. Bare
166
+ * `persistStepInQueryString()` uses the default key `'step'`; pass a
167
+ * string to override (multi-wizard pages should use distinct keys to
168
+ * avoid collisions). Pass `false` to disable.
169
+ */
170
+ persistStepInQueryString(key: string | boolean = true): this {
171
+ if (key === false) { this._persistStepInQueryString = undefined; return this }
172
+ this._persistStepInQueryString = key === true ? 'step' : key
173
+ return this
174
+ }
175
+
176
+ /**
177
+ * Customize the chrome of the built-in Submit button shown on the
178
+ * wizard's final step. By default the wizard renders a hint pointing
179
+ * at the surrounding form's Save button — calling this method instead
180
+ * mounts a real `<button type="submit">` inside the wizard chrome so
181
+ * the wizard becomes self-contained. Pair with `CreatePage.getFormActions(R)`
182
+ * returning `[]` to suppress the page-level Save when you don't want
183
+ * two submits on the same page.
184
+ */
185
+ submitAction(action: WizardActionCustomizer): this {
186
+ this._submitAction = action
187
+ return this
188
+ }
189
+
190
+ /** Customize the chrome of the built-in Next button. */
191
+ nextAction(action: WizardActionCustomizer): this {
192
+ this._nextAction = action
193
+ return this
194
+ }
195
+
196
+ /** Customize the chrome of the built-in Back / Previous button. */
197
+ previousAction(action: WizardActionCustomizer): this {
198
+ this._previousAction = action
199
+ return this
200
+ }
201
+
202
+ getPersistStepInQueryString(): string | undefined { return this._persistStepInQueryString }
203
+ getSubmitAction(): WizardActionCustomizer | undefined { return this._submitAction }
204
+ getNextAction(): WizardActionCustomizer | undefined { return this._nextAction }
205
+ getPreviousAction(): WizardActionCustomizer | undefined { return this._previousAction }
206
+
143
207
  getType(): string { return 'wizard' }
144
208
 
145
209
  toMeta(): Record<string, unknown> {
@@ -148,6 +212,9 @@ export class Wizard extends Element {
148
212
  skippable: this._skippable,
149
213
  startOnStep: this._startOnStep,
150
214
  persist: this._persist,
215
+ ...(this._persistStepInQueryString
216
+ ? { persistStepInQueryString: this._persistStepInQueryString }
217
+ : {}),
151
218
  }
152
219
  }
153
220
  }
@@ -427,6 +427,96 @@ describe('Wizard / Step (Plan #8)', () => {
427
427
  })
428
428
  })
429
429
 
430
+ describe('persistStepInQueryString', () => {
431
+ it('omits the meta key by default', async () => {
432
+ const tree = [Wizard.make().steps([Step.make('a').schema([])])]
433
+ const result = await resolveSchema(tree)
434
+ assert.equal(result[0]!['persistStepInQueryString'], undefined)
435
+ })
436
+
437
+ it('bare call defaults to "step"', async () => {
438
+ const tree = [
439
+ Wizard.make().persistStepInQueryString().steps([Step.make('a').schema([])]),
440
+ ]
441
+ const result = await resolveSchema(tree)
442
+ assert.equal(result[0]!['persistStepInQueryString'], 'step')
443
+ })
444
+
445
+ it('explicit string overrides the key', async () => {
446
+ const tree = [
447
+ Wizard.make().persistStepInQueryString('checkout').steps([Step.make('a').schema([])]),
448
+ ]
449
+ const result = await resolveSchema(tree)
450
+ assert.equal(result[0]!['persistStepInQueryString'], 'checkout')
451
+ })
452
+
453
+ it('false clears a previously-set key', async () => {
454
+ const tree = [
455
+ Wizard.make().persistStepInQueryString('foo').persistStepInQueryString(false)
456
+ .steps([Step.make('a').schema([])]),
457
+ ]
458
+ const result = await resolveSchema(tree)
459
+ assert.equal(result[0]!['persistStepInQueryString'], undefined)
460
+ })
461
+ })
462
+
463
+ describe('submitAction / nextAction / previousAction customizers', () => {
464
+ it('omits the meta keys when no customizer is set', async () => {
465
+ const tree = [Wizard.make().steps([Step.make('a').schema([])])]
466
+ const result = await resolveSchema(tree)
467
+ assert.equal(result[0]!['submitAction'], undefined)
468
+ assert.equal(result[0]!['nextAction'], undefined)
469
+ assert.equal(result[0]!['previousAction'], undefined)
470
+ })
471
+
472
+ it('customizer receives a default Action with sensible chrome', async () => {
473
+ let receivedSubmit: string | undefined
474
+ let receivedNext: string | undefined
475
+ let receivedPrevious: string | undefined
476
+ const tree = [
477
+ Wizard.make()
478
+ .submitAction(a => { receivedSubmit = a.getLabel(); return a })
479
+ .nextAction(a => { receivedNext = a.getLabel(); return a })
480
+ .previousAction(a => { receivedPrevious = a.getLabel(); return a })
481
+ .steps([Step.make('a').schema([])]),
482
+ ]
483
+ await resolveSchema(tree)
484
+ assert.equal(receivedSubmit, 'Submit')
485
+ assert.equal(receivedNext, 'Next')
486
+ assert.equal(receivedPrevious, 'Back')
487
+ })
488
+
489
+ it('customizer chrome flows through to the resolved meta', async () => {
490
+ const tree = [
491
+ Wizard.make()
492
+ .submitAction(a => a.label('Create campaign').size('lg'))
493
+ .nextAction(a => a.label('Continue').icon('arrow-right'))
494
+ .previousAction(Action.make('back').label('Go back').color('ghost'))
495
+ .steps([Step.make('a').schema([])]),
496
+ ]
497
+ const result = await resolveSchema(tree)
498
+ const submit = result[0]!['submitAction'] as Record<string, unknown>
499
+ const next = result[0]!['nextAction'] as Record<string, unknown>
500
+ const previous = result[0]!['previousAction'] as Record<string, unknown>
501
+ assert.equal(submit['label'], 'Create campaign')
502
+ assert.equal(submit['size'], 'lg')
503
+ assert.equal(next['label'], 'Continue')
504
+ assert.equal(next['icon'], 'arrow-right')
505
+ assert.equal(previous['label'], 'Go back')
506
+ assert.equal(previous['color'], 'ghost')
507
+ })
508
+
509
+ it('customizer that hides the action drops the slot from meta', async () => {
510
+ const tree = [
511
+ Wizard.make()
512
+ .nextAction(a => a.visible(false))
513
+ .steps([Step.make('a').schema([])]),
514
+ ]
515
+ const result = await resolveSchema(tree)
516
+ assert.equal(result[0]!['nextAction'], undefined)
517
+ })
518
+ })
519
+
430
520
  it('all step children resolve so cross-step $get works', async () => {
431
521
  // Step 2 has a Section that hides based on a value entered in Step 0;
432
522
  // both steps must be resolved on every cycle for the predicate to fire.
@@ -10,6 +10,9 @@ import {
10
10
  import { Text } from './Text.js'
11
11
  import { Heading } from './Heading.js'
12
12
  import { Card } from './Card.js'
13
+ import { Section } from './Section.js'
14
+ import { Form } from '../elements/Form.js'
15
+ import { TextField } from '../fields/TextField.js'
13
16
 
14
17
  beforeEach(() => _resetResolverRegistry())
15
18
 
@@ -326,4 +329,51 @@ describe('resolveSchema', () => {
326
329
  assert.equal(result[0]!._layout, undefined)
327
330
  })
328
331
  })
332
+
333
+ describe('inlineLabel cascade (Form / Section)', () => {
334
+ it('Form.inlineLabel(true) cascades to descendant fields', async () => {
335
+ const tree = [Form.make().inlineLabel().schema([TextField.make('a'), TextField.make('b')])]
336
+ const [form] = await resolveSchema(tree)
337
+ const [a, b] = form!.children! as ElementMeta[]
338
+ assert.equal((a as { inlineLabel?: boolean }).inlineLabel, true)
339
+ assert.equal((b as { inlineLabel?: boolean }).inlineLabel, true)
340
+ })
341
+
342
+ it('Field.inlineLabel(false) overrides the Form cascade', async () => {
343
+ const tree = [Form.make().inlineLabel().schema([
344
+ TextField.make('a'),
345
+ TextField.make('b').inlineLabel(false),
346
+ ])]
347
+ const [form] = await resolveSchema(tree)
348
+ const [a, b] = form!.children! as ElementMeta[]
349
+ assert.equal((a as { inlineLabel?: boolean }).inlineLabel, true)
350
+ assert.equal('inlineLabel' in (b as object), false)
351
+ })
352
+
353
+ it('nested Section.inlineLabel(false) overrides the Form cascade for its subtree', async () => {
354
+ const tree = [Form.make().inlineLabel().schema([
355
+ TextField.make('above'),
356
+ Section.make('inner').inlineLabel(false).schema([TextField.make('inside')]),
357
+ ])]
358
+ const [form] = await resolveSchema(tree)
359
+ const [above, section] = form!.children! as ElementMeta[]
360
+ const [inside] = (section as ElementMeta).children! as ElementMeta[]
361
+ assert.equal((above as { inlineLabel?: boolean }).inlineLabel, true)
362
+ assert.equal('inlineLabel' in (inside as object), false)
363
+ })
364
+
365
+ it('Section.inlineLabel(true) cascades without an outer Form', async () => {
366
+ const tree = [Section.make('s').inlineLabel().schema([TextField.make('a')])]
367
+ const [section] = await resolveSchema(tree)
368
+ const [a] = section!.children! as ElementMeta[]
369
+ assert.equal((a as { inlineLabel?: boolean }).inlineLabel, true)
370
+ })
371
+
372
+ it('default (no setter calls) emits no inlineLabel anywhere', async () => {
373
+ const tree = [Form.make().schema([TextField.make('a')])]
374
+ const [form] = await resolveSchema(tree)
375
+ const [a] = form!.children! as ElementMeta[]
376
+ assert.equal('inlineLabel' in (a as object), false)
377
+ })
378
+ })
329
379
  })
@@ -22,7 +22,9 @@ import {
22
22
  type RepeatableEntryRowMeta,
23
23
  } from '../entries/RepeatableEntry.js'
24
24
  import { Section } from './Section.js'
25
+ import { Wizard, type WizardActionCustomizer } from './Wizard.js'
25
26
  import { TextField } from '../fields/TextField.js'
27
+ import { Form } from '../elements/Form.js'
26
28
 
27
29
  export interface SchemaContext {
28
30
  user?: { name?: string; email?: string; [key: string]: unknown }
@@ -113,6 +115,14 @@ export interface RenderContext extends SchemaContext {
113
115
  */
114
116
  blockType?: string
115
117
  }
118
+ /**
119
+ * Cascading `inlineLabel` default set by an ancestor `Form` or
120
+ * `Section` via `.inlineLabel(true)`. Read by `Field.buildMeta` when the
121
+ * field hasn't called `inlineLabel()` itself; explicit field-level
122
+ * setting always wins. A nested container's `.inlineLabel(false)`
123
+ * overrides an outer container's `.inlineLabel(true)` for its subtree.
124
+ */
125
+ inlineLabelDefault?: boolean
116
126
  }
117
127
 
118
128
  export type SchemaDefinition =
@@ -305,9 +315,15 @@ async function resolveOne(el: Element, ctx: RenderContext): Promise<ElementMeta
305
315
  return meta
306
316
  }
307
317
 
318
+ // Push `inlineLabelDefault` into the child ctx when this element is a
319
+ // `Form` or `Section` whose `.inlineLabel(...)` was set. Children inherit
320
+ // until another container resets the flag. Field-level `inlineLabel(...)`
321
+ // calls always win on read (see `Field.buildMeta`).
322
+ const childCtx = deriveChildContext(el, ctx)
323
+
308
324
  const children = el.getChildren()
309
325
  if (children && children.length > 0) {
310
- meta.children = await resolveAll(children, ctx)
326
+ meta.children = await resolveAll(children, childCtx)
311
327
  }
312
328
 
313
329
  // Filament v5 — `Section.afterHeader([Action…])` resolves through the
@@ -335,6 +351,30 @@ async function resolveOne(el: Element, ctx: RenderContext): Promise<ElementMeta
335
351
  }
336
352
  }
337
353
 
354
+ // `Wizard.submitAction() / nextAction() / previousAction()` — build a
355
+ // framework default Action for each slot, run the user's customizer
356
+ // (or take the explicit Action), then resolve through the standard
357
+ // walker so `.visible() / .disabled()` rules evaluate the same way
358
+ // as anywhere else. Stamped under `meta.submitAction / nextAction /
359
+ // previousAction`; sparse — absent when the user hasn't customized.
360
+ if (el instanceof Wizard) {
361
+ const submitC = el.getSubmitAction()
362
+ const nextC = el.getNextAction()
363
+ const previousC = el.getPreviousAction()
364
+ if (submitC) {
365
+ const [resolved] = await resolveAll([resolveWizardAction(submitC, 'submit')], ctx)
366
+ if (resolved) meta['submitAction'] = resolved
367
+ }
368
+ if (nextC) {
369
+ const [resolved] = await resolveAll([resolveWizardAction(nextC, 'next')], ctx)
370
+ if (resolved) meta['nextAction'] = resolved
371
+ }
372
+ if (previousC) {
373
+ const [resolved] = await resolveAll([resolveWizardAction(previousC, 'previous')], ctx)
374
+ if (resolved) meta['previousAction'] = resolved
375
+ }
376
+ }
377
+
338
378
  // `TextField.prefixAction(Action) / suffixAction(Action)` — resolve the
339
379
  // bound Actions through `resolveAll` so visibility / disabled rules
340
380
  // evaluate the same way as anywhere else. Hidden Actions are dropped
@@ -827,6 +867,23 @@ async function evalItemCan(
827
867
  * Mirrors the `Field.buildConditionContext` shape so layout `visible(fn)`
828
868
  * callbacks can destructure the same way as `Field.showWhen` callbacks.
829
869
  */
870
+ /**
871
+ * Resolve a `WizardActionCustomizer` against the framework default for
872
+ * the given slot. The default carries a stable `name` (`wizardSubmit /
873
+ * wizardNext / wizardPrevious`) and sensible chrome — the customizer
874
+ * may pass through verbatim, mutate, or replace entirely. Returned
875
+ * `Action` is then resolved through the standard walker so visibility
876
+ * and disabled rules evaluate the same way as any other Action.
877
+ */
878
+ function resolveWizardAction(customizer: WizardActionCustomizer, slot: 'submit' | 'next' | 'previous'): Action {
879
+ const def = slot === 'submit'
880
+ ? Action.make('wizardSubmit').label('Submit').color('primary')
881
+ : slot === 'next'
882
+ ? Action.make('wizardNext').label('Next').color('primary')
883
+ : Action.make('wizardPrevious').label('Back').color('ghost')
884
+ return typeof customizer === 'function' ? customizer(def) : customizer
885
+ }
886
+
830
887
  function buildLayoutContext(ctx: RenderContext): LayoutContext {
831
888
  const out: LayoutContext = {}
832
889
  if (ctx.record !== undefined) out.record = ctx.record
@@ -837,3 +894,24 @@ function buildLayoutContext(ctx: RenderContext): LayoutContext {
837
894
  if (ctx.row !== undefined) out.row = ctx.row
838
895
  return out
839
896
  }
897
+
898
+ /**
899
+ * Derive the render context that this element's children should see.
900
+ * Currently only handles the `inlineLabelDefault` cascade — `Form` /
901
+ * `Section` with `.inlineLabel(true|false)` push the value into the
902
+ * descendant context so every nested `Field.buildMeta` can read it.
903
+ * A nested container that re-sets the flag overrides the outer value
904
+ * for its subtree (the current ctx's value is replaced, not merged).
905
+ *
906
+ * Returns the same `ctx` reference when nothing needs to change so
907
+ * call sites that pass it down skip a fresh object allocation.
908
+ */
909
+ function deriveChildContext(el: Element, ctx: RenderContext): RenderContext {
910
+ if (el instanceof Form || el instanceof Section) {
911
+ const v = (el as Form | Section).getInlineLabel?.()
912
+ if (v !== undefined && v !== ctx.inlineLabelDefault) {
913
+ return { ...ctx, inlineLabelDefault: v }
914
+ }
915
+ }
916
+ return ctx
917
+ }
@@ -104,6 +104,29 @@ describe('writePersistedListQuery', () => {
104
104
  assert.deepEqual(session.data[key], { orders_status: 'draft' })
105
105
  })
106
106
 
107
+ it('skips groupKey (drill-in is page-state, not filter-state)', () => {
108
+ const session = makeSession()
109
+ const req = { session }
110
+ const key = listFiltersKey('/admin', 'posts')
111
+ writePersistedListQuery(req, key, {
112
+ status: 'draft',
113
+ groupKey: 'archive-2025',
114
+ })
115
+ assert.deepEqual(session.data[key], { status: 'draft' })
116
+ })
117
+
118
+ it('skips Tier-3 prefixed groupKey keys', () => {
119
+ const session = makeSession()
120
+ const req = { session }
121
+ const key = listFiltersKey('/admin', 'posts')
122
+ writePersistedListQuery(req, key, {
123
+ orders_status: 'draft',
124
+ orders_groupKey: 'old',
125
+ groupKey: 'older', // bare also dropped
126
+ })
127
+ assert.deepEqual(session.data[key], { orders_status: 'draft' })
128
+ })
129
+
107
130
  it('preserves empty-string values (explicit-clear marker)', () => {
108
131
  const session = makeSession()
109
132
  const req = { session }
@@ -4,7 +4,7 @@
4
4
 
5
5
  const PREFIX = 'pilotiq:filters:'
6
6
 
7
- const EXCLUDED_KEYS = new Set(['page', 'tab'])
7
+ const EXCLUDED_KEYS = new Set(['page', 'tab', 'groupKey'])
8
8
 
9
9
  // Heuristic also catches `<prefix>_page` from `Table.queryStringIdentifier`.
10
10
  // A filter literally named `something_page` would be dropped too, but
@@ -14,6 +14,15 @@ function isPageKey(key: string): boolean {
14
14
  return key.endsWith('_page')
15
15
  }
16
16
 
17
+ // Sibling heuristic for `<prefix>_groupKey`. Group drill-in is page-
18
+ // state (click-to-drill, × to clear), not filter-state — restoring it
19
+ // on a bare visit would land the user on the bucket they last drilled
20
+ // into instead of the banded list they probably expect.
21
+ function isGroupKeyKey(key: string): boolean {
22
+ if (key === 'groupKey') return true
23
+ return key.endsWith('_groupKey')
24
+ }
25
+
17
26
  interface StorableSession {
18
27
  get<T>(key: string, fallback?: T): T | undefined
19
28
  put(key: string, value: unknown): void
@@ -73,6 +82,7 @@ export function writePersistedListQuery(
73
82
  for (const [k, v] of Object.entries(query)) {
74
83
  if (EXCLUDED_KEYS.has(k)) continue
75
84
  if (isPageKey(k)) continue
85
+ if (isGroupKeyKey(k)) continue
76
86
  if (typeof v !== 'string') continue
77
87
  slice[k] = v
78
88
  }
@@ -0,0 +1,13 @@
1
+ /* Stylesheet for `FileUploadField`'s image-cropping affordance.
2
+ *
3
+ * `react-image-crop` is a declared dep of `@pilotiq/pilotiq` and its
4
+ * CSS is required for the crop UI to render correctly. Consumers
5
+ * pull in the styles by importing this subpath from their app's CSS
6
+ * (Tailwind / global stylesheet):
7
+ *
8
+ * @import "@pilotiq/pilotiq/styles/file-upload.css";
9
+ *
10
+ * Importing through this subpath means consumers don't need to
11
+ * declare `react-image-crop` as a direct dep — the CSS @import below
12
+ * resolves through pilotiq's own node_modules. */
13
+ @import "react-image-crop/dist/ReactCrop.css";
package/src/vite.ts CHANGED
@@ -632,6 +632,7 @@ export function clusterOffset(parts: string[]): number {
632
632
  lines.push('const _all: Record<string, unknown> = {}')
633
633
  lines.push('const _clusters: Record<string, string[]> = {}')
634
634
  lines.push('const _rightPanels: Record<string, unknown> = {}')
635
+ lines.push('const _layoutProviders: unknown[] = []')
635
636
  lines.push('function _add(c: any) { if (typeof c === \'function\' && c.name) _all[c.name] = c }')
636
637
  lines.push('function _walk(p: any) {')
637
638
  lines.push(' const cfg = p?.getConfig?.()')
@@ -643,6 +644,11 @@ export function clusterOffset(parts: string[]): number {
643
644
  lines.push(' if (_c && typeof _c.id === \'string\' && _c.render) _rightPanels[_c.id] = _c.render')
644
645
  lines.push(' }')
645
646
  lines.push(' }')
647
+ lines.push(' if (Array.isArray(cfg?.layoutProviders)) {')
648
+ lines.push(' for (const _C of cfg.layoutProviders) {')
649
+ lines.push(' if (typeof _C === \'function\') _layoutProviders.push(_C)')
650
+ lines.push(' }')
651
+ lines.push(' }')
646
652
  lines.push(' if (cfg?.path && Array.isArray(cfg?.clusters)) {')
647
653
  lines.push(' const slugs = cfg.clusters.map((C: any) => (typeof C?.getSlug === \'function\' ? C.getSlug() : \'\')).filter(Boolean)')
648
654
  lines.push(' if (slugs.length > 0) _clusters[cfg.path] = slugs')
@@ -653,6 +659,7 @@ export function clusterOffset(parts: string[]): number {
653
659
  lines.push('export const componentRegistry: Record<string, unknown> = _all')
654
660
  lines.push('export const clusterSlugsByBasePath: Record<string, string[]> = _clusters')
655
661
  lines.push('export const rightPanelRegistry: Record<string, unknown> = _rightPanels')
662
+ lines.push('export const layoutProviderRegistry: unknown[] = _layoutProviders')
656
663
  lines.push('')
657
664
 
658
665
  writeIfChanged(path.join(outDir, '_components.ts'), lines.join('\n'))
@@ -668,7 +675,7 @@ function writeLayoutWithManifest(pagesRoot: string): void {
668
675
  import { usePageContext } from 'vike-react/usePageContext'
669
676
  import { AppShell, ThemeProvider, generateThemeCSS, NavigateProvider } from '@pilotiq/pilotiq/react'
670
677
  import { navigate as vikeNavigate } from 'vike/client/router'
671
- import { componentRegistry, rightPanelRegistry } from './_components.js'
678
+ import { componentRegistry, rightPanelRegistry, layoutProviderRegistry } from './_components.js'
672
679
  import type { ReactNode } from 'react'
673
680
 
674
681
  // Wrap vike's async navigate so the NavigateProvider's fire-and-forget
@@ -691,7 +698,7 @@ export default function PilotiqLayout({ children }: { children: ReactNode }) {
691
698
  <NavigateProvider navigate={navigate}>
692
699
  <ThemeProvider theme={panel.theme}>
693
700
  {themeCss && <style dangerouslySetInnerHTML={{ __html: themeCss }} />}
694
- <AppShell panel={panel} basePath={basePath} layout={layout} notifications={notifications} currentPath={currentPath} componentRegistry={componentRegistry as any} rightPanelRegistry={rightPanelRegistry as any}>
701
+ <AppShell panel={panel} basePath={basePath} layout={layout} notifications={notifications} currentPath={currentPath} componentRegistry={componentRegistry as any} rightPanelRegistry={rightPanelRegistry as any} layoutProviderRegistry={layoutProviderRegistry as any}>
695
702
  {children}
696
703
  </AppShell>
697
704
  </ThemeProvider>