@cosmicdrift/kumiko-renderer 0.239.0 → 0.241.0

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.
@@ -0,0 +1,165 @@
1
+ import type {
2
+ EditFieldViewModel,
3
+ EditWriteFormSectionViewModel,
4
+ Translate,
5
+ } from "@cosmicdrift/kumiko-headless";
6
+ import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
7
+ import { type ReactNode, useMemo, useState } from "react";
8
+ import { z } from "zod";
9
+ import { REQUIRED_FIELD_I18N_KEY } from "../app/form-schema";
10
+ import { dispatcherErrorText } from "../app/write-failed-error";
11
+ import { useForm } from "../hooks/use-form";
12
+ import { useTranslation } from "../i18n";
13
+ import { usePrimitives } from "../primitives";
14
+ import { GridCellForField } from "./grid-cell-for-field";
15
+
16
+ // Same "has a value" rule as buildFormSchema's isPresent (app/form-schema.ts)
17
+ // — duplicated because that helper walks raw EditFieldSpec + EntityDefinition,
18
+ // unavailable here: this section only ever sees computeEditViewModel's
19
+ // already-resolved EditFieldViewModel[] (required/readOnly/visible are plain
20
+ // booleans by the time render-edit.tsx hands the section to this component).
21
+ function isPresent(value: unknown): boolean {
22
+ if (value === undefined || value === null || value === "") return false;
23
+ if (Array.isArray(value) && value.length === 0) return false;
24
+ return true;
25
+ }
26
+
27
+ function buildWriteFormSchema(fields: readonly EditFieldViewModel[]): z.ZodType {
28
+ return z
29
+ .object({})
30
+ .passthrough()
31
+ .superRefine((values, ctx) => {
32
+ const record = values as Record<string, unknown>;
33
+ for (const field of fields) {
34
+ if (field.readOnly || !field.visible || !field.required) continue;
35
+ if (isPresent(record[field.field])) continue;
36
+ ctx.addIssue({
37
+ code: "custom",
38
+ path: [field.field],
39
+ message: `"${field.field}" is required.`,
40
+ params: { [I18N_KEY_PARAM]: REQUIRED_FIELD_I18N_KEY },
41
+ });
42
+ }
43
+ });
44
+ }
45
+
46
+ export type WriteFormSectionProps = {
47
+ readonly section: EditWriteFormSectionViewModel;
48
+ readonly featureName: string;
49
+ readonly translate?: Translate;
50
+ readonly hideTitle?: boolean;
51
+ /** Fired after a successful submit — projectionDetail reloads its own
52
+ * record (a new one now exists) via a full RenderEdit remount, see
53
+ * ProjectionDetailBody's reloadNonce/key in kumiko-screen.tsx. */
54
+ readonly onSubmitted: () => void;
55
+ };
56
+
57
+ // A self-persisting form section for projectionDetail (see
58
+ // EditWriteFormSection's doc). Deliberately NOT a nested <RenderEdit> — that
59
+ // would render a second, invalid nested <form> inside the host's own form
60
+ // element — so this mounts its own independent useForm() controller and
61
+ // submits via a plain button click instead of native form submission.
62
+ export function WriteFormSection({
63
+ section,
64
+ featureName,
65
+ translate,
66
+ hideTitle,
67
+ onSubmitted,
68
+ }: WriteFormSectionProps): ReactNode {
69
+ const { Section, Grid, GridCell, Button, Banner } = usePrimitives();
70
+ const t = useTranslation();
71
+ const effectiveTranslate = translate ?? t;
72
+
73
+ const initial = useMemo(
74
+ () => Object.fromEntries(section.fields.map((f: EditFieldViewModel) => [f.field, f.value])),
75
+ [section.fields],
76
+ );
77
+ const schema = useMemo(() => buildWriteFormSchema(section.fields), [section.fields]);
78
+ const { controller, snapshot } = useForm({
79
+ initial,
80
+ submit: { type: section.handler, payloadMode: "values" },
81
+ schema,
82
+ });
83
+ const [isSubmitting, setIsSubmitting] = useState(false);
84
+ const [error, setError] = useState<string | null>(null);
85
+
86
+ async function handleSubmit(): Promise<void> {
87
+ if (isSubmitting) return;
88
+ setIsSubmitting(true);
89
+ try {
90
+ const result = await controller.submit();
91
+ if (result.isSuccess) {
92
+ setError(null);
93
+ onSubmitted();
94
+ return;
95
+ }
96
+ if (result.validationBlocked) return;
97
+ // Field-level issues already surface inline via snapshot.errors (same
98
+ // GridCellForField/RenderField path RenderEdit uses) — the banner is
99
+ // only for form-level errors nothing else would show (fw#1901 pattern).
100
+ const fieldIssues = result.error.details?.fields ?? [];
101
+ setError(
102
+ fieldIssues.length === 0 ? dispatcherErrorText(result.error, effectiveTranslate) : null,
103
+ );
104
+ } finally {
105
+ setIsSubmitting(false);
106
+ }
107
+ }
108
+
109
+ const content = (
110
+ <>
111
+ <Grid columns={section.columns}>
112
+ {section.fields.map((field: EditFieldViewModel) => (
113
+ <GridCellForField
114
+ key={field.field}
115
+ field={field}
116
+ columns={section.columns}
117
+ issues={snapshot.errors[field.field]}
118
+ onChange={(v) => controller.setField(field.field, v)}
119
+ GridCell={GridCell}
120
+ featureName={featureName}
121
+ allIssues={snapshot.errors}
122
+ valueDisplay="form"
123
+ row={snapshot.values}
124
+ />
125
+ ))}
126
+ </Grid>
127
+ {error !== null && (
128
+ <Banner variant="error" testId="write-form-section-error">
129
+ {error}
130
+ </Banner>
131
+ )}
132
+ </>
133
+ );
134
+
135
+ // type="button" (not "submit") is load-bearing: this section is deliberately
136
+ // NOT a nested <form> (see the component doc above), so a "submit" type
137
+ // would instead trigger the host RenderEdit's own form submit.
138
+ const submitButton = (
139
+ <Button
140
+ type="button"
141
+ variant="primary"
142
+ icon="check"
143
+ disabled={isSubmitting}
144
+ loading={isSubmitting}
145
+ onClick={() => void handleSubmit()}
146
+ testId="write-form-section-submit"
147
+ >
148
+ {section.submitLabel ?? effectiveTranslate("kumiko.actions.save")}
149
+ </Button>
150
+ );
151
+
152
+ // Routed through Section's `actions` slot (same mechanism render-edit.tsx
153
+ // uses via Form's `actions`) so the button gets the established right-
154
+ // aligned footer treatment instead of stretching full-width inline.
155
+ return (
156
+ <Section
157
+ {...(!hideTitle && section.title !== undefined && { title: section.title })}
158
+ {...(section.icon !== undefined && { icon: section.icon })}
159
+ actions={submitButton}
160
+ testId={`write-form-${section.title ?? "section"}`}
161
+ >
162
+ {content}
163
+ </Section>
164
+ );
165
+ }
package/src/index.ts CHANGED
@@ -190,6 +190,7 @@ export type {
190
190
  GridProps,
191
191
  HeadingProps,
192
192
  InputProps,
193
+ JsonViewProps,
193
194
  LightboxProps,
194
195
  LinkProps,
195
196
  MetricProps,
@@ -1059,6 +1059,17 @@ export type MetricProps = {
1059
1059
  readonly testId?: string;
1060
1060
  };
1061
1061
 
1062
+ /** Structured JSON display (audit payload/metadata, job logs, unsupported
1063
+ * jsonb/embedded field fallback). Takes the raw value, not a pre-stringified
1064
+ * one — the implementation owns `JSON.stringify` (and must not throw on
1065
+ * circular refs / BigInt / other non-serializable input). `indent` mirrors
1066
+ * `JSON.stringify`'s space-count param, default 2. */
1067
+ export type JsonViewProps = {
1068
+ readonly value: unknown;
1069
+ readonly indent?: number;
1070
+ readonly testId?: string;
1071
+ };
1072
+
1062
1073
  // ---- Core-Registry (Kumiko-eigene Primitives) ----
1063
1074
 
1064
1075
  export type CorePrimitives = {
@@ -1112,6 +1123,10 @@ export type CorePrimitives = {
1112
1123
  * CorePrimitives mocks in tests keep compiling — additive rollout of
1113
1124
  * a new primitive shouldn't force every test double to grow a stub. */
1114
1125
  readonly Metric?: ComponentType<MetricProps>;
1126
+ /** Optional (unlike the other Core-Primitives) so existing partial
1127
+ * CorePrimitives mocks in tests keep compiling — additive rollout of
1128
+ * a new primitive shouldn't force every test double to grow a stub. */
1129
+ readonly JsonView?: ComponentType<JsonViewProps>;
1115
1130
  };
1116
1131
 
1117
1132
  /** Offene Extension-Zone für App-eigene Primitives. Devs erweitern