@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
@@ -51,6 +51,52 @@ export interface RepeaterRelationshipMeta {
51
51
  orderColumn?: string
52
52
  }
53
53
 
54
+ /**
55
+ * Mode of the underlying relation, derived from the parent's `static
56
+ * relations` map at submit time. Surfaced on `RepeaterRowContext.mode`
57
+ * so per-row hooks can branch when needed (e.g. an `afterDelete` that
58
+ * runs cleanup only when the child record was actually removed, not
59
+ * when a pivot row was detached).
60
+ */
61
+ export type RepeaterRelationMode =
62
+ | 'hasMany'
63
+ | 'morphMany'
64
+ | 'belongsToMany'
65
+ | 'morphToMany'
66
+ | 'morphedByMany'
67
+
68
+ /**
69
+ * Context passed to `Repeater.afterCreate / afterUpdate / afterDelete`
70
+ * hooks. One invocation per row; `parent` is the post-save parent record
71
+ * (PK already set), `record` carries the persisted child.
72
+ *
73
+ * For M2M modes (`belongsToMany / morphToMany / morphedByMany`)
74
+ * `afterDelete` fires after `accessor.detach(...)` — the child record
75
+ * may still exist (other parents may link to it). Branch on `ctx.mode`
76
+ * if your cleanup depends on physical deletion vs detach.
77
+ */
78
+ export interface RepeaterRowContext<P = unknown> {
79
+ /** Post-save parent record (the same `record` the surrounding form's
80
+ * `afterSave` would see). */
81
+ parent: P
82
+ /** Convenience — `parent[primaryKey]`. */
83
+ parentId: string | number
84
+ /** The Repeater field's `name`. */
85
+ field: string
86
+ /** 0-based index of the row in the submitted set; `-1` for `afterDelete`
87
+ * since deleted rows aren't in the submitted set. */
88
+ index: number
89
+ /** Underlying relation mode — see above for `afterDelete` semantics
90
+ * on M2M. */
91
+ mode: RepeaterRelationMode
92
+ }
93
+
94
+ /** Handler signature shared by the three after-hooks. */
95
+ export type RepeaterRowAfterHandler<C = unknown, P = unknown> = (
96
+ record: C,
97
+ ctx: RepeaterRowContext<P>,
98
+ ) => void | Promise<void>
99
+
54
100
  /**
55
101
  * Function evaluated once per row at meta-build to derive a human-readable
56
102
  * label for the collapsed-row header. Called with the row's submitted values
@@ -295,6 +341,9 @@ export class RepeaterField extends Field {
295
341
  private _tableColumns?: RepeaterTableColumn[]
296
342
  private _buttons: { [K in RowButtonKind]?: RowButton } = {}
297
343
  private _relationship?: RepeaterRelationshipConfig
344
+ private _afterCreate?: RepeaterRowAfterHandler
345
+ private _afterUpdate?: RepeaterRowAfterHandler
346
+ private _afterDelete?: RepeaterRowAfterHandler
298
347
 
299
348
  private constructor(name: string) {
300
349
  super(name, 'repeater')
@@ -560,6 +609,57 @@ export class RepeaterField extends Field {
560
609
  return this
561
610
  }
562
611
 
612
+ /**
613
+ * Per-row hook that fires after each newly created child record is
614
+ * persisted in `relationship()` mode. Receives the created record
615
+ * (with its primary key set) and a `RepeaterRowContext` carrying
616
+ * the parent record + row index + relation mode. Errors propagate
617
+ * — a throwing handler aborts the rest of the persist diff (the
618
+ * parent + any earlier rows have already saved; v1 is non-
619
+ * transactional).
620
+ *
621
+ * No-op outside `relationship()` mode. Use `Form.afterCreate(...)`
622
+ * for hooks that fire on the parent record's create.
623
+ */
624
+ afterCreate(fn: RepeaterRowAfterHandler): this {
625
+ if (!this._relationship) {
626
+ throw new Error(
627
+ `[Pilotiq] Repeater "${this.name}": afterCreate() requires relationship() to be configured first.`,
628
+ )
629
+ }
630
+ this._afterCreate = fn
631
+ return this
632
+ }
633
+
634
+ /** Per-row hook that fires after each existing child record is
635
+ * updated. Receives the updated record from the child model's
636
+ * `update()` return (or the post-update reload when `update`
637
+ * returns void). See `afterCreate` notes for error semantics. */
638
+ afterUpdate(fn: RepeaterRowAfterHandler): this {
639
+ if (!this._relationship) {
640
+ throw new Error(
641
+ `[Pilotiq] Repeater "${this.name}": afterUpdate() requires relationship() to be configured first.`,
642
+ )
643
+ }
644
+ this._afterUpdate = fn
645
+ return this
646
+ }
647
+
648
+ /** Per-row hook that fires after each removed row is processed.
649
+ * For `hasMany / morphMany` modes the child record was physically
650
+ * deleted via `model.delete()`; for M2M modes only the pivot row
651
+ * was detached and the child may still exist. Branch on
652
+ * `ctx.mode` when that distinction matters. */
653
+ afterDelete(fn: RepeaterRowAfterHandler): this {
654
+ if (!this._relationship) {
655
+ throw new Error(
656
+ `[Pilotiq] Repeater "${this.name}": afterDelete() requires relationship() to be configured first.`,
657
+ )
658
+ }
659
+ this._afterDelete = fn
660
+ return this
661
+ }
662
+
563
663
  /**
564
664
  * Customize the bottom Add button's chrome (label / icon / color /
565
665
  * tooltip). Equivalent to `addActionLabel()` plus icon + color
@@ -692,6 +792,10 @@ export class RepeaterField extends Field {
692
792
  /** Resolved relationship config (`undefined` when not configured). */
693
793
  getRelationship(): RepeaterRelationshipConfig | undefined { return this._relationship }
694
794
  isRelationship(): boolean { return this._relationship !== undefined }
795
+
796
+ getAfterCreate(): RepeaterRowAfterHandler | undefined { return this._afterCreate }
797
+ getAfterUpdate(): RepeaterRowAfterHandler | undefined { return this._afterUpdate }
798
+ getAfterDelete(): RepeaterRowAfterHandler | undefined { return this._afterDelete }
695
799
  /** The configured customizer for a given slot, or `undefined`. */
696
800
  getButton(kind: RowButtonKind): RowButton | undefined { return this._buttons[kind] }
697
801
  /**
@@ -1628,3 +1628,176 @@ describe('Repeater.relationship — pivotColumns', () => {
1628
1628
  assert.deepEqual(rows, [])
1629
1629
  })
1630
1630
  })
1631
+
1632
+ describe('Repeater.relationship — afterCreate / afterUpdate / afterDelete hooks', () => {
1633
+ it('afterCreate fires once per created child with parent + index + mode in ctx', async () => {
1634
+ const child = makeFakeChildModel([])
1635
+ const parent = makeFakeParentModel({
1636
+ childModel: child.model,
1637
+ childRows: child.rows,
1638
+ relationName: 'items',
1639
+ foreignKey: 'orderId',
1640
+ })
1641
+
1642
+ const calls: Array<{ record: unknown; ctx: Record<string, unknown> }> = []
1643
+ const form = Form.make()
1644
+ .schema([
1645
+ RepeaterField.make('items')
1646
+ .relationship('items')
1647
+ .schema([TextField.make('label').required()])
1648
+ .afterCreate((record, ctx) => {
1649
+ calls.push({ record, ctx: { ...ctx } as Record<string, unknown> })
1650
+ }),
1651
+ ])
1652
+ .save(async () => ({ id: 'p1', title: 'Order' }))
1653
+
1654
+ await dispatchFormSubmit(
1655
+ form,
1656
+ { items: [{ label: 'A' }, { label: 'B' }] },
1657
+ { values: { items: [{ label: 'A' }, { label: 'B' }] }, parentModel: parent },
1658
+ )
1659
+
1660
+ assert.equal(calls.length, 2)
1661
+ assert.equal((calls[0]!.record as Record<string, unknown>)['label'], 'A')
1662
+ assert.equal((calls[1]!.record as Record<string, unknown>)['label'], 'B')
1663
+ assert.equal(calls[0]!.ctx['index'], 0)
1664
+ assert.equal(calls[1]!.ctx['index'], 1)
1665
+ assert.equal(calls[0]!.ctx['field'], 'items')
1666
+ assert.equal(calls[0]!.ctx['mode'], 'hasMany')
1667
+ assert.equal(calls[0]!.ctx['parentId'], 'p1')
1668
+ assert.deepEqual(calls[0]!.ctx['parent'], { id: 'p1', title: 'Order' })
1669
+ })
1670
+
1671
+ it('afterUpdate fires per updated child (skipping pure-create rows)', async () => {
1672
+ const child = makeFakeChildModel([
1673
+ { id: 'c1', orderId: 'p1', label: 'old A' },
1674
+ ])
1675
+ const parent = makeFakeParentModel({
1676
+ childModel: child.model,
1677
+ childRows: child.rows,
1678
+ relationName: 'items',
1679
+ foreignKey: 'orderId',
1680
+ })
1681
+
1682
+ const updates: Array<{ record: unknown; index: number }> = []
1683
+ const creates: Array<{ record: unknown; index: number }> = []
1684
+ const form = Form.make()
1685
+ .schema([
1686
+ RepeaterField.make('items')
1687
+ .relationship('items')
1688
+ .schema([TextField.make('label').required()])
1689
+ .afterCreate((record, ctx) => { creates.push({ record, index: ctx.index }) })
1690
+ .afterUpdate((record, ctx) => { updates.push({ record, index: ctx.index }) }),
1691
+ ])
1692
+ .save(async () => ({ id: 'p1' }))
1693
+
1694
+ await dispatchFormSubmit(
1695
+ form,
1696
+ { items: [{ __id: 'c1', label: 'new A' }, { label: 'fresh B' }] },
1697
+ {
1698
+ values: { items: [{ __id: 'c1', label: 'new A' }, { label: 'fresh B' }] },
1699
+ record: { id: 'p1' },
1700
+ parentModel: parent,
1701
+ },
1702
+ )
1703
+
1704
+ assert.equal(updates.length, 1)
1705
+ assert.equal((updates[0]!.record as Record<string, unknown>)['label'], 'new A')
1706
+ assert.equal(updates[0]!.index, 0)
1707
+ assert.equal(creates.length, 1)
1708
+ assert.equal((creates[0]!.record as Record<string, unknown>)['label'], 'fresh B')
1709
+ assert.equal(creates[0]!.index, 1)
1710
+ })
1711
+
1712
+ it('afterDelete fires once per removed child with the previous row data', async () => {
1713
+ const child = makeFakeChildModel([
1714
+ { id: 'c1', orderId: 'p1', label: 'A' },
1715
+ { id: 'c2', orderId: 'p1', label: 'B' },
1716
+ { id: 'c3', orderId: 'p1', label: 'C' },
1717
+ ])
1718
+ const parent = makeFakeParentModel({
1719
+ childModel: child.model,
1720
+ childRows: child.rows,
1721
+ relationName: 'items',
1722
+ foreignKey: 'orderId',
1723
+ })
1724
+
1725
+ const removed: Array<{ record: unknown; ctx: Record<string, unknown> }> = []
1726
+ const form = Form.make()
1727
+ .schema([
1728
+ RepeaterField.make('items')
1729
+ .relationship('items')
1730
+ .schema([TextField.make('label').required()])
1731
+ .afterDelete((record, ctx) => {
1732
+ removed.push({ record, ctx: { ...ctx } as Record<string, unknown> })
1733
+ }),
1734
+ ])
1735
+ .save(async () => ({ id: 'p1' }))
1736
+
1737
+ // Submit only c1 — c2 and c3 disappear.
1738
+ await dispatchFormSubmit(
1739
+ form,
1740
+ { items: [{ __id: 'c1', label: 'A' }] },
1741
+ {
1742
+ values: { items: [{ __id: 'c1', label: 'A' }] },
1743
+ record: { id: 'p1' },
1744
+ parentModel: parent,
1745
+ },
1746
+ )
1747
+
1748
+ assert.equal(removed.length, 2)
1749
+ const labels = removed.map(r => (r.record as Record<string, unknown>)['label']).sort()
1750
+ assert.deepEqual(labels, ['B', 'C'])
1751
+ assert.equal(removed[0]!.ctx['index'], -1)
1752
+ assert.equal(removed[0]!.ctx['mode'], 'hasMany')
1753
+ assert.equal(removed[0]!.ctx['parentId'], 'p1')
1754
+ })
1755
+
1756
+ it('hooks are no-op outside relationship() mode (throw at config time)', () => {
1757
+ assert.throws(() =>
1758
+ RepeaterField.make('json').afterCreate(() => {}),
1759
+ /requires relationship/,
1760
+ )
1761
+ assert.throws(() =>
1762
+ RepeaterField.make('json').afterUpdate(() => {}),
1763
+ /requires relationship/,
1764
+ )
1765
+ assert.throws(() =>
1766
+ RepeaterField.make('json').afterDelete(() => {}),
1767
+ /requires relationship/,
1768
+ )
1769
+ })
1770
+
1771
+ it('throwing handler propagates and aborts the rest of the persist diff', async () => {
1772
+ const child = makeFakeChildModel([])
1773
+ const parent = makeFakeParentModel({
1774
+ childModel: child.model,
1775
+ childRows: child.rows,
1776
+ relationName: 'items',
1777
+ foreignKey: 'orderId',
1778
+ })
1779
+
1780
+ const form = Form.make()
1781
+ .schema([
1782
+ RepeaterField.make('items')
1783
+ .relationship('items')
1784
+ .schema([TextField.make('label').required()])
1785
+ .afterCreate((record) => {
1786
+ const r = record as Record<string, unknown>
1787
+ if (r['label'] === 'B') throw new Error('reject B')
1788
+ }),
1789
+ ])
1790
+ .save(async () => ({ id: 'p1' }))
1791
+
1792
+ await assert.rejects(
1793
+ () => dispatchFormSubmit(
1794
+ form,
1795
+ { items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] },
1796
+ { values: { items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] }, parentModel: parent },
1797
+ ),
1798
+ /reject B/,
1799
+ )
1800
+ // Two creates fired before the throw — no rollback (v1 isn't transactional).
1801
+ assert.equal(child.calls.filter(c => c.kind === 'create').length, 2)
1802
+ })
1803
+ })
@@ -446,6 +446,27 @@ describe('Phase A relation-view — surfaces nested-manager tabs (Phase B polish
446
446
  const strips = schema.filter(s => s['type'] === 'relation-tabs')
447
447
  assert.equal(strips.length, 1, 'expected only the post-scope strip; the comment-scope strip should be absent')
448
448
  })
449
+
450
+ it('hides a nested sibling tab when N.canViewAny returns false', async () => {
451
+ const { panel } = buildNestedWorld({
452
+ nestedOverrides: {
453
+ async canViewAny() { return false },
454
+ } as unknown as Partial<typeof RelationManager>,
455
+ })
456
+ const out = await relationManagerData(panel, {
457
+ kind: 'relation-view', slug: 'posts',
458
+ recordId: 'po1',
459
+ relationship:'comments',
460
+ childId: 'c1',
461
+ })
462
+ const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
463
+ const strips = schema.filter(s => s['type'] === 'relation-tabs') as Array<Record<string, unknown>>
464
+ // Post-scope strip still here; comment-scope strip is gone because
465
+ // the only nested manager (CommentRepliesManager) was gated away,
466
+ // collapsing the strip to just the back-link `__view` — under the
467
+ // empty-strip drop threshold.
468
+ assert.equal(strips.length, 1, 'expected only the post-scope strip after sibling gating')
469
+ })
449
470
  })
450
471
 
451
472
  describe('nestedRelationManagerData (Phase B) — RelationTabs strip', () => {
@@ -298,6 +298,19 @@ export function modelTableRecords(R: ResourceLike, table: Table): TableRecordsHa
298
298
  // and lets a tab's narrower `where` win on collision.
299
299
  if (ctx.tabQuery) q = ctx.tabQuery(q)
300
300
 
301
+ // Apply group drill-in scoper when the user clicked a banded heading.
302
+ // Runs after filters + tab so the bucket narrowing composes on top of
303
+ // whatever scope the page already had. User-supplied
304
+ // `TableGroup.scopeQueryByKey(fn)` wins over the framework default
305
+ // (exact-match `where(column, '=', key)` / whole-day range for date
306
+ // groups); throwing scopers propagate so a config bug surfaces loudly
307
+ // rather than silently rendering every row.
308
+ if (ctx.groupScope) {
309
+ const scope = ctx.groupScope
310
+ const scoper = scope.group.resolveScoper<ModelQuery>()
311
+ q = scoper(q, scope.key)
312
+ }
313
+
301
314
  if (ctx.sort) {
302
315
  q = q.orderBy(ctx.sort.column, ctx.sort.direction === 'desc' ? 'DESC' : 'ASC')
303
316
  }
@@ -619,6 +632,14 @@ export function modelRelationTableRecords(
619
632
 
620
633
  if (ctx.tabQuery) q = ctx.tabQuery(q)
621
634
 
635
+ // Group drill-in scoper — same shape as `modelTableRecords`. Composes
636
+ // with `relatedQuery` since the scoper just chains another `where`.
637
+ if (ctx.groupScope) {
638
+ const scope = ctx.groupScope
639
+ const scoper = scope.group.resolveScoper<ModelQuery>()
640
+ q = scoper(q, scope.key)
641
+ }
642
+
622
643
  if (ctx.sort) {
623
644
  q = q.orderBy(ctx.sort.column, ctx.sort.direction === 'desc' ? 'DESC' : 'ASC')
624
645
  }