@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.
- package/package.json +4 -4
- package/src/app/__tests__/projection-detail-shim.test.ts +43 -0
- package/src/app/__tests__/projection-detail-singleton.test.tsx +191 -0
- package/src/app/kumiko-screen.tsx +66 -185
- 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__/render-field-json-format.test.tsx +164 -0
- package/src/components/__tests__/write-form-section.test.tsx +225 -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/render-field.tsx +35 -4
- package/src/components/write-form-section.tsx +165 -0
- package/src/index.ts +1 -0
- package/src/primitives.tsx +15 -0
|
@@ -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
package/src/primitives.tsx
CHANGED
|
@@ -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
|