@cosmicdrift/kumiko-renderer 0.328.0 → 0.328.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/dist/app/action-form-shim.d.ts +14 -0
  2. package/{src/app/action-form-shim.ts → dist/app/action-form-shim.js} +26 -46
  3. package/dist/app/app-features-context.d.ts +8 -0
  4. package/dist/app/app-features-context.js +16 -0
  5. package/dist/app/column-renderers.d.ts +30 -0
  6. package/dist/app/column-renderers.js +34 -0
  7. package/dist/app/config-edit-shim.d.ts +11 -0
  8. package/{src/app/config-edit-shim.ts → dist/app/config-edit-shim.js} +12 -25
  9. package/dist/app/content-editors.d.ts +38 -0
  10. package/dist/app/content-editors.js +26 -0
  11. package/dist/app/content-preview.d.ts +11 -0
  12. package/dist/app/content-preview.js +30 -0
  13. package/dist/app/custom-screens.d.ts +11 -0
  14. package/dist/app/custom-screens.js +20 -0
  15. package/dist/app/dashboard-body.d.ts +16 -0
  16. package/dist/app/dashboard-body.js +11 -0
  17. package/dist/app/extension-form-submit.d.ts +32 -0
  18. package/dist/app/extension-form-submit.js +73 -0
  19. package/dist/app/extension-sections.d.ts +72 -0
  20. package/dist/app/extension-sections.js +34 -0
  21. package/dist/app/feature-schema.d.ts +4 -0
  22. package/{src/app/feature-schema.ts → dist/app/feature-schema.js} +12 -22
  23. package/dist/app/form-schema.d.ts +4 -0
  24. package/dist/app/form-schema.js +85 -0
  25. package/dist/app/kumiko-screen.d.ts +43 -0
  26. package/dist/app/kumiko-screen.js +2206 -0
  27. package/dist/app/layout-fields.d.ts +3 -0
  28. package/dist/app/layout-fields.js +20 -0
  29. package/dist/app/list-facets.d.ts +48 -0
  30. package/dist/app/list-facets.js +185 -0
  31. package/dist/app/nav.d.ts +61 -0
  32. package/dist/app/nav.js +139 -0
  33. package/dist/app/projection-detail-shim.d.ts +8 -0
  34. package/dist/app/projection-detail-shim.js +71 -0
  35. package/dist/app/projection-list-shim.d.ts +13 -0
  36. package/{src/app/projection-list-shim.ts → dist/app/projection-list-shim.js} +26 -41
  37. package/dist/app/qn.d.ts +7 -0
  38. package/{src/app/qn.ts → dist/app/qn.js} +9 -11
  39. package/dist/app/qualify-screen-id.d.ts +2 -0
  40. package/{src/app/qualify-screen-id.ts → dist/app/qualify-screen-id.js} +5 -6
  41. package/dist/app/reference-facet-bridge.d.ts +10 -0
  42. package/dist/app/reference-facet-bridge.js +22 -0
  43. package/dist/app/return-to.d.ts +31 -0
  44. package/dist/app/return-to.js +188 -0
  45. package/dist/app/row-actions.d.ts +77 -0
  46. package/dist/app/row-actions.js +416 -0
  47. package/dist/app/screen-access.d.ts +8 -0
  48. package/dist/app/screen-access.js +48 -0
  49. package/dist/app/secret-mint-body.d.ts +10 -0
  50. package/dist/app/secret-mint-body.js +140 -0
  51. package/dist/app/secrets-edit-body.d.ts +8 -0
  52. package/dist/app/secrets-edit-body.js +76 -0
  53. package/dist/app/use-embedded-screen.d.ts +6 -0
  54. package/dist/app/use-embedded-screen.js +19 -0
  55. package/dist/app/variable-chips.d.ts +7 -0
  56. package/dist/app/variable-chips.js +16 -0
  57. package/dist/app/write-failed-error.d.ts +6 -0
  58. package/dist/app/write-failed-error.js +21 -0
  59. package/dist/components/embedded-list-field.d.ts +10 -0
  60. package/dist/components/embedded-list-field.js +259 -0
  61. package/dist/components/grid-cell-for-field.d.ts +25 -0
  62. package/dist/components/grid-cell-for-field.js +31 -0
  63. package/dist/components/multi-select-checkboxes.d.ts +14 -0
  64. package/dist/components/multi-select-checkboxes.js +36 -0
  65. package/dist/components/reference-create-dialog.d.ts +13 -0
  66. package/dist/components/reference-create-dialog.js +46 -0
  67. package/dist/components/related-list-section.d.ts +25 -0
  68. package/dist/components/related-list-section.js +273 -0
  69. package/dist/components/render-edit-action-button.d.ts +22 -0
  70. package/dist/components/render-edit-action-button.js +43 -0
  71. package/dist/components/render-edit-logic.d.ts +6 -0
  72. package/dist/components/render-edit-logic.js +63 -0
  73. package/dist/components/render-edit-types.d.ts +209 -0
  74. package/dist/components/render-edit-types.js +1 -0
  75. package/dist/components/render-edit.d.ts +6 -0
  76. package/dist/components/render-edit.js +1169 -0
  77. package/dist/components/render-field.d.ts +38 -0
  78. package/dist/components/render-field.js +544 -0
  79. package/dist/components/render-list.d.ts +117 -0
  80. package/dist/components/render-list.js +232 -0
  81. package/dist/components/write-form-section.d.ts +17 -0
  82. package/dist/components/write-form-section.js +104 -0
  83. package/dist/context/dispatcher-context.d.ts +10 -0
  84. package/{src/context/dispatcher-context.tsx → dist/context/dispatcher-context.js} +16 -29
  85. package/dist/context/draft-storage-context.d.ts +12 -0
  86. package/dist/context/draft-storage-context.js +18 -0
  87. package/dist/context/user-roles-context.d.ts +7 -0
  88. package/{src/context/user-roles-context.tsx → dist/context/user-roles-context.js} +7 -15
  89. package/dist/format-when.d.ts +1 -0
  90. package/{src/format-when.ts → dist/format-when.js} +7 -7
  91. package/dist/hooks/reference-limits.d.ts +3 -0
  92. package/{src/hooks/reference-limits.ts → dist/hooks/reference-limits.js} +0 -1
  93. package/dist/hooks/use-ai-text.d.ts +52 -0
  94. package/dist/hooks/use-ai-text.js +90 -0
  95. package/dist/hooks/use-disclosure.d.ts +7 -0
  96. package/dist/hooks/use-disclosure.js +8 -0
  97. package/dist/hooks/use-form.d.ts +7 -0
  98. package/dist/hooks/use-form.js +36 -0
  99. package/dist/hooks/use-list-url-state.d.ts +43 -0
  100. package/dist/hooks/use-list-url-state.js +131 -0
  101. package/dist/hooks/use-mutation.d.ts +9 -0
  102. package/dist/hooks/use-mutation.js +37 -0
  103. package/dist/hooks/use-query.d.ts +14 -0
  104. package/dist/hooks/use-query.js +118 -0
  105. package/dist/hooks/use-reference-lookup.d.ts +14 -0
  106. package/dist/hooks/use-reference-lookup.js +63 -0
  107. package/dist/hooks/use-store.d.ts +3 -0
  108. package/dist/hooks/use-store.js +38 -0
  109. package/dist/hooks/use-stream-handler.d.ts +14 -0
  110. package/dist/hooks/use-stream-handler.js +93 -0
  111. package/dist/i18n-defaults.d.ts +2 -0
  112. package/dist/i18n-defaults.js +201 -0
  113. package/dist/i18n.d.ts +64 -0
  114. package/dist/i18n.js +166 -0
  115. package/dist/index.d.ts +77 -0
  116. package/dist/index.js +55 -0
  117. package/dist/inside-drawer.d.ts +2 -0
  118. package/{src/inside-drawer.ts → dist/inside-drawer.js} +2 -5
  119. package/dist/page-header-slot.d.ts +2 -0
  120. package/{src/page-header-slot.ts → dist/page-header-slot.js} +2 -5
  121. package/dist/primitives.d.ts +1351 -0
  122. package/dist/primitives.js +73 -0
  123. package/dist/screen-fills-height.d.ts +3 -0
  124. package/dist/screen-fills-height.js +3 -0
  125. package/dist/share-links.d.ts +4 -0
  126. package/dist/share-links.js +11 -0
  127. package/dist/sort-by-accessor.d.ts +5 -0
  128. package/dist/sort-by-accessor.js +16 -0
  129. package/dist/sse/live-events.d.ts +23 -0
  130. package/dist/sse/live-events.js +28 -0
  131. package/dist/tokens.d.ts +83 -0
  132. package/dist/tokens.js +80 -0
  133. package/package.json +16 -9
  134. package/src/__tests__/error-i18n-defaults.test.ts +0 -82
  135. package/src/__tests__/format-when.test.ts +0 -12
  136. package/src/__tests__/i18n.test.tsx +0 -283
  137. package/src/__tests__/merge-search-params-into-initial.test.ts +0 -525
  138. package/src/__tests__/qn.test.ts +0 -83
  139. package/src/__tests__/share-links.test.ts +0 -26
  140. package/src/__tests__/sort-by-accessor.test.ts +0 -48
  141. package/src/__tests__/stringify-nav-params.test.ts +0 -33
  142. package/src/__tests__/use-list-url-state.test.tsx +0 -250
  143. package/src/app/__tests__/action-form-field-labels.test.tsx +0 -92
  144. package/src/app/__tests__/action-form-shim.test.ts +0 -137
  145. package/src/app/__tests__/build-list-query-payload.test.ts +0 -107
  146. package/src/app/__tests__/config-edit-shim.test.ts +0 -57
  147. package/src/app/__tests__/content-editors.test.tsx +0 -128
  148. package/src/app/__tests__/content-preview.test.tsx +0 -184
  149. package/src/app/__tests__/entity-edit-create-search-params.test.tsx +0 -236
  150. package/src/app/__tests__/entity-edit-fill-height.test.tsx +0 -118
  151. package/src/app/__tests__/entity-edit-jsonb-embedded-submit.test.tsx +0 -239
  152. package/src/app/__tests__/entity-list-create-singleton.test.tsx +0 -128
  153. package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +0 -424
  154. package/src/app/__tests__/entity-list-row-action-icon.test.tsx +0 -248
  155. package/src/app/__tests__/entity-list-row-action-kumiko-actions-view.test.tsx +0 -162
  156. package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +0 -401
  157. package/src/app/__tests__/entity-list-screen-padding.test.tsx +0 -128
  158. package/src/app/__tests__/feature-schema.test.ts +0 -40
  159. package/src/app/__tests__/form-schema.test.ts +0 -247
  160. package/src/app/__tests__/list-daterange-facet.test.ts +0 -165
  161. package/src/app/__tests__/list-filter-facets.test.tsx +0 -521
  162. package/src/app/__tests__/nav.test.ts +0 -198
  163. package/src/app/__tests__/projection-detail-actions.test.tsx +0 -719
  164. package/src/app/__tests__/projection-detail-reference-field.test.tsx +0 -165
  165. package/src/app/__tests__/projection-detail-shim.test.ts +0 -188
  166. package/src/app/__tests__/projection-detail-singleton.test.tsx +0 -191
  167. package/src/app/__tests__/projection-list-bad-shape.test.tsx +0 -152
  168. package/src/app/__tests__/projection-list-reference-column.test.tsx +0 -219
  169. package/src/app/__tests__/projection-list-search-sort.test.tsx +0 -212
  170. package/src/app/__tests__/qualified-names.test.ts +0 -37
  171. package/src/app/__tests__/return-to.test.ts +0 -396
  172. package/src/app/__tests__/row-actions-navigate-tab.test.ts +0 -223
  173. package/src/app/__tests__/row-default-edit-action.test.tsx +0 -324
  174. package/src/app/__tests__/screen-access-allows.test.ts +0 -34
  175. package/src/app/__tests__/secret-mint-body.test.tsx +0 -404
  176. package/src/app/__tests__/variable-chips.test.tsx +0 -70
  177. package/src/app/app-features-context.tsx +0 -25
  178. package/src/app/column-renderers.tsx +0 -68
  179. package/src/app/content-editors.tsx +0 -85
  180. package/src/app/content-preview.tsx +0 -67
  181. package/src/app/custom-screens.tsx +0 -29
  182. package/src/app/dashboard-body.tsx +0 -33
  183. package/src/app/extension-form-submit.tsx +0 -123
  184. package/src/app/extension-sections.tsx +0 -111
  185. package/src/app/form-schema.ts +0 -91
  186. package/src/app/kumiko-screen.tsx +0 -3801
  187. package/src/app/layout-fields.ts +0 -31
  188. package/src/app/list-facets.ts +0 -259
  189. package/src/app/nav.tsx +0 -258
  190. package/src/app/projection-detail-shim.ts +0 -86
  191. package/src/app/reference-facet-bridge.tsx +0 -58
  192. package/src/app/return-to.tsx +0 -234
  193. package/src/app/row-actions.ts +0 -640
  194. package/src/app/screen-access.ts +0 -63
  195. package/src/app/secret-mint-body.tsx +0 -259
  196. package/src/app/secrets-edit-body.tsx +0 -194
  197. package/src/app/use-embedded-screen.ts +0 -27
  198. package/src/app/variable-chips.tsx +0 -39
  199. package/src/app/write-failed-error.ts +0 -27
  200. package/src/components/__tests__/embedded-list-field.test.tsx +0 -531
  201. package/src/components/__tests__/multi-select-checkboxes.test.tsx +0 -162
  202. package/src/components/__tests__/related-list-section-empty-state.test.tsx +0 -143
  203. package/src/components/__tests__/related-list-section.test.tsx +0 -1733
  204. package/src/components/__tests__/render-edit-action-button.test.tsx +0 -205
  205. package/src/components/__tests__/render-edit-hidden-section.test.tsx +0 -128
  206. package/src/components/__tests__/render-edit-logic.test.ts +0 -314
  207. package/src/components/__tests__/render-edit-screen-description.test.tsx +0 -152
  208. package/src/components/__tests__/render-edit-submit-actions.test.tsx +0 -460
  209. package/src/components/__tests__/render-field-app-locale.test.tsx +0 -142
  210. package/src/components/__tests__/render-field-enum-option.test.tsx +0 -99
  211. package/src/components/__tests__/render-field-icon-derivation.test.tsx +0 -119
  212. package/src/components/__tests__/render-field-json-format.test.tsx +0 -164
  213. package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +0 -212
  214. package/src/components/__tests__/render-field-money-roundtrip.test.tsx +0 -217
  215. package/src/components/__tests__/render-field-number-grouping.test.tsx +0 -76
  216. package/src/components/__tests__/render-field-number-unit.test.tsx +0 -227
  217. package/src/components/__tests__/render-field-password-format.test.tsx +0 -101
  218. package/src/components/__tests__/render-field-select-display.test.tsx +0 -134
  219. package/src/components/__tests__/render-field-unit-format.test.tsx +0 -107
  220. package/src/components/__tests__/render-field-unsupported-types.test.tsx +0 -195
  221. package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +0 -146
  222. package/src/components/__tests__/write-form-section.test.tsx +0 -375
  223. package/src/components/embedded-list-field.tsx +0 -354
  224. package/src/components/grid-cell-for-field.tsx +0 -88
  225. package/src/components/multi-select-checkboxes.tsx +0 -93
  226. package/src/components/reference-create-dialog.tsx +0 -91
  227. package/src/components/related-list-section.tsx +0 -491
  228. package/src/components/render-edit-action-button.tsx +0 -109
  229. package/src/components/render-edit-logic.ts +0 -85
  230. package/src/components/render-edit-types.ts +0 -226
  231. package/src/components/render-edit.tsx +0 -1871
  232. package/src/components/render-field.tsx +0 -951
  233. package/src/components/render-list.tsx +0 -593
  234. package/src/components/write-form-section.tsx +0 -198
  235. package/src/context/draft-storage-context.tsx +0 -39
  236. package/src/hooks/__tests__/use-ai-text.test.tsx +0 -177
  237. package/src/hooks/__tests__/use-disclosure.test.tsx +0 -25
  238. package/src/hooks/__tests__/use-form.test.tsx +0 -65
  239. package/src/hooks/__tests__/use-mutation.test.tsx +0 -77
  240. package/src/hooks/__tests__/use-query.test.tsx +0 -149
  241. package/src/hooks/__tests__/use-stream-handler.test.tsx +0 -107
  242. package/src/hooks/use-ai-text.ts +0 -172
  243. package/src/hooks/use-disclosure.ts +0 -20
  244. package/src/hooks/use-form.ts +0 -78
  245. package/src/hooks/use-list-url-state.ts +0 -207
  246. package/src/hooks/use-mutation.ts +0 -61
  247. package/src/hooks/use-query.ts +0 -166
  248. package/src/hooks/use-reference-lookup.ts +0 -76
  249. package/src/hooks/use-store.ts +0 -48
  250. package/src/hooks/use-stream-handler.ts +0 -134
  251. package/src/i18n-defaults.ts +0 -240
  252. package/src/i18n.tsx +0 -286
  253. package/src/index.ts +0 -279
  254. package/src/primitives.tsx +0 -1567
  255. package/src/screen-fills-height.ts +0 -3
  256. package/src/share-links.ts +0 -12
  257. package/src/sort-by-accessor.ts +0 -20
  258. package/src/sse/live-events.tsx +0 -56
  259. package/src/tokens.tsx +0 -170
@@ -1,951 +0,0 @@
1
- import {
2
- type EntityEditScreenDefinition,
3
- type FieldIconKey,
4
- type FieldRenderer,
5
- isFormatSpec,
6
- SYSTEM_REFERENCE_LABELS,
7
- } from "@cosmicdrift/kumiko-framework/ui-types";
8
- import {
9
- applyFormatSpec,
10
- currencyDecimals,
11
- type EditFieldViewModel,
12
- type FieldIssue,
13
- } from "@cosmicdrift/kumiko-headless";
14
- import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
15
- import { useAppFeatures } from "../app/app-features-context";
16
- import { useColumnRenderer } from "../app/column-renderers";
17
- import { extensionSectionName } from "../app/extension-sections";
18
- import { toKebab } from "../app/qn";
19
- import { screenAccessAllows } from "../app/screen-access";
20
- import { useUserRoles } from "../context/user-roles-context";
21
- import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
22
- import { useQuery } from "../hooks/use-query";
23
- import { referenceLookupSource } from "../hooks/use-reference-lookup";
24
- import { useLocale, useTranslation } from "../i18n";
25
- import { usePrimitives } from "../primitives";
26
- import { EmbeddedListField } from "./embedded-list-field";
27
- import { MultiSelectCheckboxes } from "./multi-select-checkboxes";
28
- import { ReferenceCreateDialog } from "./reference-create-dialog";
29
-
30
- // RenderField übersetzt ein EditFieldViewModel → Primitives-Baum.
31
- // Kein raw HTML mehr; alle Darstellungsentscheidungen (Label-Position,
32
- // Fehler-Layout, Input-Styling) leben in der Primitives-Implementation.
33
- //
34
- // Der field.type → Input-kind Mapping bleibt hier, weil es
35
- // Domain-Logik ist (EntityDefinition-Feldtyp) und nicht Darstellung.
36
-
37
- // No `hideLabel` prop here (fw#1870/#1871#3, deliberately out of scope):
38
- // RenderField is driven entirely by EditFieldViewModel/EntityEditScreenDefinition,
39
- // which have no per-field hideLabel slot — wiring it through would need a
40
- // schema change, not just a prop. Declarative grid/table screens stay on
41
- // visible labels until that schema work happens; imperative *Field widgets
42
- // (form-fields.tsx, AiTextField) already support it.
43
- export type RenderFieldProps = {
44
- readonly field: EditFieldViewModel;
45
- readonly issues?: readonly FieldIssue[];
46
- readonly onChange: (val: unknown) => void;
47
- /** Nur bei type:"reference" relevant — Feature-Name für die Lookup-
48
- * Query-QN (`<feature>:query:<refEntity>:list`). Andere Field-Types
49
- * ignorieren das Prop. */
50
- readonly featureName?: string;
51
- /** Optionaler Zusatz-Inhalt der nach dem Label gerendert wird (z.B.
52
- * ConfigSourceBadge). */
53
- readonly labelAppendix?: ReactNode;
54
- /** Optionaler Zusatz-Inhalt der nach dem Input gerendert wird (z.B.
55
- * ConfigCascade). */
56
- readonly fieldAppendix?: ReactNode;
57
- /** Flat issues-by-path map (FormSnapshot.errors) — only relevant for
58
- * type:"embedded" with embeddedListCells, to bucket row-/cell-issues
59
- * (`${field}.${rowIndex}` / `${field}.${rowIndex}.${cellField}`).
60
- * Other field types ignore this prop. */
61
- readonly allIssues?: Readonly<Record<string, readonly FieldIssue[]>>;
62
- /** "form" (default) renders every field as its editable Input, disabled
63
- * when `field.readOnly` — unchanged behavior. "text" renders a
64
- * `field.readOnly` field as plain text instead (projectionDetail's read
65
- * view, fw#2245); editable fields are untouched by this prop either way. */
66
- readonly valueDisplay?: "form" | "text";
67
- /** Current form values, keyed by field name. Consulted when
68
- * `field.renderer` resolves to a `{ react: { __component } }` registry
69
- * component (passed through as `ColumnRendererProps.row`, same contract
70
- * as list-column renderers, fw#2245), and to resolve a `type: "number"`
71
- * field's sibling-field `unit`. Omitted → falls back to a single-key
72
- * `{ [field.field]: field.value }` row. */
73
- readonly row?: Readonly<Record<string, unknown>>;
74
- /** Marks the field as modified against the loaded record (edit mode only). */
75
- readonly changed?: boolean;
76
- };
77
-
78
- const AllFieldsRequiredContext = createContext(false);
79
-
80
- export const AllFieldsRequiredProvider = AllFieldsRequiredContext.Provider;
81
-
82
- export function RenderField({
83
- field,
84
- issues,
85
- onChange,
86
- featureName,
87
- labelAppendix,
88
- fieldAppendix,
89
- allIssues,
90
- valueDisplay = "form",
91
- row,
92
- changed,
93
- }: RenderFieldProps): ReactNode {
94
- const { Field, Input, Banner, Text, JsonView } = usePrimitives();
95
- // App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
96
- // navigator.language (Browser-Sprache) zurück statt der gewählten
97
- // App-Sprache. BEWUSSTE API-Verschärfung (seit 0.38): RenderField ist
98
- // public exportiert und verlangt jetzt einen LocaleProvider —
99
- // Standalone-Consumer/Tests müssen wrappen (createKumikoApp tut es).
100
- const appLocale = useLocale().locale();
101
- const t = useTranslation();
102
- const allFieldsRequired = useContext(AllFieldsRequiredContext);
103
- if (!field.visible) return null;
104
-
105
- const id = inputId(field);
106
- const hasError = issues !== undefined && issues.length > 0;
107
- // An author-declared renderer always wins over the input widget, same as
108
- // the list-column path (render-list.tsx). Only takes effect once the field
109
- // is actually readOnly — a FormatSpec/PlatformComponent renderer has no
110
- // editable widget of its own, so applying it to an editable field would
111
- // silently make that field un-editable (fw#2245).
112
- const readOnlyText = valueDisplay === "text" && field.readOnly && field.renderer === undefined;
113
-
114
- // Reference-Field rendert eine eigene Component — sie nutzt
115
- // useQuery() für den Live-Lookup, also muss sie als React-
116
- // Komponente gemountet werden (nicht als pure render-Call).
117
- const control =
118
- field.readOnly && field.renderer !== undefined ? (
119
- <FieldRendererOutput
120
- field={field}
121
- renderer={field.renderer}
122
- appLocale={appLocale}
123
- {...(row !== undefined && { row })}
124
- />
125
- ) : field.type === "embedded" && field.embeddedListCells !== undefined ? (
126
- <EmbeddedListField
127
- field={field}
128
- id={id}
129
- onChange={onChange}
130
- allIssues={allIssues ?? {}}
131
- featureName={featureName ?? ""}
132
- />
133
- ) : field.type === "reference" ? (
134
- readOnlyText ? (
135
- <ReadOnlyReferenceValue field={field} featureName={featureName ?? ""} />
136
- ) : (
137
- <ReferenceInput
138
- field={field}
139
- id={id}
140
- hasError={hasError}
141
- onChange={onChange}
142
- Input={Input}
143
- featureName={featureName ?? ""}
144
- />
145
- )
146
- ) : readOnlyText && !isComplexFieldType(field.type) ? (
147
- <Text testId={`field-value-${field.field}`}>{readOnlyDisplayText(field, appLocale)}</Text>
148
- ) : (
149
- renderInput({
150
- field,
151
- id,
152
- hasError,
153
- onChange,
154
- Input,
155
- appLocale,
156
- Banner,
157
- Text,
158
- JsonView,
159
- t,
160
- row,
161
- })
162
- );
163
-
164
- return (
165
- <Field
166
- id={id}
167
- label={field.label}
168
- required={field.required && !allFieldsRequired}
169
- {...(issues !== undefined && { issues })}
170
- {...(labelAppendix !== undefined && { labelAppendix })}
171
- {...(fieldAppendix !== undefined && { fieldAppendix })}
172
- {...(changed === true && { changed })}
173
- testId={`field-${field.field}`}
174
- >
175
- {control}
176
- </Field>
177
- );
178
- }
179
-
180
- // Where a reference field's option rows come from, and which of their keys
181
- // holds the display text — the entity's own list handler plus `refLabelField`
182
- // by default, or an author-supplied handler whose rows carry `label`
183
- // (fw#2780), for targets with no readable column of their own.
184
- function referenceOptionSource(
185
- field: Pick<EditFieldViewModel, "refLabelField" | "refOptionsQuery">,
186
- refFeature: string,
187
- refEntity: string,
188
- ): { readonly queryQn: string; readonly labelKey: string } {
189
- if (field.refOptionsQuery !== undefined) {
190
- return { queryQn: field.refOptionsQuery, labelKey: "label" };
191
- }
192
- return referenceLookupSource(refFeature, refEntity, field.refLabelField ?? "id");
193
- }
194
-
195
- // Tier 2.7e-3 + 2.1c: Reference-Input rendert eine Searchable Combobox
196
- // gefüllt aus einer Live-Query auf die referenced Entity. Default-
197
- // Limit: 200 — bei größeren Datasets fehlt der Tail im Dropdown
198
- // (Tier 2.7e-Remote: server-side Search-Query mit debounce kommt später).
199
- // - Display = row[refLabelField], Default labelField "id".
200
- // - Loading-State: leeres Dropdown bis die rows da sind. Field
201
- // ist disabled während useQuery läuft.
202
- // - Multi-Mode (Tier 2.7e-Multi via field.refMultiple): value ist
203
- // ein UUID-Array, Combobox rendert Selected-Tags.
204
- //
205
- // Storage: UI-Wert ist UUID (row.id) oder UUID-Array bei multiple.
206
- // Server-Schema: z.uuid() bzw. z.array(z.uuid()).
207
- // REFERENCE_COMBOBOX_LIMIT lebt zentral in hooks/reference-limits.ts
208
- // (siehe dort für Begründung der Default-Werte).
209
-
210
- function ReferenceInput({
211
- field,
212
- id,
213
- hasError,
214
- onChange,
215
- Input,
216
- featureName,
217
- }: {
218
- readonly field: EditFieldViewModel;
219
- readonly id: string;
220
- readonly hasError: boolean;
221
- readonly onChange: (value: unknown) => void;
222
- readonly Input: ReturnType<typeof usePrimitives>["Input"];
223
- readonly featureName: string;
224
- }): ReactNode {
225
- const { Banner } = usePrimitives();
226
- const refEntity = field.refEntity ?? "";
227
- const refFeature = field.refFeature ?? featureName;
228
- const isMultiple = field.refMultiple === true;
229
- // Tier 2.7e Cross-Feature: refFeature kann ≠ featureName sein
230
- // (z.B. items.assignee → users:query:user:list). Default ist
231
- // same-feature, kommt aus dem ViewModel (parseRefTarget).
232
- // fw#2780: refOptionsQuery replaces that source with an author-supplied
233
- // handler whose rows already carry a composed `label`.
234
- const { queryQn, labelKey } = referenceOptionSource(field, refFeature, refEntity);
235
- // Issue #1681: "+ Neu" in der Combobox öffnet den Create-Screen der
236
- // referenced entity als Dialog, statt die aktuelle Form zu verlassen.
237
- // refFeature kann ein anderes Feature als das aktuell gerenderte sein
238
- // — appFeatures (createKumikoApp) kennt alle Feature-Schemas, nicht
239
- // nur das der aktiven Screen. Kein Match (Feature/Screen/Entity nicht
240
- // registriert, oder allowCreate:false) → onCreate bleibt undefined,
241
- // Combobox rendert dann ohne den Footer.
242
- const appFeatures = useAppFeatures();
243
- const userRoles = useUserRoles();
244
- const t = useTranslation();
245
- const [createOpen, setCreateOpen] = useState(false);
246
- const refTargetSchema = appFeatures.find((f) => f.featureName === refFeature);
247
- const refCreateScreen = refTargetSchema?.screens.find(
248
- (s): s is EntityEditScreenDefinition =>
249
- s.type === "entityEdit" &&
250
- s.entity === refEntity &&
251
- s.allowCreate !== false &&
252
- screenAccessAllows(s.access, userRoles),
253
- );
254
- const refEntityDef = refTargetSchema?.entities[refEntity];
255
- // Tier 2.7e Remote-Search: User tippt im Combobox → Server filtert
256
- // via existing list-payload `search`-Param (Tier 2.6c). Combobox
257
- // debounced den keystroke selbst (300ms) und ruft onSearchChange.
258
- // Initial-State leer → erste 50 Items vom Server (default-sortiert).
259
- const [searchTerm, setSearchTerm] = useState("");
260
- const queryPayload = useMemo<Record<string, unknown>>(
261
- () =>
262
- searchTerm === ""
263
- ? { limit: REFERENCE_COMBOBOX_LIMIT }
264
- : { limit: REFERENCE_COMBOBOX_LIMIT, search: searchTerm },
265
- [searchTerm],
266
- );
267
- const queryResult = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(
268
- queryQn,
269
- queryPayload,
270
- );
271
- const handleSearchChange = useCallback((q: string) => setSearchTerm(q), []);
272
- const canCreate = !field.readOnly && refCreateScreen !== undefined && refEntityDef !== undefined;
273
- const [createdWithoutIdWarning, setCreatedWithoutIdWarning] = useState(false);
274
- const handleCreated = useCallback(
275
- (newId: string | undefined) => {
276
- setCreateOpen(false);
277
- // Clear the search filter before refetching — the just-created record
278
- // won't match whatever the user had typed pre-create, and would then
279
- // be missing from `options` (shown as a raw id instead of a label).
280
- setSearchTerm("");
281
- queryResult.refetch().catch((err: unknown) => {
282
- // biome-ignore lint/suspicious/noConsole: no error-surfacing path exists in this widget yet — at minimum, don't swallow it silently.
283
- console.error("render-field: refetch after create failed", err);
284
- });
285
- if (newId === undefined) {
286
- // Record was created server-side but the payload carried no id —
287
- // can't auto-select it, surface that instead of failing silently.
288
- setCreatedWithoutIdWarning(true);
289
- return;
290
- }
291
- setCreatedWithoutIdWarning(false);
292
- if (isMultiple) {
293
- const current = Array.isArray(field.value) ? (field.value as readonly string[]) : [];
294
- onChange([...current, newId]);
295
- } else {
296
- onChange(newId);
297
- }
298
- },
299
- [isMultiple, field.value, onChange, queryResult.refetch],
300
- );
301
- const options = useMemo(() => {
302
- const rows = queryResult.data?.rows ?? [];
303
- return rows.map((row) => {
304
- const idVal = String(row["id"] ?? "");
305
- const label = String(row[labelKey] ?? idVal);
306
- return { value: idVal, label };
307
- });
308
- }, [queryResult.data, labelKey]);
309
- // Single: value ist String/null; Multi: Array. Coerce auf das was
310
- // der Combobox-Mode erwartet, damit Storage-Drift (Server liefert
311
- // alten String wo jetzt Array erwartet wird) keine Crash auslöst.
312
- const baseInputProps = {
313
- id,
314
- name: field.field,
315
- // Initial-Load disabled — danach loading-Indicator im Popover.
316
- disabled: field.readOnly || (queryResult.loading && options.length === 0),
317
- required: field.required,
318
- hasError,
319
- options,
320
- onSearchChange: handleSearchChange,
321
- loading: queryResult.loading,
322
- ...(canCreate && {
323
- onCreate: () => setCreateOpen(true),
324
- createLabel: t("kumiko.actions.create"),
325
- }),
326
- } as const;
327
- const createDialog = (
328
- <>
329
- {canCreate && refCreateScreen && refEntityDef && (
330
- <ReferenceCreateDialog
331
- open={createOpen}
332
- onClose={() => setCreateOpen(false)}
333
- onCreated={handleCreated}
334
- featureName={refFeature}
335
- screen={refCreateScreen}
336
- entity={refEntityDef}
337
- />
338
- )}
339
- {createdWithoutIdWarning && (
340
- <Banner variant="error">{t("kumiko.field.reference-created-no-id")}</Banner>
341
- )}
342
- </>
343
- );
344
- if (isMultiple) {
345
- const arrayValue: readonly string[] = Array.isArray(field.value)
346
- ? (field.value as readonly string[])
347
- : [];
348
- return (
349
- <>
350
- <Input
351
- kind="combobox"
352
- {...baseInputProps}
353
- multiple
354
- value={arrayValue}
355
- onChange={(v) => onChange(v)}
356
- />
357
- {createDialog}
358
- </>
359
- );
360
- }
361
- const stringValue = field.value === undefined || field.value === null ? "" : String(field.value);
362
- return (
363
- <>
364
- <Input
365
- kind="combobox"
366
- {...baseInputProps}
367
- value={stringValue}
368
- onChange={(v) => onChange(v === "" ? null : v)}
369
- />
370
- {createDialog}
371
- </>
372
- );
373
- }
374
-
375
- function inputId(field: EditFieldViewModel): string {
376
- return `kumiko-edit-${field.field}`;
377
- }
378
-
379
- // Doesn't resolve the `string` (cross-feature QN) FieldRenderer variant — the
380
- // list-column path (DataTableCell) doesn't either, so this stays in parity.
381
- function FieldRendererOutput({
382
- field,
383
- renderer,
384
- row,
385
- appLocale,
386
- }: {
387
- readonly field: EditFieldViewModel;
388
- readonly renderer: FieldRenderer;
389
- readonly row?: Readonly<Record<string, unknown>>;
390
- readonly appLocale: string;
391
- }): ReactNode {
392
- const { Text, JsonView } = usePrimitives();
393
- const t = useTranslation();
394
- const componentName =
395
- !isFormatSpec(renderer) && typeof renderer === "object" && renderer !== null
396
- ? extensionSectionName(renderer)
397
- : undefined;
398
- const Component = useColumnRenderer(componentName);
399
- if (isFormatSpec(renderer)) {
400
- // format:"json" wants the raw value (JsonView stringifies + highlights
401
- // itself) — applyFormatSpec's already-indented string is only the
402
- // fallback for primitives-providers without JsonView (fw#2312).
403
- if (renderer.format === "json" && JsonView !== undefined) {
404
- return (
405
- <JsonView
406
- value={field.value}
407
- indent={renderer.indent}
408
- testId={`field-value-${field.field}`}
409
- />
410
- );
411
- }
412
- // App locale as default when the FormatSpec declares none of its own —
413
- // otherwise locale-sensitive formats (timestamp/date/number/decimal/
414
- // bigInt/unit) fell back to Intl's runtime default instead of the app
415
- // language chosen via LocaleProvider (fw#2187). Prefer renderer.locale
416
- // when set; coalesce undefined (spread override) back to appLocale (#2332).
417
- return (
418
- <Text testId={`field-value-${field.field}`}>
419
- {applyFormatSpec(
420
- {
421
- ...renderer,
422
- locale: (renderer as { locale?: string }).locale ?? appLocale,
423
- },
424
- field.value,
425
- t,
426
- )}
427
- </Text>
428
- );
429
- }
430
- if (componentName !== undefined) {
431
- if (Component !== undefined) {
432
- return (
433
- <Component
434
- value={field.value}
435
- row={row ?? { [field.field]: field.value }}
436
- column={{ field: field.field }}
437
- />
438
- );
439
- }
440
- // biome-ignore lint/suspicious/noConsole: dev-warning for a registry mismatch, mirrors DataTableCell's columnRenderer warning.
441
- console.warn(`[kumiko] fieldRenderer "${componentName}" not registered`);
442
- }
443
- return <Text testId={`field-value-${field.field}`}>{stringValue(field.value)}</Text>;
444
- }
445
-
446
- // Read-only text for `type: "reference"` — resolves the referenced row's
447
- // label via the same lookup query as ReferenceInput's combobox, but shows
448
- // plain text instead of mounting an (unusable, disabled) combobox.
449
- function ReadOnlyReferenceValue({
450
- field,
451
- featureName,
452
- }: {
453
- readonly field: EditFieldViewModel;
454
- readonly featureName: string;
455
- }): ReactNode {
456
- const { Text } = usePrimitives();
457
- const t = useTranslation();
458
- const refEntity = field.refEntity ?? "";
459
- const refFeature = field.refFeature ?? featureName;
460
- const isMultiple = field.refMultiple === true;
461
- const { queryQn, labelKey } = referenceOptionSource(field, refFeature, refEntity);
462
- const queryResult = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
463
- limit: REFERENCE_COMBOBOX_LIMIT,
464
- });
465
- const ids: readonly string[] = isMultiple
466
- ? Array.isArray(field.value)
467
- ? (field.value as readonly string[])
468
- : []
469
- : typeof field.value === "string" && field.value !== ""
470
- ? [field.value]
471
- : [];
472
- if (ids.length === 0) return <Text testId={`field-value-${field.field}`}>—</Text>;
473
- const rows = queryResult.data?.rows ?? [];
474
- // System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row —
475
- // same central fallback as useReferenceLookup (render-list.tsx), so a
476
- // reference field showing one on the detail path also gets a label
477
- // instead of the raw id.
478
- const systemLabel = SYSTEM_REFERENCE_LABELS[`${refFeature}:${refEntity}`];
479
- const labels = ids.map((id) => {
480
- if (systemLabel !== undefined && id === systemLabel.id) return t(systemLabel.labelKey);
481
- const row = rows.find((r) => String(r["id"] ?? "") === id);
482
- return row !== undefined ? String(row[labelKey] ?? id) : id;
483
- });
484
- return <Text testId={`field-value-${field.field}`}>{labels.join(", ")}</Text>;
485
- }
486
-
487
- // Field types whose read display already isn't a boxed "disabled input" look
488
- // (embedded/jsonb render as an info Banner, files/images as an unsupported-
489
- // type Banner) — `readOnlyDisplayText` doesn't cover these, they keep going
490
- // through `renderInput`'s existing Banner branch in text-display mode too.
491
- function isComplexFieldType(type: string): boolean {
492
- return type === "embedded" || type === "jsonb" || type === "files" || type === "images";
493
- }
494
-
495
- // Mirrors ACTION_ICON_BY_ID (kumiko-screen.tsx) for fields: only names with
496
- // an unambiguous matching FieldIconKey are listed here, everything else
497
- // stays iconless rather than guessing.
498
- const FIELD_ICON_BY_NAME: Readonly<Partial<Record<string, FieldIconKey>>> = {
499
- email: "mail",
500
- phone: "phone",
501
- tel: "phone",
502
- mobile: "phone",
503
- url: "link",
504
- website: "link",
505
- link: "link",
506
- password: "lock",
507
- search: "search",
508
- city: "map-pin",
509
- address: "map-pin",
510
- street: "map-pin",
511
- user: "user",
512
- owner: "user",
513
- assignee: "user",
514
- };
515
-
516
- function kebabLastSegment(value: string): string {
517
- const kebab = toKebab(value);
518
- const lastDash = kebab.lastIndexOf("-");
519
- return lastDash === -1 ? kebab : kebab.slice(lastDash + 1);
520
- }
521
-
522
- // Derives a prefix icon when the field declares none: field.icon wins, then
523
- // the field name. Deliberately no type-based fallback — a hash in front of a
524
- // number field restates what the field already shows and reads as noise.
525
- function resolveFieldIcon(field: EditFieldViewModel): FieldIconKey | undefined {
526
- if (field.icon !== undefined) return field.icon;
527
- const byName =
528
- FIELD_ICON_BY_NAME[field.field.toLowerCase()] ??
529
- FIELD_ICON_BY_NAME[kebabLastSegment(field.field)];
530
- return byName;
531
- }
532
-
533
- // Dispatches field.type → Input-kind. Select threads options through
534
- // from the EditFieldViewModel (computeEditViewModel pulls them from
535
- // SelectFieldDef.options). Structural types without a widget (embedded,
536
- // jsonb, multiSelect) render read-only instead of a data-destroying text
537
- // fallback (#1834); truly unknown scalar types still fall back to text.
538
- function renderInput({
539
- field,
540
- id,
541
- hasError,
542
- onChange,
543
- Input,
544
- appLocale,
545
- Banner,
546
- Text,
547
- JsonView,
548
- t,
549
- row,
550
- }: {
551
- readonly field: EditFieldViewModel;
552
- readonly id: string;
553
- readonly hasError: boolean;
554
- readonly onChange: (value: unknown) => void;
555
- readonly Input: ReturnType<typeof usePrimitives>["Input"];
556
- readonly appLocale: string;
557
- readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
558
- readonly Text: ReturnType<typeof usePrimitives>["Text"];
559
- readonly JsonView: ReturnType<typeof usePrimitives>["JsonView"];
560
- readonly t: ReturnType<typeof useTranslation>;
561
- readonly row?: Readonly<Record<string, unknown>>;
562
- }): ReactNode {
563
- const common = {
564
- id,
565
- name: field.field,
566
- disabled: field.readOnly,
567
- required: field.required,
568
- hasError,
569
- } as const;
570
-
571
- switch (field.type) {
572
- // decimal/bigInt are both plain numbers on the wire (fieldToZod:
573
- // z.number() / z.number().int().safe()) — the number input's onChange
574
- // already emits `number | undefined`, so no extra coercion is needed
575
- // beyond what "number" already does (#1925).
576
- case "number":
577
- case "bigInt": {
578
- const icon = resolveFieldIcon(field);
579
- const unit = resolveNumberUnit(field.unit, row);
580
- return (
581
- <Input
582
- kind="number"
583
- {...common}
584
- value={numberValue(field.value)}
585
- onChange={(v) => onChange(v)}
586
- {...(icon !== undefined && { icon })}
587
- {...(unit !== undefined && { unit })}
588
- />
589
- );
590
- }
591
- case "decimal": {
592
- // step="any" disables the native stepMismatch constraint — without it
593
- // <input type="number"> defaults to step=1 and blocks form submit on
594
- // any fractional value via silent browser-native validation.
595
- const icon = resolveFieldIcon(field);
596
- return (
597
- <Input
598
- kind="number"
599
- {...common}
600
- value={numberValue(field.value)}
601
- onChange={(v) => onChange(v)}
602
- step="any"
603
- {...(icon !== undefined && { icon })}
604
- />
605
- );
606
- }
607
- case "tz":
608
- return (
609
- <Input
610
- kind="tz"
611
- {...common}
612
- value={stringValue(field.value)}
613
- onChange={(v) => onChange(v)}
614
- />
615
- );
616
- case "multiSelect": {
617
- const rawOptions = field.options ?? [];
618
- const labels = field.optionLabels;
619
- const multiSelectOptions =
620
- labels !== undefined
621
- ? rawOptions.map((value: string) => ({ value, label: labels[value] ?? value }))
622
- : rawOptions.map((value: string) => ({ value, label: value }));
623
- const arrayValue = Array.isArray(field.value) ? (field.value as readonly string[]) : [];
624
- if (field.display === "checkboxes") {
625
- return (
626
- <MultiSelectCheckboxes
627
- field={field}
628
- id={id}
629
- options={multiSelectOptions}
630
- value={arrayValue}
631
- onChange={(v) => onChange(v)}
632
- />
633
- );
634
- }
635
- return (
636
- <Input
637
- kind="combobox"
638
- {...common}
639
- multiple
640
- value={arrayValue}
641
- onChange={(v) => onChange(v)}
642
- options={multiSelectOptions}
643
- />
644
- );
645
- }
646
- case "money": {
647
- const currency = resolveMoneyCurrency(field.value, field.currency);
648
- return (
649
- <Input
650
- kind="money"
651
- {...common}
652
- value={moneyMinorValue(field.value, currency)}
653
- onChange={(v) => onChange(moneyPayload(v, currency))}
654
- currency={currency}
655
- locale={appLocale}
656
- />
657
- );
658
- }
659
- case "boolean":
660
- return (
661
- <Input
662
- kind="boolean"
663
- {...common}
664
- value={field.value === true}
665
- onChange={(v) => onChange(v)}
666
- />
667
- );
668
- case "date":
669
- return (
670
- <Input
671
- kind="date"
672
- {...common}
673
- value={stringValue(field.value)}
674
- onChange={(v) => onChange(v)}
675
- locale={field.dateLocale ?? appLocale}
676
- {...(field.min !== undefined && { min: field.min })}
677
- {...(field.max !== undefined && { max: field.max })}
678
- />
679
- );
680
- case "timestamp":
681
- return (
682
- <Input
683
- kind="timestamp"
684
- {...common}
685
- value={stringValue(field.value)}
686
- onChange={(v) => onChange(v)}
687
- locale={field.dateLocale ?? appLocale}
688
- {...(field.wallClock !== undefined && { wallClock: field.wallClock })}
689
- {...(field.min !== undefined && { min: field.min })}
690
- {...(field.max !== undefined && { max: field.max })}
691
- />
692
- );
693
- case "locatedTimestamp":
694
- return (
695
- <Input
696
- kind="locatedTimestamp"
697
- {...common}
698
- value={locatedValue(field.value)}
699
- onChange={(v) => onChange(v)}
700
- locale={field.dateLocale ?? appLocale}
701
- {...(field.min !== undefined && { min: field.min })}
702
- {...(field.max !== undefined && { max: field.max })}
703
- />
704
- );
705
- case "select": {
706
- // Translated Option-Labels kommen aus dem ViewModel-Builder
707
- // (computeEditViewModel, Convention-Key
708
- // `<feature>:entity:<entity>:field:<field>:option:<value>`).
709
- // Wenn keine Translations registriert sind, fallback auf raw
710
- // value als Label — der ComboboxInput zeigt dann unverändert.
711
- const rawOptions = field.options ?? [];
712
- const labels = field.optionLabels;
713
- const selectOptions =
714
- labels !== undefined
715
- ? rawOptions.map((value: string) => ({ value, label: labels[value] ?? value }))
716
- : rawOptions;
717
- // `display` is shared with multiSelect's "checkboxes", which is not a
718
- // select presentation — narrow to the two a select field can request.
719
- const display =
720
- field.display === "radio" || field.display === "dropdown" ? field.display : undefined;
721
- return (
722
- <Input
723
- kind="select"
724
- {...common}
725
- value={stringValue(field.value)}
726
- onChange={(v) => onChange(v)}
727
- options={selectOptions}
728
- {...(display !== undefined && { display })}
729
- />
730
- );
731
- }
732
- case "file":
733
- case "image": {
734
- const kind = field.type === "image" ? ("image" as const) : ("file" as const);
735
- const fileId = typeof field.value === "string" && field.value !== "" ? field.value : null;
736
- return (
737
- <Input
738
- kind={kind}
739
- {...common}
740
- value={fileId}
741
- onChange={(v) => onChange(v)}
742
- {...(field.accept !== undefined && { accept: field.accept })}
743
- {...(field.maxSize !== undefined && { maxSize: field.maxSize })}
744
- {...(field.entityType !== undefined && { entityType: field.entityType })}
745
- {...(field.fieldName !== undefined && { fieldName: field.fieldName })}
746
- {...(field.imageVariant !== undefined && { imageVariant: field.imageVariant })}
747
- {...(field.capture !== undefined && { capture: field.capture })}
748
- />
749
- );
750
- }
751
- // embedded (without embeddedListCells — that's embeddedList, which has
752
- // had its own EmbeddedListField widget since #1838) and jsonb carry
753
- // arbitrary objects; files/images carry a FileRef-UUID array and have
754
- // no multi-upload widget yet (deliberately deferred, #1925). Without a
755
- // dedicated widget these must NOT fall through to a text input:
756
- // stringValue() turns them into "[object Object]" / a comma-joined
757
- // string, and saving that overwrites the real data with the mangled
758
- // string (#1834). A `required: true` on any of these is caught loudly
759
- // at boot (validateNoWidgetRequiredField in the framework package)
760
- // instead of silently failing here.
761
- case "embedded":
762
- case "jsonb":
763
- case "files":
764
- case "images": {
765
- const hasValue = field.value !== undefined && field.value !== null && field.value !== "";
766
- return (
767
- <Banner id={id} variant="info">
768
- {t("kumiko.field.unsupported")}
769
- {hasValue &&
770
- (JsonView !== undefined ? (
771
- <JsonView value={field.value} testId={`field-value-${field.field}`} />
772
- ) : (
773
- <Text variant="code">{JSON.stringify(field.value)}</Text>
774
- ))}
775
- </Banner>
776
- );
777
- }
778
- default: {
779
- if (field.type === "text" && field.format === "password") {
780
- return (
781
- <Input
782
- kind="password"
783
- {...common}
784
- value={stringValue(field.value)}
785
- onChange={(v) => onChange(v)}
786
- />
787
- );
788
- }
789
- // text + unknown scalar type → text input. If TextFieldDef.multiline
790
- // is set (the view-model carries it), the renderer switches to
791
- // textarea. longText always renders a textarea — that's the point of
792
- // the type — regardless of whether `multiline` is set; `multiline`
793
- // only supplies an optional `{ rows }` override for it (#1925).
794
- if (field.type === "longText" || (field.type === "text" && field.multiline)) {
795
- const rows = typeof field.multiline === "object" ? field.multiline.rows : undefined;
796
- return (
797
- <Input
798
- kind="textarea"
799
- {...common}
800
- value={stringValue(field.value)}
801
- onChange={(v) => onChange(v)}
802
- {...(rows !== undefined && { rows })}
803
- />
804
- );
805
- }
806
- const icon = resolveFieldIcon(field);
807
- return (
808
- <Input
809
- kind="text"
810
- {...common}
811
- value={stringValue(field.value)}
812
- onChange={(v) => onChange(v)}
813
- {...(icon !== undefined && { icon })}
814
- />
815
- );
816
- }
817
- }
818
- }
819
-
820
- function stringValue(v: unknown): string {
821
- if (v === undefined || v === null) return "";
822
- return typeof v === "string" ? v : String(v);
823
- }
824
-
825
- function numberValue(v: unknown): number | "" {
826
- if (v === undefined || v === null || v === "") return "";
827
- return typeof v === "number" ? v : Number(v);
828
- }
829
-
830
- // Read/initial value → MoneyInput's minor-units contract. The server's
831
- // read shape is `{amount, currency, amountMinor}` in MAJOR units — deliberately
832
- // NOT reading `amountMinor` here: the server derives it via a flat
833
- // MINOR_UNIT_SCALE=100 (money.ts), which disagrees with currencyDecimals for
834
- // zero-decimal currencies like JPY, so it would double-scale JPY amounts.
835
- // Deriving from `amount * 10**currencyDecimals(currency)` keeps this the
836
- // exact inverse of moneyPayload below. A bare number (e.g. ConfigEditBody's
837
- // stored-config coercion) is MAJOR units too — every producer in this repo
838
- // hands rehydrateMoney's `{amount,…}` shape or a raw major-unit number, never
839
- // pre-scaled minor units.
840
- function resolveMoneyCurrency(value: unknown, fieldCurrency: string | undefined): string {
841
- if (
842
- typeof value === "object" &&
843
- value !== null &&
844
- typeof (value as { currency?: unknown }).currency === "string"
845
- ) {
846
- return (value as { currency: string }).currency;
847
- }
848
- return fieldCurrency ?? "EUR";
849
- }
850
-
851
- // Static unit → used as-is. Sibling-field reference → read the live row
852
- // value; missing/empty/non-string sibling means no suffix, never a guess.
853
- function resolveNumberUnit(
854
- unit: string | { readonly field: string } | undefined,
855
- row: Readonly<Record<string, unknown>> | undefined,
856
- ): string | undefined {
857
- if (unit === undefined) return undefined;
858
- if (typeof unit === "string") return unit;
859
- const sibling = row?.[unit.field];
860
- return typeof sibling === "string" && sibling.length > 0 ? sibling : undefined;
861
- }
862
-
863
- function moneyMinorValue(v: unknown, currency: string): number | "" {
864
- if (v === undefined || v === null || v === "") return "";
865
- if (typeof v === "number") return Math.round(v * 10 ** currencyDecimals(currency));
866
- if (typeof v === "object") {
867
- const amount = (v as { amount?: unknown }).amount;
868
- if (typeof amount === "number") return Math.round(amount * 10 ** currencyDecimals(currency));
869
- }
870
- return "";
871
- }
872
-
873
- // MoneyInput's minor-units onChange → the server payload shape
874
- // (`z.object({amount, currency})`, schema-builder.ts) — MAJOR units, no
875
- // `amountMinor` (the server derives that itself on write).
876
- function moneyPayload(minorUnits: number | undefined, currency: string): unknown {
877
- if (minorUnits === undefined) return undefined;
878
- return { amount: minorUnits / 10 ** currencyDecimals(currency), currency };
879
- }
880
-
881
- // locatedTimestamp-Feldwert: das Read-Wrapper liefert `{ at, tz, utc }`; leer
882
- // (noch nicht gesetzt) → "" als Empty-Sentinel, analog money/timestamp.
883
- function locatedValue(v: unknown): { at: string; tz: string; utc?: string } | "" {
884
- if (v !== null && typeof v === "object" && "at" in v && "tz" in v) {
885
- const o = v as { at?: unknown; tz?: unknown; utc?: unknown };
886
- return {
887
- at: typeof o.at === "string" ? o.at : "",
888
- tz: typeof o.tz === "string" ? o.tz : "",
889
- ...(typeof o.utc === "string" && { utc: o.utc }),
890
- };
891
- }
892
- return "";
893
- }
894
-
895
- // Read-only text for a `field.readOnly` field without its own `renderer` —
896
- // per-type formatting that reuses the same value-shaping helpers as the
897
- // editable widgets above, so a text value and its would-be Input widget stay
898
- // derived from the identical parse (fw#2245). `type: "reference"` isn't
899
- // covered here — it needs a live label lookup, see ReadOnlyReferenceValue.
900
- // `isComplexFieldType` types aren't covered either — callers keep those on
901
- // `renderInput`'s existing Banner fallback.
902
- function readOnlyDisplayText(field: EditFieldViewModel, appLocale: string): string {
903
- const { type, value } = field;
904
- if (value === undefined || value === null || value === "") return "—";
905
- if (type === "text" && field.format === "password") return "••••••••";
906
- switch (type) {
907
- case "boolean":
908
- return applyFormatSpec({ format: "boolean" }, value);
909
- case "date":
910
- return applyFormatSpec({ format: "date", locale: field.dateLocale ?? appLocale }, value);
911
- case "timestamp":
912
- return applyFormatSpec({ format: "timestamp", locale: field.dateLocale ?? appLocale }, value);
913
- case "locatedTimestamp": {
914
- const located = locatedValue(value);
915
- if (located === "" || located.at === "") return "—";
916
- return applyFormatSpec(
917
- { format: "timestamp", locale: field.dateLocale ?? appLocale },
918
- located.utc ?? located.at,
919
- );
920
- }
921
- case "number":
922
- case "bigInt":
923
- case "decimal": {
924
- const n = numberValue(value);
925
- return n === ""
926
- ? "—"
927
- : new Intl.NumberFormat(appLocale, { useGrouping: field.grouping ?? true }).format(n);
928
- }
929
- case "money": {
930
- const currency = resolveMoneyCurrency(value, field.currency);
931
- const minor = moneyMinorValue(value, currency);
932
- if (minor === "") return "—";
933
- const major = minor / 10 ** currencyDecimals(currency);
934
- return new Intl.NumberFormat(appLocale, { style: "currency", currency }).format(major);
935
- }
936
- case "select":
937
- case "multiSelect": {
938
- const labels = field.optionLabels;
939
- const values = Array.isArray(value) ? value : [value];
940
- if (values.length === 0) return "—";
941
- return values
942
- .map((v) => (typeof v === "string" ? (labels?.[v] ?? v) : stringValue(v)))
943
- .join(", ");
944
- }
945
- case "file":
946
- case "image":
947
- return typeof value === "string" ? value : "—";
948
- default:
949
- return stringValue(value);
950
- }
951
- }