@isi-ui7/bos7-shared 0.2.3 → 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/src/form-types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
- import type { Ui7FormDensity } from "./style-contract";
2
+ import type { Ui7FormDensity, Ui7FormWidth } from "./style-contract";
3
3
  import type { T_LookupTblStruct } from "@isi-ui7/lookup-input";
4
+ import type { EditableColumnDef } from "@isi-ui7/editable-table";
4
5
 
5
6
  export type FormMode = "create" | "edit" | "view";
6
7
 
@@ -31,7 +32,8 @@ export type FieldValidation<TData extends Record<string, unknown>> = {
31
32
 
32
33
  export type FormFieldType =
33
34
  | "text" | "textarea" | "number" | "date"
34
- | "select" | "lookup" | "checkbox" | "toggle";
35
+ | "select" | "lookup" | "checkbox" | "toggle"
36
+ | "detail-rows";
35
37
 
36
38
  export type FormFieldOption = { value: string; label: string };
37
39
 
@@ -45,6 +47,17 @@ export type FormFieldLookupConfig<TData extends Record<string, unknown>> = {
45
47
  dataSource?: "api" | "direct";
46
48
  /** Return a patch to merge into the form when a lookup row is selected. */
47
49
  onDataPatch?: (row: Record<string, unknown>, data: TData) => Partial<TData>;
50
+ /**
51
+ * Form field keys whose values combine into the initial display text on
52
+ * Edit load. Example: `["kode_cabang","branch_name"]` renders
53
+ * "001 KANTOR CABANG BANDUNG" instead of just "001". Typical pairing
54
+ * with an onDataPatch that stashes the picked row's name into a
55
+ * companion field. If any referenced field is empty, the lookup falls
56
+ * back to the field's own primary-key value.
57
+ */
58
+ initialDisplayFields?: (keyof TData)[];
59
+ /** Separator joining initialDisplayFields. Default: " " */
60
+ initialDisplaySeparator?: string;
48
61
  };
49
62
 
50
63
  export type FormFieldNumericConfig<TData extends Record<string, unknown>> =
@@ -53,11 +66,56 @@ export type FormFieldNumericConfig<TData extends Record<string, unknown>> =
53
66
  | { kind: "integer" }
54
67
  | { kind: "phone"; minDigits: number; maxDigits: number; allowedPrefixes?: string[] };
55
68
 
69
+ /**
70
+ * Toggle field state mapping for non-boolean stored values.
71
+ *
72
+ * Example for a "Y" / "N" string column:
73
+ * { type: "toggle", toggle: { valueOn: "Y", valueOff: "N" } }
74
+ *
75
+ * Toggled = (current value === valueOn). Flipping the toggle writes
76
+ * either `valueOn` or `valueOff` into the form data. Defaults to
77
+ * boolean true / false when omitted.
78
+ */
79
+ export type FormFieldToggleConfig = {
80
+ valueOn?: unknown;
81
+ valueOff?: unknown;
82
+ };
83
+
84
+ /**
85
+ * Detail-rows config — embeds an inline `<EditableTable>` inside the form for
86
+ * master-detail entry. The field value at `key` must be an array of plain
87
+ * objects (e.g. `details: BucketRow[]`). The renderer reads / writes that
88
+ * array atomically via `onChange({ ...data, [key]: newRows })`.
89
+ *
90
+ * `columns`, `newRowFactory`, `validateRow`, `maxRows` mirror the props of
91
+ * `<EditableTable>` from `@isi-ui7/editable-table`.
92
+ */
93
+ export type DetailRowsConfig = {
94
+ columns: EditableColumnDef[];
95
+ newRowFactory: () => Record<string, unknown>;
96
+ validateRow?: (
97
+ row: Record<string, unknown>,
98
+ index: number,
99
+ ) => Record<string, string>;
100
+ maxRows?: number;
101
+ };
102
+
56
103
  export type FormField<TData extends Record<string, unknown>> = {
57
104
  key: keyof TData;
58
105
  label: ReactNode;
59
106
  type: FormFieldType;
60
107
  span?: number;
108
+ /**
109
+ * Force this field to start a new row even when the current row still
110
+ * has space for its `span`. Useful to break a logical group onto its
111
+ * own line (e.g. a wide picker that should sit alone, or a sub-group
112
+ * header pattern within one section).
113
+ *
114
+ * Implemented via `gridColumnStart: 1` — the CSS grid skips the
115
+ * remaining columns of the previous row and places this field at the
116
+ * left edge of a new row.
117
+ */
118
+ breakBefore?: boolean;
61
119
  /** Static or dynamic readonly. Function receives current mode + form data. */
62
120
  readonly?: boolean | ((mode: FormMode, data: TData) => boolean);
63
121
  /** Static or dynamic visibility. Hidden fields are skipped in validation. */
@@ -72,6 +130,8 @@ export type FormField<TData extends Record<string, unknown>> = {
72
130
  options?: FormFieldOption[];
73
131
  lookup?: FormFieldLookupConfig<TData>;
74
132
  numeric?: FormFieldNumericConfig<TData>;
133
+ toggle?: FormFieldToggleConfig;
134
+ detailRows?: DetailRowsConfig;
75
135
  format?: (value: TData[keyof TData], data: TData) => ReactNode;
76
136
  validation?: FieldValidation<TData>;
77
137
  };
@@ -111,6 +171,11 @@ export type CrudForm<TData extends Record<string, unknown>> = {
111
171
  backLabel?: { view?: string; others?: string};
112
172
  /** Form layout density. Defaults to "compact". */
113
173
  density?: Ui7FormDensity;
174
+ /**
175
+ * Desktop form-panel width — "half" | "two-thirds" | "full".
176
+ * Default "two-thirds". Tablet + mobile (<1056px) always stretch full.
177
+ */
178
+ width?: Ui7FormWidth;
114
179
 
115
180
  emptyData: TData;
116
181
 
@@ -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
+ });