@cosmicdrift/kumiko-renderer 0.328.0 → 0.328.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/dist/app/action-form-shim.d.ts +14 -0
  2. package/{src/app/action-form-shim.ts → dist/app/action-form-shim.js} +26 -46
  3. package/dist/app/app-features-context.d.ts +8 -0
  4. package/dist/app/app-features-context.js +16 -0
  5. package/dist/app/column-renderers.d.ts +30 -0
  6. package/dist/app/column-renderers.js +34 -0
  7. package/dist/app/config-edit-shim.d.ts +11 -0
  8. package/{src/app/config-edit-shim.ts → dist/app/config-edit-shim.js} +12 -25
  9. package/dist/app/content-editors.d.ts +38 -0
  10. package/dist/app/content-editors.js +26 -0
  11. package/dist/app/content-preview.d.ts +11 -0
  12. package/dist/app/content-preview.js +30 -0
  13. package/dist/app/custom-screens.d.ts +11 -0
  14. package/dist/app/custom-screens.js +20 -0
  15. package/dist/app/dashboard-body.d.ts +16 -0
  16. package/dist/app/dashboard-body.js +11 -0
  17. package/dist/app/extension-form-submit.d.ts +32 -0
  18. package/dist/app/extension-form-submit.js +73 -0
  19. package/dist/app/extension-sections.d.ts +72 -0
  20. package/dist/app/extension-sections.js +34 -0
  21. package/dist/app/feature-schema.d.ts +4 -0
  22. package/{src/app/feature-schema.ts → dist/app/feature-schema.js} +12 -22
  23. package/dist/app/form-schema.d.ts +4 -0
  24. package/dist/app/form-schema.js +85 -0
  25. package/dist/app/kumiko-screen.d.ts +43 -0
  26. package/dist/app/kumiko-screen.js +2206 -0
  27. package/dist/app/layout-fields.d.ts +3 -0
  28. package/dist/app/layout-fields.js +20 -0
  29. package/dist/app/list-facets.d.ts +48 -0
  30. package/dist/app/list-facets.js +185 -0
  31. package/dist/app/nav.d.ts +61 -0
  32. package/dist/app/nav.js +139 -0
  33. package/dist/app/projection-detail-shim.d.ts +8 -0
  34. package/dist/app/projection-detail-shim.js +71 -0
  35. package/dist/app/projection-list-shim.d.ts +13 -0
  36. package/{src/app/projection-list-shim.ts → dist/app/projection-list-shim.js} +26 -41
  37. package/dist/app/qn.d.ts +7 -0
  38. package/{src/app/qn.ts → dist/app/qn.js} +9 -11
  39. package/dist/app/qualify-screen-id.d.ts +2 -0
  40. package/{src/app/qualify-screen-id.ts → dist/app/qualify-screen-id.js} +5 -6
  41. package/dist/app/reference-facet-bridge.d.ts +10 -0
  42. package/dist/app/reference-facet-bridge.js +22 -0
  43. package/dist/app/return-to.d.ts +31 -0
  44. package/dist/app/return-to.js +188 -0
  45. package/dist/app/row-actions.d.ts +77 -0
  46. package/dist/app/row-actions.js +416 -0
  47. package/dist/app/screen-access.d.ts +8 -0
  48. package/dist/app/screen-access.js +48 -0
  49. package/dist/app/secret-mint-body.d.ts +10 -0
  50. package/dist/app/secret-mint-body.js +140 -0
  51. package/dist/app/secrets-edit-body.d.ts +8 -0
  52. package/dist/app/secrets-edit-body.js +76 -0
  53. package/dist/app/use-embedded-screen.d.ts +6 -0
  54. package/dist/app/use-embedded-screen.js +19 -0
  55. package/dist/app/variable-chips.d.ts +7 -0
  56. package/dist/app/variable-chips.js +16 -0
  57. package/dist/app/write-failed-error.d.ts +6 -0
  58. package/dist/app/write-failed-error.js +21 -0
  59. package/dist/components/embedded-list-field.d.ts +10 -0
  60. package/dist/components/embedded-list-field.js +259 -0
  61. package/dist/components/grid-cell-for-field.d.ts +25 -0
  62. package/dist/components/grid-cell-for-field.js +31 -0
  63. package/dist/components/multi-select-checkboxes.d.ts +14 -0
  64. package/dist/components/multi-select-checkboxes.js +36 -0
  65. package/dist/components/reference-create-dialog.d.ts +13 -0
  66. package/dist/components/reference-create-dialog.js +46 -0
  67. package/dist/components/related-list-section.d.ts +25 -0
  68. package/dist/components/related-list-section.js +273 -0
  69. package/dist/components/render-edit-action-button.d.ts +22 -0
  70. package/dist/components/render-edit-action-button.js +43 -0
  71. package/dist/components/render-edit-logic.d.ts +6 -0
  72. package/dist/components/render-edit-logic.js +63 -0
  73. package/dist/components/render-edit-types.d.ts +209 -0
  74. package/dist/components/render-edit-types.js +1 -0
  75. package/dist/components/render-edit.d.ts +6 -0
  76. package/dist/components/render-edit.js +1169 -0
  77. package/dist/components/render-field.d.ts +38 -0
  78. package/dist/components/render-field.js +544 -0
  79. package/dist/components/render-list.d.ts +117 -0
  80. package/dist/components/render-list.js +232 -0
  81. package/dist/components/write-form-section.d.ts +17 -0
  82. package/dist/components/write-form-section.js +104 -0
  83. package/dist/context/dispatcher-context.d.ts +10 -0
  84. package/{src/context/dispatcher-context.tsx → dist/context/dispatcher-context.js} +16 -29
  85. package/dist/context/draft-storage-context.d.ts +12 -0
  86. package/dist/context/draft-storage-context.js +18 -0
  87. package/dist/context/user-roles-context.d.ts +7 -0
  88. package/{src/context/user-roles-context.tsx → dist/context/user-roles-context.js} +7 -15
  89. package/dist/format-when.d.ts +1 -0
  90. package/{src/format-when.ts → dist/format-when.js} +7 -7
  91. package/dist/hooks/reference-limits.d.ts +3 -0
  92. package/{src/hooks/reference-limits.ts → dist/hooks/reference-limits.js} +0 -1
  93. package/dist/hooks/use-ai-text.d.ts +52 -0
  94. package/dist/hooks/use-ai-text.js +90 -0
  95. package/dist/hooks/use-disclosure.d.ts +7 -0
  96. package/dist/hooks/use-disclosure.js +8 -0
  97. package/dist/hooks/use-form.d.ts +7 -0
  98. package/dist/hooks/use-form.js +36 -0
  99. package/dist/hooks/use-list-url-state.d.ts +43 -0
  100. package/dist/hooks/use-list-url-state.js +131 -0
  101. package/dist/hooks/use-mutation.d.ts +9 -0
  102. package/dist/hooks/use-mutation.js +37 -0
  103. package/dist/hooks/use-query.d.ts +14 -0
  104. package/dist/hooks/use-query.js +118 -0
  105. package/dist/hooks/use-reference-lookup.d.ts +14 -0
  106. package/dist/hooks/use-reference-lookup.js +63 -0
  107. package/dist/hooks/use-store.d.ts +3 -0
  108. package/dist/hooks/use-store.js +38 -0
  109. package/dist/hooks/use-stream-handler.d.ts +14 -0
  110. package/dist/hooks/use-stream-handler.js +93 -0
  111. package/dist/i18n-defaults.d.ts +2 -0
  112. package/dist/i18n-defaults.js +201 -0
  113. package/dist/i18n.d.ts +64 -0
  114. package/dist/i18n.js +166 -0
  115. package/dist/index.d.ts +77 -0
  116. package/dist/index.js +55 -0
  117. package/dist/inside-drawer.d.ts +2 -0
  118. package/{src/inside-drawer.ts → dist/inside-drawer.js} +2 -5
  119. package/dist/page-header-slot.d.ts +2 -0
  120. package/{src/page-header-slot.ts → dist/page-header-slot.js} +2 -5
  121. package/dist/primitives.d.ts +1351 -0
  122. package/dist/primitives.js +73 -0
  123. package/dist/screen-fills-height.d.ts +3 -0
  124. package/dist/screen-fills-height.js +3 -0
  125. package/dist/share-links.d.ts +4 -0
  126. package/dist/share-links.js +11 -0
  127. package/dist/sort-by-accessor.d.ts +5 -0
  128. package/dist/sort-by-accessor.js +16 -0
  129. package/dist/sse/live-events.d.ts +23 -0
  130. package/dist/sse/live-events.js +28 -0
  131. package/dist/tokens.d.ts +83 -0
  132. package/dist/tokens.js +80 -0
  133. package/package.json +16 -9
  134. package/src/__tests__/error-i18n-defaults.test.ts +0 -82
  135. package/src/__tests__/format-when.test.ts +0 -12
  136. package/src/__tests__/i18n.test.tsx +0 -283
  137. package/src/__tests__/merge-search-params-into-initial.test.ts +0 -525
  138. package/src/__tests__/qn.test.ts +0 -83
  139. package/src/__tests__/share-links.test.ts +0 -26
  140. package/src/__tests__/sort-by-accessor.test.ts +0 -48
  141. package/src/__tests__/stringify-nav-params.test.ts +0 -33
  142. package/src/__tests__/use-list-url-state.test.tsx +0 -250
  143. package/src/app/__tests__/action-form-field-labels.test.tsx +0 -92
  144. package/src/app/__tests__/action-form-shim.test.ts +0 -137
  145. package/src/app/__tests__/build-list-query-payload.test.ts +0 -107
  146. package/src/app/__tests__/config-edit-shim.test.ts +0 -57
  147. package/src/app/__tests__/content-editors.test.tsx +0 -128
  148. package/src/app/__tests__/content-preview.test.tsx +0 -184
  149. package/src/app/__tests__/entity-edit-create-search-params.test.tsx +0 -236
  150. package/src/app/__tests__/entity-edit-fill-height.test.tsx +0 -118
  151. package/src/app/__tests__/entity-edit-jsonb-embedded-submit.test.tsx +0 -239
  152. package/src/app/__tests__/entity-list-create-singleton.test.tsx +0 -128
  153. package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +0 -424
  154. package/src/app/__tests__/entity-list-row-action-icon.test.tsx +0 -248
  155. package/src/app/__tests__/entity-list-row-action-kumiko-actions-view.test.tsx +0 -162
  156. package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +0 -401
  157. package/src/app/__tests__/entity-list-screen-padding.test.tsx +0 -128
  158. package/src/app/__tests__/feature-schema.test.ts +0 -40
  159. package/src/app/__tests__/form-schema.test.ts +0 -247
  160. package/src/app/__tests__/list-daterange-facet.test.ts +0 -165
  161. package/src/app/__tests__/list-filter-facets.test.tsx +0 -521
  162. package/src/app/__tests__/nav.test.ts +0 -198
  163. package/src/app/__tests__/projection-detail-actions.test.tsx +0 -719
  164. package/src/app/__tests__/projection-detail-reference-field.test.tsx +0 -165
  165. package/src/app/__tests__/projection-detail-shim.test.ts +0 -188
  166. package/src/app/__tests__/projection-detail-singleton.test.tsx +0 -191
  167. package/src/app/__tests__/projection-list-bad-shape.test.tsx +0 -152
  168. package/src/app/__tests__/projection-list-reference-column.test.tsx +0 -219
  169. package/src/app/__tests__/projection-list-search-sort.test.tsx +0 -212
  170. package/src/app/__tests__/qualified-names.test.ts +0 -37
  171. package/src/app/__tests__/return-to.test.ts +0 -396
  172. package/src/app/__tests__/row-actions-navigate-tab.test.ts +0 -223
  173. package/src/app/__tests__/row-default-edit-action.test.tsx +0 -324
  174. package/src/app/__tests__/screen-access-allows.test.ts +0 -34
  175. package/src/app/__tests__/secret-mint-body.test.tsx +0 -404
  176. package/src/app/__tests__/variable-chips.test.tsx +0 -70
  177. package/src/app/app-features-context.tsx +0 -25
  178. package/src/app/column-renderers.tsx +0 -68
  179. package/src/app/content-editors.tsx +0 -85
  180. package/src/app/content-preview.tsx +0 -67
  181. package/src/app/custom-screens.tsx +0 -29
  182. package/src/app/dashboard-body.tsx +0 -33
  183. package/src/app/extension-form-submit.tsx +0 -123
  184. package/src/app/extension-sections.tsx +0 -111
  185. package/src/app/form-schema.ts +0 -91
  186. package/src/app/kumiko-screen.tsx +0 -3801
  187. package/src/app/layout-fields.ts +0 -31
  188. package/src/app/list-facets.ts +0 -259
  189. package/src/app/nav.tsx +0 -258
  190. package/src/app/projection-detail-shim.ts +0 -86
  191. package/src/app/reference-facet-bridge.tsx +0 -58
  192. package/src/app/return-to.tsx +0 -234
  193. package/src/app/row-actions.ts +0 -640
  194. package/src/app/screen-access.ts +0 -63
  195. package/src/app/secret-mint-body.tsx +0 -259
  196. package/src/app/secrets-edit-body.tsx +0 -194
  197. package/src/app/use-embedded-screen.ts +0 -27
  198. package/src/app/variable-chips.tsx +0 -39
  199. package/src/app/write-failed-error.ts +0 -27
  200. package/src/components/__tests__/embedded-list-field.test.tsx +0 -531
  201. package/src/components/__tests__/multi-select-checkboxes.test.tsx +0 -162
  202. package/src/components/__tests__/related-list-section-empty-state.test.tsx +0 -143
  203. package/src/components/__tests__/related-list-section.test.tsx +0 -1733
  204. package/src/components/__tests__/render-edit-action-button.test.tsx +0 -205
  205. package/src/components/__tests__/render-edit-hidden-section.test.tsx +0 -128
  206. package/src/components/__tests__/render-edit-logic.test.ts +0 -314
  207. package/src/components/__tests__/render-edit-screen-description.test.tsx +0 -152
  208. package/src/components/__tests__/render-edit-submit-actions.test.tsx +0 -460
  209. package/src/components/__tests__/render-field-app-locale.test.tsx +0 -142
  210. package/src/components/__tests__/render-field-enum-option.test.tsx +0 -99
  211. package/src/components/__tests__/render-field-icon-derivation.test.tsx +0 -119
  212. package/src/components/__tests__/render-field-json-format.test.tsx +0 -164
  213. package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +0 -212
  214. package/src/components/__tests__/render-field-money-roundtrip.test.tsx +0 -217
  215. package/src/components/__tests__/render-field-number-grouping.test.tsx +0 -76
  216. package/src/components/__tests__/render-field-number-unit.test.tsx +0 -227
  217. package/src/components/__tests__/render-field-password-format.test.tsx +0 -101
  218. package/src/components/__tests__/render-field-select-display.test.tsx +0 -134
  219. package/src/components/__tests__/render-field-unit-format.test.tsx +0 -107
  220. package/src/components/__tests__/render-field-unsupported-types.test.tsx +0 -195
  221. package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +0 -146
  222. package/src/components/__tests__/write-form-section.test.tsx +0 -375
  223. package/src/components/embedded-list-field.tsx +0 -354
  224. package/src/components/grid-cell-for-field.tsx +0 -88
  225. package/src/components/multi-select-checkboxes.tsx +0 -93
  226. package/src/components/reference-create-dialog.tsx +0 -91
  227. package/src/components/related-list-section.tsx +0 -491
  228. package/src/components/render-edit-action-button.tsx +0 -109
  229. package/src/components/render-edit-logic.ts +0 -85
  230. package/src/components/render-edit-types.ts +0 -226
  231. package/src/components/render-edit.tsx +0 -1871
  232. package/src/components/render-field.tsx +0 -951
  233. package/src/components/render-list.tsx +0 -593
  234. package/src/components/write-form-section.tsx +0 -198
  235. package/src/context/draft-storage-context.tsx +0 -39
  236. package/src/hooks/__tests__/use-ai-text.test.tsx +0 -177
  237. package/src/hooks/__tests__/use-disclosure.test.tsx +0 -25
  238. package/src/hooks/__tests__/use-form.test.tsx +0 -65
  239. package/src/hooks/__tests__/use-mutation.test.tsx +0 -77
  240. package/src/hooks/__tests__/use-query.test.tsx +0 -149
  241. package/src/hooks/__tests__/use-stream-handler.test.tsx +0 -107
  242. package/src/hooks/use-ai-text.ts +0 -172
  243. package/src/hooks/use-disclosure.ts +0 -20
  244. package/src/hooks/use-form.ts +0 -78
  245. package/src/hooks/use-list-url-state.ts +0 -207
  246. package/src/hooks/use-mutation.ts +0 -61
  247. package/src/hooks/use-query.ts +0 -166
  248. package/src/hooks/use-reference-lookup.ts +0 -76
  249. package/src/hooks/use-store.ts +0 -48
  250. package/src/hooks/use-stream-handler.ts +0 -134
  251. package/src/i18n-defaults.ts +0 -240
  252. package/src/i18n.tsx +0 -286
  253. package/src/index.ts +0 -279
  254. package/src/primitives.tsx +0 -1567
  255. package/src/screen-fills-height.ts +0 -3
  256. package/src/share-links.ts +0 -12
  257. package/src/sort-by-accessor.ts +0 -20
  258. package/src/sse/live-events.tsx +0 -56
  259. package/src/tokens.tsx +0 -170
@@ -1,491 +0,0 @@
1
- import type {
2
- EntityDefinition,
3
- EntityListScreenDefinition,
4
- RowActionDrawer,
5
- RowActionNavigate,
6
- } from "@cosmicdrift/kumiko-framework/ui-types";
7
- import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
8
- import type {
9
- EditRelatedListSectionViewModel,
10
- ListRowViewModel,
11
- Translate,
12
- } from "@cosmicdrift/kumiko-headless";
13
- import { type ReactNode, useCallback, useMemo, useState } from "react";
14
- import { useAppFeatures } from "../app/app-features-context";
15
- import {
16
- buildFilterFacets,
17
- buildFilterPayload,
18
- mergeReferenceFacetOptions,
19
- resolveProjectionFacetSpecs,
20
- } from "../app/list-facets";
21
- import { useNav } from "../app/nav";
22
- import { ReferenceFacetBridges, type ReferenceFacetOption } from "../app/reference-facet-bridge";
23
- import { navigateWithReturnTo, useReturnHost } from "../app/return-to";
24
- import {
25
- buildDefaultEditRowAction,
26
- buildProjectionRowActions,
27
- buildProjectionToolbarActions,
28
- buildRecordActions,
29
- runProjectionRowNavigate,
30
- } from "../app/row-actions";
31
- import { findEditScreenFor } from "../app/screen-access";
32
- import { dispatcherErrorText } from "../app/write-failed-error";
33
- import { useOptionalDispatcher } from "../context/dispatcher-context";
34
- import { useUserRoles } from "../context/user-roles-context";
35
- import type { ListSort } from "../hooks/use-list-url-state";
36
- import { useQuery } from "../hooks/use-query";
37
- import { useTranslation } from "../i18n";
38
- import { PageHeaderSlotAvailableProvider } from "../page-header-slot";
39
- import { type DataTableFacet, usePrimitives } from "../primitives";
40
- import { sortByAccessor } from "../sort-by-accessor";
41
- import { RenderEditActionButton } from "./render-edit-action-button";
42
- import { RenderList } from "./render-list";
43
-
44
- const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
45
-
46
- // Same paged envelope as ProjectionListBody's PagedRows (kumiko-screen.tsx) —
47
- // duplicated locally because that type isn't exported (projectionList and
48
- // relatedList are independent query call-sites, not a shared abstraction).
49
- type PagedRows = {
50
- readonly rows: Readonly<Record<string, unknown>>[];
51
- readonly nextCursor: string | null;
52
- readonly total?: number;
53
- };
54
-
55
- // Minimal EntityDefinition from the section's own columns — same shape as
56
- // projection-list-shim's synthesizeProjectionEntity, but sortable is read
57
- // per-column (ListColumnSpec.sortable) instead of screen-wide, since a
58
- // relatedList section has no single Zod schema to derive it from.
59
- function synthesizeRelatedListEntity(
60
- columns: EditRelatedListSectionViewModel["columns"],
61
- ): EntityDefinition {
62
- const fields: Record<string, { type: string; sortable: boolean }> = {};
63
- for (const col of columns) {
64
- const normalized = normalizeListColumn(col);
65
- fields[normalized.field] = {
66
- type: normalized.valueType ?? "text",
67
- sortable: normalized.sortable === true,
68
- };
69
- }
70
- return { fields } as unknown as EntityDefinition;
71
- }
72
-
73
- // Mirrors row-actions.ts's own warnDrawerActionDropped (module-private
74
- // there, so not reusable directly) — a drawer-kind emptyState.action with no
75
- // onOpenDrawer wired is dropped (not rendered) with a dev warning, same as a
76
- // drawer-kind rowAction/toolbarAction without one.
77
- function warnRelatedListEmptyStateDrawerDropped(actionId: string): void {
78
- // biome-ignore lint/suspicious/noConsole: dev-time warning, same pattern as row-actions.ts
79
- console.warn(
80
- `[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`,
81
- );
82
- }
83
-
84
- export function RelatedListSection({
85
- section,
86
- parentId,
87
- record,
88
- featureName,
89
- translate,
90
- hideTitle,
91
- grow,
92
- onOpenDrawer,
93
- actions,
94
- }: {
95
- readonly section: EditRelatedListSectionViewModel;
96
- readonly parentId: string;
97
- /** The enclosing projectionDetail's own record (the "Akte") — evaluates
98
- * toolbarActions' `visible`/`params` (RelatedListToolbarAction), the same
99
- * record header actions already use. */
100
- readonly record: Readonly<Record<string, unknown>>;
101
- readonly featureName: string;
102
- readonly translate?: Translate;
103
- readonly hideTitle?: boolean;
104
- /** Under a fixed-height screen the tab panel chain stretches so the count footer sits at the bottom. */
105
- readonly grow?: boolean;
106
- /** Opens a drawer-kind rowAction (fw#2710). Supplied by the parent
107
- * (ProjectionDetailBody), which owns schema + the actual Drawer render —
108
- * this component only ever invokes the callback. */
109
- readonly onOpenDrawer?: (
110
- action: RowActionDrawer,
111
- initialValues: Readonly<Record<string, unknown>> | undefined,
112
- ) => void;
113
- /** section.actions, already resolved into buttons by the caller —
114
- * rendered in the Section's title row. Only reachable in the standalone
115
- * (non-`hideTitle`) branch below — `hideTitle` uses `FillContainer`, which
116
- * has no title row to render actions into (see the comment on that branch). */
117
- readonly actions?: ReactNode;
118
- }): ReactNode {
119
- const { Banner, Section, FillContainer, Text, Button, Dialog } = usePrimitives();
120
- const [emptyStateActionError, setEmptyStateActionError] = useState<string | null>(null);
121
- const t = useTranslation();
122
- const effectiveTranslate = translate ?? t;
123
- const nav = useNav();
124
- const host = useReturnHost();
125
- const dispatcher = useOptionalDispatcher();
126
- const appFeatures = useAppFeatures();
127
- const userRoles = useUserRoles();
128
- const defaultEditScreen = useMemo(() => {
129
- const targetEntity = section.rowClick?.entity;
130
- if (targetEntity === undefined) return undefined;
131
- return findEditScreenFor(targetEntity, appFeatures, userRoles);
132
- }, [appFeatures, section.rowClick, userRoles]);
133
- const defaultEditRowAction = useMemo(
134
- () => buildDefaultEditRowAction(defaultEditScreen, section.rowClick?.idColumn ?? "id"),
135
- [defaultEditScreen, section.rowClick],
136
- );
137
-
138
- const entity = useMemo(() => synthesizeRelatedListEntity(section.columns), [section.columns]);
139
- const listScreen = useMemo(
140
- (): EntityListScreenDefinition => ({
141
- // Empty id → RenderList's own toolbarTitle resolves to "" (its
142
- // `screen:${id}.title` lookup misses and falls back to `id`) — this
143
- // component renders the visible heading itself via `Section` below,
144
- // so RenderList's toolbar carries none.
145
- id: "",
146
- type: "entityList",
147
- entity: RELATED_LIST_PSEUDO_ENTITY,
148
- columns: section.columns,
149
- }),
150
- [section.columns],
151
- );
152
-
153
- // Local state, not URL state: a section `id` is optional, so there is no
154
- // stable URL key to namespace against — the section's sort is local for the
155
- // same reason. Consequence: search/filters reset on reload.
156
- const [search, setSearch] = useState("");
157
- const [filters, setFilters] = useState<Readonly<Record<string, readonly string[]>>>({});
158
-
159
- const facetSpecs = useMemo(
160
- () => resolveProjectionFacetSpecs(section.facets, effectiveTranslate, featureName),
161
- [section.facets, effectiveTranslate, featureName],
162
- );
163
- const [referenceFacetOptions, setReferenceFacetOptions] = useState<
164
- Record<string, readonly ReferenceFacetOption[]>
165
- >({});
166
- const handleFacetOptions = useCallback(
167
- (field: string, options: readonly ReferenceFacetOption[]) =>
168
- setReferenceFacetOptions((prev) =>
169
- prev[field] === options ? prev : { ...prev, [field]: options },
170
- ),
171
- [],
172
- );
173
- const resolvedFacetSpecs = useMemo(
174
- () => mergeReferenceFacetOptions(facetSpecs, referenceFacetOptions),
175
- [facetSpecs, referenceFacetOptions],
176
- );
177
- const filterPayload = useMemo(
178
- () =>
179
- buildFilterPayload(
180
- filters,
181
- (field) => resolvedFacetSpecs.find((spec) => spec.field === field)?.type,
182
- ),
183
- [filters, resolvedFacetSpecs],
184
- );
185
- const filterFacets = useMemo<DataTableFacet[]>(
186
- () => buildFilterFacets(resolvedFacetSpecs),
187
- [resolvedFacetSpecs],
188
- );
189
-
190
- const payload = useMemo(
191
- () => ({
192
- // `parentFilter` sends the parent id as a server-side `filter` clause
193
- // instead of a bespoke top-level key — the generic `<entity>:list`
194
- // query understands `filter`, not an arbitrary `parentParam` key. Kept
195
- // separate from `filterPayload` (user facets) below so a facet
196
- // selection can never clear or overwrite it.
197
- ...(section.parentFilter !== undefined
198
- ? { filter: { field: section.parentFilter.field, op: "eq" as const, value: parentId } }
199
- : { [section.parentParam ?? "id"]: parentId }),
200
- ...(section.pageSize !== undefined && { limit: section.pageSize }),
201
- // Gated on the declared capability, not just on state carrying a value —
202
- // same rule as ProjectionListBody: a param the bound query's Zod schema
203
- // doesn't accept would 422 the whole section.
204
- ...(section.searchable === true && search !== "" && { search }),
205
- ...(section.facets !== undefined && filterPayload.length > 0 && { filters: filterPayload }),
206
- }),
207
- [
208
- section.parentFilter,
209
- section.parentParam,
210
- section.pageSize,
211
- section.searchable,
212
- section.facets,
213
- parentId,
214
- search,
215
- filterPayload,
216
- ],
217
- );
218
-
219
- const rowsQuery = useQuery<PagedRows>(section.query, payload);
220
-
221
- const onFilterChange = useCallback(
222
- (field: string, values: readonly string[]) =>
223
- setFilters((prev) => ({ ...prev, [field]: values })),
224
- [],
225
- );
226
- const onFilterReset = useCallback(() => setFilters({}), []);
227
-
228
- // Sorted client-side over the already-loaded rows — this section has no
229
- // pager (see `payload` above: a one-shot fetch, no cursor/offset), so the
230
- // loaded set already IS the full display set and there is no "other page"
231
- // a client-side sort could misleadingly hide (fw#2722).
232
- const [sort, setSort] = useState<ListSort | null>(section.defaultSort ?? null);
233
- const sortAccessors = useMemo(() => {
234
- const accessors: Record<string, (row: Readonly<Record<string, unknown>>) => string | number> =
235
- {};
236
- for (const col of section.columns) {
237
- const field = normalizeListColumn(col).field;
238
- accessors[field] = (row) => {
239
- const value = row[field];
240
- return typeof value === "number" ? value : String(value ?? "");
241
- };
242
- }
243
- return accessors;
244
- }, [section.columns]);
245
- const sortedRows = useMemo(
246
- () => sortByAccessor(rowsQuery.data?.rows ?? [], sort, sortAccessors),
247
- [rowsQuery.data, sort, sortAccessors],
248
- );
249
-
250
- const rowClick = section.rowClick;
251
- // A row-body click target comes from EITHER the legacy `rowClick` field OR
252
- // a `rowActions` entry marked rowClick:true — the boot-validator rejects
253
- // both being set, so this order is just a fallback, not a precedence rule.
254
- const rowClickAction = section.rowActions?.find(
255
- (a): a is RowActionNavigate => a.kind === "navigate" && a.rowClick === true,
256
- );
257
- const onRowClick =
258
- rowClick !== undefined
259
- ? (row: ListRowViewModel) => {
260
- const id = String(row.values[rowClick.idColumn ?? "id"] ?? "");
261
- if (id === "") return;
262
- navigateWithReturnTo(nav, { entity: rowClick.entity, id }, host);
263
- }
264
- : rowClickAction !== undefined
265
- ? (row: ListRowViewModel) => runProjectionRowNavigate(nav, rowClickAction, row, host)
266
- : undefined;
267
-
268
- // Same execution path as projectionList's rowActions (kumiko-screen.tsx) —
269
- // a relatedList row has the identical "no guaranteed id field" shape a
270
- // query-projection row has, so navigate/writeHandler dispatch is shared
271
- // rather than a second implementation (fw editable-detail-screens).
272
- const rowActions = useMemo(
273
- () =>
274
- buildProjectionRowActions({
275
- rowActions: section.rowActions,
276
- translate: effectiveTranslate,
277
- dispatcher,
278
- nav,
279
- refetch: rowsQuery.refetch,
280
- openDrawer: onOpenDrawer,
281
- defaultEditRowAction,
282
- host,
283
- }),
284
- [
285
- section.rowActions,
286
- effectiveTranslate,
287
- dispatcher,
288
- nav,
289
- rowsQuery.refetch,
290
- onOpenDrawer,
291
- defaultEditRowAction,
292
- host,
293
- ],
294
- );
295
-
296
- // Toolbar actions (e.g. "+ Create" above the tab table) — same schema
297
- // and dispatch semantics as entityList/projectionList.
298
- const toolbarActionButtons = useMemo(
299
- () =>
300
- buildProjectionToolbarActions({
301
- toolbarActions: section.toolbarActions,
302
- translate: effectiveTranslate,
303
- dispatcher,
304
- nav,
305
- refetch: rowsQuery.refetch,
306
- navigatePrefill:
307
- section.parentFilter !== undefined
308
- ? { [section.parentFilter.field]: parentId }
309
- : { [section.parentParam ?? "id"]: parentId },
310
- record,
311
- host,
312
- ...(onOpenDrawer !== undefined && {
313
- openDrawer: (action) => onOpenDrawer(action, undefined),
314
- }),
315
- }),
316
- [
317
- section.toolbarActions,
318
- section.parentParam,
319
- section.parentFilter,
320
- parentId,
321
- record,
322
- effectiveTranslate,
323
- dispatcher,
324
- nav,
325
- host,
326
- rowsQuery.refetch,
327
- onOpenDrawer,
328
- ],
329
- );
330
-
331
- // emptyState.action is a RowAction — the same record-bound action shape
332
- // (and evalRowExtractor payload/params evaluation) screen-level and
333
- // section-level actions already use via buildRecordActions, NOT the
334
- // toolbar-action builder (whose `payload` is a literal Record, not a
335
- // RowFieldExtractor — casting a RowAction into it would silently skip the
336
- // declared `pick`/`map` evaluation). "Row" here is the parent record: a
337
- // relatedList's emptyState has no row of its own.
338
- const emptyStateActionButton = useMemo(() => {
339
- const action = section.emptyState?.action;
340
- if (action === undefined) return undefined;
341
- if (action.kind === "drawer" && onOpenDrawer === undefined) {
342
- warnRelatedListEmptyStateDrawerDropped(action.id);
343
- return undefined;
344
- }
345
- return buildRecordActions({
346
- actions: [action],
347
- record,
348
- translate: effectiveTranslate,
349
- nav,
350
- host,
351
- dispatcher,
352
- openDrawer: onOpenDrawer ?? (() => {}),
353
- onWriteSuccess: rowsQuery.refetch,
354
- })?.[0];
355
- }, [
356
- section.emptyState,
357
- record,
358
- effectiveTranslate,
359
- nav,
360
- host,
361
- dispatcher,
362
- onOpenDrawer,
363
- rowsQuery.refetch,
364
- ]);
365
- const emptyStateContent =
366
- section.emptyState !== undefined ? (
367
- <>
368
- <Text>{section.emptyState.title}</Text>
369
- {section.emptyState.description !== undefined && (
370
- <Text variant="small">{section.emptyState.description}</Text>
371
- )}
372
- {emptyStateActionButton !== undefined && (
373
- <RenderEditActionButton
374
- action={emptyStateActionButton}
375
- Button={Button}
376
- Dialog={Dialog}
377
- onError={setEmptyStateActionError}
378
- />
379
- )}
380
- </>
381
- ) : undefined;
382
-
383
- // A truncated fetch means `sortedRows` is a sort of a partial set, not of
384
- // the full related-row set — the client-side sort above (or even plain
385
- // unsorted display) would silently claim "these are the top N" when they
386
- // are really just "the first N in the server's own order" (fw#2722 review).
387
- // `nextCursor` is exactly how the paged envelope marks that: non-null means
388
- // more rows exist server-side beyond what was fetched.
389
- // A handler that omits `nextCursor` (or reports a `total` the fetched rows
390
- // already cover) has no further rows — only a real cursor marks truncation.
391
- const truncated =
392
- rowsQuery.data !== null &&
393
- typeof rowsQuery.data.nextCursor === "string" &&
394
- (rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
395
-
396
- const content =
397
- rowsQuery.loading && rowsQuery.data === null ? (
398
- <Banner padded variant="loading" testId="related-list-loading">
399
- Loading…
400
- </Banner>
401
- ) : rowsQuery.error ? (
402
- <Banner padded variant="error" testId="related-list-error">
403
- {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
404
- </Banner>
405
- ) : (
406
- <>
407
- {truncated && (
408
- <Banner variant="info" testId="related-list-truncated">
409
- {effectiveTranslate("kumiko.list.related-list-truncated", {
410
- count: sortedRows.length,
411
- })}
412
- </Banner>
413
- )}
414
- {emptyStateActionError !== null && (
415
- <Banner variant="error" testId="related-list-empty-state-action-error">
416
- {emptyStateActionError}
417
- </Banner>
418
- )}
419
- <PageHeaderSlotAvailableProvider value={false}>
420
- <RenderList
421
- screen={listScreen}
422
- entity={entity}
423
- rows={sortedRows}
424
- featureName={featureName}
425
- translate={effectiveTranslate}
426
- {...(section.description !== undefined && { description: section.description })}
427
- {...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun })}
428
- sort={sort}
429
- onSortChange={setSort}
430
- {...(section.searchable === true && {
431
- searchable: true,
432
- searchValue: search,
433
- onSearchChange: setSearch,
434
- })}
435
- {...(filterFacets.length > 0 && {
436
- filterFacets,
437
- filterValues: filters,
438
- onFilterChange,
439
- onFilterReset,
440
- })}
441
- {...(onRowClick !== undefined && { onRowClick })}
442
- {...(rowActions !== undefined && { rowActions })}
443
- {...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons })}
444
- {...(emptyStateContent !== undefined && { emptyState: emptyStateContent })}
445
- {...(hideTitle === true && {
446
- scrollBody: true,
447
- screenPadding: false,
448
- chromeless: true,
449
- })}
450
- />
451
- </PageHeaderSlotAvailableProvider>
452
- </>
453
- );
454
-
455
- // hideTitle (tabs mode) → the caller (render-edit.tsx) already wraps this
456
- // component's own return value in the same Card frame every other tab
457
- // kind gets — no card here, or the two would nest. `scrollBody` caps the
458
- // table to the panel height; `FillContainer` is this section's link in the
459
- // fillHeight flex chain that Card's `options.fillHeight` continues (see
460
- // render-edit.tsx). A platform primitive (not a raw `<div>`) because
461
- // `renderer` stays DOM-free. Stacked (non-tabs) sections keep their own
462
- // Section card and document-flow height since they render a visible title
463
- // and aren't confined to a tab panel.
464
- const bridges = <ReferenceFacetBridges specs={facetSpecs} onOptions={handleFacetOptions} />;
465
-
466
- if (hideTitle) {
467
- return (
468
- <>
469
- {bridges}
470
- {FillContainer !== undefined ? (
471
- <FillContainer {...(grow === true && { grow: true })}>{content}</FillContainer>
472
- ) : (
473
- content
474
- )}
475
- </>
476
- );
477
- }
478
-
479
- return (
480
- <>
481
- {bridges}
482
- <Section
483
- title={section.title}
484
- {...(actions !== undefined && { actions })}
485
- testId={`related-list-${section.title}`}
486
- >
487
- {content}
488
- </Section>
489
- </>
490
- );
491
- }
@@ -1,109 +0,0 @@
1
- import type { ReactNode } from "react";
2
- import { useState } from "react";
3
- import type { usePrimitives } from "../primitives";
4
- import type { RenderEditAction } from "./render-edit-types";
5
-
6
- // Same rule as RowActionWriteHandler: "danger" forces a confirm even
7
- // without an explicit confirm key — unless `confirmRequired` overrides it
8
- // (schema-driven navigate/drawer actions, where the target form is itself
9
- // the confirmation).
10
- export function needsActionConfirm(action: RenderEditAction): boolean {
11
- return action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
12
- }
13
-
14
- export function RenderEditActionConfirmDialog({
15
- action,
16
- open,
17
- onOpenChange,
18
- onConfirm,
19
- Dialog,
20
- }: {
21
- readonly action: RenderEditAction;
22
- readonly open: boolean;
23
- readonly onOpenChange: (open: boolean) => void;
24
- readonly onConfirm: () => void | Promise<void>;
25
- readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
26
- }): ReactNode {
27
- return (
28
- <Dialog
29
- open={open}
30
- onOpenChange={onOpenChange}
31
- title={action.label}
32
- {...(action.confirm !== undefined && { description: action.confirm })}
33
- confirmLabel={action.confirmLabel ?? action.label}
34
- {...(action.style === "danger" && { variant: "danger" as const })}
35
- onConfirm={onConfirm}
36
- testId={`render-edit-action-${action.id}-dialog`}
37
- />
38
- );
39
- }
40
-
41
- // One header action + its own busy/confirm state — same pattern as
42
- // render-list.tsx's ToolbarActionView (each RenderEditAction is
43
- // independently bound by the caller, there is no shared trigger pipeline
44
- // to hook into like the built-in onDelete/onSubmit paths have).
45
- export function RenderEditActionButton({
46
- action,
47
- iconOnly = false,
48
- hideIcon = false,
49
- Button,
50
- Dialog,
51
- onError,
52
- }: {
53
- readonly action: RenderEditAction;
54
- /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
55
- * effect when this action actually resolved an icon. */
56
- readonly iconOnly?: boolean;
57
- /** Record-header actions are text-only; the icon still shows when collapsed to icon-only. */
58
- readonly hideIcon?: boolean;
59
- readonly Button: ReturnType<typeof usePrimitives>["Button"];
60
- readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
61
- readonly onError: (text: string | null) => void;
62
- }): ReactNode {
63
- const [busy, setBusy] = useState(false);
64
- const [confirmOpen, setConfirmOpen] = useState(false);
65
-
66
- const trigger = async (): Promise<void> => {
67
- setBusy(true);
68
- onError(null);
69
- try {
70
- await action.onPress();
71
- } catch (e) {
72
- onError(e instanceof Error ? e.message : String(e));
73
- } finally {
74
- setBusy(false);
75
- }
76
- };
77
-
78
- const variant = action.style ?? "secondary";
79
- const showIconOnly = iconOnly && action.icon !== undefined;
80
-
81
- return (
82
- <>
83
- <Button
84
- type="button"
85
- variant={variant}
86
- loading={busy}
87
- {...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon })}
88
- {...(showIconOnly && { size: "icon" as const, ariaLabel: action.label })}
89
- onClick={() => {
90
- if (needsActionConfirm(action)) {
91
- setConfirmOpen(true);
92
- } else {
93
- void trigger();
94
- }
95
- }}
96
- testId={`render-edit-action-${action.id}`}
97
- >
98
- {showIconOnly ? null : action.label}
99
- </Button>
100
- <RenderEditActionConfirmDialog
101
- action={action}
102
- open={confirmOpen}
103
- onOpenChange={setConfirmOpen}
104
- onConfirm={trigger}
105
- Dialog={Dialog}
106
- />
107
- </>
108
- );
109
- }
@@ -1,85 +0,0 @@
1
- import type { EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
2
-
3
- // Hides the Save button when no field is editable and no extension section
4
- // contributes to the composed form submit. Extensions that persist via their
5
- // own dispatcher writes must not opt in (fw#2359).
6
- // Explicit-positive per kind (653/1), not `s.kind !== "fields"` — a future
7
- // third EditSectionViewModel member would otherwise default to "editable"
8
- // without anyone deciding that on purpose.
9
- export function hasEditableSection(sections: readonly EditSectionViewModel[]): boolean {
10
- return sections.some(
11
- (s) =>
12
- (s.kind === "extension" && s.contributesToFormSubmit) ||
13
- (s.kind === "fields" && s.visible && s.fields.some((f) => !f.readOnly && f.visible)),
14
- );
15
- }
16
-
17
- // Single source of truth for the extension-section entity-id. The section mount
18
- // and persistExtensions MUST resolve the same id — otherwise a section mounts
19
- // editable against one id (vm.id) while the persist step writes to (or skips)
20
- // another (null), silently dropping the user's input. An explicit `null` prop
21
- // forces "no entity" (no extension persistence); an omitted prop (undefined)
22
- // falls back to vm.id (= values["id"]), which the update form carries for the
23
- // existing row, so editing custom fields on that row actually persists.
24
- export function resolveExtensionEntityId(
25
- entityIdProp: string | null | undefined,
26
- vmId: string | null,
27
- ): string | null {
28
- return entityIdProp !== undefined ? entityIdProp : vmId;
29
- }
30
-
31
- // After a submit, decide whether to invoke the caller's onSubmit. The success
32
- // callback typically navigates away, which unmounts the extension-error banner.
33
- // Suppress the callback ONLY when the entity write succeeded but an extension-
34
- // section persist failed: the user must stay on the form to see the banner and
35
- // retry. Every other case still notifies the caller — entity failures and
36
- // validation blocks carry information the caller needs.
37
- export function shouldNotifyCaller(
38
- result: SubmitResult<unknown>,
39
- extensionsPersisted: boolean,
40
- ): boolean {
41
- return !(result.isSuccess && !extensionsPersisted);
42
- }
43
-
44
- // Matched on the first path segment (embedded-list rows are dotted, e.g. `tasks.2.title`).
45
- // No issues at all counts as unmatched, so the caller keeps the banner.
46
- export function hasIssueWithoutRenderedField(
47
- issuePaths: readonly string[],
48
- sections: readonly EditSectionViewModel[],
49
- ): boolean {
50
- if (issuePaths.length === 0) return true;
51
- const renderedFields = new Set(
52
- sections
53
- .filter((s) => s.kind === "fields" && (s.visible || s.fields.length === 0))
54
- .flatMap((s) => (s.kind === "fields" ? s.fields.filter((f) => f.visible) : []))
55
- .map((f) => f.field),
56
- );
57
- return issuePaths.some((path) => !renderedFields.has(path.split(".")[0] ?? path));
58
- }
59
-
60
- // Extension, relatedList and writeForm sections skip the `fields` filter
61
- // (writeForm's fields belong to its own independent form, not the host's);
62
- // a `fields` section left with zero fields after filtering is dropped, not
63
- // rendered empty.
64
- export function filterEditSections(
65
- sections: readonly EditSectionViewModel[],
66
- fieldsFilter: readonly string[] | undefined,
67
- ): readonly EditSectionViewModel[] {
68
- if (fieldsFilter === undefined) return sections;
69
- const filterSet = new Set(fieldsFilter);
70
- const result: EditSectionViewModel[] = [];
71
- for (const section of sections) {
72
- if (
73
- section.kind === "extension" ||
74
- section.kind === "relatedList" ||
75
- section.kind === "writeForm"
76
- ) {
77
- result.push(section);
78
- continue;
79
- }
80
- const fields = section.fields.filter((f) => filterSet.has(f.field));
81
- if (fields.length === 0) continue;
82
- result.push({ ...section, fields });
83
- }
84
- return result;
85
- }