@cosmicdrift/kumiko-renderer 0.238.0 → 0.240.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,155 @@
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
+ <Button
133
+ type="button"
134
+ variant="primary"
135
+ disabled={isSubmitting}
136
+ loading={isSubmitting}
137
+ onClick={() => void handleSubmit()}
138
+ testId="write-form-section-submit"
139
+ >
140
+ {section.submitLabel ?? effectiveTranslate("kumiko.actions.save")}
141
+ </Button>
142
+ </>
143
+ );
144
+
145
+ if (hideTitle || section.title === undefined) return content;
146
+ return (
147
+ <Section
148
+ title={section.title}
149
+ {...(section.icon !== undefined && { icon: section.icon })}
150
+ testId={`write-form-${section.title}`}
151
+ >
152
+ {content}
153
+ </Section>
154
+ );
155
+ }