@cosmicdrift/kumiko-renderer-web 0.275.0 → 0.276.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.275.0",
3
+ "version": "0.276.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.275.0",
20
- "@cosmicdrift/kumiko-headless": "0.275.0",
21
- "@cosmicdrift/kumiko-renderer": "0.275.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.276.0",
20
+ "@cosmicdrift/kumiko-headless": "0.276.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.276.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",
@@ -66,7 +66,7 @@
66
66
  "@types/react-dom": "^19.2.3",
67
67
  "jsdom": "^29.1.1",
68
68
  "tailwindcss": "^4.3.0",
69
- "@cosmicdrift/kumiko-locale-de": "0.275.0"
69
+ "@cosmicdrift/kumiko-locale-de": "0.276.0"
70
70
  },
71
71
  "repository": {
72
72
  "type": "git",
@@ -0,0 +1,188 @@
1
+ // fw#2933: a `money` field declaring `currency: { kind: "tenant" }` resolves
2
+ // its empty-value currency from the tenant-settings config key
3
+ // (`tenant-settings:config:currency`) via `config:query:values`, instead of
4
+ // entity.defaultCurrency ?? "EUR". A stored value always keeps its own
5
+ // currency, and a field without the declaration is unaffected.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type {
9
+ EntityDefinition,
10
+ EntityEditScreenDefinition,
11
+ } from "@cosmicdrift/kumiko-framework/ui-types";
12
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
13
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
14
+ import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
15
+ import userEvent from "@testing-library/user-event";
16
+ import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
17
+
18
+ // `price` opts into the tenant-settings currency; `cost`/`fee` don't and
19
+ // keep today's entity.defaultCurrency ("EUR") fallback.
20
+ const invoiceEntity = {
21
+ defaultCurrency: "EUR",
22
+ fields: {
23
+ price: { type: "money", currency: { kind: "tenant" } },
24
+ cost: { type: "money" },
25
+ fee: { type: "money" },
26
+ },
27
+ } as unknown as EntityDefinition;
28
+
29
+ const editScreen: EntityEditScreenDefinition = {
30
+ id: "invoice-edit",
31
+ type: "entityEdit",
32
+ entity: "invoice",
33
+ layout: { sections: [{ fields: ["price", "cost", "fee"] }] },
34
+ };
35
+
36
+ const schema: FeatureSchema = {
37
+ featureName: "billing",
38
+ entities: { invoice: invoiceEntity },
39
+ screens: [editScreen],
40
+ };
41
+
42
+ const TENANT_CURRENCY_VALUES = {
43
+ "tenant-settings:config:currency": { value: "GBP", scope: "tenant", source: "tenant-row" },
44
+ };
45
+
46
+ function makeDispatcher(overrides: Partial<Dispatcher> = {}): Dispatcher {
47
+ const base = createMockDispatcher({
48
+ query: (async (type: string) => {
49
+ if (type === "billing:query:invoice:detail") {
50
+ return {
51
+ isSuccess: true,
52
+ data: {
53
+ id: "inv-1",
54
+ version: 4,
55
+ price: null,
56
+ cost: { amount: 50, currency: "EUR" },
57
+ fee: null,
58
+ },
59
+ };
60
+ }
61
+ if (type === "config:query:values") {
62
+ return { isSuccess: true, data: TENANT_CURRENCY_VALUES };
63
+ }
64
+ return { isSuccess: true, data: { rows: [], nextCursor: null } };
65
+ }) as unknown as Dispatcher["query"],
66
+ });
67
+ return { ...base, ...overrides };
68
+ }
69
+
70
+ async function typeMoney(testId: string, amount: string): Promise<void> {
71
+ const input = screen.getByTestId(testId).querySelector("input");
72
+ if (!input) throw new Error(`expected an <input> inside ${testId}`);
73
+ const user = userEvent.setup();
74
+ await user.clear(input);
75
+ await user.type(input, amount);
76
+ }
77
+
78
+ describe("money field currency: { kind: 'tenant' } (fw#2933)", () => {
79
+ test("update: empty tenant-declared field uses the tenant currency; stored value keeps its own currency; undeclared empty field keeps entity.defaultCurrency", async () => {
80
+ const writeCalls: { type: string; payload: unknown }[] = [];
81
+ const dispatcher = makeDispatcher({
82
+ write: (async (type: string, payload: unknown) => {
83
+ writeCalls.push({ type, payload });
84
+ return { isSuccess: true, data: { id: "inv-1" } };
85
+ }) as unknown as Dispatcher["write"],
86
+ });
87
+
88
+ render(
89
+ <DispatcherProvider dispatcher={dispatcher}>
90
+ <KumikoScreen schema={schema} qn="billing:screen:invoice-edit" entityId="inv-1" />
91
+ </DispatcherProvider>,
92
+ );
93
+
94
+ // Two sequential real async stages (detail, then tenant-currency) — the
95
+ // default 1000ms waitFor timeout flakes under load (render-edit.test.tsx
96
+ // uses the same 3000ms bump for comparable multi-stage async waits).
97
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull(), {
98
+ timeout: 3000,
99
+ });
100
+ expect(screen.getByTestId("render-edit-form")).toBeTruthy();
101
+
102
+ await typeMoney("field-price", "10.00");
103
+ await typeMoney("field-cost", "75.00");
104
+ await typeMoney("field-fee", "5.00");
105
+ await userEvent.setup().click(screen.getByTestId("render-edit-submit"));
106
+
107
+ await waitFor(() => expect(writeCalls.length).toBe(1));
108
+ const [call] = writeCalls;
109
+ expect(call?.type).toBe("billing:write:invoice:update");
110
+ expect(call?.payload).toEqual({
111
+ id: "inv-1",
112
+ version: 4,
113
+ changes: {
114
+ price: { amount: 10, currency: "GBP" },
115
+ cost: { amount: 75, currency: "EUR" },
116
+ fee: { amount: 5, currency: "EUR" },
117
+ },
118
+ });
119
+ });
120
+
121
+ test("create: untouched tenant-declared field submits with the tenant currency, not EUR", async () => {
122
+ const writeCalls: { type: string; payload: unknown }[] = [];
123
+ const dispatcher = makeDispatcher({
124
+ write: (async (type: string, payload: unknown) => {
125
+ writeCalls.push({ type, payload });
126
+ return { isSuccess: true, data: { id: "inv-2" } };
127
+ }) as unknown as Dispatcher["write"],
128
+ });
129
+
130
+ render(
131
+ <DispatcherProvider dispatcher={dispatcher}>
132
+ <KumikoScreen schema={schema} qn="billing:screen:invoice-edit" />
133
+ </DispatcherProvider>,
134
+ );
135
+
136
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull(), {
137
+ timeout: 3000,
138
+ });
139
+ // The submit button stays disabled while the form is pristine — touch an
140
+ // unrelated field so the click below actually fires; `price` itself is
141
+ // deliberately left untouched to prove its initial value already carries
142
+ // the tenant currency.
143
+ await typeMoney("field-cost", "20.00");
144
+ await userEvent.setup().click(screen.getByTestId("render-edit-submit"));
145
+
146
+ await waitFor(() => expect(writeCalls.length).toBe(1));
147
+ const [call] = writeCalls;
148
+ expect(call?.type).toBe("billing:write:invoice:create");
149
+ const payload = call?.payload as { price?: unknown; cost?: unknown; fee?: unknown };
150
+ expect(payload.price).toEqual({ amount: 0, currency: "GBP" });
151
+ expect(payload.cost).toEqual({ amount: 20, currency: "EUR" });
152
+ expect(payload.fee).toEqual({ amount: 0, currency: "EUR" });
153
+ });
154
+
155
+ test("entity without a tenant-declared money field never calls config:query:values", async () => {
156
+ const plainEntity = {
157
+ defaultCurrency: "EUR",
158
+ fields: { cost: { type: "money" } },
159
+ } as unknown as EntityDefinition;
160
+ const plainScreen: EntityEditScreenDefinition = {
161
+ id: "plain-edit",
162
+ type: "entityEdit",
163
+ entity: "plain",
164
+ layout: { sections: [{ fields: ["cost"] }] },
165
+ };
166
+ const plainSchema: FeatureSchema = {
167
+ featureName: "billing",
168
+ entities: { plain: plainEntity },
169
+ screens: [plainScreen],
170
+ };
171
+ const queriedTypes: string[] = [];
172
+ const dispatcher = makeDispatcher({
173
+ query: (async (type: string) => {
174
+ queriedTypes.push(type);
175
+ return { isSuccess: true, data: { rows: [], nextCursor: null } };
176
+ }) as unknown as Dispatcher["query"],
177
+ });
178
+
179
+ render(
180
+ <DispatcherProvider dispatcher={dispatcher}>
181
+ <KumikoScreen schema={plainSchema} qn="billing:screen:plain-edit" />
182
+ </DispatcherProvider>,
183
+ );
184
+
185
+ await waitFor(() => expect(screen.getByTestId("render-edit-form")).toBeTruthy());
186
+ expect(queriedTypes).not.toContain("config:query:values");
187
+ });
188
+ });
@@ -3621,6 +3621,75 @@ describe("RenderEdit fields filter", () => {
3621
3621
  });
3622
3622
  });
3623
3623
 
3624
+ describe("RenderEdit — slots.titleAction", () => {
3625
+ function TitleChips({ entityId }: { readonly entityId: string | null }): ReactNode {
3626
+ return (
3627
+ <span data-testid="title-chips" data-entity-id={entityId ?? "(create)"}>
3628
+ 3 left
3629
+ </span>
3630
+ );
3631
+ }
3632
+
3633
+ test("titleAction renders in the form title row, next to the title and outside the actions", () => {
3634
+ render(
3635
+ <DispatcherProvider dispatcher={makeDispatcher()}>
3636
+ <ExtensionSectionsProvider value={{ TitleChips }}>
3637
+ <RenderEdit<TestValues>
3638
+ screen={{
3639
+ id: "orders:screen:order-edit-title-action",
3640
+ type: "entityEdit",
3641
+ entity: "order",
3642
+ description: "Edit the order",
3643
+ layout: {
3644
+ sections: [{ title: "Basics", columns: 1, fields: [{ field: "title" }] }],
3645
+ },
3646
+ slots: { titleAction: { react: { __component: "TitleChips" } } },
3647
+ }}
3648
+ entity={orderEntity}
3649
+ featureName="orders"
3650
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
3651
+ writeCommand="order:update"
3652
+ entityId="order-1"
3653
+ />
3654
+ </ExtensionSectionsProvider>
3655
+ </DispatcherProvider>,
3656
+ );
3657
+
3658
+ const titleAction = screen.getByTestId("render-edit-form-title-action");
3659
+ const chips = screen.getByTestId("title-chips");
3660
+ expect(titleAction.contains(chips)).toBe(true);
3661
+ expect(chips.getAttribute("data-entity-id")).toBe("order-1");
3662
+ const titleRow = titleAction.parentElement;
3663
+ expect(titleRow?.contains(screen.getByTestId("render-edit-form-title"))).toBe(true);
3664
+ expect(
3665
+ screen.getByTestId("render-edit-form-actions").querySelector('[data-testid="title-chips"]'),
3666
+ ).toBeNull();
3667
+ });
3668
+
3669
+ test("without titleAction the title row has no action container", () => {
3670
+ render(
3671
+ <DispatcherProvider dispatcher={makeDispatcher()}>
3672
+ <RenderEdit<TestValues>
3673
+ screen={{
3674
+ id: "orders:screen:order-edit-plain-title",
3675
+ type: "entityEdit",
3676
+ entity: "order",
3677
+ description: "Edit the order",
3678
+ layout: { sections: [{ title: "Basics", columns: 1, fields: [{ field: "title" }] }] },
3679
+ }}
3680
+ entity={orderEntity}
3681
+ featureName="orders"
3682
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
3683
+ writeCommand="order:update"
3684
+ entityId="order-1"
3685
+ />
3686
+ </DispatcherProvider>,
3687
+ );
3688
+
3689
+ expect(screen.queryByTestId("render-edit-form-title-action")).toBeNull();
3690
+ });
3691
+ });
3692
+
3624
3693
  describe("RenderEdit — slots.header", () => {
3625
3694
  function HeaderExtra({ entityId }: { readonly entityId: string | null }): ReactNode {
3626
3695
  return (
@@ -2294,39 +2294,52 @@ function FormSections({
2294
2294
  function FormTitleBlock({
2295
2295
  title,
2296
2296
  subtitle,
2297
+ titleAction,
2297
2298
  testId,
2298
2299
  bordered,
2299
2300
  fillHeight,
2300
2301
  }: {
2301
2302
  readonly title: ReactNode;
2302
2303
  readonly subtitle: ReactNode;
2304
+ readonly titleAction: ReactNode;
2303
2305
  readonly testId: string | undefined;
2304
2306
  readonly bordered: boolean;
2305
2307
  readonly fillHeight: boolean | undefined;
2306
2308
  }): ReactNode {
2307
- if (title === undefined && subtitle === undefined) return null;
2309
+ if (title === undefined && subtitle === undefined && titleAction === undefined) return null;
2308
2310
  return (
2309
2311
  <div
2310
2312
  className={cn(
2313
+ "flex items-start justify-between gap-4",
2311
2314
  bordered ? cn(cardHeaderBorder, "px-6 pb-4 pt-5") : "pb-4",
2312
2315
  fillHeight === true && "shrink-0",
2313
2316
  )}
2314
2317
  >
2315
- {title !== undefined && (
2316
- <h2
2317
- data-testid={testId !== undefined ? `${testId}-title` : undefined}
2318
- className="text-lg font-semibold tracking-tight"
2319
- >
2320
- {title}
2321
- </h2>
2322
- )}
2323
- {subtitle !== undefined && (
2324
- <p
2325
- data-testid={testId !== undefined ? `${testId}-subtitle` : undefined}
2326
- className="mt-1 text-sm text-muted-foreground"
2318
+ <div className="min-w-0">
2319
+ {title !== undefined && (
2320
+ <h2
2321
+ data-testid={testId !== undefined ? `${testId}-title` : undefined}
2322
+ className="text-lg font-semibold tracking-tight"
2323
+ >
2324
+ {title}
2325
+ </h2>
2326
+ )}
2327
+ {subtitle !== undefined && (
2328
+ <p
2329
+ data-testid={testId !== undefined ? `${testId}-subtitle` : undefined}
2330
+ className="mt-1 text-sm text-muted-foreground"
2331
+ >
2332
+ {subtitle}
2333
+ </p>
2334
+ )}
2335
+ </div>
2336
+ {titleAction !== undefined && (
2337
+ <div
2338
+ data-testid={testId !== undefined ? `${testId}-title-action` : undefined}
2339
+ className="flex shrink-0 flex-wrap items-center justify-end gap-2"
2327
2340
  >
2328
- {subtitle}
2329
- </p>
2341
+ {titleAction}
2342
+ </div>
2330
2343
  )}
2331
2344
  </div>
2332
2345
  );
@@ -2397,6 +2410,7 @@ function DefaultForm({
2397
2410
  width,
2398
2411
  stickyActions,
2399
2412
  headerRegion,
2413
+ titleAction,
2400
2414
  fillHeight,
2401
2415
  chromeless,
2402
2416
  }: FormProps): ReactNode {
@@ -2473,6 +2487,7 @@ function DefaultForm({
2473
2487
  <FormTitleBlock
2474
2488
  title={title}
2475
2489
  subtitle={subtitle}
2490
+ titleAction={titleAction}
2476
2491
  testId={testId}
2477
2492
  bordered={false}
2478
2493
  fillHeight={fillHeight}
@@ -2519,6 +2534,7 @@ function DefaultForm({
2519
2534
  <FormTitleBlock
2520
2535
  title={title}
2521
2536
  subtitle={subtitle}
2537
+ titleAction={titleAction}
2522
2538
  testId={testId}
2523
2539
  bordered={true}
2524
2540
  fillHeight={fillHeight}