@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.
|
|
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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "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
|
-
//
|
|
289
|
-
// EditFieldViewModel (computeEditViewModel
|
|
290
|
-
// SelectFieldDef.options).
|
|
291
|
-
//
|
|
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.
|
|
426
|
-
//
|
|
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 (
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -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",
|
package/src/primitives.tsx
CHANGED
|
@@ -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 = {
|