@cosmicdrift/kumiko-renderer 0.327.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 (257) 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 -44
  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 -38
  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/dist/inside-drawer.js +9 -0
  119. package/dist/page-header-slot.d.ts +2 -0
  120. package/dist/page-header-slot.js +9 -0
  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/changes.json +52 -0
  135. package/src/__tests__/error-i18n-defaults.test.ts +0 -82
  136. package/src/__tests__/format-when.test.ts +0 -12
  137. package/src/__tests__/i18n.test.tsx +0 -283
  138. package/src/__tests__/merge-search-params-into-initial.test.ts +0 -525
  139. package/src/__tests__/qn.test.ts +0 -83
  140. package/src/__tests__/share-links.test.ts +0 -26
  141. package/src/__tests__/sort-by-accessor.test.ts +0 -48
  142. package/src/__tests__/stringify-nav-params.test.ts +0 -33
  143. package/src/__tests__/use-list-url-state.test.tsx +0 -250
  144. package/src/app/__tests__/action-form-field-labels.test.tsx +0 -92
  145. package/src/app/__tests__/action-form-shim.test.ts +0 -137
  146. package/src/app/__tests__/build-list-query-payload.test.ts +0 -107
  147. package/src/app/__tests__/config-edit-shim.test.ts +0 -57
  148. package/src/app/__tests__/content-editors.test.tsx +0 -128
  149. package/src/app/__tests__/content-preview.test.tsx +0 -184
  150. package/src/app/__tests__/entity-edit-create-search-params.test.tsx +0 -236
  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 -111
  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 -3614
  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 -85
  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 -639
  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 -1669
  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 -60
  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 -467
  228. package/src/components/render-edit-action-button.tsx +0 -106
  229. package/src/components/render-edit-logic.ts +0 -85
  230. package/src/components/render-edit-types.ts +0 -216
  231. package/src/components/render-edit.tsx +0 -1700
  232. package/src/components/render-field.tsx +0 -942
  233. package/src/components/render-list.tsx +0 -561
  234. package/src/components/write-form-section.tsx +0 -199
  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-stream-handler.test.tsx +0 -107
  241. package/src/hooks/use-ai-text.ts +0 -172
  242. package/src/hooks/use-disclosure.ts +0 -20
  243. package/src/hooks/use-form.ts +0 -78
  244. package/src/hooks/use-list-url-state.ts +0 -171
  245. package/src/hooks/use-mutation.ts +0 -61
  246. package/src/hooks/use-query.ts +0 -132
  247. package/src/hooks/use-reference-lookup.ts +0 -76
  248. package/src/hooks/use-store.ts +0 -48
  249. package/src/hooks/use-stream-handler.ts +0 -134
  250. package/src/i18n-defaults.ts +0 -220
  251. package/src/i18n.tsx +0 -286
  252. package/src/index.ts +0 -272
  253. package/src/primitives.tsx +0 -1446
  254. package/src/share-links.ts +0 -12
  255. package/src/sort-by-accessor.ts +0 -20
  256. package/src/sse/live-events.tsx +0 -56
  257. package/src/tokens.tsx +0 -170
@@ -1,199 +0,0 @@
1
- import type {
2
- EditFieldViewModel,
3
- EditWriteFormSectionViewModel,
4
- Translate,
5
- } from "@cosmicdrift/kumiko-headless";
6
- import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
7
- import { type ReactNode, useMemo, useState } from "react";
8
- import * as z from "zod";
9
- import { REQUIRED_FIELD_I18N_KEY } from "../app/form-schema";
10
- import { dispatcherErrorText } from "../app/write-failed-error";
11
- import { useForm } from "../hooks/use-form";
12
- import { useTranslation } from "../i18n";
13
- import { usePrimitives } from "../primitives";
14
- import { GridCellForField } from "./grid-cell-for-field";
15
-
16
- // Same "has a value" rule as buildFormSchema's isPresent (app/form-schema.ts)
17
- // — duplicated because that helper walks raw EditFieldSpec + EntityDefinition,
18
- // unavailable here: this section only ever sees computeEditViewModel's
19
- // already-resolved EditFieldViewModel[] (required/readOnly/visible are plain
20
- // booleans by the time render-edit.tsx hands the section to this component).
21
- function isPresent(value: unknown): boolean {
22
- if (value === undefined || value === null || value === "") return false;
23
- if (Array.isArray(value) && value.length === 0) return false;
24
- return true;
25
- }
26
-
27
- function buildWriteFormSchema(fields: readonly EditFieldViewModel[]): z.ZodType {
28
- return z
29
- .object({})
30
- .passthrough()
31
- .superRefine((values, ctx) => {
32
- const record = values as Record<string, unknown>;
33
- for (const field of fields) {
34
- if (field.readOnly || !field.visible || !field.required) continue;
35
- if (isPresent(record[field.field])) continue;
36
- ctx.addIssue({
37
- code: "custom",
38
- path: [field.field],
39
- message: `"${field.field}" is required.`,
40
- params: { [I18N_KEY_PARAM]: REQUIRED_FIELD_I18N_KEY },
41
- });
42
- }
43
- });
44
- }
45
-
46
- export type WriteFormSectionProps = {
47
- readonly section: EditWriteFormSectionViewModel;
48
- readonly featureName: string;
49
- readonly translate?: Translate;
50
- readonly hideTitle?: boolean;
51
- /** Fired after a successful submit — projectionDetail reloads its own
52
- * record (a new one now exists) via a full RenderEdit remount, see
53
- * ProjectionDetailBody's reloadNonce/key in kumiko-screen.tsx. */
54
- readonly onSubmitted: () => void;
55
- /** section.actions, already resolved into buttons by the caller —
56
- * rendered alongside (before) the section's own submit button in the
57
- * title-row actions slot. */
58
- readonly actions?: ReactNode;
59
- };
60
-
61
- // A self-persisting form section for projectionDetail (see
62
- // EditWriteFormSection's doc). Deliberately NOT a nested <RenderEdit> — that
63
- // would render a second, invalid nested <form> inside the host's own form
64
- // element — so this mounts its own independent useForm() controller and
65
- // submits via a plain button click instead of native form submission.
66
- export function WriteFormSection({
67
- section,
68
- featureName,
69
- translate,
70
- hideTitle,
71
- onSubmitted,
72
- actions,
73
- }: WriteFormSectionProps): ReactNode {
74
- const { Section, Card, Grid, GridCell, Button, Banner } = usePrimitives();
75
- const t = useTranslation();
76
- const effectiveTranslate = translate ?? t;
77
-
78
- const initial = useMemo(
79
- () => Object.fromEntries(section.fields.map((f: EditFieldViewModel) => [f.field, f.value])),
80
- [section.fields],
81
- );
82
- const schema = useMemo(() => buildWriteFormSchema(section.fields), [section.fields]);
83
- const { controller, snapshot } = useForm({
84
- initial,
85
- submit: { type: section.handler, payloadMode: "values" },
86
- schema,
87
- });
88
- const [isSubmitting, setIsSubmitting] = useState(false);
89
- const [error, setError] = useState<string | null>(null);
90
-
91
- async function handleSubmit(): Promise<void> {
92
- if (isSubmitting) return;
93
- setIsSubmitting(true);
94
- try {
95
- const result = await controller.submit();
96
- if (result.isSuccess) {
97
- setError(null);
98
- onSubmitted();
99
- return;
100
- }
101
- if (result.validationBlocked) return;
102
- // Field-level issues already surface inline via snapshot.errors (same
103
- // GridCellForField/RenderField path RenderEdit uses) — the banner is
104
- // only for form-level errors nothing else would show (fw#1901 pattern).
105
- const fieldIssues = result.error.details?.fields ?? [];
106
- setError(
107
- fieldIssues.length === 0 ? dispatcherErrorText(result.error, effectiveTranslate) : null,
108
- );
109
- } finally {
110
- setIsSubmitting(false);
111
- }
112
- }
113
-
114
- const content = (
115
- <>
116
- <Grid columns={section.columns}>
117
- {section.fields.map((field: EditFieldViewModel) => (
118
- <GridCellForField
119
- key={field.field}
120
- field={field}
121
- columns={section.columns}
122
- issues={snapshot.errors[field.field]}
123
- onChange={(v) => controller.setField(field.field, v)}
124
- GridCell={GridCell}
125
- featureName={featureName}
126
- allIssues={snapshot.errors}
127
- valueDisplay="form"
128
- row={snapshot.values}
129
- />
130
- ))}
131
- </Grid>
132
- {error !== null && (
133
- <Banner variant="error" testId="write-form-section-error">
134
- {error}
135
- </Banner>
136
- )}
137
- </>
138
- );
139
-
140
- // type="button" (not "submit") is load-bearing: this section is deliberately
141
- // NOT a nested <form> (see the component doc above), so a "submit" type
142
- // would instead trigger the host RenderEdit's own form submit.
143
- const submitButton = (
144
- <Button
145
- type="button"
146
- variant="primary"
147
- icon="check"
148
- disabled={isSubmitting}
149
- loading={isSubmitting}
150
- onClick={() => void handleSubmit()}
151
- testId="write-form-section-submit"
152
- >
153
- {section.submitLabel ?? effectiveTranslate("kumiko.actions.save")}
154
- </Button>
155
- );
156
-
157
- const titleRowActions =
158
- actions !== undefined ? (
159
- <>
160
- {actions}
161
- {submitButton}
162
- </>
163
- ) : (
164
- submitButton
165
- );
166
-
167
- // Section always flattens to a borderless divider when rendered inside
168
- // RenderEdit's own <Form> in tabs mode (hideTitle) — Card frames it there
169
- // instead, same reason render-edit.tsx's tabs-mode fields/extension
170
- // branches stay on Card.
171
- if (hideTitle) {
172
- return (
173
- <Card
174
- slots={{
175
- ...(section.description !== undefined && { subtitle: section.description }),
176
- headerActions: titleRowActions,
177
- }}
178
- testId={`write-form-${section.title ?? "section"}`}
179
- >
180
- {content}
181
- </Card>
182
- );
183
- }
184
-
185
- // Routed through Section's `actions` slot (same mechanism render-edit.tsx
186
- // uses via Form's `actions`) so the button gets the established right-
187
- // aligned footer treatment instead of stretching full-width inline.
188
- return (
189
- <Section
190
- {...(section.title !== undefined && { title: section.title })}
191
- {...(section.description !== undefined && { subtitle: section.description })}
192
- {...(section.icon !== undefined && { icon: section.icon })}
193
- actions={titleRowActions}
194
- testId={`write-form-${section.title ?? "section"}`}
195
- >
196
- {content}
197
- </Section>
198
- );
199
- }
@@ -1,39 +0,0 @@
1
- import { createContext, type ReactNode, useContext } from "react";
2
-
3
- // Client-side draftId storage for RenderEdit's create-mode draftKey (issue
4
- // #1913) — `${screen.id}:new:${draftId}` needs the same `draftId` across a
5
- // same-tab reload so the wizard resumes the right row instead of the leader
6
- // on the last-write-wins upsert. Contract only, platform-neutral — the
7
- // concrete impl (window.sessionStorage on web) is injected by the platform
8
- // package, same pattern as NavApi/window.history in app/nav.tsx. This
9
- // package touches no browser storage itself.
10
- export type DraftStorage = {
11
- readonly getDraftId: (screenId: string) => string | null;
12
- readonly setDraftId: (screenId: string, draftId: string) => void;
13
- readonly clearDraftId: (screenId: string) => void;
14
- };
15
-
16
- // No-op default: without a mounted provider (unwired platform, or a test
17
- // that doesn't care about drafts) same-tab reload just loses draftId
18
- // persistence — RenderEdit falls back to its `form-draft:query:list`
19
- // resume path, the same one a cleared sessionStorage takes on the web.
20
- const noopDraftStorage: DraftStorage = {
21
- getDraftId: () => null,
22
- setDraftId: () => {},
23
- clearDraftId: () => {},
24
- };
25
-
26
- const DraftStorageContext = createContext<DraftStorage>(noopDraftStorage);
27
-
28
- export type DraftStorageProviderProps = {
29
- readonly value: DraftStorage;
30
- readonly children: ReactNode;
31
- };
32
-
33
- export function DraftStorageProvider({ value, children }: DraftStorageProviderProps): ReactNode {
34
- return <DraftStorageContext value={value}>{children}</DraftStorageContext>;
35
- }
36
-
37
- export function useDraftStorage(): DraftStorage {
38
- return useContext(DraftStorageContext);
39
- }
@@ -1,177 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
- import { act, renderHook, waitFor } from "@testing-library/react";
4
- import type { ReactNode } from "react";
5
- import { DispatcherProvider } from "../../context/dispatcher-context";
6
- import { useAiTextAction, useCompletion } from "../use-ai-text";
7
-
8
- function makeDispatcher(query: Dispatcher["query"]): Dispatcher {
9
- return {
10
- query,
11
- write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
12
- batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
13
- statusStore: {
14
- getState: () => "online",
15
- subscribe: () => () => {},
16
- } as unknown as Dispatcher["statusStore"],
17
- async *stream() {},
18
- pendingWrites: () => [],
19
- pendingFiles: () => [],
20
- };
21
- }
22
-
23
- function wrapperFor(dispatcher: Dispatcher) {
24
- return ({ children }: { readonly children: ReactNode }) => (
25
- <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
26
- );
27
- }
28
-
29
- describe("useAiTextAction", () => {
30
- test("success → state='success', result carries the text", async () => {
31
- const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => ({
32
- isSuccess: true,
33
- data: {
34
- type: "text",
35
- text: `echo:${(payload as { text: string }).text}`,
36
- usage: { inputTokens: 1, outputTokens: 1 },
37
- },
38
- })) as unknown as Dispatcher["query"]);
39
-
40
- const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
41
-
42
- expect(result.current.state).toBe("idle");
43
- await act(async () => {
44
- await result.current.run({ mode: "correct", text: "hi" });
45
- });
46
-
47
- expect(result.current.state).toBe("success");
48
- expect(result.current.result).toEqual({
49
- type: "text",
50
- text: "echo:hi",
51
- usage: { inputTokens: 1, outputTokens: 1 },
52
- });
53
- expect(result.current.error).toBeNull();
54
- });
55
-
56
- test("cap_exceeded error → state='cap-exceeded'", async () => {
57
- const dispatcher = makeDispatcher((async () => ({
58
- isSuccess: false,
59
- error: { code: "cap_exceeded", message: "capped", i18nKey: "errors.cap" },
60
- })) as unknown as Dispatcher["query"]);
61
-
62
- const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
63
-
64
- await act(async () => {
65
- await result.current.run({ mode: "correct", text: "hi" });
66
- });
67
-
68
- expect(result.current.state).toBe("cap-exceeded");
69
- expect(result.current.error?.code).toBe("cap_exceeded");
70
- });
71
-
72
- test("feature_disabled error → state='unavailable' (graceful degradation)", async () => {
73
- const dispatcher = makeDispatcher((async () => ({
74
- isSuccess: false,
75
- error: { code: "feature_disabled", message: "off", i18nKey: "errors.disabled" },
76
- })) as unknown as Dispatcher["query"]);
77
-
78
- const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
79
-
80
- await act(async () => {
81
- await result.current.run({ mode: "complete", text: "hi" });
82
- });
83
-
84
- expect(result.current.state).toBe("unavailable");
85
- });
86
-
87
- test("reset clears state/result/error back to idle", async () => {
88
- const dispatcher = makeDispatcher((async () => ({
89
- isSuccess: false,
90
- error: { code: "conflict", message: "boom", i18nKey: "errors.conflict" },
91
- })) as unknown as Dispatcher["query"]);
92
-
93
- const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
94
- await act(async () => {
95
- await result.current.run({ mode: "correct", text: "hi" });
96
- });
97
- expect(result.current.state).toBe("error");
98
-
99
- act(() => result.current.reset());
100
- expect(result.current.state).toBe("idle");
101
- expect(result.current.error).toBeNull();
102
- expect(result.current.result).toBeNull();
103
- });
104
- });
105
-
106
- describe("useCompletion", () => {
107
- test("debounces requestCompletion — only the last call within the window fires", async () => {
108
- let calls = 0;
109
- const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => {
110
- calls++;
111
- return {
112
- isSuccess: true,
113
- data: {
114
- type: "text",
115
- text: `suggestion for "${(payload as { text: string }).text}"`,
116
- usage: { inputTokens: 1, outputTokens: 1 },
117
- },
118
- };
119
- }) as unknown as Dispatcher["query"]);
120
-
121
- const { result } = renderHook(() => useCompletion(20), { wrapper: wrapperFor(dispatcher) });
122
-
123
- act(() => {
124
- result.current.requestCompletion("a");
125
- result.current.requestCompletion("ab");
126
- result.current.requestCompletion("abc");
127
- });
128
-
129
- await waitFor(() => expect(result.current.suggestion).not.toBeNull(), { timeout: 1000 });
130
-
131
- expect(calls).toBe(1);
132
- expect(result.current.suggestion).toBe('suggestion for "abc"');
133
- });
134
-
135
- test("empty text resets immediately without a request", async () => {
136
- let calls = 0;
137
- const dispatcher = makeDispatcher((async () => {
138
- calls++;
139
- return {
140
- isSuccess: true,
141
- data: { type: "text", text: "x", usage: { inputTokens: 1, outputTokens: 1 } },
142
- };
143
- }) as unknown as Dispatcher["query"]);
144
-
145
- const { result } = renderHook(() => useCompletion(10), { wrapper: wrapperFor(dispatcher) });
146
-
147
- act(() => {
148
- result.current.requestCompletion("");
149
- });
150
- await new Promise((r) => setTimeout(r, 30));
151
-
152
- expect(calls).toBe(0);
153
- expect(result.current.suggestion).toBeNull();
154
- });
155
-
156
- test("clear cancels a pending debounce and resets the suggestion", async () => {
157
- let calls = 0;
158
- const dispatcher = makeDispatcher((async () => {
159
- calls++;
160
- return {
161
- isSuccess: true,
162
- data: { type: "text", text: "x", usage: { inputTokens: 1, outputTokens: 1 } },
163
- };
164
- }) as unknown as Dispatcher["query"]);
165
-
166
- const { result } = renderHook(() => useCompletion(30), { wrapper: wrapperFor(dispatcher) });
167
-
168
- act(() => {
169
- result.current.requestCompletion("hello");
170
- });
171
- act(() => result.current.clear());
172
- await new Promise((r) => setTimeout(r, 60));
173
-
174
- expect(calls).toBe(0);
175
- expect(result.current.suggestion).toBeNull();
176
- });
177
- });
@@ -1,25 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { act, renderHook } from "@testing-library/react";
3
- import { useDisclosure } from "../use-disclosure";
4
-
5
- describe("useDisclosure", () => {
6
- test("open/close/toggle steuern den Zustand", () => {
7
- const { result } = renderHook(() => useDisclosure());
8
- expect(result.current.open).toBe(false);
9
- act(() => result.current.onOpen());
10
- expect(result.current.open).toBe(true);
11
- act(() => result.current.onClose());
12
- expect(result.current.open).toBe(false);
13
- act(() => result.current.onToggle());
14
- expect(result.current.open).toBe(true);
15
- });
16
-
17
- test("Callbacks sind referenz-stabil über Re-Renders", () => {
18
- const { result, rerender } = renderHook(() => useDisclosure(true));
19
- const first = result.current;
20
- rerender();
21
- expect(result.current.onOpen).toBe(first.onOpen);
22
- expect(result.current.onClose).toBe(first.onClose);
23
- expect(result.current.onToggle).toBe(first.onToggle);
24
- });
25
- });
@@ -1,65 +0,0 @@
1
- import { describe, expect, mock, test } from "bun:test";
2
- import type { Dispatcher, WriteResult } from "@cosmicdrift/kumiko-headless";
3
- import { act, renderHook } from "@testing-library/react";
4
- import { useForm } from "../use-form";
5
-
6
- function makeDispatcher(response?: WriteResult): Dispatcher & {
7
- readonly writeSpy: ReturnType<typeof mock>;
8
- } {
9
- const writeSpy = mock(
10
- async () => response ?? ({ isSuccess: true, data: { id: "srv-1" } } as WriteResult),
11
- );
12
- return {
13
- writeSpy,
14
- write: writeSpy as unknown as Dispatcher["write"],
15
- query: (async () => ({ isSuccess: true, data: null })) as unknown as Dispatcher["query"],
16
- batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
17
- async *stream() {},
18
- statusStore: {
19
- getState: () => "online",
20
- subscribe: () => () => {},
21
- } as unknown as Dispatcher["statusStore"],
22
- pendingWrites: () => [],
23
- pendingFiles: () => [],
24
- };
25
- }
26
-
27
- describe("useForm — mounted without a DispatcherProvider", () => {
28
- test("does not throw on mount — fw#1999 regression", () => {
29
- const { result } = renderHook(() =>
30
- useForm({ initial: { title: "" }, submit: { type: "app:write:task:create" } }),
31
- );
32
-
33
- expect(result.current.snapshot.values.title).toBe("");
34
- });
35
-
36
- test("submit() without an explicit dispatcher returns isSuccess:false instead of crashing the hook", async () => {
37
- const { result } = renderHook(() =>
38
- useForm({ initial: { title: "hello" }, submit: { type: "app:write:task:create" } }),
39
- );
40
-
41
- const submitResult = await result.current.controller.submit();
42
- expect(submitResult.validationBlocked).toBe(false);
43
- expect(submitResult.isSuccess).toBe(false);
44
- if (submitResult.isSuccess || submitResult.validationBlocked) {
45
- throw new Error("expected write failure");
46
- }
47
- expect(submitResult.error.message).toMatch(/submit\(\) called without a dispatcher/);
48
- });
49
-
50
- test("submit() with an explicit dispatcher still works without a provider", async () => {
51
- const dispatcher = makeDispatcher();
52
- const { result } = renderHook(() =>
53
- useForm({
54
- initial: { title: "hello" },
55
- submit: { type: "app:write:task:create", dispatcher },
56
- }),
57
- );
58
-
59
- await act(async () => {
60
- await result.current.controller.submit();
61
- });
62
-
63
- expect(dispatcher.writeSpy).toHaveBeenCalledWith("app:write:task:create", { title: "hello" });
64
- });
65
- });
@@ -1,77 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
- import { act, renderHook, waitFor } from "@testing-library/react";
4
- import type { ReactNode } from "react";
5
- import { DispatcherProvider } from "../../context/dispatcher-context";
6
- import { useMutation } from "../use-mutation";
7
-
8
- function makeDispatcher(write: Dispatcher["write"]): Dispatcher {
9
- return {
10
- write,
11
- query: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["query"],
12
- batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
13
- statusStore: {
14
- getState: () => "online",
15
- subscribe: () => () => {},
16
- } as unknown as Dispatcher["statusStore"],
17
- async *stream() {},
18
- pendingWrites: () => [],
19
- pendingFiles: () => [],
20
- };
21
- }
22
-
23
- function wrapperFor(dispatcher: Dispatcher) {
24
- return ({ children }: { readonly children: ReactNode }) => (
25
- <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
26
- );
27
- }
28
-
29
- describe("useMutation", () => {
30
- test("Success setzt data, pending toggelt, Result wird durchgereicht", async () => {
31
- let resolve: (() => void) | undefined;
32
- const gate = new Promise<void>((r) => {
33
- resolve = r;
34
- });
35
- const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => {
36
- await gate;
37
- return { isSuccess: true, data: { echoed: payload } };
38
- }) as unknown as Dispatcher["write"]);
39
-
40
- const { result } = renderHook(() => useMutation<{ echoed: unknown }>("f:write:x:create"), {
41
- wrapper: wrapperFor(dispatcher),
42
- });
43
-
44
- expect(result.current.pending).toBe(false);
45
- let outcome: Awaited<ReturnType<typeof result.current.mutate>> | undefined;
46
- act(() => {
47
- void result.current.mutate({ name: "a" }).then((r) => {
48
- outcome = r;
49
- });
50
- });
51
- await waitFor(() => expect(result.current.pending).toBe(true));
52
- act(() => resolve?.());
53
- await waitFor(() => expect(result.current.pending).toBe(false));
54
- expect(result.current.data).toEqual({ echoed: { name: "a" } });
55
- expect(result.current.error).toBeNull();
56
- expect(outcome?.isSuccess).toBe(true);
57
- });
58
-
59
- test("Failure setzt error, reset räumt auf", async () => {
60
- const dispatcher = makeDispatcher((async () => ({
61
- isSuccess: false,
62
- error: { code: "conflict", message: "boom", i18nKey: "errors.conflict" },
63
- })) as unknown as Dispatcher["write"]);
64
-
65
- const { result } = renderHook(() => useMutation("f:write:x:create"), {
66
- wrapper: wrapperFor(dispatcher),
67
- });
68
-
69
- await act(async () => {
70
- await result.current.mutate({});
71
- });
72
- expect(result.current.error?.code).toBe("conflict");
73
- act(() => result.current.reset());
74
- expect(result.current.error).toBeNull();
75
- expect(result.current.data).toBeNull();
76
- });
77
- });
@@ -1,107 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { Dispatcher, DispatcherError } from "@cosmicdrift/kumiko-headless";
3
- import { act, renderHook, waitFor } from "@testing-library/react";
4
- import type { ReactNode } from "react";
5
- import { DispatcherProvider } from "../../context/dispatcher-context";
6
- import { useStreamHandler } from "../use-stream-handler";
7
-
8
- function makeDispatcher(streamImpl: unknown): Dispatcher {
9
- return {
10
- write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
11
- query: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["query"],
12
- batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
13
- stream: streamImpl as Dispatcher["stream"],
14
- statusStore: {
15
- getState: () => "online",
16
- subscribe: () => () => {},
17
- } as unknown as Dispatcher["statusStore"],
18
- pendingWrites: () => [],
19
- pendingFiles: () => [],
20
- };
21
- }
22
-
23
- function wrapperFor(dispatcher: Dispatcher) {
24
- return ({ children }: { readonly children: ReactNode }) => (
25
- <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
26
- );
27
- }
28
-
29
- describe("useStreamHandler", () => {
30
- test("start accumulates chunks then status=done", async () => {
31
- const dispatcher = makeDispatcher(async function* () {
32
- yield { i: 0 };
33
- yield { i: 1 };
34
- });
35
-
36
- const { result } = renderHook(() => useStreamHandler<{ i: number }>("f:stream:x:tail"), {
37
- wrapper: wrapperFor(dispatcher),
38
- });
39
-
40
- await act(async () => {
41
- await result.current.start({ count: 2 });
42
- });
43
-
44
- await waitFor(() => expect(result.current.status).toBe("done"));
45
- expect(result.current.chunks).toEqual([{ i: 0 }, { i: 1 }]);
46
- expect(result.current.error).toBeNull();
47
- });
48
-
49
- test("stream error sets status=error and error envelope", async () => {
50
- const err: DispatcherError = {
51
- code: "access_denied",
52
- httpStatus: 403,
53
- i18nKey: "errors.access",
54
- message: "denied",
55
- };
56
- const dispatcher = makeDispatcher(async function* () {
57
- yield* []; // satisfy generator shape; error is the only outcome
58
- throw err;
59
- });
60
-
61
- const { result } = renderHook(() => useStreamHandler("f:stream:x:tail"), {
62
- wrapper: wrapperFor(dispatcher),
63
- });
64
-
65
- await act(async () => {
66
- await result.current.start();
67
- });
68
-
69
- await waitFor(() => expect(result.current.status).toBe("error"));
70
- expect(result.current.error?.code).toBe("access_denied");
71
- });
72
-
73
- test("abort during stream resets status to idle (no late done)", async () => {
74
- let release!: () => void;
75
- const gate = new Promise<void>((r) => {
76
- release = r;
77
- });
78
- const dispatcher = makeDispatcher(async function* (
79
- _t: string,
80
- _p: unknown,
81
- opts?: { signal?: AbortSignal },
82
- ) {
83
- yield { i: 0 };
84
- await gate;
85
- if (opts?.signal?.aborted) {
86
- const e = { code: "aborted", httpStatus: 0, i18nKey: "x", message: "aborted" };
87
- throw e;
88
- }
89
- yield { i: 1 };
90
- });
91
-
92
- const { result } = renderHook(() => useStreamHandler<{ i: number }>("f:stream:x:tail"), {
93
- wrapper: wrapperFor(dispatcher),
94
- });
95
-
96
- let started!: Promise<void>;
97
- act(() => {
98
- started = result.current.start();
99
- });
100
- await waitFor(() => expect(result.current.chunks).toEqual([{ i: 0 }]));
101
- act(() => result.current.abort());
102
- release();
103
- await started;
104
- expect(result.current.chunks).toEqual([{ i: 0 }]);
105
- expect(result.current.status).toBe("idle");
106
- });
107
- });