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