@isi-ui7/bos7-shared 0.2.4 → 0.2.7

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
 
@@ -30,10 +31,17 @@ export type FieldValidation<TData extends Record<string, unknown>> = {
30
31
  // ── Field ─────────────────────────────────────────────────────────────────────
31
32
 
32
33
  export type FormFieldType =
33
- | "text" | "textarea" | "number" | "date"
34
- | "select" | "lookup" | "checkbox" | "toggle";
35
-
36
- export type FormFieldOption = { value: string; label: string };
34
+ | "text" | "textarea" | "number" | "date" | "datetime"
35
+ | "select" | "lookup" | "checkbox" | "toggle"
36
+ | "detail-rows";
37
+
38
+ /**
39
+ * Select option. `value` may be a number for numeric-coded columns (e.g.
40
+ * kolektibilitas 1–5); the renderer compares via `String(value)` and the
41
+ * native `<select>` still round-trips strings, so `format`/`onDataPatch`
42
+ * should not assume the runtime type survives an edit.
43
+ */
44
+ export type FormFieldOption = { value: string | number; label: string };
37
45
 
38
46
  export type FormFieldLookupConfig<TData extends Record<string, unknown>> = {
39
47
  lookupAPI: string;
@@ -45,6 +53,17 @@ export type FormFieldLookupConfig<TData extends Record<string, unknown>> = {
45
53
  dataSource?: "api" | "direct";
46
54
  /** Return a patch to merge into the form when a lookup row is selected. */
47
55
  onDataPatch?: (row: Record<string, unknown>, data: TData) => Partial<TData>;
56
+ /**
57
+ * Form field keys whose values combine into the initial display text on
58
+ * Edit load. Example: `["kode_cabang","branch_name"]` renders
59
+ * "001 KANTOR CABANG BANDUNG" instead of just "001". Typical pairing
60
+ * with an onDataPatch that stashes the picked row's name into a
61
+ * companion field. If any referenced field is empty, the lookup falls
62
+ * back to the field's own primary-key value.
63
+ */
64
+ initialDisplayFields?: (keyof TData)[];
65
+ /** Separator joining initialDisplayFields. Default: " " */
66
+ initialDisplaySeparator?: string;
48
67
  };
49
68
 
50
69
  export type FormFieldNumericConfig<TData extends Record<string, unknown>> =
@@ -53,11 +72,63 @@ export type FormFieldNumericConfig<TData extends Record<string, unknown>> =
53
72
  | { kind: "integer" }
54
73
  | { kind: "phone"; minDigits: number; maxDigits: number; allowedPrefixes?: string[] };
55
74
 
75
+ /**
76
+ * Toggle field state mapping for non-boolean stored values.
77
+ *
78
+ * Example for a "Y" / "N" string column:
79
+ * { type: "toggle", toggle: { valueOn: "Y", valueOff: "N" } }
80
+ *
81
+ * Toggled = (current value === valueOn). Flipping the toggle writes
82
+ * either `valueOn` or `valueOff` into the form data. Defaults to
83
+ * boolean true / false when omitted.
84
+ */
85
+ export type FormFieldToggleConfig = {
86
+ valueOn?: unknown;
87
+ valueOff?: unknown;
88
+ };
89
+
90
+ /**
91
+ * Detail-rows config — embeds an inline `<EditableTable>` inside the form for
92
+ * master-detail entry. The field value at `key` must be an array of plain
93
+ * objects (e.g. `details: BucketRow[]`). The renderer reads / writes that
94
+ * array atomically via `onChange({ ...data, [key]: newRows })`.
95
+ *
96
+ * `columns`, `newRowFactory`, `validateRow`, `maxRows` mirror the props of
97
+ * `<EditableTable>` from `@isi-ui7/editable-table`.
98
+ */
99
+ export type DetailRowsConfig = {
100
+ columns: EditableColumnDef[];
101
+ newRowFactory: () => Record<string, unknown>;
102
+ validateRow?: (
103
+ row: Record<string, unknown>,
104
+ index: number,
105
+ ) => Record<string, string>;
106
+ maxRows?: number;
107
+ /**
108
+ * Assignment/master-detail mode: rows loaded from the server render
109
+ * delete-only (read-only cells, no lookup picker) while rows added in the
110
+ * current session stay editable. Defaults to `false` (normal edit-in-place).
111
+ * Forwarded to `<EditableTable lockExistingRows>`.
112
+ */
113
+ lockExistingRows?: boolean;
114
+ };
115
+
56
116
  export type FormField<TData extends Record<string, unknown>> = {
57
117
  key: keyof TData;
58
118
  label: ReactNode;
59
119
  type: FormFieldType;
60
120
  span?: number;
121
+ /**
122
+ * Force this field to start a new row even when the current row still
123
+ * has space for its `span`. Useful to break a logical group onto its
124
+ * own line (e.g. a wide picker that should sit alone, or a sub-group
125
+ * header pattern within one section).
126
+ *
127
+ * Implemented via `gridColumnStart: 1` — the CSS grid skips the
128
+ * remaining columns of the previous row and places this field at the
129
+ * left edge of a new row.
130
+ */
131
+ breakBefore?: boolean;
61
132
  /** Static or dynamic readonly. Function receives current mode + form data. */
62
133
  readonly?: boolean | ((mode: FormMode, data: TData) => boolean);
63
134
  /** Static or dynamic visibility. Hidden fields are skipped in validation. */
@@ -72,6 +143,8 @@ export type FormField<TData extends Record<string, unknown>> = {
72
143
  options?: FormFieldOption[];
73
144
  lookup?: FormFieldLookupConfig<TData>;
74
145
  numeric?: FormFieldNumericConfig<TData>;
146
+ toggle?: FormFieldToggleConfig;
147
+ detailRows?: DetailRowsConfig;
75
148
  format?: (value: TData[keyof TData], data: TData) => ReactNode;
76
149
  validation?: FieldValidation<TData>;
77
150
  };
@@ -111,6 +184,11 @@ export type CrudForm<TData extends Record<string, unknown>> = {
111
184
  backLabel?: { view?: string; others?: string};
112
185
  /** Form layout density. Defaults to "compact". */
113
186
  density?: Ui7FormDensity;
187
+ /**
188
+ * Desktop form-panel width — "half" | "two-thirds" | "full".
189
+ * Default "two-thirds". Tablet + mobile (<1056px) always stretch full.
190
+ */
191
+ width?: Ui7FormWidth;
114
192
 
115
193
  emptyData: TData;
116
194
 
@@ -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
+ });