@cosmicdrift/kumiko-renderer 0.187.0 → 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 +5 -4
- package/src/__tests__/merge-search-params-into-initial.test.ts +13 -1
- package/src/app/__tests__/form-schema.test.ts +165 -0
- package/src/app/extension-sections.tsx +17 -0
- package/src/app/form-schema.ts +82 -0
- package/src/app/kumiko-screen.tsx +62 -27
- package/src/app/layout-fields.ts +27 -0
- package/src/components/__tests__/render-edit-logic.test.ts +48 -0
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +189 -0
- package/src/components/reference-create-dialog.tsx +4 -1
- package/src/components/render-edit-logic.ts +27 -0
- package/src/components/render-edit.tsx +516 -58
- package/src/components/render-field.tsx +36 -6
- package/src/context/draft-storage-context.tsx +39 -0
- package/src/i18n-defaults.ts +15 -0
- package/src/index.ts +8 -1
- package/src/primitives.tsx +11 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// kumiko-framework#1923: money didn't round-trip on the auto-wired
|
|
2
|
+
// entityEdit path — create sent a bare number against the server's
|
|
3
|
+
// `{amount, currency}` schema, update rendered the rehydrated read value as
|
|
4
|
+
// NaN, and a naive fix would have been 100x off (minor vs. major units).
|
|
5
|
+
//
|
|
6
|
+
// This test walks the whole chain with real functions, no mocks:
|
|
7
|
+
// computeEditViewModel (headless) → RenderField → simulated widget
|
|
8
|
+
// onChange → buildInsertSchema/buildUpdateSchema (framework/engine). The
|
|
9
|
+
// widget's own minor-units math (MoneyInput) is pinned separately in
|
|
10
|
+
// packages/renderer-web/src/primitives/__tests__/money-input.test.tsx —
|
|
11
|
+
// this test starts one step in, at the value RenderField hands to/from the
|
|
12
|
+
// widget contract (`number | ""` minor units).
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import { rehydrateMoney } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
+
import { buildInsertSchema, buildUpdateSchema } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import type {
|
|
18
|
+
EntityDefinition,
|
|
19
|
+
EntityEditScreenDefinition,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
21
|
+
import { computeEditViewModel, type EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
22
|
+
import { render } from "@testing-library/react";
|
|
23
|
+
import type { ComponentType, ReactNode } from "react";
|
|
24
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
25
|
+
import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
|
|
26
|
+
import { RenderField } from "../render-field";
|
|
27
|
+
|
|
28
|
+
let captured: InputProps | undefined;
|
|
29
|
+
const captureInput: ComponentType<InputProps> = (props) => {
|
|
30
|
+
captured = props;
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
const noop = (): ReactNode => null;
|
|
34
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
35
|
+
|
|
36
|
+
const testPrimitives: CorePrimitives = {
|
|
37
|
+
Button: noop,
|
|
38
|
+
Banner: noop,
|
|
39
|
+
Field: passChildren,
|
|
40
|
+
Input: captureInput,
|
|
41
|
+
DataTable: noop,
|
|
42
|
+
Form: noop,
|
|
43
|
+
Section: noop,
|
|
44
|
+
Card: noop,
|
|
45
|
+
Grid: noop,
|
|
46
|
+
GridCell: noop,
|
|
47
|
+
Text: noop,
|
|
48
|
+
Heading: noop,
|
|
49
|
+
Dialog: noop,
|
|
50
|
+
Modal: noop,
|
|
51
|
+
Lightbox: noop,
|
|
52
|
+
ConfigSourceBadge: noop,
|
|
53
|
+
ConfigCascadeView: noop,
|
|
54
|
+
Link: noop,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function buildEntity(defaultCurrency: string): EntityDefinition {
|
|
58
|
+
return {
|
|
59
|
+
fields: {
|
|
60
|
+
price: { type: "money", required: true },
|
|
61
|
+
},
|
|
62
|
+
defaultCurrency,
|
|
63
|
+
} as EntityDefinition;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildScreen(): EntityEditScreenDefinition {
|
|
67
|
+
return {
|
|
68
|
+
id: "product-edit",
|
|
69
|
+
type: "entityEdit",
|
|
70
|
+
entity: "product",
|
|
71
|
+
layout: { sections: [{ columns: 1, fields: ["price"] }] },
|
|
72
|
+
} as EntityEditScreenDefinition;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function priceField(entity: EntityDefinition, values: Record<string, unknown>): EditFieldViewModel {
|
|
76
|
+
const vm = computeEditViewModel({
|
|
77
|
+
screen: buildScreen(),
|
|
78
|
+
entity,
|
|
79
|
+
values,
|
|
80
|
+
translate: (key) => key,
|
|
81
|
+
featureName: "shop",
|
|
82
|
+
});
|
|
83
|
+
const section = vm.sections[0];
|
|
84
|
+
if (section === undefined || section.kind !== "fields") {
|
|
85
|
+
throw new Error("expected a fields section");
|
|
86
|
+
}
|
|
87
|
+
const field = section.fields[0];
|
|
88
|
+
if (field === undefined) throw new Error("expected a price field");
|
|
89
|
+
return field;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderMoneyField(
|
|
93
|
+
entity: EntityDefinition,
|
|
94
|
+
values: Record<string, unknown>,
|
|
95
|
+
onChange: (v: unknown) => void,
|
|
96
|
+
): InputProps {
|
|
97
|
+
captured = undefined;
|
|
98
|
+
render(
|
|
99
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
|
|
100
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
101
|
+
<RenderField field={priceField(entity, values)} onChange={onChange} />
|
|
102
|
+
</PrimitivesProvider>
|
|
103
|
+
</LocaleProvider>,
|
|
104
|
+
);
|
|
105
|
+
if (captured === undefined) throw new Error("money field did not render an Input");
|
|
106
|
+
return captured;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("RenderField money round-trip (kumiko-framework#1923)", () => {
|
|
110
|
+
test("create: untouched required field, buildInitialValues default validates against buildInsertSchema", () => {
|
|
111
|
+
const entity = buildEntity("USD");
|
|
112
|
+
// Same default shape kumiko-screen.tsx's buildInitialValues produces
|
|
113
|
+
// for a money field once a defaultCurrency is threaded through.
|
|
114
|
+
const initialPrice = { amount: 0, currency: "USD" };
|
|
115
|
+
const result = buildInsertSchema(entity).safeParse({ price: initialPrice });
|
|
116
|
+
expect(result.success).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("create: user-entered amount becomes a payload that validates against buildInsertSchema", () => {
|
|
120
|
+
const entity = buildEntity("USD");
|
|
121
|
+
let payload: unknown;
|
|
122
|
+
const field = renderMoneyField(entity, {}, (v) => {
|
|
123
|
+
payload = v;
|
|
124
|
+
});
|
|
125
|
+
expect(field.kind).toBe("money");
|
|
126
|
+
if (field.kind !== "money") return;
|
|
127
|
+
expect(field.value).toBe(""); // untouched — empty widget state, not NaN/0
|
|
128
|
+
field.onChange(1299); // widget emits minor units: 12.99 USD
|
|
129
|
+
expect(payload).toEqual({ amount: 12.99, currency: "USD" });
|
|
130
|
+
const result = buildInsertSchema(entity).safeParse({ price: payload });
|
|
131
|
+
expect(result.success).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("update: server-rehydrated value renders as a real number, not NaN (regression)", () => {
|
|
135
|
+
const entity = buildEntity("USD");
|
|
136
|
+
const row = { price: 1299, priceCurrency: "USD" };
|
|
137
|
+
const record = rehydrateMoney(row, entity);
|
|
138
|
+
const field = renderMoneyField(entity, record, () => {});
|
|
139
|
+
expect(field.kind).toBe("money");
|
|
140
|
+
if (field.kind !== "money") return;
|
|
141
|
+
expect(field.value).toBe(1299);
|
|
142
|
+
expect(Number.isNaN(field.value)).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("update: edited amount becomes a payload that validates against buildUpdateSchema", () => {
|
|
146
|
+
const entity = buildEntity("USD");
|
|
147
|
+
const row = { price: 1299, priceCurrency: "USD" };
|
|
148
|
+
const record = rehydrateMoney(row, entity);
|
|
149
|
+
let payload: unknown;
|
|
150
|
+
const field = renderMoneyField(entity, record, (v) => {
|
|
151
|
+
payload = v;
|
|
152
|
+
});
|
|
153
|
+
if (field.kind !== "money") throw new Error("expected money kind");
|
|
154
|
+
field.onChange(1500); // user edits to 15.00
|
|
155
|
+
expect(payload).toEqual({ amount: 15, currency: "USD" });
|
|
156
|
+
const result = buildUpdateSchema(entity).safeParse({ price: payload });
|
|
157
|
+
expect(result.success).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("JPY (zero-decimal currency): amountMinor is not read, so the widget value is not 100x off", () => {
|
|
161
|
+
const entity = buildEntity("JPY");
|
|
162
|
+
// DB stores minor units at the framework's flat MINOR_UNIT_SCALE=100,
|
|
163
|
+
// so ¥500 is row-stored as 50000 — rehydrateMoney's amountMinor mirrors
|
|
164
|
+
// that scale, which disagrees with JPY's real 0 decimal places. Reading
|
|
165
|
+
// amountMinor here would render 50000 instead of 500.
|
|
166
|
+
const row = { price: 50000, priceCurrency: "JPY" };
|
|
167
|
+
const record = rehydrateMoney(row, entity);
|
|
168
|
+
let payload: unknown;
|
|
169
|
+
const field = renderMoneyField(entity, record, (v) => {
|
|
170
|
+
payload = v;
|
|
171
|
+
});
|
|
172
|
+
if (field.kind !== "money") throw new Error("expected money kind");
|
|
173
|
+
expect(field.value).toBe(500);
|
|
174
|
+
field.onChange(500); // user submits the same amount unchanged
|
|
175
|
+
expect(payload).toEqual({ amount: 500, currency: "JPY" });
|
|
176
|
+
const result = buildInsertSchema(entity).safeParse({ price: payload });
|
|
177
|
+
expect(result.success).toBe(true);
|
|
178
|
+
expect(result.data?.["price"]).toEqual({ amount: 500, currency: "JPY" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("server schema strips an unexpected amountMinor key instead of rejecting the payload", () => {
|
|
182
|
+
const entity = buildEntity("USD");
|
|
183
|
+
const result = buildUpdateSchema(entity).safeParse({
|
|
184
|
+
price: { amount: 12.99, currency: "USD", amountMinor: 1299 },
|
|
185
|
+
});
|
|
186
|
+
expect(result.success).toBe(true);
|
|
187
|
+
expect(result.data?.["price"]).toEqual({ amount: 12.99, currency: "USD" });
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -52,7 +52,10 @@ export function ReferenceCreateDialog({
|
|
|
52
52
|
}: ReferenceCreateDialogProps): ReactNode {
|
|
53
53
|
const { Modal } = usePrimitives();
|
|
54
54
|
const t = useTranslation();
|
|
55
|
-
const initial = useMemo(
|
|
55
|
+
const initial = useMemo(
|
|
56
|
+
() => buildInitialValues(entity.fields, entity.defaultCurrency ?? "EUR") as FormValues,
|
|
57
|
+
[entity.fields, entity.defaultCurrency],
|
|
58
|
+
);
|
|
56
59
|
const writeCommand = entityWriteCommand(featureName, screen.entity);
|
|
57
60
|
const handleSubmitted = (result: SubmitResult<unknown>): void => {
|
|
58
61
|
if (result.validationBlocked || !result.isSuccess) return;
|
|
@@ -39,3 +39,30 @@ export function shouldNotifyCaller(
|
|
|
39
39
|
): boolean {
|
|
40
40
|
return !(result.isSuccess && !extensionsPersisted);
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
// Restricts rendered sections to the caller's `fields` filter, keeping
|
|
44
|
+
// section order/title/visibility from the layout unchanged. A `fields`
|
|
45
|
+
// section whose filtered field list is empty is dropped entirely — an empty
|
|
46
|
+
// section container would read as a broken layout, not "nothing to show
|
|
47
|
+
// here". Extension sections are never filtered — they carry their own field
|
|
48
|
+
// set, unrelated to the `field`-name filter. `fieldsFilter === undefined`
|
|
49
|
+
// (no prop passed) returns the same array reference, so callers that skip
|
|
50
|
+
// the prop keep unchanged render behavior.
|
|
51
|
+
export function filterEditSections(
|
|
52
|
+
sections: readonly EditSectionViewModel[],
|
|
53
|
+
fieldsFilter: readonly string[] | undefined,
|
|
54
|
+
): readonly EditSectionViewModel[] {
|
|
55
|
+
if (fieldsFilter === undefined) return sections;
|
|
56
|
+
const filterSet = new Set(fieldsFilter);
|
|
57
|
+
const result: EditSectionViewModel[] = [];
|
|
58
|
+
for (const section of sections) {
|
|
59
|
+
if (section.kind === "extension") {
|
|
60
|
+
result.push(section);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const fields = section.fields.filter((f) => filterSet.has(f.field));
|
|
64
|
+
if (fields.length === 0) continue;
|
|
65
|
+
result.push({ ...section, fields });
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|