@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/dist/auth7/delegated-proxy.d.ts +8 -1
- package/dist/auth7/index.d.ts +2 -0
- package/dist/auth7/switch-branch-bff.d.ts +7 -0
- package/dist/crud-components.d.ts +2 -0
- package/dist/crud-hooks.d.ts +1 -1
- package/dist/data-table/proxy.d.ts +25 -0
- 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 +3 -0
- package/dist/index.js +4579 -4215
- 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 +12 -9
- package/src/auth7/delegated-proxy.ts +62 -5
- package/src/auth7/index.ts +2 -0
- package/src/auth7/switch-branch-bff.test.ts +121 -0
- package/src/auth7/switch-branch-bff.ts +119 -0
- package/src/crud-components.tsx +27 -2
- package/src/crud-hooks.ts +5 -2
- package/src/data-table/proxy.ts +73 -0
- 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 +3 -0
- package/src/shell/app-shell-layout.tsx +56 -49
- package/src/style-contract.test.ts +1 -0
- package/src/style-contract.ts +38 -5
- package/src/workflow/starter.ts +48 -11
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Server-side only. Forwards a DataTable POST /query request to a backend service
|
|
2
|
+
// using a delegated JWT (RFC 8693 token exchange).
|
|
3
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
4
|
+
import { exchangeUserToken } from "../auth7/token-exchange";
|
|
5
|
+
|
|
6
|
+
export interface ProxyBackendPostOptions {
|
|
7
|
+
backendUrl: string;
|
|
8
|
+
path: string;
|
|
9
|
+
audience: string;
|
|
10
|
+
accessTokenCookie?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function proxyBackendPost(
|
|
14
|
+
req: NextRequest,
|
|
15
|
+
opts: ProxyBackendPostOptions,
|
|
16
|
+
): Promise<NextResponse> {
|
|
17
|
+
const accessToken = req.cookies.get(opts.accessTokenCookie ?? "access_token")?.value;
|
|
18
|
+
const result = await exchangeUserToken(accessToken ?? "", opts.audience);
|
|
19
|
+
const body = await req.json();
|
|
20
|
+
const res = await fetch(`${opts.backendUrl}${opts.path}`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: {
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
Authorization: `Bearer ${result.accessToken}`,
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
cache: "no-store",
|
|
28
|
+
});
|
|
29
|
+
const data = await res.json();
|
|
30
|
+
return NextResponse.json(data, { status: res.status });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ProxyQueryRouteOptions extends ProxyBackendPostOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Extra column type metadata to merge into the backend response's columnTypes.
|
|
36
|
+
* Useful when the BFF layer knows additional column types not present in the backend response.
|
|
37
|
+
* Optional — if omitted, backend response is forwarded as-is.
|
|
38
|
+
*/
|
|
39
|
+
extraColumnTypes?: Record<string, string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Forwards a DataTable POST /query request to a backend Pattern B endpoint.
|
|
44
|
+
* Semantically identical to proxyBackendPost but signals "this is a query/lookup
|
|
45
|
+
* endpoint" — used by ServerDataTable and LookupInput interchangeably.
|
|
46
|
+
*
|
|
47
|
+
* If extraColumnTypes is provided, merges them into the response's columnTypes field.
|
|
48
|
+
*/
|
|
49
|
+
export async function proxyQueryRoute(
|
|
50
|
+
req: NextRequest,
|
|
51
|
+
opts: ProxyQueryRouteOptions,
|
|
52
|
+
): Promise<NextResponse> {
|
|
53
|
+
if (!opts.extraColumnTypes) {
|
|
54
|
+
return proxyBackendPost(req, opts);
|
|
55
|
+
}
|
|
56
|
+
// Re-implement forward to avoid double body-stream consumption when merging columnTypes.
|
|
57
|
+
const accessToken = req.cookies.get(opts.accessTokenCookie ?? "access_token")?.value;
|
|
58
|
+
const result = await exchangeUserToken(accessToken ?? "", opts.audience);
|
|
59
|
+
const body = await req.json();
|
|
60
|
+
const res = await fetch(`${opts.backendUrl}${opts.path}`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: {
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
Authorization: `Bearer ${result.accessToken}`,
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify(body),
|
|
67
|
+
cache: "no-store",
|
|
68
|
+
});
|
|
69
|
+
const data = await res.json() as Record<string, unknown>;
|
|
70
|
+
const existing = (data.columnTypes as Record<string, string>) ?? {};
|
|
71
|
+
data.columnTypes = { ...existing, ...opts.extraColumnTypes };
|
|
72
|
+
return NextResponse.json(data, { status: res.status });
|
|
73
|
+
}
|
package/src/form-contract.css
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
/* Form contract — Carbon overrides scoped to .ui7-form-contract
|
|
2
2
|
Token source of truth: UI7_FORM_VISUAL_TOKENS in style-contract.ts */
|
|
3
3
|
|
|
4
|
+
/* Panel width — desktop-first (matches the rest of this file).
|
|
5
|
+
The width modifier (one of --width-half / --width-two-thirds / --width-full)
|
|
6
|
+
is always present on the root — `getUi7FormContractClassName` emits one,
|
|
7
|
+
defaulting to two-thirds when the consumer omits the option. Base scope
|
|
8
|
+
sets only the hard ceiling + alignment; modifier sets the width. The
|
|
9
|
+
breakpoint override at the bottom of the file releases the cap on tablet
|
|
10
|
+
+ mobile (<1056px) so fields can use full width on narrow screens. */
|
|
11
|
+
.ui7-form-contract {
|
|
12
|
+
max-width: 1200px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.ui7-form-contract--width-half {
|
|
16
|
+
width: 50%;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.ui7-form-contract--width-two-thirds {
|
|
20
|
+
width: 66.6667%;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.ui7-form-contract--width-full {
|
|
24
|
+
width: 100%;
|
|
25
|
+
}
|
|
26
|
+
|
|
4
27
|
/* Label: 12px / 500 / 16px */
|
|
5
28
|
.ui7-form-contract .cds--label {
|
|
6
29
|
margin-block-end: 0;
|
|
@@ -21,15 +44,54 @@
|
|
|
21
44
|
line-height: 1.25rem;
|
|
22
45
|
}
|
|
23
46
|
|
|
47
|
+
/* Control height — keep Select aligned with TextInput / DatePicker per density.
|
|
48
|
+
Carbon uses logical `block-size` (not `height`) on every input element and
|
|
49
|
+
resolves it through --cds-layout-size-height-* tokens that the React `size`
|
|
50
|
+
prop sets on the wrapper. A `height` rule alone loses the cascade because
|
|
51
|
+
`block-size` is a separate physical→logical property in horizontal writing
|
|
52
|
+
mode. We do TWO things to win cleanly:
|
|
53
|
+
1. Redirect Carbon's height tokens at the density-modifier class
|
|
54
|
+
(see @isi-ui7/corporate-themes globals.scss).
|
|
55
|
+
2. Set both `block-size` AND `height` here as a safety net, anchored to
|
|
56
|
+
the same var so density swaps stay in sync across all input flavours.
|
|
57
|
+
--ui7-form-control-height resolves to 1.75rem (compact) / 2.5rem (comfortable). */
|
|
58
|
+
.ui7-form-contract .cds--text-input,
|
|
59
|
+
.ui7-form-contract .cds--select-input,
|
|
60
|
+
.ui7-form-contract .cds--date-picker__input,
|
|
61
|
+
.ui7-form-contract .cds--number input,
|
|
62
|
+
.ui7-form-contract .cds--combo-box__input,
|
|
63
|
+
.ui7-form-contract .cds--dropdown {
|
|
64
|
+
block-size: var(--ui7-form-control-height);
|
|
65
|
+
min-block-size: var(--ui7-form-control-height);
|
|
66
|
+
max-block-size: var(--ui7-form-control-height);
|
|
67
|
+
height: var(--ui7-form-control-height);
|
|
68
|
+
min-height: var(--ui7-form-control-height);
|
|
69
|
+
}
|
|
70
|
+
|
|
24
71
|
/* Full-width date picker */
|
|
25
72
|
.ui7-form-contract .cds--date-picker,
|
|
26
73
|
.ui7-form-contract .cds--date-picker-container {
|
|
27
74
|
width: 100%;
|
|
28
75
|
}
|
|
29
76
|
|
|
77
|
+
/* ── Tablet + mobile: release the desktop width cap ─────────────────────── */
|
|
78
|
+
/* Below Carbon's lg breakpoint (1056px), narrow viewports need every
|
|
79
|
+
available pixel — any desktop width modifier (half / two-thirds / full)
|
|
80
|
+
would leave fields cramped. Release back to full width here, beating
|
|
81
|
+
the modifier selectors via specificity (compound) + later declaration. */
|
|
82
|
+
@media (max-width: 1055px) {
|
|
83
|
+
.ui7-form-contract,
|
|
84
|
+
.ui7-form-contract.ui7-form-contract--width-half,
|
|
85
|
+
.ui7-form-contract.ui7-form-contract--width-two-thirds,
|
|
86
|
+
.ui7-form-contract.ui7-form-contract--width-full {
|
|
87
|
+
width: 100%;
|
|
88
|
+
max-width: 100%;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
30
92
|
/* ── Mobile: collapse all form panels to single column ───────────────────── */
|
|
31
|
-
/*
|
|
32
|
-
|
|
93
|
+
/* Multi-column span values set via inline style are overridden below Carbon's
|
|
94
|
+
md breakpoint (671px). !important required to beat inline style. */
|
|
33
95
|
@media (max-width: 671px) {
|
|
34
96
|
.ui7-form-contract .ui7-form-row > .ui7-form-column {
|
|
35
97
|
grid-column: 1 / -1 !important;
|
package/src/form-numeric.ts
CHANGED
|
@@ -171,3 +171,19 @@ export const INDONESIA_CURRENCY_CONFIG: CurrencyPrecisionConfig = {
|
|
|
171
171
|
precisionByCurrency: { IDR: 2, USD: 4 },
|
|
172
172
|
defaultPrecision: 2,
|
|
173
173
|
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Whole-rupiah currency config — used for ledger-style fields like plafond,
|
|
177
|
+
* subsidi, agent limit, where the convention in Indonesian retail banking is
|
|
178
|
+
* to round to whole IDR (no sen). USD/USD-denominated fields still keep 2
|
|
179
|
+
* decimals (standard cents). Default precision is 0 so any other currency
|
|
180
|
+
* defaults to whole units in these contexts.
|
|
181
|
+
*
|
|
182
|
+
* Use this for "agent plafond / saldo / subsidi" style fields. For amounts
|
|
183
|
+
* that genuinely need sub-rupiah precision (e.g. percentage-derived fees,
|
|
184
|
+
* accrued interest), prefer `INDONESIA_CURRENCY_CONFIG` instead.
|
|
185
|
+
*/
|
|
186
|
+
export const INDONESIA_RUPIAH_WHOLE_CONFIG: CurrencyPrecisionConfig = {
|
|
187
|
+
precisionByCurrency: { IDR: 0, USD: 2 },
|
|
188
|
+
defaultPrecision: 0,
|
|
189
|
+
};
|
package/src/form-renderer.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
Toggle,
|
|
13
13
|
} from "@carbon/react";
|
|
14
14
|
import { LookupInput } from "@isi-ui7/lookup-input";
|
|
15
|
+
import { EditableTable } from "@isi-ui7/editable-table";
|
|
15
16
|
import { useI18n } from "@isi-ui7/i18n";
|
|
16
17
|
import type { Ui7Locale } from "@isi-ui7/i18n";
|
|
17
18
|
import {
|
|
@@ -27,7 +28,7 @@ import {
|
|
|
27
28
|
UI7_FORM_VISUAL_CLASSNAMES,
|
|
28
29
|
UI7_FORM_VISUAL_TOKENS,
|
|
29
30
|
} from "./style-contract";
|
|
30
|
-
import type { Ui7FormDensity } from "./style-contract";
|
|
31
|
+
import type { Ui7FormDensity, Ui7FormWidth } from "./style-contract";
|
|
31
32
|
import { useBosSharedI18n } from "./i18n";
|
|
32
33
|
import type { FormField, FormMode, FormSection } from "./form-types";
|
|
33
34
|
|
|
@@ -44,6 +45,8 @@ export type SchemaFormRendererProps<TData extends Record<string, unknown>> = {
|
|
|
44
45
|
scopeClassName?: string;
|
|
45
46
|
/** Form layout density. Defaults to "compact". */
|
|
46
47
|
density?: Ui7FormDensity;
|
|
48
|
+
/** Desktop form-panel width. Defaults to "two-thirds". */
|
|
49
|
+
width?: Ui7FormWidth;
|
|
47
50
|
};
|
|
48
51
|
|
|
49
52
|
function clampSpan(span: number | undefined, defaultSpan: number): number {
|
|
@@ -80,7 +83,27 @@ function makeDisplayRenderer(
|
|
|
80
83
|
): ReactNode {
|
|
81
84
|
if (field.format) return field.format(value as never, data as never);
|
|
82
85
|
if (field.type === "checkbox") return value ? labels.boolYes : labels.boolNo;
|
|
83
|
-
if (field.type === "toggle")
|
|
86
|
+
if (field.type === "toggle") {
|
|
87
|
+
// valueOn / valueOff override the default boolean truthiness check
|
|
88
|
+
// for non-boolean stored values (e.g., "Y"/"N").
|
|
89
|
+
if (field.toggle && field.toggle.valueOn !== undefined) {
|
|
90
|
+
return value === field.toggle.valueOn ? labels.toggleOn : labels.toggleOff;
|
|
91
|
+
}
|
|
92
|
+
return value ? labels.toggleOn : labels.toggleOff;
|
|
93
|
+
}
|
|
94
|
+
if (field.type === "lookup") {
|
|
95
|
+
// View/read-only mode mirrors the LookupInput initialDisplay logic:
|
|
96
|
+
// when initialDisplayFields is set and all referenced FormData
|
|
97
|
+
// values are non-empty, render the compound "kode - nama". Falls
|
|
98
|
+
// back to the raw primary key when companion fields are missing.
|
|
99
|
+
const lookup = field.lookup;
|
|
100
|
+
if (lookup?.initialDisplayFields?.length) {
|
|
101
|
+
const sep = lookup.initialDisplaySeparator ?? " ";
|
|
102
|
+
const parts = lookup.initialDisplayFields.map((k) => asString(data[String(k)]));
|
|
103
|
+
if (parts.every((p) => p !== "")) return parts.join(sep);
|
|
104
|
+
}
|
|
105
|
+
return asString(value) || labels.emptyValue;
|
|
106
|
+
}
|
|
84
107
|
if (field.type === "number" && typeof value === "number") {
|
|
85
108
|
const nloc = locale === "en" ? "en-US" : "id-ID";
|
|
86
109
|
const numeric = field.numeric;
|
|
@@ -158,6 +181,7 @@ function NumericField({
|
|
|
158
181
|
value,
|
|
159
182
|
numericKind,
|
|
160
183
|
prec,
|
|
184
|
+
currencyCode,
|
|
161
185
|
locale,
|
|
162
186
|
size,
|
|
163
187
|
disabled,
|
|
@@ -172,6 +196,13 @@ function NumericField({
|
|
|
172
196
|
value: number;
|
|
173
197
|
numericKind: "currency" | "percent" | "integer" | "decimal";
|
|
174
198
|
prec?: number;
|
|
199
|
+
/**
|
|
200
|
+
* Currency ISO code (e.g. "IDR", "USD") — only used when numericKind ===
|
|
201
|
+
* "currency". When set, the code is shown as a subdued suffix next to the
|
|
202
|
+
* field label so the user sees which currency they're entering, mirroring
|
|
203
|
+
* the view-mode renderer which shows `Rp 1.000.000` / `IDR 1.000.000`.
|
|
204
|
+
*/
|
|
205
|
+
currencyCode?: string;
|
|
175
206
|
locale: Ui7Locale;
|
|
176
207
|
size?: "sm" | "md" | "lg";
|
|
177
208
|
disabled?: boolean;
|
|
@@ -209,19 +240,94 @@ function NumericField({
|
|
|
209
240
|
return parsed.ok ? parsed.value : 0;
|
|
210
241
|
};
|
|
211
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Input-time sanitizer — drops any character the user types that isn't part
|
|
245
|
+
* of a valid number in the active locale. Without this, typing "abc" would
|
|
246
|
+
* stick in the textbox (because `raw` stores the user's verbatim keystrokes)
|
|
247
|
+
* while the parsed numeric value silently becomes 0. Filtering at input gives
|
|
248
|
+
* immediate visual feedback: rejected chars never appear at all.
|
|
249
|
+
*
|
|
250
|
+
* Rules:
|
|
251
|
+
* - Integer: 0–9 plus a single leading minus.
|
|
252
|
+
* - Decimal / currency / percent: 0–9, thousand separator, a single decimal
|
|
253
|
+
* separator, leading minus. Decimal separator is rejected entirely when
|
|
254
|
+
* `prec === 0` (no fractional part allowed), and excess fraction digits
|
|
255
|
+
* beyond `prec` are clipped on the fly so users see the limit enforced.
|
|
256
|
+
*/
|
|
257
|
+
const sanitizeInput = (input: string): string => {
|
|
258
|
+
let out = "";
|
|
259
|
+
let seenMinus = false;
|
|
260
|
+
if (numericKind === "integer") {
|
|
261
|
+
for (let i = 0; i < input.length; i++) {
|
|
262
|
+
const c = input[i];
|
|
263
|
+
if (c >= "0" && c <= "9") out += c;
|
|
264
|
+
else if (c === "-" && out === "" && !seenMinus) {
|
|
265
|
+
out += c;
|
|
266
|
+
seenMinus = true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
let seenDecimal = false;
|
|
272
|
+
const allowDecimal = prec === undefined || prec > 0;
|
|
273
|
+
for (let i = 0; i < input.length; i++) {
|
|
274
|
+
const c = input[i];
|
|
275
|
+
if (c >= "0" && c <= "9") out += c;
|
|
276
|
+
else if (c === decimalSep && !seenDecimal && allowDecimal) {
|
|
277
|
+
out += c;
|
|
278
|
+
seenDecimal = true;
|
|
279
|
+
} else if (c === thousandSep) out += c;
|
|
280
|
+
else if (c === "-" && out === "" && !seenMinus) {
|
|
281
|
+
out += c;
|
|
282
|
+
seenMinus = true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Clip excess fraction digits to declared precision.
|
|
286
|
+
if (typeof prec === "number" && prec > 0 && seenDecimal) {
|
|
287
|
+
const decIdx = out.indexOf(decimalSep);
|
|
288
|
+
const intPart = out.slice(0, decIdx);
|
|
289
|
+
const fracPart = out.slice(decIdx + 1);
|
|
290
|
+
if (fracPart.length > prec) {
|
|
291
|
+
out = intPart + decimalSep + fracPart.slice(0, prec);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
};
|
|
296
|
+
|
|
212
297
|
const displayValue = raw !== null ? raw : formatDisplay(value);
|
|
213
298
|
|
|
214
|
-
|
|
299
|
+
// Auto helper-text for currency fields with non-zero precision — surfaces
|
|
300
|
+
// the decimal limit to the user so the silent truncation in parseRaw
|
|
301
|
+
// doesn't surprise them. Skipped when prec === 0 (the display format
|
|
302
|
+
// already shows no decimal separator, no extra explanation needed) or
|
|
303
|
+
// when caller already supplied a helperText.
|
|
304
|
+
const autoCurrencyHelper = (() => {
|
|
305
|
+
if (helperText !== undefined) return undefined;
|
|
306
|
+
if (numericKind !== "currency" || !currencyCode) return undefined;
|
|
307
|
+
const p = prec ?? 0;
|
|
308
|
+
if (p <= 0) return undefined;
|
|
309
|
+
if (locale === "en") {
|
|
310
|
+
return `Up to ${p} decimal place${p > 1 ? "s" : ""}`;
|
|
311
|
+
}
|
|
312
|
+
return `Maksimal ${p} angka desimal`;
|
|
313
|
+
})();
|
|
314
|
+
const effectiveHelperText = helperText ?? autoCurrencyHelper;
|
|
315
|
+
|
|
316
|
+
const input = (
|
|
215
317
|
<TextInput
|
|
216
318
|
id={id}
|
|
217
319
|
labelText={labelText}
|
|
218
|
-
helperText={
|
|
320
|
+
helperText={effectiveHelperText}
|
|
219
321
|
value={displayValue}
|
|
220
322
|
onFocus={() => {
|
|
221
323
|
setRaw(value === 0 ? "" : String(value).replace(".", decimalSep));
|
|
222
324
|
}}
|
|
223
325
|
onChange={(e) => {
|
|
224
|
-
|
|
326
|
+
// Filter non-numeric input at the source so the textbox can never
|
|
327
|
+
// visibly display characters that aren't part of a valid number.
|
|
328
|
+
// Caret behaviour stays natural because we only ever DROP chars —
|
|
329
|
+
// never reorder.
|
|
330
|
+
const next = sanitizeInput(e.target.value);
|
|
225
331
|
setRaw(next);
|
|
226
332
|
onChange(parseRaw(next));
|
|
227
333
|
}}
|
|
@@ -238,6 +344,79 @@ function NumericField({
|
|
|
238
344
|
placeholder={placeholder}
|
|
239
345
|
/>
|
|
240
346
|
);
|
|
347
|
+
|
|
348
|
+
// Currency chip — rendered to the right of the TextInput, height + Y position
|
|
349
|
+
// matched to the actual input field. We anchor via `align-items: flex-end` so
|
|
350
|
+
// the chip bottom sits flush with the input bottom (which is just above the
|
|
351
|
+
// helper-text / invalid-text row), then offset the chip up past those rows
|
|
352
|
+
// so its bottom lands on the input bottom edge regardless of whether the
|
|
353
|
+
// label wraps to two lines. `box-sizing: border-box` keeps height parity
|
|
354
|
+
// with the Carbon input (which also includes its 1px border in 2rem/2.5rem).
|
|
355
|
+
if (numericKind === "currency" && currencyCode) {
|
|
356
|
+
// Resolve locale-aware currency display so the chip matches what view-mode
|
|
357
|
+
// shows — `Intl.NumberFormat(... style:'currency')` renders IDR as "Rp" in
|
|
358
|
+
// id-ID and as "IDR" in en-US.
|
|
359
|
+
const chipText = (() => {
|
|
360
|
+
try {
|
|
361
|
+
const parts = new Intl.NumberFormat(nloc, {
|
|
362
|
+
style: "currency",
|
|
363
|
+
currency: currencyCode,
|
|
364
|
+
minimumFractionDigits: 0,
|
|
365
|
+
maximumFractionDigits: 0,
|
|
366
|
+
}).formatToParts(0);
|
|
367
|
+
const sym = parts.find((p) => p.type === "currency")?.value;
|
|
368
|
+
return sym && sym.trim() ? sym.trim() : currencyCode;
|
|
369
|
+
} catch {
|
|
370
|
+
return currencyCode;
|
|
371
|
+
}
|
|
372
|
+
})();
|
|
373
|
+
|
|
374
|
+
// Chip height + horizontal min-width follow the same CSS variable that the
|
|
375
|
+
// form contract uses to size .cds--text-input (1.75rem compact / 2.5rem
|
|
376
|
+
// comfortable). Fallback covers the rare host that mounts NumericField
|
|
377
|
+
// outside the .ui7-form-contract scope; map by Carbon `size` so the chip
|
|
378
|
+
// still matches Carbon's native input height there.
|
|
379
|
+
const chipHeightFallback = size === "lg" ? "3rem" : size === "md" ? "2.5rem" : "1.75rem";
|
|
380
|
+
const chipHeight = `var(--ui7-form-control-height, ${chipHeightFallback})`;
|
|
381
|
+
// `.cds--form-requirement` (invalid) ~1rem + 0.25rem margin; same for
|
|
382
|
+
// `.cds--form__helper-text`. Offset the chip up by that combined ~1.25rem
|
|
383
|
+
// so its bottom sits flush with the input bottom regardless of subtext.
|
|
384
|
+
const subtextRowOffset =
|
|
385
|
+
invalid && invalidText ? "1.25rem" : effectiveHelperText ? "1.25rem" : "0";
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<div style={{ display: "flex", alignItems: "flex-end", gap: "0.5rem" }}>
|
|
389
|
+
<div style={{ flex: "1 1 auto", minWidth: 0 }}>{input}</div>
|
|
390
|
+
<span
|
|
391
|
+
aria-label={`Mata uang: ${chipText}`}
|
|
392
|
+
title={currencyCode}
|
|
393
|
+
style={{
|
|
394
|
+
flex: "0 0 auto",
|
|
395
|
+
boxSizing: "border-box",
|
|
396
|
+
marginBlockEnd: subtextRowOffset,
|
|
397
|
+
height: chipHeight,
|
|
398
|
+
minWidth: chipHeight,
|
|
399
|
+
paddingInline: "0.625rem",
|
|
400
|
+
display: "inline-flex",
|
|
401
|
+
alignItems: "center",
|
|
402
|
+
justifyContent: "center",
|
|
403
|
+
fontSize: "0.75rem",
|
|
404
|
+
fontWeight: 500,
|
|
405
|
+
letterSpacing: "0.02em",
|
|
406
|
+
color: "var(--cds-text-primary, #161616)",
|
|
407
|
+
background: "var(--cds-layer-accent-01, #e8e8e8)",
|
|
408
|
+
border: "1px solid var(--cds-border-subtle, #e0e0e0)",
|
|
409
|
+
whiteSpace: "nowrap",
|
|
410
|
+
userSelect: "none",
|
|
411
|
+
}}
|
|
412
|
+
>
|
|
413
|
+
{chipText}
|
|
414
|
+
</span>
|
|
415
|
+
</div>
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return input;
|
|
241
420
|
}
|
|
242
421
|
|
|
243
422
|
export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
@@ -251,12 +430,13 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
251
430
|
showSectionHeader = true,
|
|
252
431
|
scopeClassName,
|
|
253
432
|
density = "compact",
|
|
433
|
+
width,
|
|
254
434
|
}: SchemaFormRendererProps<TData>) {
|
|
255
435
|
const isView = mode === "view";
|
|
256
436
|
const dt = UI7_FORM_DENSITY_TOKENS[density];
|
|
257
437
|
const carbonSize = density === "compact" ? "sm" : "md";
|
|
258
438
|
const rootClassName =
|
|
259
|
-
scopeClassName ?? getUi7FormContractClassName({ density, readonly: isView });
|
|
439
|
+
scopeClassName ?? getUi7FormContractClassName({ density, width, readonly: isView });
|
|
260
440
|
const labels = useBosSharedI18n();
|
|
261
441
|
const { locale } = useI18n();
|
|
262
442
|
const renderDisplayValue = makeDisplayRenderer(labels, locale);
|
|
@@ -338,11 +518,65 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
338
518
|
Boolean(field.validation?.required) && !effectiveReadonly;
|
|
339
519
|
const invalidText = errors?.[field.key];
|
|
340
520
|
const span = clampSpan(field.span, defaultSpan);
|
|
521
|
+
// `breakBefore` forces a new row by pinning to column 1.
|
|
522
|
+
// Without it, CSS grid auto-flow may fit the field on the
|
|
523
|
+
// current row if remaining columns are enough for `span`.
|
|
341
524
|
const wrapperStyle = {
|
|
342
|
-
gridColumn:
|
|
525
|
+
gridColumn: field.breakBefore
|
|
526
|
+
? `1 / span ${span}`
|
|
527
|
+
: `span ${span} / span ${span}`,
|
|
343
528
|
minWidth: 0,
|
|
344
529
|
} as const;
|
|
345
530
|
|
|
531
|
+
// ── Detail-rows (master-detail editor) ──────────────────────
|
|
532
|
+
// Rendered BEFORE the readonly branch so view / edit / create
|
|
533
|
+
// share the same `<EditableTable>` instance — the table
|
|
534
|
+
// renders rows even when `readOnly={true}` and just hides
|
|
535
|
+
// the add/delete affordances. Default span = 12 (full width)
|
|
536
|
+
// because tables rarely look right in a half-column.
|
|
537
|
+
if (field.type === "detail-rows") {
|
|
538
|
+
const detail = field.detailRows;
|
|
539
|
+
if (!detail) return [];
|
|
540
|
+
const rows = Array.isArray(rawValue)
|
|
541
|
+
? (rawValue as Record<string, unknown>[])
|
|
542
|
+
: [];
|
|
543
|
+
const detailSpan = clampSpan(field.span, 12);
|
|
544
|
+
const detailWrapperStyle = {
|
|
545
|
+
gridColumn: field.breakBefore
|
|
546
|
+
? `1 / span ${detailSpan}`
|
|
547
|
+
: `span ${detailSpan} / span ${detailSpan}`,
|
|
548
|
+
minWidth: 0,
|
|
549
|
+
} as const;
|
|
550
|
+
return [
|
|
551
|
+
<div
|
|
552
|
+
key={key}
|
|
553
|
+
className={UI7_FORM_VISUAL_CLASSNAMES.column}
|
|
554
|
+
style={detailWrapperStyle}
|
|
555
|
+
>
|
|
556
|
+
<FieldShell
|
|
557
|
+
label={field.label}
|
|
558
|
+
required={effectiveRequired}
|
|
559
|
+
helperText={field.helperText}
|
|
560
|
+
>
|
|
561
|
+
<EditableTable
|
|
562
|
+
columns={detail.columns}
|
|
563
|
+
value={rows}
|
|
564
|
+
onChange={(newRows) =>
|
|
565
|
+
setValue(
|
|
566
|
+
field.key,
|
|
567
|
+
(newRows as unknown) as TData[keyof TData],
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
newRowFactory={detail.newRowFactory}
|
|
571
|
+
maxRows={detail.maxRows}
|
|
572
|
+
readOnly={effectiveReadonly}
|
|
573
|
+
validateRow={detail.validateRow}
|
|
574
|
+
/>
|
|
575
|
+
</FieldShell>
|
|
576
|
+
</div>,
|
|
577
|
+
];
|
|
578
|
+
}
|
|
579
|
+
|
|
346
580
|
// ── View / readonly ─────────────────────────────────────────
|
|
347
581
|
if (effectiveReadonly) {
|
|
348
582
|
return [
|
|
@@ -376,6 +610,18 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
376
610
|
// ── Lookup ──────────────────────────────────────────────────
|
|
377
611
|
if (field.type === "lookup") {
|
|
378
612
|
const lookup = field.lookup;
|
|
613
|
+
// Compose initialDisplay from sibling form fields, e.g.
|
|
614
|
+
// ["kode_cabang","branch_name"] → "001 KANTOR CABANG …".
|
|
615
|
+
// Falls back to undefined if any referenced field is
|
|
616
|
+
// empty so LookupInput uses its default (the key value).
|
|
617
|
+
let initialDisplay: string | undefined;
|
|
618
|
+
if (lookup?.initialDisplayFields?.length) {
|
|
619
|
+
const sep = lookup.initialDisplaySeparator ?? " ";
|
|
620
|
+
const parts = lookup.initialDisplayFields.map((k) => asString(value[k]));
|
|
621
|
+
if (parts.every((p) => p !== "")) {
|
|
622
|
+
initialDisplay = parts.join(sep);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
379
625
|
return [
|
|
380
626
|
<div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
|
|
381
627
|
<FieldShell label={field.label} required={effectiveRequired} helperText={field.helperText} htmlFor={key}>
|
|
@@ -383,6 +629,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
383
629
|
id={key}
|
|
384
630
|
labelText=""
|
|
385
631
|
value={asString(rawValue)}
|
|
632
|
+
initialDisplay={initialDisplay}
|
|
386
633
|
onChange={(next: string) => setValue(field.key, next as TData[keyof TData])}
|
|
387
634
|
onDataSelected={(row: Record<string, unknown>) => {
|
|
388
635
|
if (lookup?.onDataPatch) {
|
|
@@ -465,10 +712,12 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
465
712
|
const numeric = field.numeric;
|
|
466
713
|
let numericKind: "currency" | "percent" | "integer" | "decimal" = "decimal";
|
|
467
714
|
let prec: number | undefined;
|
|
715
|
+
let currencyCode: string | undefined;
|
|
468
716
|
|
|
469
717
|
if (numeric?.kind === "currency") {
|
|
470
718
|
numericKind = "currency";
|
|
471
719
|
const code = String(value[numeric.currencyField] ?? "");
|
|
720
|
+
currencyCode = code || undefined;
|
|
472
721
|
prec = getCurrencyPrecision(code, {
|
|
473
722
|
precisionByCurrency: numeric.precisionByCurrency,
|
|
474
723
|
defaultPrecision: numeric.defaultPrecision,
|
|
@@ -480,20 +729,46 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
480
729
|
numericKind = "integer";
|
|
481
730
|
}
|
|
482
731
|
|
|
732
|
+
// Live min/max validation — surfaces invalidText immediately
|
|
733
|
+
// as the user types instead of waiting for Save. Falls back
|
|
734
|
+
// to the submit-time error from the form host's errors map
|
|
735
|
+
// when the value is within bounds (or no bounds declared).
|
|
736
|
+
const numericValue = typeof rawValue === "number" ? rawValue : 0;
|
|
737
|
+
let liveInvalidText: string | undefined;
|
|
738
|
+
const v = field.validation;
|
|
739
|
+
if (v?.min !== undefined) {
|
|
740
|
+
const minV = Array.isArray(v.min) ? v.min[0] : v.min;
|
|
741
|
+
if (numericValue < minV) {
|
|
742
|
+
liveInvalidText = Array.isArray(v.min)
|
|
743
|
+
? v.min[1]
|
|
744
|
+
: labels.validMin(minV);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (!liveInvalidText && v?.max !== undefined) {
|
|
748
|
+
const maxV = Array.isArray(v.max) ? v.max[0] : v.max;
|
|
749
|
+
if (numericValue > maxV) {
|
|
750
|
+
liveInvalidText = Array.isArray(v.max)
|
|
751
|
+
? v.max[1]
|
|
752
|
+
: labels.validMax(maxV);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const effectiveInvalidText = invalidText ?? liveInvalidText;
|
|
756
|
+
|
|
483
757
|
return [
|
|
484
758
|
<div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
|
|
485
759
|
<NumericField
|
|
486
760
|
id={key}
|
|
487
761
|
labelText={mkLabel(field.label, effectiveRequired)}
|
|
488
762
|
helperText={field.helperText}
|
|
489
|
-
value={
|
|
763
|
+
value={numericValue}
|
|
490
764
|
numericKind={numericKind}
|
|
491
765
|
prec={prec}
|
|
766
|
+
currencyCode={currencyCode}
|
|
492
767
|
locale={locale}
|
|
493
768
|
size={carbonSize}
|
|
494
769
|
disabled={disabled}
|
|
495
|
-
invalid={Boolean(
|
|
496
|
-
invalidText={
|
|
770
|
+
invalid={Boolean(effectiveInvalidText)}
|
|
771
|
+
invalidText={effectiveInvalidText}
|
|
497
772
|
placeholder={field.placeholder}
|
|
498
773
|
onChange={(v) => setValue(field.key, v as TData[keyof TData])}
|
|
499
774
|
/>
|
|
@@ -520,6 +795,19 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
520
795
|
|
|
521
796
|
// ── Toggle ──────────────────────────────────────────────────
|
|
522
797
|
if (field.type === "toggle") {
|
|
798
|
+
// Optional valueOn/valueOff for non-boolean stored state
|
|
799
|
+
// (e.g., "Y"/"N"). When unset, falls back to plain Boolean.
|
|
800
|
+
const hasCustomMapping =
|
|
801
|
+
field.toggle && field.toggle.valueOn !== undefined;
|
|
802
|
+
const toggled = hasCustomMapping
|
|
803
|
+
? rawValue === field.toggle!.valueOn
|
|
804
|
+
: Boolean(rawValue);
|
|
805
|
+
const onValue: unknown = hasCustomMapping
|
|
806
|
+
? field.toggle!.valueOn
|
|
807
|
+
: true;
|
|
808
|
+
const offValue: unknown = hasCustomMapping
|
|
809
|
+
? field.toggle!.valueOff
|
|
810
|
+
: false;
|
|
523
811
|
return [
|
|
524
812
|
<div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
|
|
525
813
|
<FieldShell label={field.label} required={effectiveRequired} helperText={field.helperText}>
|
|
@@ -529,9 +817,12 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
529
817
|
labelText=""
|
|
530
818
|
labelA={labels.toggleOff}
|
|
531
819
|
labelB={labels.toggleOn}
|
|
532
|
-
toggled={
|
|
820
|
+
toggled={toggled}
|
|
533
821
|
onToggle={(checked) =>
|
|
534
|
-
setValue(
|
|
822
|
+
setValue(
|
|
823
|
+
field.key,
|
|
824
|
+
(checked ? onValue : offValue) as TData[keyof TData]
|
|
825
|
+
)
|
|
535
826
|
}
|
|
536
827
|
disabled={disabled}
|
|
537
828
|
/>
|