@isi-ui7/bos7-shared 0.2.4 → 0.2.6
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/dist/crud-components.d.ts +2 -0
- package/dist/crud-hooks.d.ts +1 -1
- package/dist/crud-types.d.ts +0 -2
- package/dist/form-numeric.d.ts +12 -0
- package/dist/form-renderer.d.ts +4 -2
- package/dist/form-types.d.ts +61 -2
- package/dist/form-value-schema.d.ts +118 -0
- package/dist/index.d.ts +1 -0
- package/dist/{index.es-B4p6mwaz.js → index.es-Bylk5fh-.js} +1 -1
- package/dist/index.js +4766 -4535
- package/dist/{jspdf.es.min-CpRUhh3E.js → jspdf.es.min-Dzm5j8wC.js} +2 -2
- package/dist/{jspdf.plugin.autotable-DFaknRns.js → jspdf.plugin.autotable-DcntYaes.js} +22 -19
- package/dist/purify.es-CiEWEeUM.js +605 -0
- package/dist/shell/app-shell-layout.d.ts +20 -5
- package/dist/style-contract.d.ts +20 -5
- package/dist/workflow/starter.d.ts +13 -0
- package/package.json +10 -7
- package/src/crud-components.tsx +27 -2
- package/src/crud-hooks.ts +5 -2
- package/src/crud-types.ts +0 -2
- package/src/form-contract.css +64 -2
- package/src/form-numeric.ts +16 -0
- package/src/form-renderer.tsx +303 -12
- package/src/form-types.ts +67 -2
- package/src/form-value-schema.test.ts +263 -0
- package/src/form-value-schema.ts +495 -0
- package/src/index.ts +1 -0
- package/src/shell/app-shell-layout.tsx +20 -44
- package/src/style-contract.test.ts +1 -0
- package/src/style-contract.ts +38 -5
- package/src/workflow/starter.ts +48 -11
- package/dist/purify.es-Cm3utOpm.js +0 -560
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildValueSections, type JSONSchema } from "./form-value-schema";
|
|
3
|
+
import type { FormFieldNumericConfig } from "./form-types";
|
|
4
|
+
|
|
5
|
+
// Mock translate: prefer the provided fallback, else echo the key.
|
|
6
|
+
const t = (key: string, fallback?: string) => fallback ?? key;
|
|
7
|
+
|
|
8
|
+
type ValueData = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
describe("buildValueSections — transaction_limit (two-limit + x-rules)", () => {
|
|
11
|
+
const schema: JSONSchema = {
|
|
12
|
+
type: "object",
|
|
13
|
+
required: ["transaction_limit", "authorization_limit", "currency"],
|
|
14
|
+
properties: {
|
|
15
|
+
transaction_limit: {
|
|
16
|
+
type: "number",
|
|
17
|
+
minimum: 0,
|
|
18
|
+
"x-ui": {
|
|
19
|
+
labelKey: "policy.value.txnLimit",
|
|
20
|
+
numeric: { kind: "currency", currencyField: "currency" },
|
|
21
|
+
span: 6,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
authorization_limit: {
|
|
25
|
+
type: "number",
|
|
26
|
+
minimum: 0,
|
|
27
|
+
"x-ui": {
|
|
28
|
+
labelKey: "policy.value.authLimit",
|
|
29
|
+
numeric: { kind: "currency", currencyField: "currency" },
|
|
30
|
+
span: 6,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
currency: {
|
|
34
|
+
type: "string",
|
|
35
|
+
enum: ["IDR"],
|
|
36
|
+
default: "IDR",
|
|
37
|
+
"x-ui": { widget: "select", labelKey: "policy.value.currency", span: 6 },
|
|
38
|
+
},
|
|
39
|
+
scope: {
|
|
40
|
+
type: "string",
|
|
41
|
+
enum: ["per_transaction", "per_day", "per_month"],
|
|
42
|
+
"x-ui": { widget: "select", labelKey: "policy.value.scope", span: 6 },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
"x-ui": {
|
|
46
|
+
layout: {
|
|
47
|
+
sections: [
|
|
48
|
+
{ titleKey: "policy.section.limits", fields: ["transaction_limit", "authorization_limit"] },
|
|
49
|
+
{ titleKey: "policy.section.meta", fields: ["currency", "scope"] },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
"x-rules": [
|
|
54
|
+
{
|
|
55
|
+
op: "lte",
|
|
56
|
+
left: "authorization_limit",
|
|
57
|
+
right: "transaction_limit",
|
|
58
|
+
message: "Authorization limit must be ≤ transaction limit",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
it("groups properties into the two layout sections", () => {
|
|
64
|
+
const { sections } = buildValueSections(schema, t);
|
|
65
|
+
expect(sections).toHaveLength(2);
|
|
66
|
+
expect(sections[0].title).toBe("policy.section.limits");
|
|
67
|
+
expect(sections[0].fields.map((f) => f.key)).toEqual([
|
|
68
|
+
"transaction_limit",
|
|
69
|
+
"authorization_limit",
|
|
70
|
+
]);
|
|
71
|
+
expect(sections[1].title).toBe("policy.section.meta");
|
|
72
|
+
expect(sections[1].fields.map((f) => f.key)).toEqual(["currency", "scope"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("renders the two limits as side-by-side currency fields", () => {
|
|
76
|
+
const { sections } = buildValueSections(schema, t);
|
|
77
|
+
const [txn, auth] = sections[0].fields;
|
|
78
|
+
expect(txn.type).toBe("number");
|
|
79
|
+
expect(txn.span).toBe(6);
|
|
80
|
+
const num = txn.numeric as Extract<FormFieldNumericConfig<ValueData>, { kind: "currency" }>;
|
|
81
|
+
expect(num.kind).toBe("currency");
|
|
82
|
+
expect(num.currencyField).toBe("currency");
|
|
83
|
+
// precision defaults applied when x-ui omits them
|
|
84
|
+
expect(num.precisionByCurrency).toMatchObject({ IDR: 2 });
|
|
85
|
+
expect(auth.numeric).toMatchObject({ kind: "currency", currencyField: "currency" });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("maps required + minimum into per-field validation", () => {
|
|
89
|
+
const { sections } = buildValueSections(schema, t);
|
|
90
|
+
const txn = sections[0].fields[0];
|
|
91
|
+
expect(txn.validation).toMatchObject({ required: true, min: 0 });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("renders currency/scope as selects with enum options", () => {
|
|
95
|
+
const { sections } = buildValueSections(schema, t);
|
|
96
|
+
const [currency, scope] = sections[1].fields;
|
|
97
|
+
expect(currency.type).toBe("select");
|
|
98
|
+
expect(currency.options).toEqual([{ value: "IDR", label: "IDR" }]);
|
|
99
|
+
expect(scope.options?.map((o) => o.value)).toEqual([
|
|
100
|
+
"per_transaction",
|
|
101
|
+
"per_day",
|
|
102
|
+
"per_month",
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("produces an lte cross-field validator that blocks an over-limit value", () => {
|
|
107
|
+
const { validate } = buildValueSections(schema, t);
|
|
108
|
+
expect(validate({ transaction_limit: 1000, authorization_limit: 2000 })).toEqual({
|
|
109
|
+
authorization_limit: "Authorization limit must be ≤ transaction limit",
|
|
110
|
+
});
|
|
111
|
+
// valid case: auth ≤ txn → no error
|
|
112
|
+
expect(validate({ transaction_limit: 2000, authorization_limit: 1000 })).toEqual({});
|
|
113
|
+
// incomplete input → skip (per-field required handles emptiness)
|
|
114
|
+
expect(validate({ transaction_limit: 1000 })).toEqual({});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("buildValueSections — rate", () => {
|
|
119
|
+
const schema: JSONSchema = {
|
|
120
|
+
type: "object",
|
|
121
|
+
required: ["rate", "rate_unit"],
|
|
122
|
+
properties: {
|
|
123
|
+
rate: { type: "number", minimum: 0, "x-ui": { numeric: { kind: "percent", maxFractionDigits: 4 }, span: 6 } },
|
|
124
|
+
rate_unit: {
|
|
125
|
+
type: "string",
|
|
126
|
+
enum: ["percent_per_year", "percent_per_month"],
|
|
127
|
+
"x-ui": { widget: "select", span: 6 },
|
|
128
|
+
},
|
|
129
|
+
tenor_months: { type: "integer", minimum: 0, "x-ui": { numeric: { kind: "integer" }, span: 6 } },
|
|
130
|
+
calculation_method: {
|
|
131
|
+
type: "string",
|
|
132
|
+
enum: ["simple_interest", "compound"],
|
|
133
|
+
"x-ui": { widget: "select", span: 6 },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
it("falls back to a single 'Value' section ordered by declaration", () => {
|
|
139
|
+
const { sections } = buildValueSections(schema, t);
|
|
140
|
+
expect(sections).toHaveLength(1);
|
|
141
|
+
expect(sections[0].title).toBe("Value");
|
|
142
|
+
expect(sections[0].fields.map((f) => f.key)).toEqual([
|
|
143
|
+
"rate",
|
|
144
|
+
"rate_unit",
|
|
145
|
+
"tenor_months",
|
|
146
|
+
"calculation_method",
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("maps percent + integer numeric kinds", () => {
|
|
151
|
+
const { sections } = buildValueSections(schema, t);
|
|
152
|
+
const [rate, , tenor] = sections[0].fields;
|
|
153
|
+
expect(rate.numeric).toEqual({ kind: "percent", maxFractionDigits: 4 });
|
|
154
|
+
expect(tenor.numeric).toEqual({ kind: "integer" });
|
|
155
|
+
expect(tenor.inputMode).toBe("numeric");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("has no x-rules → validator returns no errors", () => {
|
|
159
|
+
const { validate } = buildValueSections(schema, t);
|
|
160
|
+
expect(validate({ rate: 5, rate_unit: "percent_per_year" })).toEqual({});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("buildValueSections — operational_hours", () => {
|
|
165
|
+
const schema: JSONSchema = {
|
|
166
|
+
type: "object",
|
|
167
|
+
required: ["day_of_week"],
|
|
168
|
+
properties: {
|
|
169
|
+
day_of_week: {
|
|
170
|
+
type: "string",
|
|
171
|
+
enum: ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"],
|
|
172
|
+
"x-ui": { widget: "select", span: 4 },
|
|
173
|
+
},
|
|
174
|
+
is_open: { type: "boolean", "x-ui": { widget: "toggle", span: 4 } },
|
|
175
|
+
open_time: { type: "string", pattern: "^[0-2][0-9]:[0-5][0-9]$", "x-ui": { placeholder: "HH:MM", span: 4 } },
|
|
176
|
+
close_time: { type: "string", pattern: "^[0-2][0-9]:[0-5][0-9]$", "x-ui": { placeholder: "HH:MM", span: 4 } },
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
it("infers toggle, select, and pattern-validated text fields", () => {
|
|
181
|
+
const { sections } = buildValueSections(schema, t);
|
|
182
|
+
const fields = sections[0].fields;
|
|
183
|
+
const byKey = Object.fromEntries(fields.map((f) => [f.key, f]));
|
|
184
|
+
expect(byKey.day_of_week.type).toBe("select");
|
|
185
|
+
expect(byKey.is_open.type).toBe("toggle");
|
|
186
|
+
expect(byKey.open_time.type).toBe("text");
|
|
187
|
+
expect(byKey.open_time.placeholder).toBe("HH:MM");
|
|
188
|
+
expect((byKey.open_time.validation?.pattern as RegExp).source).toBe("^[0-2][0-9]:[0-5][0-9]$");
|
|
189
|
+
expect(byKey.day_of_week.validation?.required).toBe(true);
|
|
190
|
+
expect(byKey.is_open.validation).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("buildValueSections — tiered array → detail-rows", () => {
|
|
195
|
+
const schema: JSONSchema = {
|
|
196
|
+
type: "object",
|
|
197
|
+
properties: {
|
|
198
|
+
tiers: {
|
|
199
|
+
type: "array",
|
|
200
|
+
items: {
|
|
201
|
+
type: "object",
|
|
202
|
+
required: ["tenor_months", "rate"],
|
|
203
|
+
properties: {
|
|
204
|
+
tenor_months: { type: "integer", "x-ui": { numeric: { kind: "integer" } } },
|
|
205
|
+
rate: { type: "number", "x-ui": { numeric: { kind: "percent", maxFractionDigits: 4 } } },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
"x-ui": { widget: "detail-rows", labelKey: "policy.value.tiers", maxRows: 20, span: 12 },
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
it("maps an array-of-object property to a detail-rows field", () => {
|
|
214
|
+
const { sections } = buildValueSections(schema, t);
|
|
215
|
+
const field = sections[0].fields[0];
|
|
216
|
+
expect(field.type).toBe("detail-rows");
|
|
217
|
+
expect(field.span).toBe(12);
|
|
218
|
+
expect(field.label).toBe("tiers"); // mock t() returns the fallback (field name)
|
|
219
|
+
expect(field.detailRows?.maxRows).toBe(20);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("derives columns + types from items.properties", () => {
|
|
223
|
+
const { sections } = buildValueSections(schema, t);
|
|
224
|
+
const detail = sections[0].fields[0].detailRows!;
|
|
225
|
+
expect(detail.columns.map((c) => c.field)).toEqual(["tenor_months", "rate"]);
|
|
226
|
+
expect(detail.columns[0].columnType).toMatchObject({ type: "number", decimals: 0 });
|
|
227
|
+
expect(detail.columns[1].columnType).toMatchObject({ type: "number", decimals: 4 });
|
|
228
|
+
expect(detail.columns.every((c) => c.required)).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("newRowFactory seeds typed defaults; validateRow flags missing required", () => {
|
|
232
|
+
const { sections } = buildValueSections(schema, t);
|
|
233
|
+
const detail = sections[0].fields[0].detailRows!;
|
|
234
|
+
expect(detail.newRowFactory()).toEqual({ tenor_months: 0, rate: 0 });
|
|
235
|
+
expect(detail.validateRow?.({ tenor_months: "", rate: 5 }, 0)).toHaveProperty("tenor_months");
|
|
236
|
+
expect(detail.validateRow?.({ tenor_months: 12, rate: 5 }, 0)).toEqual({});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("buildXRulesValidator op coverage", () => {
|
|
241
|
+
it("handles gte / lt / gt / eq / required-if", () => {
|
|
242
|
+
const mk = (rule: NonNullable<JSONSchema["x-rules"]>[number]) =>
|
|
243
|
+
buildValueSections({ type: "object", properties: {}, "x-rules": [rule] }, t).validate;
|
|
244
|
+
|
|
245
|
+
expect(mk({ op: "gte", left: "a", right: "b", message: "ge" })({ a: 1, b: 2 })).toEqual({ a: "ge" });
|
|
246
|
+
expect(mk({ op: "lt", left: "a", value: 10, message: "lt" })({ a: 10 })).toEqual({ a: "lt" });
|
|
247
|
+
expect(mk({ op: "gt", left: "a", value: 0, message: "gt" })({ a: 0 })).toEqual({ a: "gt" });
|
|
248
|
+
expect(mk({ op: "eq", left: "a", value: "IDR", message: "eq" })({ a: "USD" })).toEqual({ a: "eq" });
|
|
249
|
+
expect(
|
|
250
|
+
mk({ op: "required-if", left: "reason", right: "kind", value: "OTHER", message: "req" })({
|
|
251
|
+
kind: "OTHER",
|
|
252
|
+
reason: "",
|
|
253
|
+
}),
|
|
254
|
+
).toEqual({ reason: "req" });
|
|
255
|
+
// condition not met → no error
|
|
256
|
+
expect(
|
|
257
|
+
mk({ op: "required-if", left: "reason", right: "kind", value: "OTHER", message: "req" })({
|
|
258
|
+
kind: "NORMAL",
|
|
259
|
+
reason: "",
|
|
260
|
+
}),
|
|
261
|
+
).toEqual({});
|
|
262
|
+
});
|
|
263
|
+
});
|