@cosmicdrift/kumiko-bundled-features 0.43.0 → 0.45.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
createStaticLocaleResolver,
|
|
4
|
+
ExtensionFormRegistryProvider,
|
|
4
5
|
LocaleProvider,
|
|
5
6
|
PrimitivesProvider,
|
|
6
7
|
} from "@cosmicdrift/kumiko-renderer";
|
|
@@ -382,3 +383,30 @@ describe("CustomFieldsFormSection — boolean/date-Pfade", () => {
|
|
|
382
383
|
expect(trigger.textContent).not.toContain("—");
|
|
383
384
|
});
|
|
384
385
|
});
|
|
386
|
+
|
|
387
|
+
describe("CustomFieldsFormSection — composed mode (Bug-Bash 3 #1)", () => {
|
|
388
|
+
test("Registry vorhanden → kein eigener Save-Button (Section schreibt am Haupt-Form mit)", () => {
|
|
389
|
+
mockedQueryRows = [
|
|
390
|
+
{
|
|
391
|
+
id: "f1",
|
|
392
|
+
entityName: "component",
|
|
393
|
+
fieldKey: "vendor",
|
|
394
|
+
type: "text",
|
|
395
|
+
required: false,
|
|
396
|
+
displayOrder: 1,
|
|
397
|
+
},
|
|
398
|
+
];
|
|
399
|
+
const stubRegistry = { upsert: () => {}, remove: () => {} };
|
|
400
|
+
render(
|
|
401
|
+
<Wrapper>
|
|
402
|
+
<ExtensionFormRegistryProvider value={stubRegistry}>
|
|
403
|
+
<CustomFieldsFormSection entityName="component" entityId="row-42" />
|
|
404
|
+
</ExtensionFormRegistryProvider>
|
|
405
|
+
</Wrapper>,
|
|
406
|
+
);
|
|
407
|
+
// Inputs sind da …
|
|
408
|
+
expect(screen.getByTestId("custom-fields-form-section")).toBeTruthy();
|
|
409
|
+
// … aber KEIN standalone-Save-Button (composed: ein Save im Haupt-Form).
|
|
410
|
+
expect(screen.queryByTestId("custom-fields-form-save")).toBeNull();
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
// referenziert.
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
+
type ExtensionSubmitResult,
|
|
14
15
|
useDispatcher,
|
|
16
|
+
useExtensionFormSubmit,
|
|
15
17
|
usePrimitives,
|
|
16
18
|
useQuery,
|
|
17
19
|
useTranslation,
|
|
@@ -57,6 +59,61 @@ export function CustomFieldsFormSection({
|
|
|
57
59
|
const [saving, setSaving] = useState(false);
|
|
58
60
|
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
59
61
|
|
|
62
|
+
// Vor den early-returns berechnet, weil useExtensionFormSubmit (Hook) davor
|
|
63
|
+
// laufen muss. matchingFields ist während des Loadings []; dirty bleibt dann
|
|
64
|
+
// false. Dirty = weicht vom GESPEICHERTEN Wert ab (nicht von ""), sonst ist
|
|
65
|
+
// das Leeren eines Bestandswerts unsichtbar und würde übersprungen statt
|
|
66
|
+
// gecleart.
|
|
67
|
+
const matchingFields = (query.data?.rows ?? [])
|
|
68
|
+
.filter((f) => f.entityName === entityName)
|
|
69
|
+
.slice()
|
|
70
|
+
.sort((a, b) => a.displayOrder - b.displayOrder);
|
|
71
|
+
const initialDisplay = (field: (typeof matchingFields)[number]): string =>
|
|
72
|
+
displayValue(field.type, initialValues?.[field.fieldKey]);
|
|
73
|
+
const changedFields = matchingFields.filter((field) => {
|
|
74
|
+
const raw = pending[field.fieldKey];
|
|
75
|
+
return raw !== undefined && raw !== initialDisplay(field);
|
|
76
|
+
});
|
|
77
|
+
const dirty = changedFields.length > 0;
|
|
78
|
+
|
|
79
|
+
// Schreibt alle geänderten Felder via set/clear. Genutzt vom composed-Submit
|
|
80
|
+
// (Haupt-Form ruft den Handler nach dem Entity-Write) UND vom standalone-
|
|
81
|
+
// Button (wenn die Section ohne umgebende composed-Form gemountet ist).
|
|
82
|
+
const flushChanges = async (targetEntityId: string): Promise<ExtensionSubmitResult> => {
|
|
83
|
+
for (const field of changedFields) {
|
|
84
|
+
const raw = pending[field.fieldKey] ?? "";
|
|
85
|
+
const result =
|
|
86
|
+
raw === ""
|
|
87
|
+
? await dispatcher.write(CustomFieldsHandlers.clearCustomField, {
|
|
88
|
+
entityName,
|
|
89
|
+
entityId: targetEntityId,
|
|
90
|
+
fieldKey: field.fieldKey,
|
|
91
|
+
})
|
|
92
|
+
: await dispatcher.write(CustomFieldsHandlers.setCustomField, {
|
|
93
|
+
entityName,
|
|
94
|
+
entityId: targetEntityId,
|
|
95
|
+
fieldKey: field.fieldKey,
|
|
96
|
+
value: coerceValue(field.type, raw),
|
|
97
|
+
});
|
|
98
|
+
if (!result.isSuccess) {
|
|
99
|
+
return {
|
|
100
|
+
isSuccess: false,
|
|
101
|
+
errorKey: result.error?.i18nKey ?? "custom-fields.errors.saveFailed",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
setPending({});
|
|
106
|
+
return { isSuccess: true };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// composed = innerhalb eines entityEdit-Forms → kein eigener Save-Button,
|
|
110
|
+
// die Section schreibt beim Haupt-Save mit (Bug-Bash 3 #1). Außerhalb einer
|
|
111
|
+
// composed-Form (composed === false) bleibt der standalone-Button.
|
|
112
|
+
const composed = useExtensionFormSubmit({
|
|
113
|
+
dirty,
|
|
114
|
+
onSubmit: (ctx) => flushChanges(ctx.entityId),
|
|
115
|
+
});
|
|
116
|
+
|
|
60
117
|
if (entityId === null) {
|
|
61
118
|
return (
|
|
62
119
|
<Banner variant="info" testId="custom-fields-form-create-mode">
|
|
@@ -78,12 +135,6 @@ export function CustomFieldsFormSection({
|
|
|
78
135
|
</Banner>
|
|
79
136
|
);
|
|
80
137
|
}
|
|
81
|
-
|
|
82
|
-
const matchingFields = (query.data?.rows ?? [])
|
|
83
|
-
.filter((f) => f.entityName === entityName)
|
|
84
|
-
.slice()
|
|
85
|
-
.sort((a, b) => a.displayOrder - b.displayOrder);
|
|
86
|
-
|
|
87
138
|
if (matchingFields.length === 0) {
|
|
88
139
|
return (
|
|
89
140
|
<Banner variant="info" testId="custom-fields-form-empty">
|
|
@@ -92,48 +143,19 @@ export function CustomFieldsFormSection({
|
|
|
92
143
|
);
|
|
93
144
|
}
|
|
94
145
|
|
|
95
|
-
|
|
96
|
-
// das Leeren eines gespeicherten Werts unsichtbar (Button disabled) und
|
|
97
|
-
// handleSave würde es überspringen statt zu clearen.
|
|
98
|
-
const initialDisplay = (field: (typeof matchingFields)[number]): string =>
|
|
99
|
-
displayValue(field.type, initialValues?.[field.fieldKey]);
|
|
100
|
-
const changedFields = matchingFields.filter((field) => {
|
|
101
|
-
const raw = pending[field.fieldKey];
|
|
102
|
-
return raw !== undefined && raw !== initialDisplay(field);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
const handleSave = async (): Promise<void> => {
|
|
146
|
+
const handleStandaloneSave = async (): Promise<void> => {
|
|
106
147
|
setSaving(true);
|
|
107
148
|
setErrorKey(null);
|
|
108
149
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
raw === ""
|
|
113
|
-
? await dispatcher.write(CustomFieldsHandlers.clearCustomField, {
|
|
114
|
-
entityName,
|
|
115
|
-
entityId,
|
|
116
|
-
fieldKey: field.fieldKey,
|
|
117
|
-
})
|
|
118
|
-
: await dispatcher.write(CustomFieldsHandlers.setCustomField, {
|
|
119
|
-
entityName,
|
|
120
|
-
entityId,
|
|
121
|
-
fieldKey: field.fieldKey,
|
|
122
|
-
value: coerceValue(field.type, raw),
|
|
123
|
-
});
|
|
124
|
-
if (!result.isSuccess) {
|
|
125
|
-
setErrorKey(result.error?.i18nKey ?? "custom-fields.errors.saveFailed");
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
150
|
+
const result = await flushChanges(entityId);
|
|
151
|
+
if (!result.isSuccess) {
|
|
152
|
+
setErrorKey(result.errorKey ?? "custom-fields.errors.saveFailed");
|
|
128
153
|
}
|
|
129
|
-
setPending({});
|
|
130
154
|
} finally {
|
|
131
155
|
setSaving(false);
|
|
132
156
|
}
|
|
133
157
|
};
|
|
134
158
|
|
|
135
|
-
const dirty = changedFields.length > 0;
|
|
136
|
-
|
|
137
159
|
return (
|
|
138
160
|
<div data-testid="custom-fields-form-section">
|
|
139
161
|
{matchingFields.map((field) => (
|
|
@@ -150,15 +172,17 @@ export function CustomFieldsFormSection({
|
|
|
150
172
|
)}
|
|
151
173
|
</Field>
|
|
152
174
|
))}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
175
|
+
{!composed && (
|
|
176
|
+
<Button
|
|
177
|
+
variant="primary"
|
|
178
|
+
onClick={() => void handleStandaloneSave()}
|
|
179
|
+
disabled={saving || !dirty}
|
|
180
|
+
testId="custom-fields-form-save"
|
|
181
|
+
>
|
|
182
|
+
{saving ? t("custom-fields.form.saving") : t("custom-fields.form.save")}
|
|
183
|
+
</Button>
|
|
184
|
+
)}
|
|
185
|
+
{!composed && errorKey !== null && (
|
|
162
186
|
<Banner variant="error" testId="custom-fields-form-save-error">
|
|
163
187
|
<Text>{t(errorKey)}</Text>
|
|
164
188
|
</Banner>
|
|
@@ -83,7 +83,10 @@ function ChangePasswordSection(): ReactNode {
|
|
|
83
83
|
|
|
84
84
|
const submitting = status.kind === "submitting";
|
|
85
85
|
return (
|
|
86
|
-
<section
|
|
86
|
+
<section
|
|
87
|
+
data-testid="profile-password"
|
|
88
|
+
className="flex flex-col gap-4 rounded-lg border bg-card p-6"
|
|
89
|
+
>
|
|
87
90
|
<Heading variant="section">{t("profile.password.title")}</Heading>
|
|
88
91
|
<Form onSubmit={onSubmit} testId="profile-password-form">
|
|
89
92
|
<Field id="profile-old-password" label={t("profile.password.old")} required>
|
|
@@ -170,7 +173,10 @@ function ChangeEmailSection({
|
|
|
170
173
|
|
|
171
174
|
const submitting = status.kind === "submitting";
|
|
172
175
|
return (
|
|
173
|
-
<section
|
|
176
|
+
<section
|
|
177
|
+
data-testid="profile-email"
|
|
178
|
+
className="flex flex-col gap-4 rounded-lg border bg-card p-6"
|
|
179
|
+
>
|
|
174
180
|
<Heading variant="section">{t("profile.email.title")}</Heading>
|
|
175
181
|
<p className="text-sm text-muted-foreground" data-testid="profile-email-current">
|
|
176
182
|
{t("profile.email.current")}: {me.email}
|
|
@@ -245,7 +251,10 @@ function DangerZoneSection({
|
|
|
245
251
|
};
|
|
246
252
|
|
|
247
253
|
return (
|
|
248
|
-
<section
|
|
254
|
+
<section
|
|
255
|
+
data-testid="profile-danger"
|
|
256
|
+
className="flex flex-col gap-4 rounded-lg border border-destructive/40 bg-card p-6"
|
|
257
|
+
>
|
|
249
258
|
<Heading variant="section">{t("profile.danger.title")}</Heading>
|
|
250
259
|
{deletionRequested ? (
|
|
251
260
|
<>
|
|
@@ -316,7 +325,7 @@ export function ProfileScreen(): ReactNode {
|
|
|
316
325
|
};
|
|
317
326
|
|
|
318
327
|
return (
|
|
319
|
-
<div className="p-6 flex flex-col gap-
|
|
328
|
+
<div className="p-6 flex flex-col gap-6 max-w-2xl" data-testid="profile-screen">
|
|
320
329
|
<Heading variant="page">{t("profile.title")}</Heading>
|
|
321
330
|
<ChangeEmailSection me={me} onChanged={refetch} />
|
|
322
331
|
<ChangePasswordSection />
|