@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
@@ -0,0 +1,273 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { useCallback, useMemo, useState } from "react";
4
+ import { useAppFeatures } from "../app/app-features-context.js";
5
+ import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
6
+ import { useNav } from "../app/nav.js";
7
+ import { ReferenceFacetBridges } from "../app/reference-facet-bridge.js";
8
+ import { navigateWithReturnTo, useReturnHost } from "../app/return-to.js";
9
+ import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, runProjectionRowNavigate, } from "../app/row-actions.js";
10
+ import { findEditScreenFor } from "../app/screen-access.js";
11
+ import { dispatcherErrorText } from "../app/write-failed-error.js";
12
+ import { useOptionalDispatcher } from "../context/dispatcher-context.js";
13
+ import { useUserRoles } from "../context/user-roles-context.js";
14
+ import { useQuery } from "../hooks/use-query.js";
15
+ import { useTranslation } from "../i18n.js";
16
+ import { PageHeaderSlotAvailableProvider } from "../page-header-slot.js";
17
+ import { usePrimitives } from "../primitives.js";
18
+ import { sortByAccessor } from "../sort-by-accessor.js";
19
+ import { RenderEditActionButton } from "./render-edit-action-button.js";
20
+ import { RenderList } from "./render-list.js";
21
+ const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
22
+ // Minimal EntityDefinition from the section's own columns — same shape as
23
+ // projection-list-shim's synthesizeProjectionEntity, but sortable is read
24
+ // per-column (ListColumnSpec.sortable) instead of screen-wide, since a
25
+ // relatedList section has no single Zod schema to derive it from.
26
+ function synthesizeRelatedListEntity(columns) {
27
+ const fields = {};
28
+ for (const col of columns) {
29
+ const normalized = normalizeListColumn(col);
30
+ fields[normalized.field] = {
31
+ type: normalized.valueType ?? "text",
32
+ sortable: normalized.sortable === true,
33
+ };
34
+ }
35
+ return { fields };
36
+ }
37
+ // Mirrors row-actions.ts's own warnDrawerActionDropped (module-private
38
+ // there, so not reusable directly) — a drawer-kind emptyState.action with no
39
+ // onOpenDrawer wired is dropped (not rendered) with a dev warning, same as a
40
+ // drawer-kind rowAction/toolbarAction without one.
41
+ function warnRelatedListEmptyStateDrawerDropped(actionId) {
42
+ // biome-ignore lint/suspicious/noConsole: dev-time warning, same pattern as row-actions.ts
43
+ console.warn(`[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`);
44
+ }
45
+ export function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, }) {
46
+ const { Banner, Section, FillContainer, Text, Button, Dialog } = usePrimitives();
47
+ const [emptyStateActionError, setEmptyStateActionError] = useState(null);
48
+ const t = useTranslation();
49
+ const effectiveTranslate = translate ?? t;
50
+ const nav = useNav();
51
+ const host = useReturnHost();
52
+ const dispatcher = useOptionalDispatcher();
53
+ const appFeatures = useAppFeatures();
54
+ const userRoles = useUserRoles();
55
+ const defaultEditScreen = useMemo(() => {
56
+ const targetEntity = section.rowClick?.entity;
57
+ if (targetEntity === undefined)
58
+ return undefined;
59
+ return findEditScreenFor(targetEntity, appFeatures, userRoles);
60
+ }, [appFeatures, section.rowClick, userRoles]);
61
+ const defaultEditRowAction = useMemo(() => buildDefaultEditRowAction(defaultEditScreen, section.rowClick?.idColumn ?? "id"), [defaultEditScreen, section.rowClick]);
62
+ const entity = useMemo(() => synthesizeRelatedListEntity(section.columns), [section.columns]);
63
+ const listScreen = useMemo(() => ({
64
+ // Empty id → RenderList's own toolbarTitle resolves to "" (its
65
+ // `screen:${id}.title` lookup misses and falls back to `id`) — this
66
+ // component renders the visible heading itself via `Section` below,
67
+ // so RenderList's toolbar carries none.
68
+ id: "",
69
+ type: "entityList",
70
+ entity: RELATED_LIST_PSEUDO_ENTITY,
71
+ columns: section.columns,
72
+ }), [section.columns]);
73
+ // Local state, not URL state: a section `id` is optional, so there is no
74
+ // stable URL key to namespace against — the section's sort is local for the
75
+ // same reason. Consequence: search/filters reset on reload.
76
+ const [search, setSearch] = useState("");
77
+ const [filters, setFilters] = useState({});
78
+ const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(section.facets, effectiveTranslate, featureName), [section.facets, effectiveTranslate, featureName]);
79
+ const [referenceFacetOptions, setReferenceFacetOptions] = useState({});
80
+ const handleFacetOptions = useCallback((field, options) => setReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
81
+ const resolvedFacetSpecs = useMemo(() => mergeReferenceFacetOptions(facetSpecs, referenceFacetOptions), [facetSpecs, referenceFacetOptions]);
82
+ const filterPayload = useMemo(() => buildFilterPayload(filters, (field) => resolvedFacetSpecs.find((spec) => spec.field === field)?.type), [filters, resolvedFacetSpecs]);
83
+ const filterFacets = useMemo(() => buildFilterFacets(resolvedFacetSpecs), [resolvedFacetSpecs]);
84
+ const payload = useMemo(() => ({
85
+ // `parentFilter` sends the parent id as a server-side `filter` clause
86
+ // instead of a bespoke top-level key — the generic `<entity>:list`
87
+ // query understands `filter`, not an arbitrary `parentParam` key. Kept
88
+ // separate from `filterPayload` (user facets) below so a facet
89
+ // selection can never clear or overwrite it.
90
+ ...(section.parentFilter !== undefined
91
+ ? { filter: { field: section.parentFilter.field, op: "eq", value: parentId } }
92
+ : { [section.parentParam ?? "id"]: parentId }),
93
+ ...(section.pageSize !== undefined && { limit: section.pageSize }),
94
+ // Gated on the declared capability, not just on state carrying a value —
95
+ // same rule as ProjectionListBody: a param the bound query's Zod schema
96
+ // doesn't accept would 422 the whole section.
97
+ ...(section.searchable === true && search !== "" && { search }),
98
+ ...(section.facets !== undefined && filterPayload.length > 0 && { filters: filterPayload }),
99
+ }), [
100
+ section.parentFilter,
101
+ section.parentParam,
102
+ section.pageSize,
103
+ section.searchable,
104
+ section.facets,
105
+ parentId,
106
+ search,
107
+ filterPayload,
108
+ ]);
109
+ const rowsQuery = useQuery(section.query, payload);
110
+ const onFilterChange = useCallback((field, values) => setFilters((prev) => ({ ...prev, [field]: values })), []);
111
+ const onFilterReset = useCallback(() => setFilters({}), []);
112
+ // Sorted client-side over the already-loaded rows — this section has no
113
+ // pager (see `payload` above: a one-shot fetch, no cursor/offset), so the
114
+ // loaded set already IS the full display set and there is no "other page"
115
+ // a client-side sort could misleadingly hide (fw#2722).
116
+ const [sort, setSort] = useState(section.defaultSort ?? null);
117
+ const sortAccessors = useMemo(() => {
118
+ const accessors = {};
119
+ for (const col of section.columns) {
120
+ const field = normalizeListColumn(col).field;
121
+ accessors[field] = (row) => {
122
+ const value = row[field];
123
+ return typeof value === "number" ? value : String(value ?? "");
124
+ };
125
+ }
126
+ return accessors;
127
+ }, [section.columns]);
128
+ const sortedRows = useMemo(() => sortByAccessor(rowsQuery.data?.rows ?? [], sort, sortAccessors), [rowsQuery.data, sort, sortAccessors]);
129
+ const rowClick = section.rowClick;
130
+ // A row-body click target comes from EITHER the legacy `rowClick` field OR
131
+ // a `rowActions` entry marked rowClick:true — the boot-validator rejects
132
+ // both being set, so this order is just a fallback, not a precedence rule.
133
+ const rowClickAction = section.rowActions?.find((a) => a.kind === "navigate" && a.rowClick === true);
134
+ const onRowClick = rowClick !== undefined
135
+ ? (row) => {
136
+ const id = String(row.values[rowClick.idColumn ?? "id"] ?? "");
137
+ if (id === "")
138
+ return;
139
+ navigateWithReturnTo(nav, { entity: rowClick.entity, id }, host);
140
+ }
141
+ : rowClickAction !== undefined
142
+ ? (row) => runProjectionRowNavigate(nav, rowClickAction, row, host)
143
+ : undefined;
144
+ // Same execution path as projectionList's rowActions (kumiko-screen.tsx) —
145
+ // a relatedList row has the identical "no guaranteed id field" shape a
146
+ // query-projection row has, so navigate/writeHandler dispatch is shared
147
+ // rather than a second implementation (fw editable-detail-screens).
148
+ const rowActions = useMemo(() => buildProjectionRowActions({
149
+ rowActions: section.rowActions,
150
+ translate: effectiveTranslate,
151
+ dispatcher,
152
+ nav,
153
+ refetch: rowsQuery.refetch,
154
+ openDrawer: onOpenDrawer,
155
+ defaultEditRowAction,
156
+ host,
157
+ }), [
158
+ section.rowActions,
159
+ effectiveTranslate,
160
+ dispatcher,
161
+ nav,
162
+ rowsQuery.refetch,
163
+ onOpenDrawer,
164
+ defaultEditRowAction,
165
+ host,
166
+ ]);
167
+ // Toolbar actions (e.g. "+ Create" above the tab table) — same schema
168
+ // and dispatch semantics as entityList/projectionList.
169
+ const toolbarActionButtons = useMemo(() => buildProjectionToolbarActions({
170
+ toolbarActions: section.toolbarActions,
171
+ translate: effectiveTranslate,
172
+ dispatcher,
173
+ nav,
174
+ refetch: rowsQuery.refetch,
175
+ navigatePrefill: section.parentFilter !== undefined
176
+ ? { [section.parentFilter.field]: parentId }
177
+ : { [section.parentParam ?? "id"]: parentId },
178
+ record,
179
+ host,
180
+ ...(onOpenDrawer !== undefined && {
181
+ openDrawer: (action) => onOpenDrawer(action, undefined),
182
+ }),
183
+ }), [
184
+ section.toolbarActions,
185
+ section.parentParam,
186
+ section.parentFilter,
187
+ parentId,
188
+ record,
189
+ effectiveTranslate,
190
+ dispatcher,
191
+ nav,
192
+ host,
193
+ rowsQuery.refetch,
194
+ onOpenDrawer,
195
+ ]);
196
+ // emptyState.action is a RowAction — the same record-bound action shape
197
+ // (and evalRowExtractor payload/params evaluation) screen-level and
198
+ // section-level actions already use via buildRecordActions, NOT the
199
+ // toolbar-action builder (whose `payload` is a literal Record, not a
200
+ // RowFieldExtractor — casting a RowAction into it would silently skip the
201
+ // declared `pick`/`map` evaluation). "Row" here is the parent record: a
202
+ // relatedList's emptyState has no row of its own.
203
+ const emptyStateActionButton = useMemo(() => {
204
+ const action = section.emptyState?.action;
205
+ if (action === undefined)
206
+ return undefined;
207
+ if (action.kind === "drawer" && onOpenDrawer === undefined) {
208
+ warnRelatedListEmptyStateDrawerDropped(action.id);
209
+ return undefined;
210
+ }
211
+ return buildRecordActions({
212
+ actions: [action],
213
+ record,
214
+ translate: effectiveTranslate,
215
+ nav,
216
+ host,
217
+ dispatcher,
218
+ openDrawer: onOpenDrawer ?? (() => { }),
219
+ onWriteSuccess: rowsQuery.refetch,
220
+ })?.[0];
221
+ }, [
222
+ section.emptyState,
223
+ record,
224
+ effectiveTranslate,
225
+ nav,
226
+ host,
227
+ dispatcher,
228
+ onOpenDrawer,
229
+ rowsQuery.refetch,
230
+ ]);
231
+ const emptyStateContent = section.emptyState !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: section.emptyState.title }), section.emptyState.description !== undefined && (_jsx(Text, { variant: "small", children: section.emptyState.description })), emptyStateActionButton !== undefined && (_jsx(RenderEditActionButton, { action: emptyStateActionButton, Button: Button, Dialog: Dialog, onError: setEmptyStateActionError }))] })) : undefined;
232
+ // A truncated fetch means `sortedRows` is a sort of a partial set, not of
233
+ // the full related-row set — the client-side sort above (or even plain
234
+ // unsorted display) would silently claim "these are the top N" when they
235
+ // are really just "the first N in the server's own order" (fw#2722 review).
236
+ // `nextCursor` is exactly how the paged envelope marks that: non-null means
237
+ // more rows exist server-side beyond what was fetched.
238
+ // A handler that omits `nextCursor` (or reports a `total` the fetched rows
239
+ // already cover) has no further rows — only a real cursor marks truncation.
240
+ const truncated = rowsQuery.data !== null &&
241
+ typeof rowsQuery.data.nextCursor === "string" &&
242
+ (rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
243
+ const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: "Loading\u2026" })) : rowsQuery.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
244
+ count: sortedRows.length,
245
+ }) })), emptyStateActionError !== null && (_jsx(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: emptyStateActionError })), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: sortedRows, featureName: featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
246
+ searchable: true,
247
+ searchValue: search,
248
+ onSearchChange: setSearch,
249
+ }), ...(filterFacets.length > 0 && {
250
+ filterFacets,
251
+ filterValues: filters,
252
+ onFilterChange,
253
+ onFilterReset,
254
+ }), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), ...(hideTitle === true && {
255
+ scrollBody: true,
256
+ screenPadding: false,
257
+ chromeless: true,
258
+ }) }) })] }));
259
+ // hideTitle (tabs mode) → the caller (render-edit.tsx) already wraps this
260
+ // component's own return value in the same Card frame every other tab
261
+ // kind gets — no card here, or the two would nest. `scrollBody` caps the
262
+ // table to the panel height; `FillContainer` is this section's link in the
263
+ // fillHeight flex chain that Card's `options.fillHeight` continues (see
264
+ // render-edit.tsx). A platform primitive (not a raw `<div>`) because
265
+ // `renderer` stays DOM-free. Stacked (non-tabs) sections keep their own
266
+ // Section card and document-flow height since they render a visible title
267
+ // and aren't confined to a tab panel.
268
+ const bridges = _jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleFacetOptions });
269
+ if (hideTitle) {
270
+ return (_jsxs(_Fragment, { children: [bridges, FillContainer !== undefined ? (_jsx(FillContainer, { ...(grow === true && { grow: true }), children: content })) : (content)] }));
271
+ }
272
+ return (_jsxs(_Fragment, { children: [bridges, _jsx(Section, { title: section.title, ...(actions !== undefined && { actions }), testId: `related-list-${section.title}`, children: content })] }));
273
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+ import type { usePrimitives } from "../primitives.js";
3
+ import type { RenderEditAction } from "./render-edit-types.js";
4
+ export declare function needsActionConfirm(action: RenderEditAction): boolean;
5
+ export declare function RenderEditActionConfirmDialog({ action, open, onOpenChange, onConfirm, Dialog, }: {
6
+ readonly action: RenderEditAction;
7
+ readonly open: boolean;
8
+ readonly onOpenChange: (open: boolean) => void;
9
+ readonly onConfirm: () => void | Promise<void>;
10
+ readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
11
+ }): ReactNode;
12
+ export declare function RenderEditActionButton({ action, iconOnly, hideIcon, Button, Dialog, onError, }: {
13
+ readonly action: RenderEditAction;
14
+ /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
15
+ * effect when this action actually resolved an icon. */
16
+ readonly iconOnly?: boolean;
17
+ /** Record-header actions are text-only; the icon still shows when collapsed to icon-only. */
18
+ readonly hideIcon?: boolean;
19
+ readonly Button: ReturnType<typeof usePrimitives>["Button"];
20
+ readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
21
+ readonly onError: (text: string | null) => void;
22
+ }): ReactNode;
@@ -0,0 +1,43 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ // Same rule as RowActionWriteHandler: "danger" forces a confirm even
4
+ // without an explicit confirm key — unless `confirmRequired` overrides it
5
+ // (schema-driven navigate/drawer actions, where the target form is itself
6
+ // the confirmation).
7
+ export function needsActionConfirm(action) {
8
+ return action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
9
+ }
10
+ export function RenderEditActionConfirmDialog({ action, open, onOpenChange, onConfirm, Dialog, }) {
11
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: onConfirm, testId: `render-edit-action-${action.id}-dialog` }));
12
+ }
13
+ // One header action + its own busy/confirm state — same pattern as
14
+ // render-list.tsx's ToolbarActionView (each RenderEditAction is
15
+ // independently bound by the caller, there is no shared trigger pipeline
16
+ // to hook into like the built-in onDelete/onSubmit paths have).
17
+ export function RenderEditActionButton({ action, iconOnly = false, hideIcon = false, Button, Dialog, onError, }) {
18
+ const [busy, setBusy] = useState(false);
19
+ const [confirmOpen, setConfirmOpen] = useState(false);
20
+ const trigger = async () => {
21
+ setBusy(true);
22
+ onError(null);
23
+ try {
24
+ await action.onPress();
25
+ }
26
+ catch (e) {
27
+ onError(e instanceof Error ? e.message : String(e));
28
+ }
29
+ finally {
30
+ setBusy(false);
31
+ }
32
+ };
33
+ const variant = action.style ?? "secondary";
34
+ const showIconOnly = iconOnly && action.icon !== undefined;
35
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(showIconOnly && { size: "icon", ariaLabel: action.label }), onClick: () => {
36
+ if (needsActionConfirm(action)) {
37
+ setConfirmOpen(true);
38
+ }
39
+ else {
40
+ void trigger();
41
+ }
42
+ }, testId: `render-edit-action-${action.id}`, children: showIconOnly ? null : action.label }), _jsx(RenderEditActionConfirmDialog, { action: action, open: confirmOpen, onOpenChange: setConfirmOpen, onConfirm: trigger, Dialog: Dialog })] }));
43
+ }
@@ -0,0 +1,6 @@
1
+ import type { EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
2
+ export declare function hasEditableSection(sections: readonly EditSectionViewModel[]): boolean;
3
+ export declare function resolveExtensionEntityId(entityIdProp: string | null | undefined, vmId: string | null): string | null;
4
+ export declare function shouldNotifyCaller(result: SubmitResult<unknown>, extensionsPersisted: boolean): boolean;
5
+ export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[]): boolean;
6
+ export declare function filterEditSections(sections: readonly EditSectionViewModel[], fieldsFilter: readonly string[] | undefined): readonly EditSectionViewModel[];
@@ -0,0 +1,63 @@
1
+ // Hides the Save button when no field is editable and no extension section
2
+ // contributes to the composed form submit. Extensions that persist via their
3
+ // own dispatcher writes must not opt in (fw#2359).
4
+ // Explicit-positive per kind (653/1), not `s.kind !== "fields"` — a future
5
+ // third EditSectionViewModel member would otherwise default to "editable"
6
+ // without anyone deciding that on purpose.
7
+ export function hasEditableSection(sections) {
8
+ return sections.some((s) => (s.kind === "extension" && s.contributesToFormSubmit) ||
9
+ (s.kind === "fields" && s.visible && s.fields.some((f) => !f.readOnly && f.visible)));
10
+ }
11
+ // Single source of truth for the extension-section entity-id. The section mount
12
+ // and persistExtensions MUST resolve the same id — otherwise a section mounts
13
+ // editable against one id (vm.id) while the persist step writes to (or skips)
14
+ // another (null), silently dropping the user's input. An explicit `null` prop
15
+ // forces "no entity" (no extension persistence); an omitted prop (undefined)
16
+ // falls back to vm.id (= values["id"]), which the update form carries for the
17
+ // existing row, so editing custom fields on that row actually persists.
18
+ export function resolveExtensionEntityId(entityIdProp, vmId) {
19
+ return entityIdProp !== undefined ? entityIdProp : vmId;
20
+ }
21
+ // After a submit, decide whether to invoke the caller's onSubmit. The success
22
+ // callback typically navigates away, which unmounts the extension-error banner.
23
+ // Suppress the callback ONLY when the entity write succeeded but an extension-
24
+ // section persist failed: the user must stay on the form to see the banner and
25
+ // retry. Every other case still notifies the caller — entity failures and
26
+ // validation blocks carry information the caller needs.
27
+ export function shouldNotifyCaller(result, extensionsPersisted) {
28
+ return !(result.isSuccess && !extensionsPersisted);
29
+ }
30
+ // Matched on the first path segment (embedded-list rows are dotted, e.g. `tasks.2.title`).
31
+ // No issues at all counts as unmatched, so the caller keeps the banner.
32
+ export function hasIssueWithoutRenderedField(issuePaths, sections) {
33
+ if (issuePaths.length === 0)
34
+ return true;
35
+ const renderedFields = new Set(sections
36
+ .filter((s) => s.kind === "fields" && (s.visible || s.fields.length === 0))
37
+ .flatMap((s) => (s.kind === "fields" ? s.fields.filter((f) => f.visible) : []))
38
+ .map((f) => f.field));
39
+ return issuePaths.some((path) => !renderedFields.has(path.split(".")[0] ?? path));
40
+ }
41
+ // Extension, relatedList and writeForm sections skip the `fields` filter
42
+ // (writeForm's fields belong to its own independent form, not the host's);
43
+ // a `fields` section left with zero fields after filtering is dropped, not
44
+ // rendered empty.
45
+ export function filterEditSections(sections, fieldsFilter) {
46
+ if (fieldsFilter === undefined)
47
+ return sections;
48
+ const filterSet = new Set(fieldsFilter);
49
+ const result = [];
50
+ for (const section of sections) {
51
+ if (section.kind === "extension" ||
52
+ section.kind === "relatedList" ||
53
+ section.kind === "writeForm") {
54
+ result.push(section);
55
+ continue;
56
+ }
57
+ const fields = section.fields.filter((f) => filterSet.has(f.field));
58
+ if (fields.length === 0)
59
+ continue;
60
+ result.push({ ...section, fields });
61
+ }
62
+ return result;
63
+ }
@@ -0,0 +1,209 @@
1
+ import type { EntityDefinition, EntityEditScreenDefinition, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { FormSnapshot, FormValues, SubmitResult, Translate } from "@cosmicdrift/kumiko-headless";
3
+ import type { ReactNode } from "react";
4
+ import type * as z from "zod";
5
+ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
6
+ readonly screen: EntityEditScreenDefinition;
7
+ readonly entity: EntityDefinition;
8
+ readonly featureName: string;
9
+ readonly initial: TValues;
10
+ /** Real entity id for extension-section mounts (set-value UI). Mount AND
11
+ * persistExtensions resolve it via `resolveExtensionEntityId(entityIdProp,
12
+ * vm.id)` — the same value, so the section doesn't mount editable against
13
+ * one id while persist writes against another (or none at all).
14
+ * Omitting (undefined) = fallback to `vm.id` (= values["id"]), which the
15
+ * update form carries for the existing row. Explicit `null` = "no entity"
16
+ * (create mode / no extension persistence). */
17
+ readonly entityId?: string | null;
18
+ /** Already-persisted extension values (e.g. `record.customFields`) for
19
+ * extension-section mounts. Lets the section display its current stock
20
+ * during edit. Only the update body provides this. */
21
+ readonly extensionInitialValues?: Readonly<Record<string, unknown>>;
22
+ /** Standard single-write submit path. Ignored when `customSubmit` is set
23
+ * (configEdit screens dispatch multiple writes per submit, where a single
24
+ * writeCommand makes no sense). */
25
+ readonly writeCommand?: string;
26
+ /** Override for the submit pipeline. When set, runs controller.validate()
27
+ * and then customSubmit(snapshot) instead of controller.submit(). On
28
+ * success the form state rebases so that isUnchanged/isDirty become false
29
+ * again — without that, save button and banner stay stale. */
30
+ readonly customSubmit?: (snapshot: FormSnapshot<TValues>) => Promise<SubmitResult<unknown>>;
31
+ readonly translate?: Translate;
32
+ readonly ctx?: TCtx;
33
+ readonly schema?: z.ZodType;
34
+ readonly onSubmit?: (result: SubmitResult<unknown>) => void;
35
+ readonly payloadMode?: "values" | "changes";
36
+ readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
37
+ /** Prefix to strip from server validation issue paths before mapping them
38
+ * onto form fields — see SubmitConfig.serverFieldPathPrefix. Only needed
39
+ * when buildPayload nests the form values under a key. */
40
+ readonly serverFieldPathPrefix?: string;
41
+ readonly onDelete?: () => Promise<void> | void;
42
+ readonly onCancel?: () => void;
43
+ readonly onReload?: () => void;
44
+ /** Fires when the form gains or loses unsaved input (field changes or a
45
+ * dirty extension section). Lets a host such as a drawer guard closing. */
46
+ readonly onDirtyChange?: (dirty: boolean) => void;
47
+ /** Copy-link action (issue #912) — only set in update mode (create mode
48
+ * has no entity id yet, hence no permalink). The callback is already fully
49
+ * bound (URL building + clipboard happen outside, in
50
+ * `@cosmicdrift/kumiko-renderer-web`'s RoutedScreen — this platform-neutral
51
+ * package must not touch `navigator`/`window`, see
52
+ * guard-renderer-boundaries). undefined = no button. */
53
+ readonly onCopyLink?: () => Promise<void> | void;
54
+ /** Header action buttons, rendered before the built-in copy-link/
55
+ * delete/cancel/save controls. Each callback is already fully
56
+ * bound (screen-type/nav/dispatcher resolution happens in the caller,
57
+ * same split as `onCopyLink`) — RenderEdit only wires the button, its
58
+ * busy state and its confirm dialog. */
59
+ readonly actions?: readonly RenderEditAction[];
60
+ /** Opens a relatedList section row's drawer-kind action (fw#2710).
61
+ * RenderEdit has no `schema` to resolve the target actionForm itself —
62
+ * the caller (ProjectionDetailBody, which does have schema) supplies the
63
+ * opener and owns the actual Drawer state/rendering. Only projectionDetail
64
+ * passes this — the boot validator rejects relatedList sections on every
65
+ * other screen type that shares this layout. */
66
+ readonly onRelatedListDrawerAction?: (action: RowActionDrawer, initialValues: Readonly<Record<string, unknown>> | undefined) => void;
67
+ /** i18n key for the submit button. Default: "kumiko.actions.save".
68
+ * Action forms (tier 2.7d) pass their screen.submitLabel here so that
69
+ * "Save" can be replaced by domain-specific strings ("Approve" /
70
+ * "Dispatch" / etc.). */
71
+ readonly submitLabel?: string;
72
+ /** Visual style of the submit button (actionForm `submitStyle`). Default "primary". */
73
+ readonly submitVariant?: "primary" | "danger";
74
+ /** Context box above the drawer form (title + optional subtitle, already resolved). */
75
+ readonly summary?: {
76
+ readonly title: string;
77
+ readonly subtitle?: string;
78
+ };
79
+ /** Per-field extra content inline after the label (e.g.
80
+ * ConfigSourceBadge). Called with the field name, returns a ReactNode or
81
+ * undefined. */
82
+ readonly labelAppendix?: (fieldName: string) => ReactNode | undefined;
83
+ /** Per-field extra content below the input (e.g. ConfigCascadeView).
84
+ * Called with the field name, returns a ReactNode or undefined. */
85
+ readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
86
+ /** Controlled mode (issue #1887): fires on every values-snapshot change
87
+ * (typing, `patch(...)` from outside) with the current values. `changes`
88
+ * is the delta against the initial values — same semantics as
89
+ * `payloadMode: "changes"` — so a caller never overwrites unseen fields.
90
+ * `valid` is a pure dry-run parse against `schema` (not a
91
+ * `controller.validate()` call), so it does not paint field errors into
92
+ * the UI and can diverge from the currently rendered `snapshot.errors` —
93
+ * always `true` without `schema`. A caller that patches a fresh object
94
+ * reference on every call must not do so unconditionally: `setValues` is
95
+ * a no-op when the merged value is reference-equal to the current one,
96
+ * so only a converging patch settles instead of looping. Without this
97
+ * prop, existing behavior is unchanged. `onControlsReady` is guaranteed
98
+ * to have already fired by the time the mount-time `onChange` call
99
+ * happens, so a caller patching dependent fields from inside `onChange`
100
+ * never has to guard against `controls` being undefined. */
101
+ readonly onChange?: (state: RenderEditChangeState<TValues>) => void;
102
+ /** Controlled mode (issue #1887): called once after mount, hands the
103
+ * caller `patch`/`validate`/`getValues` bound to this RenderEdit
104
+ * instance — addressable from outside without a remount. `patch` merges
105
+ * only the given keys (existing `controller.setValues` semantics),
106
+ * values on unmentioned fields stay untouched. `validate` runs without a
107
+ * write and reports field issues via `snapshot.errors` on the field
108
+ * itself rather than as a summary banner. Without this prop, existing
109
+ * behavior is unchanged. */
110
+ readonly onControlsReady?: (controls: RenderEditControls<TValues>) => void;
111
+ /** Renders only these fields (by `field` name from the layout) — section
112
+ * order, title, and visibility still come from the layout, so the caller
113
+ * doesn't duplicate its shape. A section with no fields left after
114
+ * filtering is dropped entirely (not rendered empty). Submit validation
115
+ * is scoped to the actually-rendered fields the same way — a required
116
+ * field outside this list doesn't block submit. Omitting this prop keeps
117
+ * unchanged behavior. Read once at mount for `controller.submit()`'s
118
+ * validation scope (the underlying `useForm` controller is mount-lived);
119
+ * rendering and `controls.validate()` do stay reactive to later changes. */
120
+ readonly fields?: readonly string[];
121
+ /** Locked state (issue #1896): every rendered field and the submit button
122
+ * go visibly inactive, no write possible. For cases where input becomes
123
+ * moot — e.g. Solon's editor pointing at an existing record instead of
124
+ * creating a new one. Extension sections are out of scope: RenderEdit has
125
+ * no way to force-disable an arbitrary registered component. Omitting
126
+ * this prop keeps unchanged behavior.
127
+ *
128
+ * ponytail: direct-consumer only — kumiko-screen.tsx's RenderEdit call
129
+ * sites pass explicit prop lists without a spread and never forward
130
+ * `disabled`, so a screen-driven app can't set the locked state today.
131
+ * Upgrade path if that's needed: thread a screen-spec flag through to
132
+ * `EntityEditCreateBody`/`EntityEditEditBody`. */
133
+ readonly disabled?: boolean;
134
+ /** Renders the fields without RenderEdit's own action bar (save, cancel,
135
+ * delete, copy-link). For hosts that put those controls into their own
136
+ * chrome — a drawer footer, a wizard shell — and drive the write through
137
+ * `onControlsReady`'s `submit`. Omitting this prop keeps unchanged
138
+ * behavior. */
139
+ readonly hideActions?: boolean;
140
+ /** "form" (default) — every field renders as its Input widget, disabled
141
+ * when `field.readOnly`, unchanged behavior. "text" renders a
142
+ * `field.readOnly` field as plain text instead of a disabled Input
143
+ * (ProjectionDetailBody's read view, fw#2245) — editable fields are
144
+ * unaffected either way, so this only changes forms that already have
145
+ * readOnly fields. */
146
+ readonly valueDisplay?: "form" | "text";
147
+ /** Suppresses every section's own title (fields-section header, relatedList
148
+ * Section title) AND the form's own top-level title/subtitle — for a host
149
+ * that already renders that label itself elsewhere (e.g. a tab strip
150
+ * whose label duplicates it, or a page-level header above RenderEdit).
151
+ * Without this, the form's title (`screen:<id>.title` / `screen.id`)
152
+ * renders unconditionally regardless of section-level suppression — a
153
+ * host relying on this prop to fully own the heading otherwise sees a
154
+ * redundant title above its content. Omitting this prop keeps unchanged
155
+ * behavior. */
156
+ readonly hideSectionTitles?: boolean;
157
+ /** Screen body fills the shell height: the form gets `fillHeight` and
158
+ * `stickyActions` (sections scroll, footer pinned). Set by KumikoScreen for
159
+ * screens with `fillHeight !== false`; dialogs and other embedded hosts
160
+ * omit it and keep document-flow height. */
161
+ readonly fillScreenHeight?: boolean;
162
+ /** Extra content rendered above the card, sharing its left padding and
163
+ * width — for a host with its own header region (title/metrics/tabs)
164
+ * that would otherwise render as unpadded siblings before RenderEdit.
165
+ * Omitting this prop keeps unchanged behavior. */
166
+ readonly headerRegion?: ReactNode | ((headerSlot: ReactNode | undefined) => ReactNode);
167
+ /** resolves an `EditFieldsSection`/`EditExtensionSection`/
168
+ * `EditRelatedListSection`/`EditWriteFormSection`'s own `actions` into
169
+ * already-bound buttons, rendered in that section's title row — same
170
+ * split as `onCopyLink`/header `actions`: RenderEdit has no dispatcher/nav
171
+ * context of its own, so the caller (which does) resolves each RowAction
172
+ * against the record being edited. Returns `undefined`/`[]` to render no
173
+ * actions for that section. */
174
+ readonly buildSectionActions?: (actions: readonly RowAction[]) => readonly RenderEditAction[] | undefined;
175
+ };
176
+ export type RenderEditAction = {
177
+ readonly id: string;
178
+ readonly label: string;
179
+ readonly onPress: () => void | Promise<void>;
180
+ readonly style?: "primary" | "secondary" | "danger";
181
+ readonly confirm?: string;
182
+ readonly confirmLabel?: string;
183
+ /** Overrides the default "danger implies a confirm dialog" rule. Schema-driven
184
+ * navigate/drawer actions set it to false: the colour marks the action as
185
+ * destructive, but the target form is the confirmation. */
186
+ readonly confirmRequired?: boolean;
187
+ /** Resolved icon (author `RowAction.icon` or the id-derived default) —
188
+ * drives both the icon-left-of-text render and the icon-only collapse
189
+ * rule (see `shouldRenderActionsIconOnly`). */
190
+ readonly icon?: IconKey;
191
+ };
192
+ export type RenderEditChangeState<TValues extends FormValues> = {
193
+ readonly values: TValues;
194
+ readonly changes: Partial<TValues>;
195
+ readonly dirty: boolean;
196
+ readonly valid: boolean;
197
+ readonly submitting: boolean;
198
+ };
199
+ export type RenderEditControls<TValues extends FormValues> = {
200
+ readonly patch: (partial: Partial<TValues>) => void;
201
+ readonly validate: () => boolean;
202
+ readonly getValues: () => TValues;
203
+ /** Runs the same pipeline the built-in save button runs: validation,
204
+ * `customSubmit`/`writeCommand`, extension-section persistence, draft
205
+ * discard, state rebase. Unlike the button it carries no unchanged-form
206
+ * guard — a host showing a pre-filled proposal must be able to accept it
207
+ * untouched. */
208
+ readonly submit: () => Promise<void>;
209
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { FormValues } from "@cosmicdrift/kumiko-headless";
2
+ import type { RenderEditProps } from "./render-edit-types.js";
3
+ export type { RenderEditAction, RenderEditChangeState, RenderEditControls, RenderEditProps, } from "./render-edit-types.js";
4
+ import { type ReactNode } from "react";
5
+ export declare const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
6
+ export declare function RenderEdit<TValues extends FormValues, TCtx = unknown>(props: RenderEditProps<TValues, TCtx>): ReactNode;