@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/dist/crud-components.d.ts +2 -0
- package/dist/crud-hooks.d.ts +1 -1
- package/dist/form-numeric.d.ts +12 -0
- package/dist/form-renderer.d.ts +4 -2
- package/dist/form-types.d.ts +75 -3
- package/dist/form-value-schema.d.ts +118 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4768 -4534
- package/dist/shell/app-shell-layout.d.ts +21 -6
- 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/form-contract.css +64 -2
- package/src/form-numeric.ts +16 -0
- package/src/form-renderer.tsx +381 -18
- package/src/form-types.ts +83 -5
- package/src/form-value-schema.test.ts +263 -0
- package/src/form-value-schema.ts +500 -0
- package/src/index.ts +1 -0
- package/src/shell/app-shell-layout.tsx +22 -46
- package/src/style-contract.test.ts +1 -0
- package/src/style-contract.ts +38 -5
- package/src/workflow/starter.ts +48 -11
package/src/form-renderer.tsx
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
Toggle,
|
|
13
13
|
} from "@carbon/react";
|
|
14
14
|
import { LookupInput } from "@isi-ui7/lookup-input";
|
|
15
|
+
import type { T_LookupTblStruct } from "@isi-ui7/lookup-input";
|
|
16
|
+
import { EditableTable } from "@isi-ui7/editable-table";
|
|
15
17
|
import { useI18n } from "@isi-ui7/i18n";
|
|
16
18
|
import type { Ui7Locale } from "@isi-ui7/i18n";
|
|
17
19
|
import {
|
|
@@ -27,7 +29,7 @@ import {
|
|
|
27
29
|
UI7_FORM_VISUAL_CLASSNAMES,
|
|
28
30
|
UI7_FORM_VISUAL_TOKENS,
|
|
29
31
|
} from "./style-contract";
|
|
30
|
-
import type { Ui7FormDensity } from "./style-contract";
|
|
32
|
+
import type { Ui7FormDensity, Ui7FormWidth } from "./style-contract";
|
|
31
33
|
import { useBosSharedI18n } from "./i18n";
|
|
32
34
|
import type { FormField, FormMode, FormSection } from "./form-types";
|
|
33
35
|
|
|
@@ -44,6 +46,8 @@ export type SchemaFormRendererProps<TData extends Record<string, unknown>> = {
|
|
|
44
46
|
scopeClassName?: string;
|
|
45
47
|
/** Form layout density. Defaults to "compact". */
|
|
46
48
|
density?: Ui7FormDensity;
|
|
49
|
+
/** Desktop form-panel width. Defaults to "two-thirds". */
|
|
50
|
+
width?: Ui7FormWidth;
|
|
47
51
|
};
|
|
48
52
|
|
|
49
53
|
function clampSpan(span: number | undefined, defaultSpan: number): number {
|
|
@@ -80,7 +84,60 @@ function makeDisplayRenderer(
|
|
|
80
84
|
): ReactNode {
|
|
81
85
|
if (field.format) return field.format(value as never, data as never);
|
|
82
86
|
if (field.type === "checkbox") return value ? labels.boolYes : labels.boolNo;
|
|
83
|
-
if (field.type === "toggle")
|
|
87
|
+
if (field.type === "toggle") {
|
|
88
|
+
// valueOn / valueOff override the default boolean truthiness check
|
|
89
|
+
// for non-boolean stored values (e.g., "Y"/"N").
|
|
90
|
+
if (field.toggle && field.toggle.valueOn !== undefined) {
|
|
91
|
+
return value === field.toggle.valueOn ? labels.toggleOn : labels.toggleOff;
|
|
92
|
+
}
|
|
93
|
+
return value ? labels.toggleOn : labels.toggleOff;
|
|
94
|
+
}
|
|
95
|
+
if (field.type === "select") {
|
|
96
|
+
// Resolve the option label so read-only mode shows "Active" rather than
|
|
97
|
+
// the stored code "active". Falls back to the raw value when no option
|
|
98
|
+
// matches (e.g. a legacy value no longer in the options list).
|
|
99
|
+
const match = (field.options ?? []).find((o) => String(o.value) === asString(value));
|
|
100
|
+
if (match) return match.label;
|
|
101
|
+
return asString(value) || labels.emptyValue;
|
|
102
|
+
}
|
|
103
|
+
if (field.type === "date") {
|
|
104
|
+
const raw = asString(value);
|
|
105
|
+
if (!raw) return labels.emptyValue;
|
|
106
|
+
const d = new Date(raw);
|
|
107
|
+
if (Number.isNaN(d.getTime())) return raw;
|
|
108
|
+
const nloc = locale === "en" ? "en-GB" : "id-ID";
|
|
109
|
+
return new Intl.DateTimeFormat(nloc, { day: "2-digit", month: "short", year: "numeric" }).format(d);
|
|
110
|
+
}
|
|
111
|
+
if (field.type === "datetime") {
|
|
112
|
+
// ISO timestamp → locale-aware "18 Jun 2026, 10:23" (24h). Echoes the
|
|
113
|
+
// raw value if unparseable. Edit mode falls through to a text input.
|
|
114
|
+
const raw = asString(value);
|
|
115
|
+
if (!raw) return labels.emptyValue;
|
|
116
|
+
const d = new Date(raw);
|
|
117
|
+
if (Number.isNaN(d.getTime())) return raw;
|
|
118
|
+
const nloc = locale === "en" ? "en-GB" : "id-ID";
|
|
119
|
+
return new Intl.DateTimeFormat(nloc, {
|
|
120
|
+
day: "2-digit",
|
|
121
|
+
month: "short",
|
|
122
|
+
year: "numeric",
|
|
123
|
+
hour: "2-digit",
|
|
124
|
+
minute: "2-digit",
|
|
125
|
+
hour12: false,
|
|
126
|
+
}).format(d);
|
|
127
|
+
}
|
|
128
|
+
if (field.type === "lookup") {
|
|
129
|
+
// View/read-only mode mirrors the LookupInput initialDisplay logic:
|
|
130
|
+
// when initialDisplayFields is set and all referenced FormData
|
|
131
|
+
// values are non-empty, render the compound "kode - nama". Falls
|
|
132
|
+
// back to the raw primary key when companion fields are missing.
|
|
133
|
+
const lookup = field.lookup;
|
|
134
|
+
if (lookup?.initialDisplayFields?.length) {
|
|
135
|
+
const sep = lookup.initialDisplaySeparator ?? " ";
|
|
136
|
+
const parts = lookup.initialDisplayFields.map((k) => asString(data[String(k)]));
|
|
137
|
+
if (parts.every((p) => p !== "")) return parts.join(sep);
|
|
138
|
+
}
|
|
139
|
+
return asString(value) || labels.emptyValue;
|
|
140
|
+
}
|
|
84
141
|
if (field.type === "number" && typeof value === "number") {
|
|
85
142
|
const nloc = locale === "en" ? "en-US" : "id-ID";
|
|
86
143
|
const numeric = field.numeric;
|
|
@@ -158,6 +215,7 @@ function NumericField({
|
|
|
158
215
|
value,
|
|
159
216
|
numericKind,
|
|
160
217
|
prec,
|
|
218
|
+
currencyCode,
|
|
161
219
|
locale,
|
|
162
220
|
size,
|
|
163
221
|
disabled,
|
|
@@ -172,6 +230,13 @@ function NumericField({
|
|
|
172
230
|
value: number;
|
|
173
231
|
numericKind: "currency" | "percent" | "integer" | "decimal";
|
|
174
232
|
prec?: number;
|
|
233
|
+
/**
|
|
234
|
+
* Currency ISO code (e.g. "IDR", "USD") — only used when numericKind ===
|
|
235
|
+
* "currency". When set, the code is shown as a subdued suffix next to the
|
|
236
|
+
* field label so the user sees which currency they're entering, mirroring
|
|
237
|
+
* the view-mode renderer which shows `Rp 1.000.000` / `IDR 1.000.000`.
|
|
238
|
+
*/
|
|
239
|
+
currencyCode?: string;
|
|
175
240
|
locale: Ui7Locale;
|
|
176
241
|
size?: "sm" | "md" | "lg";
|
|
177
242
|
disabled?: boolean;
|
|
@@ -209,19 +274,94 @@ function NumericField({
|
|
|
209
274
|
return parsed.ok ? parsed.value : 0;
|
|
210
275
|
};
|
|
211
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Input-time sanitizer — drops any character the user types that isn't part
|
|
279
|
+
* of a valid number in the active locale. Without this, typing "abc" would
|
|
280
|
+
* stick in the textbox (because `raw` stores the user's verbatim keystrokes)
|
|
281
|
+
* while the parsed numeric value silently becomes 0. Filtering at input gives
|
|
282
|
+
* immediate visual feedback: rejected chars never appear at all.
|
|
283
|
+
*
|
|
284
|
+
* Rules:
|
|
285
|
+
* - Integer: 0–9 plus a single leading minus.
|
|
286
|
+
* - Decimal / currency / percent: 0–9, thousand separator, a single decimal
|
|
287
|
+
* separator, leading minus. Decimal separator is rejected entirely when
|
|
288
|
+
* `prec === 0` (no fractional part allowed), and excess fraction digits
|
|
289
|
+
* beyond `prec` are clipped on the fly so users see the limit enforced.
|
|
290
|
+
*/
|
|
291
|
+
const sanitizeInput = (input: string): string => {
|
|
292
|
+
let out = "";
|
|
293
|
+
let seenMinus = false;
|
|
294
|
+
if (numericKind === "integer") {
|
|
295
|
+
for (let i = 0; i < input.length; i++) {
|
|
296
|
+
const c = input[i];
|
|
297
|
+
if (c >= "0" && c <= "9") out += c;
|
|
298
|
+
else if (c === "-" && out === "" && !seenMinus) {
|
|
299
|
+
out += c;
|
|
300
|
+
seenMinus = true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
let seenDecimal = false;
|
|
306
|
+
const allowDecimal = prec === undefined || prec > 0;
|
|
307
|
+
for (let i = 0; i < input.length; i++) {
|
|
308
|
+
const c = input[i];
|
|
309
|
+
if (c >= "0" && c <= "9") out += c;
|
|
310
|
+
else if (c === decimalSep && !seenDecimal && allowDecimal) {
|
|
311
|
+
out += c;
|
|
312
|
+
seenDecimal = true;
|
|
313
|
+
} else if (c === thousandSep) out += c;
|
|
314
|
+
else if (c === "-" && out === "" && !seenMinus) {
|
|
315
|
+
out += c;
|
|
316
|
+
seenMinus = true;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Clip excess fraction digits to declared precision.
|
|
320
|
+
if (typeof prec === "number" && prec > 0 && seenDecimal) {
|
|
321
|
+
const decIdx = out.indexOf(decimalSep);
|
|
322
|
+
const intPart = out.slice(0, decIdx);
|
|
323
|
+
const fracPart = out.slice(decIdx + 1);
|
|
324
|
+
if (fracPart.length > prec) {
|
|
325
|
+
out = intPart + decimalSep + fracPart.slice(0, prec);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
};
|
|
330
|
+
|
|
212
331
|
const displayValue = raw !== null ? raw : formatDisplay(value);
|
|
213
332
|
|
|
214
|
-
|
|
333
|
+
// Auto helper-text for currency fields with non-zero precision — surfaces
|
|
334
|
+
// the decimal limit to the user so the silent truncation in parseRaw
|
|
335
|
+
// doesn't surprise them. Skipped when prec === 0 (the display format
|
|
336
|
+
// already shows no decimal separator, no extra explanation needed) or
|
|
337
|
+
// when caller already supplied a helperText.
|
|
338
|
+
const autoCurrencyHelper = (() => {
|
|
339
|
+
if (helperText !== undefined) return undefined;
|
|
340
|
+
if (numericKind !== "currency" || !currencyCode) return undefined;
|
|
341
|
+
const p = prec ?? 0;
|
|
342
|
+
if (p <= 0) return undefined;
|
|
343
|
+
if (locale === "en") {
|
|
344
|
+
return `Up to ${p} decimal place${p > 1 ? "s" : ""}`;
|
|
345
|
+
}
|
|
346
|
+
return `Maksimal ${p} angka desimal`;
|
|
347
|
+
})();
|
|
348
|
+
const effectiveHelperText = helperText ?? autoCurrencyHelper;
|
|
349
|
+
|
|
350
|
+
const input = (
|
|
215
351
|
<TextInput
|
|
216
352
|
id={id}
|
|
217
353
|
labelText={labelText}
|
|
218
|
-
helperText={
|
|
354
|
+
helperText={effectiveHelperText}
|
|
219
355
|
value={displayValue}
|
|
220
356
|
onFocus={() => {
|
|
221
357
|
setRaw(value === 0 ? "" : String(value).replace(".", decimalSep));
|
|
222
358
|
}}
|
|
223
359
|
onChange={(e) => {
|
|
224
|
-
|
|
360
|
+
// Filter non-numeric input at the source so the textbox can never
|
|
361
|
+
// visibly display characters that aren't part of a valid number.
|
|
362
|
+
// Caret behaviour stays natural because we only ever DROP chars —
|
|
363
|
+
// never reorder.
|
|
364
|
+
const next = sanitizeInput(e.target.value);
|
|
225
365
|
setRaw(next);
|
|
226
366
|
onChange(parseRaw(next));
|
|
227
367
|
}}
|
|
@@ -232,12 +372,84 @@ function NumericField({
|
|
|
232
372
|
size={size}
|
|
233
373
|
disabled={disabled}
|
|
234
374
|
inputMode={numericKind === "integer" ? "numeric" : "decimal"}
|
|
235
|
-
light
|
|
236
375
|
invalid={invalid}
|
|
237
376
|
invalidText={invalidText}
|
|
238
377
|
placeholder={placeholder}
|
|
239
378
|
/>
|
|
240
379
|
);
|
|
380
|
+
|
|
381
|
+
// Currency chip — rendered to the right of the TextInput, height + Y position
|
|
382
|
+
// matched to the actual input field. We anchor via `align-items: flex-end` so
|
|
383
|
+
// the chip bottom sits flush with the input bottom (which is just above the
|
|
384
|
+
// helper-text / invalid-text row), then offset the chip up past those rows
|
|
385
|
+
// so its bottom lands on the input bottom edge regardless of whether the
|
|
386
|
+
// label wraps to two lines. `box-sizing: border-box` keeps height parity
|
|
387
|
+
// with the Carbon input (which also includes its 1px border in 2rem/2.5rem).
|
|
388
|
+
if (numericKind === "currency" && currencyCode) {
|
|
389
|
+
// Resolve locale-aware currency display so the chip matches what view-mode
|
|
390
|
+
// shows — `Intl.NumberFormat(... style:'currency')` renders IDR as "Rp" in
|
|
391
|
+
// id-ID and as "IDR" in en-US.
|
|
392
|
+
const chipText = (() => {
|
|
393
|
+
try {
|
|
394
|
+
const parts = new Intl.NumberFormat(nloc, {
|
|
395
|
+
style: "currency",
|
|
396
|
+
currency: currencyCode,
|
|
397
|
+
minimumFractionDigits: 0,
|
|
398
|
+
maximumFractionDigits: 0,
|
|
399
|
+
}).formatToParts(0);
|
|
400
|
+
const sym = parts.find((p) => p.type === "currency")?.value;
|
|
401
|
+
return sym && sym.trim() ? sym.trim() : currencyCode;
|
|
402
|
+
} catch {
|
|
403
|
+
return currencyCode;
|
|
404
|
+
}
|
|
405
|
+
})();
|
|
406
|
+
|
|
407
|
+
// Chip height + horizontal min-width follow the same CSS variable that the
|
|
408
|
+
// form contract uses to size .cds--text-input (1.75rem compact / 2.5rem
|
|
409
|
+
// comfortable). Fallback covers the rare host that mounts NumericField
|
|
410
|
+
// outside the .ui7-form-contract scope; map by Carbon `size` so the chip
|
|
411
|
+
// still matches Carbon's native input height there.
|
|
412
|
+
const chipHeightFallback = size === "lg" ? "3rem" : size === "md" ? "2.5rem" : "1.75rem";
|
|
413
|
+
const chipHeight = `var(--ui7-form-control-height, ${chipHeightFallback})`;
|
|
414
|
+
// `.cds--form-requirement` (invalid) ~1rem + 0.25rem margin; same for
|
|
415
|
+
// `.cds--form__helper-text`. Offset the chip up by that combined ~1.25rem
|
|
416
|
+
// so its bottom sits flush with the input bottom regardless of subtext.
|
|
417
|
+
const subtextRowOffset =
|
|
418
|
+
invalid && invalidText ? "1.25rem" : effectiveHelperText ? "1.25rem" : "0";
|
|
419
|
+
|
|
420
|
+
return (
|
|
421
|
+
<div style={{ display: "flex", alignItems: "flex-end", gap: "0.5rem" }}>
|
|
422
|
+
<div style={{ flex: "1 1 auto", minWidth: 0 }}>{input}</div>
|
|
423
|
+
<span
|
|
424
|
+
aria-label={`Mata uang: ${chipText}`}
|
|
425
|
+
title={currencyCode}
|
|
426
|
+
style={{
|
|
427
|
+
flex: "0 0 auto",
|
|
428
|
+
boxSizing: "border-box",
|
|
429
|
+
marginBlockEnd: subtextRowOffset,
|
|
430
|
+
height: chipHeight,
|
|
431
|
+
minWidth: chipHeight,
|
|
432
|
+
paddingInline: "0.625rem",
|
|
433
|
+
display: "inline-flex",
|
|
434
|
+
alignItems: "center",
|
|
435
|
+
justifyContent: "center",
|
|
436
|
+
fontSize: "0.75rem",
|
|
437
|
+
fontWeight: 500,
|
|
438
|
+
letterSpacing: "0.02em",
|
|
439
|
+
color: "var(--cds-text-primary, #161616)",
|
|
440
|
+
background: "var(--cds-layer-accent-01, #e8e8e8)",
|
|
441
|
+
border: "1px solid var(--cds-border-subtle, #e0e0e0)",
|
|
442
|
+
whiteSpace: "nowrap",
|
|
443
|
+
userSelect: "none",
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
{chipText}
|
|
447
|
+
</span>
|
|
448
|
+
</div>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return input;
|
|
241
453
|
}
|
|
242
454
|
|
|
243
455
|
export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
@@ -251,12 +463,13 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
251
463
|
showSectionHeader = true,
|
|
252
464
|
scopeClassName,
|
|
253
465
|
density = "compact",
|
|
466
|
+
width,
|
|
254
467
|
}: SchemaFormRendererProps<TData>) {
|
|
255
468
|
const isView = mode === "view";
|
|
256
469
|
const dt = UI7_FORM_DENSITY_TOKENS[density];
|
|
257
470
|
const carbonSize = density === "compact" ? "sm" : "md";
|
|
258
471
|
const rootClassName =
|
|
259
|
-
scopeClassName ?? getUi7FormContractClassName({ density, readonly: isView });
|
|
472
|
+
scopeClassName ?? getUi7FormContractClassName({ density, width, readonly: isView });
|
|
260
473
|
const labels = useBosSharedI18n();
|
|
261
474
|
const { locale } = useI18n();
|
|
262
475
|
const renderDisplayValue = makeDisplayRenderer(labels, locale);
|
|
@@ -281,7 +494,10 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
281
494
|
style={{
|
|
282
495
|
padding: "1rem 1.125rem",
|
|
283
496
|
border: "1px solid var(--cds-border-subtle, #e0e0e0)",
|
|
284
|
-
background
|
|
497
|
+
// Section sits flat on the page background (white) and is set
|
|
498
|
+
// apart by its border line only — fields carry the contrasting
|
|
499
|
+
// fill instead (see read-only box + non-light Carbon inputs).
|
|
500
|
+
background: "var(--cds-background, #ffffff)",
|
|
285
501
|
}}
|
|
286
502
|
>
|
|
287
503
|
{showSectionHeader && (section.title || section.description) ? (
|
|
@@ -338,11 +554,98 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
338
554
|
Boolean(field.validation?.required) && !effectiveReadonly;
|
|
339
555
|
const invalidText = errors?.[field.key];
|
|
340
556
|
const span = clampSpan(field.span, defaultSpan);
|
|
557
|
+
// `breakBefore` forces a new row by pinning to column 1.
|
|
558
|
+
// Without it, CSS grid auto-flow may fit the field on the
|
|
559
|
+
// current row if remaining columns are enough for `span`.
|
|
341
560
|
const wrapperStyle = {
|
|
342
|
-
gridColumn:
|
|
561
|
+
gridColumn: field.breakBefore
|
|
562
|
+
? `1 / span ${span}`
|
|
563
|
+
: `span ${span} / span ${span}`,
|
|
343
564
|
minWidth: 0,
|
|
344
565
|
} as const;
|
|
345
566
|
|
|
567
|
+
// ── Detail-rows (master-detail editor) ──────────────────────
|
|
568
|
+
// Rendered BEFORE the readonly branch so view / edit / create
|
|
569
|
+
// share the same `<EditableTable>` instance — the table
|
|
570
|
+
// renders rows even when `readOnly={true}` and just hides
|
|
571
|
+
// the add/delete affordances. Default span = 12 (full width)
|
|
572
|
+
// because tables rarely look right in a half-column.
|
|
573
|
+
if (field.type === "detail-rows") {
|
|
574
|
+
const detail = field.detailRows;
|
|
575
|
+
if (!detail) return [];
|
|
576
|
+
const rows = Array.isArray(rawValue)
|
|
577
|
+
? (rawValue as Record<string, unknown>[])
|
|
578
|
+
: [];
|
|
579
|
+
const detailSpan = clampSpan(field.span, 12);
|
|
580
|
+
const detailWrapperStyle = {
|
|
581
|
+
gridColumn: field.breakBefore
|
|
582
|
+
? `1 / span ${detailSpan}`
|
|
583
|
+
: `span ${detailSpan} / span ${detailSpan}`,
|
|
584
|
+
minWidth: 0,
|
|
585
|
+
} as const;
|
|
586
|
+
return [
|
|
587
|
+
<div
|
|
588
|
+
key={key}
|
|
589
|
+
className={UI7_FORM_VISUAL_CLASSNAMES.column}
|
|
590
|
+
style={detailWrapperStyle}
|
|
591
|
+
>
|
|
592
|
+
<FieldShell
|
|
593
|
+
label={field.label}
|
|
594
|
+
required={effectiveRequired}
|
|
595
|
+
helperText={field.helperText}
|
|
596
|
+
>
|
|
597
|
+
<EditableTable
|
|
598
|
+
columns={detail.columns}
|
|
599
|
+
value={rows}
|
|
600
|
+
onChange={(newRows) =>
|
|
601
|
+
setValue(
|
|
602
|
+
field.key,
|
|
603
|
+
(newRows as unknown) as TData[keyof TData],
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
newRowFactory={detail.newRowFactory}
|
|
607
|
+
maxRows={detail.maxRows}
|
|
608
|
+
readOnly={effectiveReadonly}
|
|
609
|
+
lockExistingRows={detail.lockExistingRows}
|
|
610
|
+
validateRow={detail.validateRow}
|
|
611
|
+
lookupCellRenderer={(args) => {
|
|
612
|
+
const ct = args.column.columnType;
|
|
613
|
+
if (ct.type !== "lookup") return null;
|
|
614
|
+
let initialDisplay: string | undefined;
|
|
615
|
+
if (ct.initialDisplayFields?.length) {
|
|
616
|
+
const sep = ct.initialDisplaySeparator ?? " ";
|
|
617
|
+
const parts = ct.initialDisplayFields.map((k) => asString(args.row[k]));
|
|
618
|
+
if (parts.every((p) => p !== "")) initialDisplay = parts.join(sep);
|
|
619
|
+
}
|
|
620
|
+
return (
|
|
621
|
+
<LookupInput
|
|
622
|
+
id={`et-${String(field.key)}-${args.rowIndex}-${args.column.field}`}
|
|
623
|
+
labelText=""
|
|
624
|
+
value={args.value}
|
|
625
|
+
initialDisplay={initialDisplay}
|
|
626
|
+
onChange={() => {}}
|
|
627
|
+
onDataSelected={(picked: Record<string, unknown>) => {
|
|
628
|
+
const patch = ct.patch
|
|
629
|
+
? ct.patch(picked)
|
|
630
|
+
: { [args.column.field]: asString(picked[args.column.field]) };
|
|
631
|
+
args.onPatch(patch);
|
|
632
|
+
}}
|
|
633
|
+
dataSource="api"
|
|
634
|
+
lookupAPI={ct.lookupAPI}
|
|
635
|
+
lookupDataStructure={ct.lookupDataStructure as Record<string, T_LookupTblStruct>}
|
|
636
|
+
lookupFieldTitles={ct.lookupFieldTitles ?? {}}
|
|
637
|
+
displayFieldNames={ct.displayFieldNames ?? []}
|
|
638
|
+
popupWidthPx={ct.popupWidthPx}
|
|
639
|
+
readOnly={args.readOnly}
|
|
640
|
+
/>
|
|
641
|
+
);
|
|
642
|
+
}}
|
|
643
|
+
/>
|
|
644
|
+
</FieldShell>
|
|
645
|
+
</div>,
|
|
646
|
+
];
|
|
647
|
+
}
|
|
648
|
+
|
|
346
649
|
// ── View / readonly ─────────────────────────────────────────
|
|
347
650
|
if (effectiveReadonly) {
|
|
348
651
|
return [
|
|
@@ -351,12 +654,18 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
351
654
|
<div
|
|
352
655
|
className={`${UI7_FORM_VISUAL_CLASSNAMES.control} ${UI7_FORM_VISUAL_CLASSNAMES.controlReadonly}`}
|
|
353
656
|
style={{
|
|
354
|
-
|
|
657
|
+
// Match the editable control height for the active
|
|
658
|
+
// density (compact = 2rem) so read-only fields line
|
|
659
|
+
// up with create/edit inputs instead of being 40px.
|
|
660
|
+
minHeight: dt.controlHeight,
|
|
355
661
|
display: "flex",
|
|
356
662
|
alignItems: "center",
|
|
357
663
|
fontSize: UI7_FORM_VISUAL_TOKENS.inputFontSize,
|
|
358
664
|
lineHeight: UI7_FORM_VISUAL_TOKENS.inputLineHeight,
|
|
359
665
|
background: "var(--ui7-form-readonly-background, #f4f4f4)",
|
|
666
|
+
// No bottom rule for non-editable fields — the gray
|
|
667
|
+
// fill alone sets them apart from the white section;
|
|
668
|
+
// an input-style underline would imply editability.
|
|
360
669
|
cursor: "var(--ui7-form-readonly-cursor, default)",
|
|
361
670
|
width: "100%",
|
|
362
671
|
padding: "0 0.75rem",
|
|
@@ -376,6 +685,18 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
376
685
|
// ── Lookup ──────────────────────────────────────────────────
|
|
377
686
|
if (field.type === "lookup") {
|
|
378
687
|
const lookup = field.lookup;
|
|
688
|
+
// Compose initialDisplay from sibling form fields, e.g.
|
|
689
|
+
// ["kode_cabang","branch_name"] → "001 KANTOR CABANG …".
|
|
690
|
+
// Falls back to undefined if any referenced field is
|
|
691
|
+
// empty so LookupInput uses its default (the key value).
|
|
692
|
+
let initialDisplay: string | undefined;
|
|
693
|
+
if (lookup?.initialDisplayFields?.length) {
|
|
694
|
+
const sep = lookup.initialDisplaySeparator ?? " ";
|
|
695
|
+
const parts = lookup.initialDisplayFields.map((k) => asString(value[k]));
|
|
696
|
+
if (parts.every((p) => p !== "")) {
|
|
697
|
+
initialDisplay = parts.join(sep);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
379
700
|
return [
|
|
380
701
|
<div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
|
|
381
702
|
<FieldShell label={field.label} required={effectiveRequired} helperText={field.helperText} htmlFor={key}>
|
|
@@ -383,6 +704,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
383
704
|
id={key}
|
|
384
705
|
labelText=""
|
|
385
706
|
value={asString(rawValue)}
|
|
707
|
+
initialDisplay={initialDisplay}
|
|
386
708
|
onChange={(next: string) => setValue(field.key, next as TData[keyof TData])}
|
|
387
709
|
onDataSelected={(row: Record<string, unknown>) => {
|
|
388
710
|
if (lookup?.onDataPatch) {
|
|
@@ -448,7 +770,6 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
448
770
|
value={asString(rawValue)}
|
|
449
771
|
onChange={(e) => setValue(field.key, e.target.value as TData[keyof TData])}
|
|
450
772
|
disabled={disabled}
|
|
451
|
-
light
|
|
452
773
|
invalid={Boolean(invalidText)}
|
|
453
774
|
invalidText={invalidText}
|
|
454
775
|
>
|
|
@@ -465,10 +786,12 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
465
786
|
const numeric = field.numeric;
|
|
466
787
|
let numericKind: "currency" | "percent" | "integer" | "decimal" = "decimal";
|
|
467
788
|
let prec: number | undefined;
|
|
789
|
+
let currencyCode: string | undefined;
|
|
468
790
|
|
|
469
791
|
if (numeric?.kind === "currency") {
|
|
470
792
|
numericKind = "currency";
|
|
471
793
|
const code = String(value[numeric.currencyField] ?? "");
|
|
794
|
+
currencyCode = code || undefined;
|
|
472
795
|
prec = getCurrencyPrecision(code, {
|
|
473
796
|
precisionByCurrency: numeric.precisionByCurrency,
|
|
474
797
|
defaultPrecision: numeric.defaultPrecision,
|
|
@@ -480,20 +803,46 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
480
803
|
numericKind = "integer";
|
|
481
804
|
}
|
|
482
805
|
|
|
806
|
+
// Live min/max validation — surfaces invalidText immediately
|
|
807
|
+
// as the user types instead of waiting for Save. Falls back
|
|
808
|
+
// to the submit-time error from the form host's errors map
|
|
809
|
+
// when the value is within bounds (or no bounds declared).
|
|
810
|
+
const numericValue = typeof rawValue === "number" ? rawValue : 0;
|
|
811
|
+
let liveInvalidText: string | undefined;
|
|
812
|
+
const v = field.validation;
|
|
813
|
+
if (v?.min !== undefined) {
|
|
814
|
+
const minV = Array.isArray(v.min) ? v.min[0] : v.min;
|
|
815
|
+
if (numericValue < minV) {
|
|
816
|
+
liveInvalidText = Array.isArray(v.min)
|
|
817
|
+
? v.min[1]
|
|
818
|
+
: labels.validMin(minV);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (!liveInvalidText && v?.max !== undefined) {
|
|
822
|
+
const maxV = Array.isArray(v.max) ? v.max[0] : v.max;
|
|
823
|
+
if (numericValue > maxV) {
|
|
824
|
+
liveInvalidText = Array.isArray(v.max)
|
|
825
|
+
? v.max[1]
|
|
826
|
+
: labels.validMax(maxV);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
const effectiveInvalidText = invalidText ?? liveInvalidText;
|
|
830
|
+
|
|
483
831
|
return [
|
|
484
832
|
<div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
|
|
485
833
|
<NumericField
|
|
486
834
|
id={key}
|
|
487
835
|
labelText={mkLabel(field.label, effectiveRequired)}
|
|
488
836
|
helperText={field.helperText}
|
|
489
|
-
value={
|
|
837
|
+
value={numericValue}
|
|
490
838
|
numericKind={numericKind}
|
|
491
839
|
prec={prec}
|
|
840
|
+
currencyCode={currencyCode}
|
|
492
841
|
locale={locale}
|
|
493
842
|
size={carbonSize}
|
|
494
843
|
disabled={disabled}
|
|
495
|
-
invalid={Boolean(
|
|
496
|
-
invalidText={
|
|
844
|
+
invalid={Boolean(effectiveInvalidText)}
|
|
845
|
+
invalidText={effectiveInvalidText}
|
|
497
846
|
placeholder={field.placeholder}
|
|
498
847
|
onChange={(v) => setValue(field.key, v as TData[keyof TData])}
|
|
499
848
|
/>
|
|
@@ -520,6 +869,19 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
520
869
|
|
|
521
870
|
// ── Toggle ──────────────────────────────────────────────────
|
|
522
871
|
if (field.type === "toggle") {
|
|
872
|
+
// Optional valueOn/valueOff for non-boolean stored state
|
|
873
|
+
// (e.g., "Y"/"N"). When unset, falls back to plain Boolean.
|
|
874
|
+
const hasCustomMapping =
|
|
875
|
+
field.toggle && field.toggle.valueOn !== undefined;
|
|
876
|
+
const toggled = hasCustomMapping
|
|
877
|
+
? rawValue === field.toggle!.valueOn
|
|
878
|
+
: Boolean(rawValue);
|
|
879
|
+
const onValue: unknown = hasCustomMapping
|
|
880
|
+
? field.toggle!.valueOn
|
|
881
|
+
: true;
|
|
882
|
+
const offValue: unknown = hasCustomMapping
|
|
883
|
+
? field.toggle!.valueOff
|
|
884
|
+
: false;
|
|
523
885
|
return [
|
|
524
886
|
<div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
|
|
525
887
|
<FieldShell label={field.label} required={effectiveRequired} helperText={field.helperText}>
|
|
@@ -529,9 +891,12 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
529
891
|
labelText=""
|
|
530
892
|
labelA={labels.toggleOff}
|
|
531
893
|
labelB={labels.toggleOn}
|
|
532
|
-
toggled={
|
|
894
|
+
toggled={toggled}
|
|
533
895
|
onToggle={(checked) =>
|
|
534
|
-
setValue(
|
|
896
|
+
setValue(
|
|
897
|
+
field.key,
|
|
898
|
+
(checked ? onValue : offValue) as TData[keyof TData]
|
|
899
|
+
)
|
|
535
900
|
}
|
|
536
901
|
disabled={disabled}
|
|
537
902
|
/>
|
|
@@ -582,7 +947,6 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
582
947
|
disabled={disabled}
|
|
583
948
|
maxLength={field.maxLength}
|
|
584
949
|
inputMode="tel"
|
|
585
|
-
light
|
|
586
950
|
invalid={Boolean(invalidText)}
|
|
587
951
|
invalidText={invalidText}
|
|
588
952
|
placeholder={field.placeholder}
|
|
@@ -606,7 +970,6 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
606
970
|
disabled={disabled}
|
|
607
971
|
maxLength={field.maxLength}
|
|
608
972
|
inputMode={field.inputMode}
|
|
609
|
-
light
|
|
610
973
|
invalid={Boolean(invalidText)}
|
|
611
974
|
invalidText={invalidText}
|
|
612
975
|
placeholder={field.placeholder}
|