@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.
- package/package.json +4 -4
- package/src/app/__tests__/projection-detail-shim.test.ts +43 -0
- package/src/app/kumiko-screen.tsx +45 -179
- package/src/app/row-actions.ts +201 -0
- package/src/components/__tests__/related-list-section.test.tsx +163 -0
- package/src/components/__tests__/render-edit-logic.test.ts +14 -0
- package/src/components/__tests__/write-form-section.test.tsx +205 -0
- package/src/components/grid-cell-for-field.tsx +60 -0
- package/src/components/related-list-section.tsx +36 -1
- package/src/components/render-edit-logic.ts +9 -4
- package/src/components/render-edit.tsx +24 -65
- package/src/components/write-form-section.tsx +155 -0
|
@@ -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
|
+
}
|