@cosmicdrift/kumiko-bundled-features 0.44.0 → 0.45.1

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.44.0",
3
+ "version": "0.45.1",
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
- // Dirty heißt: weicht vom GESPEICHERTEN Wert ab — nicht von "". Sonst ist
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
- for (const field of changedFields) {
110
- const raw = pending[field.fieldKey] ?? "";
111
- const result =
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
- <Button
154
- variant="primary"
155
- onClick={() => void handleSave()}
156
- disabled={saving || !dirty}
157
- testId="custom-fields-form-save"
158
- >
159
- {saving ? t("custom-fields.form.saving") : t("custom-fields.form.save")}
160
- </Button>
161
- {errorKey !== null && (
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 data-testid="profile-password" className="flex flex-col gap-4">
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 data-testid="profile-email" className="flex flex-col gap-4">
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 data-testid="profile-danger" className="flex flex-col gap-4">
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-10 max-w-xl" data-testid="profile-screen">
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 />