@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.
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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.
|
|
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 (
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
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
|
-
{
|
|
2329
|
-
</
|
|
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}
|