@cosmicdrift/kumiko-renderer 0.185.0 → 0.186.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-renderer",
3
- "version": "0.185.0",
3
+ "version": "0.186.1",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.185.0",
19
- "@cosmicdrift/kumiko-headless": "0.185.0",
18
+ "@cosmicdrift/kumiko-framework": "0.186.1",
19
+ "@cosmicdrift/kumiko-headless": "0.186.1",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2"
22
22
  },
@@ -0,0 +1,110 @@
1
+ // Regression #1834: field types without a dedicated widget (embedded
2
+ // without embeddedListCells, jsonb, multiSelect) must render read-only
3
+ // instead of falling back to a text input. The old default-branch
4
+ // fallback ran the value through stringValue() — an object/array turned
5
+ // into "[object Object]"/"a,b", and saving that overwrote the real data.
6
+ //
7
+ // Capture-Input/Banner instead of real primitives, same pattern as
8
+ // render-field-app-locale.test.tsx.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
12
+ import { render } from "@testing-library/react";
13
+ import type { ComponentType, ReactNode } from "react";
14
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
15
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
16
+ import {
17
+ type BannerProps,
18
+ type CorePrimitives,
19
+ type InputProps,
20
+ PrimitivesProvider,
21
+ } from "../../primitives";
22
+ import { RenderField } from "../render-field";
23
+
24
+ let capturedInput: InputProps | undefined;
25
+ let capturedBanner: BannerProps | undefined;
26
+ const captureInput: ComponentType<InputProps> = (props) => {
27
+ capturedInput = props;
28
+ return null;
29
+ };
30
+ const captureBanner: ComponentType<BannerProps> = (props) => {
31
+ capturedBanner = props;
32
+ return null;
33
+ };
34
+ const noop = (): ReactNode => null;
35
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
36
+
37
+ const testPrimitives: CorePrimitives = {
38
+ Button: noop,
39
+ Banner: captureBanner,
40
+ Field: passChildren,
41
+ Input: captureInput,
42
+ DataTable: noop,
43
+ Form: noop,
44
+ Section: noop,
45
+ Card: noop,
46
+ Grid: noop,
47
+ GridCell: noop,
48
+ Text: noop,
49
+ Heading: noop,
50
+ Dialog: noop,
51
+ Modal: noop,
52
+ Lightbox: noop,
53
+ ConfigSourceBadge: noop,
54
+ ConfigCascadeView: noop,
55
+ Link: noop,
56
+ };
57
+
58
+ function baseField(overrides: Partial<EditFieldViewModel>): EditFieldViewModel {
59
+ return {
60
+ field: "positions",
61
+ label: "Positionen",
62
+ type: "text",
63
+ value: null,
64
+ visible: true,
65
+ readOnly: false,
66
+ required: false,
67
+ ...overrides,
68
+ };
69
+ }
70
+
71
+ function renderField(field: EditFieldViewModel): void {
72
+ capturedInput = undefined;
73
+ capturedBanner = undefined;
74
+ render(
75
+ <LocaleProvider
76
+ resolver={createStaticLocaleResolver({ locale: "de-DE" })}
77
+ fallbackBundles={[kumikoDefaultTranslations]}
78
+ >
79
+ <PrimitivesProvider value={testPrimitives}>
80
+ <RenderField field={field} onChange={() => {}} />
81
+ </PrimitivesProvider>
82
+ </LocaleProvider>,
83
+ );
84
+ }
85
+
86
+ describe.each([
87
+ ["embedded (kein embeddedListCells)", baseField({ type: "embedded", value: { note: "x" } })],
88
+ ["jsonb", baseField({ type: "jsonb", value: { a: 1 } })],
89
+ ["multiSelect", baseField({ type: "multiSelect", value: ["a", "b"] })],
90
+ ])("RenderField — %s ohne Widget", (_name, field) => {
91
+ test("rendert einen schreibgeschützten Banner statt eines editierbaren Text-Inputs", () => {
92
+ renderField(field);
93
+ expect(capturedInput).toBeUndefined();
94
+ expect(capturedBanner).toBeDefined();
95
+ expect(capturedBanner?.variant).toBe("info");
96
+ expect(capturedBanner?.children).toBe("Dieser Feldtyp kann hier noch nicht bearbeitet werden.");
97
+ // The surrounding <Field> always renders a <label htmlFor={id}> —
98
+ // without an id on the Banner itself that label points at nothing (#1834 review).
99
+ expect(capturedBanner?.id).toBe("kumiko-edit-positions");
100
+ });
101
+ });
102
+
103
+ describe("RenderField — text bleibt weiterhin editierbar", () => {
104
+ test("plain text-Feld rendert weiterhin ein Text-Input, kein Banner", () => {
105
+ renderField(baseField({ type: "text", value: "hallo" }));
106
+ expect(capturedBanner).toBeUndefined();
107
+ expect(capturedInput).toBeDefined();
108
+ expect(capturedInput?.kind).toBe("text");
109
+ });
110
+ });
@@ -49,13 +49,14 @@ export function RenderField({
49
49
  fieldAppendix,
50
50
  allIssues,
51
51
  }: RenderFieldProps): ReactNode {
52
- const { Field, Input } = usePrimitives();
52
+ const { Field, Input, Banner } = usePrimitives();
53
53
  // App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
54
54
  // navigator.language (Browser-Sprache) zurück statt der gewählten
55
55
  // App-Sprache. BEWUSSTE API-Verschärfung (seit 0.38): RenderField ist
56
56
  // public exportiert und verlangt jetzt einen LocaleProvider —
57
57
  // Standalone-Consumer/Tests müssen wrappen (createKumikoApp tut es).
58
58
  const appLocale = useLocale().locale();
59
+ const t = useTranslation();
59
60
  if (!field.visible) return null;
60
61
 
61
62
  const id = inputId(field);
@@ -83,7 +84,7 @@ export function RenderField({
83
84
  featureName={featureName ?? ""}
84
85
  />
85
86
  ) : (
86
- renderInput({ field, id, hasError, onChange, Input, appLocale })
87
+ renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, t })
87
88
  );
88
89
 
89
90
  return (
@@ -285,10 +286,11 @@ function inputId(field: EditFieldViewModel): string {
285
286
  return `kumiko-edit-${field.field}`;
286
287
  }
287
288
 
288
- // Dispatch auf field.type → Input-Kind. Select threaded options aus dem
289
- // EditFieldViewModel (computeEditViewModel zieht sie aus
290
- // SelectFieldDef.options). Unknown-Types fallen auf text zurück damit
291
- // die Form was Sinnvolles rendert statt blank zu sein.
289
+ // Dispatches field.type → Input-kind. Select threads options through
290
+ // from the EditFieldViewModel (computeEditViewModel pulls them from
291
+ // SelectFieldDef.options). Structural types without a widget (embedded,
292
+ // jsonb, multiSelect) render read-only instead of a data-destroying text
293
+ // fallback (#1834); truly unknown scalar types still fall back to text.
292
294
  function renderInput({
293
295
  field,
294
296
  id,
@@ -296,6 +298,8 @@ function renderInput({
296
298
  onChange,
297
299
  Input,
298
300
  appLocale,
301
+ Banner,
302
+ t,
299
303
  }: {
300
304
  readonly field: EditFieldViewModel;
301
305
  readonly id: string;
@@ -303,6 +307,8 @@ function renderInput({
303
307
  readonly onChange: (value: unknown) => void;
304
308
  readonly Input: ReturnType<typeof usePrimitives>["Input"];
305
309
  readonly appLocale: string;
310
+ readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
311
+ readonly t: ReturnType<typeof useTranslation>;
306
312
  }): ReactNode {
307
313
  const common = {
308
314
  id,
@@ -421,9 +427,23 @@ function renderInput({
421
427
  />
422
428
  );
423
429
  }
430
+ // embedded (without embeddedListCells — that's embeddedList, which has
431
+ // had its own EmbeddedListField widget since #1838), jsonb and
432
+ // multiSelect carry objects/arrays. Without a dedicated widget these
433
+ // must NOT fall through to a text input: stringValue() turns them into
434
+ // "[object Object]" / "a,b", and saving that overwrites the real data
435
+ // with the mangled string (#1834).
436
+ case "embedded":
437
+ case "jsonb":
438
+ case "multiSelect":
439
+ return (
440
+ <Banner id={id} variant="info">
441
+ {t("kumiko.field.unsupported")}
442
+ </Banner>
443
+ );
424
444
  default: {
425
- // text + unknown → text input. Wenn TextFieldDef.multiline gesetzt
426
- // ist (das ViewModel hält's), wechselt der Renderer auf textarea.
445
+ // text + unknown scalar type → text input. If TextFieldDef.multiline
446
+ // is set (the view-model carries it), the renderer switches to textarea.
427
447
  if (field.type === "text" && field.multiline) {
428
448
  const rows = typeof field.multiline === "object" ? field.multiline.rows : undefined;
429
449
  return (
@@ -36,6 +36,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
36
36
  "kumiko.field.locatedTzHint": "Zeit lokal am angegebenen Ort",
37
37
  "kumiko.field.reference-created-no-id":
38
38
  "Datensatz wurde angelegt, konnte aber nicht automatisch ausgewählt werden. Bitte manuell auswählen.",
39
+ "kumiko.field.unsupported": "Dieser Feldtyp kann hier noch nicht bearbeitet werden.",
39
40
  "kumiko.field.embedded-list.add-row": "Zeile hinzufügen",
40
41
  "kumiko.field.embedded-list.remove-row": "Zeile entfernen",
41
42
  "kumiko.field.embedded-list.duplicate-row": "Zeile duplizieren",
@@ -199,6 +200,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
199
200
  "kumiko.field.locatedTzHint": "Time local to the given location",
200
201
  "kumiko.field.reference-created-no-id":
201
202
  "Record was created but could not be selected automatically. Please select it manually.",
203
+ "kumiko.field.unsupported": "This field type can't be edited here yet.",
202
204
  "kumiko.field.embedded-list.add-row": "Add row",
203
205
  "kumiko.field.embedded-list.remove-row": "Remove row",
204
206
  "kumiko.field.embedded-list.duplicate-row": "Duplicate row",
@@ -126,6 +126,10 @@ export type BannerProps = {
126
126
  * "loading", etc.). Web fügt p-6 als Margin um den Banner. */
127
127
  readonly padded?: boolean;
128
128
  readonly testId?: string;
129
+ /** Set when a `<Field>` wraps this Banner as its control (no actual
130
+ * input exists to receive the label's `htmlFor` otherwise — see
131
+ * render-field.tsx's unsupported-field-type fallback, #1834). */
132
+ readonly id?: string;
129
133
  };
130
134
 
131
135
  export type FieldProps = {