@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.186.3",
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.186.3",
20
- "@cosmicdrift/kumiko-headless": "0.186.3",
21
- "@cosmicdrift/kumiko-renderer": "0.186.3",
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", () => {