@cosmicdrift/kumiko-renderer-web 0.186.3 → 0.188.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/__tests__/config-edit.test.tsx +66 -0
- package/src/__tests__/primitives.test.tsx +121 -0
- package/src/__tests__/render-edit.test.tsx +1368 -1
- package/src/__tests__/test-utils.tsx +24 -0
- package/src/__tests__/wizard-form-validation.test.tsx +73 -0
- package/src/app/create-app.tsx +33 -20
- package/src/app/draft-storage.ts +59 -0
- package/src/index.ts +4 -0
- package/src/primitives/__tests__/date-parse.test.ts +26 -1
- package/src/primitives/__tests__/embedded-list-input.test.tsx +29 -0
- package/src/primitives/date-field.tsx +13 -5
- package/src/primitives/date-parse.ts +30 -7
- package/src/primitives/embedded-list-input.tsx +5 -3
- package/src/primitives/index.tsx +49 -7
- package/src/primitives/money-input.tsx +5 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.188.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.188.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.188.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.188.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -38,6 +38,28 @@ const settingsScreen: ConfigEditScreenDefinition = {
|
|
|
38
38
|
},
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
// A money-typed field is a legitimate FieldDefinition (screen.fields reuses
|
|
42
|
+
// the entity field-shape, kumiko-framework#1923 render-field.tsx money case)
|
|
43
|
+
// even though ConfigKeyType (write-helpers.ts) never carries "money" — the
|
|
44
|
+
// server side of a config key is always a bare scalar.
|
|
45
|
+
const moneySettingsScreen: ConfigEditScreenDefinition = {
|
|
46
|
+
id: "money-settings",
|
|
47
|
+
type: "configEdit",
|
|
48
|
+
scope: "tenant",
|
|
49
|
+
configKeys: { priceLimit: "demo:config:price-limit" },
|
|
50
|
+
fields: {
|
|
51
|
+
priceLimit: { type: "money" },
|
|
52
|
+
// @cast-boundary inline schema-author shape — FieldDefinition union too narrow
|
|
53
|
+
} as ConfigEditScreenDefinition["fields"],
|
|
54
|
+
layout: { sections: [{ title: "Basics", fields: ["priceLimit"] }] },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const moneySchema: FeatureSchema = {
|
|
58
|
+
featureName: "demo",
|
|
59
|
+
entities: {},
|
|
60
|
+
screens: [moneySettingsScreen],
|
|
61
|
+
};
|
|
62
|
+
|
|
41
63
|
const schema: FeatureSchema = {
|
|
42
64
|
featureName: "demo",
|
|
43
65
|
entities: {},
|
|
@@ -138,6 +160,50 @@ describe("KumikoScreen / configEdit", () => {
|
|
|
138
160
|
});
|
|
139
161
|
});
|
|
140
162
|
|
|
163
|
+
// kumiko-framework#1923: RenderField's money case now always emits the
|
|
164
|
+
// entityEdit `{amount, currency}` payload shape. A config key's server
|
|
165
|
+
// value is always a bare scalar (ConfigKeyType has no "money" variant) —
|
|
166
|
+
// customSubmit must unwrap back to `.amount` before dispatching, or the
|
|
167
|
+
// batch's config:write:set value fails its `string | number | boolean`
|
|
168
|
+
// schema entirely (regression guard for that unwrap).
|
|
169
|
+
test("money field submit unwraps {amount, currency} back to a bare number", async () => {
|
|
170
|
+
const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
|
|
171
|
+
isSuccess: true as const,
|
|
172
|
+
results: [],
|
|
173
|
+
}));
|
|
174
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
175
|
+
query: (async () => ({
|
|
176
|
+
isSuccess: true,
|
|
177
|
+
data: { "demo:config:price-limit": { value: 12.99, scope: "tenant" } },
|
|
178
|
+
})) as unknown as Dispatcher["query"],
|
|
179
|
+
batch: batchSpy as unknown as Dispatcher["batch"],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const user = userEvent.setup();
|
|
183
|
+
render(
|
|
184
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
185
|
+
<KumikoScreen schema={moneySchema} qn="demo:screen:money-settings" />
|
|
186
|
+
</DispatcherProvider>,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
190
|
+
const priceInput = screen.getByTestId("field-priceLimit").querySelector("input");
|
|
191
|
+
if (!priceInput) throw new Error("expected priceLimit input");
|
|
192
|
+
|
|
193
|
+
await user.clear(priceInput);
|
|
194
|
+
await user.type(priceInput, "15.00");
|
|
195
|
+
await user.click(screen.getByTestId("render-edit-submit"));
|
|
196
|
+
|
|
197
|
+
await waitFor(() => expect(batchSpy).toHaveBeenCalled());
|
|
198
|
+
const commands = batchSpy.mock.calls[0]?.[0];
|
|
199
|
+
if (!commands) throw new Error("batchSpy not called");
|
|
200
|
+
expect(commands).toHaveLength(1);
|
|
201
|
+
expect(commands[0]).toEqual({
|
|
202
|
+
type: "config:write:set",
|
|
203
|
+
payload: { key: "demo:config:price-limit", value: 15, scope: "tenant" },
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
141
207
|
test("save-button disabled after successful submit (rebase fired)", async () => {
|
|
142
208
|
const dispatcher: Dispatcher = createMockDispatcher({
|
|
143
209
|
query: (async () => ({
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// abhängen werden.
|
|
9
9
|
|
|
10
10
|
import { describe, expect, mock, test } from "bun:test";
|
|
11
|
+
import { type ColumnRendererProps, ColumnRenderersProvider } from "@cosmicdrift/kumiko-renderer";
|
|
11
12
|
import userEvent from "@testing-library/user-event";
|
|
13
|
+
import type { ReactNode } from "react";
|
|
12
14
|
import { defaultPrimitives, END_LABEL_MIN_ROWS } from "../primitives";
|
|
13
15
|
import { PageSection, Stack } from "../primitives/layout";
|
|
14
16
|
import { fireEvent, render, screen } from "./test-utils";
|
|
@@ -876,6 +878,125 @@ describe("DataTable", () => {
|
|
|
876
878
|
expect(onRowClick).not.toHaveBeenCalled();
|
|
877
879
|
});
|
|
878
880
|
});
|
|
881
|
+
|
|
882
|
+
describe("highlighted column", () => {
|
|
883
|
+
const cols = [
|
|
884
|
+
{ field: "name", label: "Name", type: "string", sortable: false },
|
|
885
|
+
{ field: "y2026", label: "2026", type: "number", sortable: false, highlighted: true },
|
|
886
|
+
] as const;
|
|
887
|
+
const oneRow = [{ id: "r1", values: { name: "Alice", y2026: 42 } }];
|
|
888
|
+
|
|
889
|
+
test("marks header + cell of the highlighted column, leaves others alone", () => {
|
|
890
|
+
render(<DataTable columns={cols} rows={oneRow} />);
|
|
891
|
+
expect(screen.getByTestId("column-y2026").getAttribute("data-highlighted")).toBe("true");
|
|
892
|
+
expect(screen.getByTestId("column-name").getAttribute("data-highlighted")).toBeNull();
|
|
893
|
+
expect(screen.getByTestId("cell-r1-y2026").getAttribute("data-highlighted")).toBe("true");
|
|
894
|
+
expect(screen.getByTestId("cell-r1-name").getAttribute("data-highlighted")).toBeNull();
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
describe("testId overrides", () => {
|
|
899
|
+
const cols = [{ field: "name", label: "Name", type: "string", sortable: false }] as const;
|
|
900
|
+
const oneRow = [{ id: "r1", values: { name: "Alice" } }];
|
|
901
|
+
|
|
902
|
+
test("getRowTestId/getCellTestId override the row-<id>/cell-<id>-<field> defaults", () => {
|
|
903
|
+
render(
|
|
904
|
+
<DataTable
|
|
905
|
+
columns={cols}
|
|
906
|
+
rows={oneRow}
|
|
907
|
+
getRowTestId={(row) => `own-row-${row.id}`}
|
|
908
|
+
getCellTestId={(row, field) => `own-cell-${row.id}-${field}`}
|
|
909
|
+
/>,
|
|
910
|
+
);
|
|
911
|
+
expect(screen.getByTestId("own-row-r1")).not.toBeNull();
|
|
912
|
+
expect(screen.getByTestId("own-cell-r1-name")).not.toBeNull();
|
|
913
|
+
expect(screen.queryByTestId("row-r1")).toBeNull();
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test("without overrides, defaults stay row-<id>/cell-<id>-<field>", () => {
|
|
917
|
+
render(<DataTable columns={cols} rows={oneRow} />);
|
|
918
|
+
expect(screen.getByTestId("row-r1")).not.toBeNull();
|
|
919
|
+
expect(screen.getByTestId("cell-r1-name")).not.toBeNull();
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
describe("onCellChange (editable cell)", () => {
|
|
924
|
+
function EditableSwatch({ value, column, onChange }: ColumnRendererProps): ReactNode {
|
|
925
|
+
return (
|
|
926
|
+
<input
|
|
927
|
+
data-testid={`edit-${column.field}`}
|
|
928
|
+
value={String(value)}
|
|
929
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
930
|
+
/>
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
test("column-renderer receives onChange bound to (rowId, field, value) when onCellChange is set", () => {
|
|
935
|
+
const onCellChange = mock();
|
|
936
|
+
const cols = [
|
|
937
|
+
{
|
|
938
|
+
field: "name",
|
|
939
|
+
label: "Name",
|
|
940
|
+
type: "string",
|
|
941
|
+
sortable: false,
|
|
942
|
+
renderer: { react: { __component: "EditableSwatch" } },
|
|
943
|
+
},
|
|
944
|
+
] as const;
|
|
945
|
+
render(
|
|
946
|
+
<ColumnRenderersProvider value={{ EditableSwatch }}>
|
|
947
|
+
<DataTable
|
|
948
|
+
columns={cols}
|
|
949
|
+
rows={[{ id: "r1", values: { name: "Alice" } }]}
|
|
950
|
+
onCellChange={onCellChange}
|
|
951
|
+
/>
|
|
952
|
+
</ColumnRenderersProvider>,
|
|
953
|
+
);
|
|
954
|
+
fireEvent.change(screen.getByTestId("edit-name"), { target: { value: "Bob" } });
|
|
955
|
+
expect(onCellChange).toHaveBeenCalledWith("r1", "name", "Bob");
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
test("without onCellChange, the component renderer gets no onChange (read-only)", () => {
|
|
959
|
+
const cols = [
|
|
960
|
+
{
|
|
961
|
+
field: "name",
|
|
962
|
+
label: "Name",
|
|
963
|
+
type: "string",
|
|
964
|
+
sortable: false,
|
|
965
|
+
renderer: { react: { __component: "EditableSwatch" } },
|
|
966
|
+
},
|
|
967
|
+
] as const;
|
|
968
|
+
render(
|
|
969
|
+
<ColumnRenderersProvider value={{ EditableSwatch }}>
|
|
970
|
+
<DataTable columns={cols} rows={[{ id: "r1", values: { name: "Alice" } }]} />
|
|
971
|
+
</ColumnRenderersProvider>,
|
|
972
|
+
);
|
|
973
|
+
// onChange is undefined → EditableSwatch's onChange?.(...) is a no-op;
|
|
974
|
+
// rendering itself must still succeed (no crash) with a stable value.
|
|
975
|
+
expect((screen.getByTestId("edit-name") as HTMLInputElement).value).toBe("Alice");
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
test("format-spec renderer ignores onCellChange — no crash, plain formatted text", () => {
|
|
979
|
+
const onCellChange = mock();
|
|
980
|
+
const cols = [
|
|
981
|
+
{
|
|
982
|
+
field: "amount",
|
|
983
|
+
label: "Amount",
|
|
984
|
+
type: "number",
|
|
985
|
+
sortable: false,
|
|
986
|
+
renderer: { format: "currency", symbol: "€" },
|
|
987
|
+
},
|
|
988
|
+
] as const;
|
|
989
|
+
render(
|
|
990
|
+
<DataTable
|
|
991
|
+
columns={cols}
|
|
992
|
+
rows={[{ id: "r1", values: { amount: 42 } }]}
|
|
993
|
+
onCellChange={onCellChange}
|
|
994
|
+
/>,
|
|
995
|
+
);
|
|
996
|
+
expect(screen.getByTestId("cell-r1-amount").textContent).toBe("42 €");
|
|
997
|
+
expect(onCellChange).not.toHaveBeenCalled();
|
|
998
|
+
});
|
|
999
|
+
});
|
|
879
1000
|
});
|
|
880
1001
|
|
|
881
1002
|
describe("Form", () => {
|