@isi-ui7/bos7-shared 0.2.0

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.
Files changed (108) hide show
  1. package/dist/api.d.ts +4 -0
  2. package/dist/auth7/client.d.ts +26 -0
  3. package/dist/auth7/delegated-proxy.d.ts +33 -0
  4. package/dist/auth7/hooks/useAuth.d.ts +10 -0
  5. package/dist/auth7/hooks/useSession.d.ts +12 -0
  6. package/dist/auth7/index.d.ts +12 -0
  7. package/dist/auth7/provider.d.ts +13 -0
  8. package/dist/auth7/scope-guard.d.ts +5 -0
  9. package/dist/auth7/service-account.d.ts +2 -0
  10. package/dist/auth7/token-exchange.d.ts +11 -0
  11. package/dist/auth7/types.d.ts +63 -0
  12. package/dist/backend.d.ts +20 -0
  13. package/dist/crud-components.d.ts +47 -0
  14. package/dist/crud-hooks.d.ts +15 -0
  15. package/dist/crud-types.d.ts +86 -0
  16. package/dist/data-table/cursor-adapter.d.ts +74 -0
  17. package/dist/event-bus/index.d.ts +20 -0
  18. package/dist/event-bus/useEventBus.d.ts +21 -0
  19. package/dist/event-bus.d.ts +1 -0
  20. package/dist/export.d.ts +7 -0
  21. package/dist/form-layout.d.ts +39 -0
  22. package/dist/form-numeric.d.ts +35 -0
  23. package/dist/form-renderer-utils.d.ts +10 -0
  24. package/dist/form-renderer.d.ts +18 -0
  25. package/dist/form-rules.d.ts +12 -0
  26. package/dist/form-types.d.ts +130 -0
  27. package/dist/html2canvas.esm-d2sM-0Wm.js +4870 -0
  28. package/dist/i18n.d.ts +35 -0
  29. package/dist/index.d.ts +18 -0
  30. package/dist/index.es-Bylk5fh-.js +5649 -0
  31. package/dist/index.js +20779 -0
  32. package/dist/jspdf.es.min-Dzm5j8wC.js +9977 -0
  33. package/dist/jspdf.plugin.autotable-DcntYaes.js +1078 -0
  34. package/dist/modal-layout.d.ts +45 -0
  35. package/dist/nats/client.d.ts +54 -0
  36. package/dist/notif7.d.ts +21 -0
  37. package/dist/notification/routing.d.ts +4 -0
  38. package/dist/notifications-bff.d.ts +22 -0
  39. package/dist/observability/otel-init.d.ts +1 -0
  40. package/dist/page-layout.d.ts +24 -0
  41. package/dist/purify.es-CiEWEeUM.js +605 -0
  42. package/dist/shell/app-shell-layout.d.ts +70 -0
  43. package/dist/shell/branch.d.ts +6 -0
  44. package/dist/shell/interaction.d.ts +2 -0
  45. package/dist/shell/provider.d.ts +15 -0
  46. package/dist/style-contract.d.ts +72 -0
  47. package/dist/types/core.d.ts +39 -0
  48. package/dist/types/notification.d.ts +30 -0
  49. package/dist/workflow/api-client.d.ts +2 -0
  50. package/dist/workflow/backend.d.ts +9 -0
  51. package/dist/workflow/crud-client.d.ts +3 -0
  52. package/dist/workflow/notif7.d.ts +6 -0
  53. package/dist/workflow/starter.d.ts +44 -0
  54. package/dist/workflow/use-crud-form.d.ts +27 -0
  55. package/dist/workflow/use-workflow-tracker.d.ts +40 -0
  56. package/package.json +92 -0
  57. package/src/api.ts +40 -0
  58. package/src/auth7/client.ts +248 -0
  59. package/src/auth7/delegated-proxy.ts +80 -0
  60. package/src/auth7/hooks/useAuth.ts +21 -0
  61. package/src/auth7/hooks/useSession.ts +75 -0
  62. package/src/auth7/index.ts +14 -0
  63. package/src/auth7/provider.tsx +256 -0
  64. package/src/auth7/scope-guard.ts +54 -0
  65. package/src/auth7/service-account.ts +71 -0
  66. package/src/auth7/token-exchange.ts +125 -0
  67. package/src/auth7/types.ts +72 -0
  68. package/src/backend.ts +96 -0
  69. package/src/crud-components.tsx +580 -0
  70. package/src/crud-hooks.test.ts +32 -0
  71. package/src/crud-hooks.ts +31 -0
  72. package/src/crud-types.ts +64 -0
  73. package/src/data-table/cursor-adapter.ts +239 -0
  74. package/src/event-bus/index.ts +90 -0
  75. package/src/event-bus/useEventBus.ts +93 -0
  76. package/src/event-bus.ts +1 -0
  77. package/src/export.ts +62 -0
  78. package/src/form-contract.css +52 -0
  79. package/src/form-layout.tsx +90 -0
  80. package/src/form-numeric.ts +173 -0
  81. package/src/form-renderer-utils.ts +139 -0
  82. package/src/form-renderer.tsx +624 -0
  83. package/src/form-rules.ts +54 -0
  84. package/src/form-types.ts +126 -0
  85. package/src/i18n.tsx +92 -0
  86. package/src/index.ts +20 -0
  87. package/src/modal-layout.tsx +60 -0
  88. package/src/nats/client.ts +130 -0
  89. package/src/notif7.ts +76 -0
  90. package/src/notification/routing.ts +64 -0
  91. package/src/notifications-bff.ts +273 -0
  92. package/src/observability/otel-init.ts +41 -0
  93. package/src/page-layout.tsx +69 -0
  94. package/src/shell/app-shell-layout.tsx +494 -0
  95. package/src/shell/branch.ts +48 -0
  96. package/src/shell/interaction.ts +17 -0
  97. package/src/shell/provider.tsx +34 -0
  98. package/src/style-contract.test.ts +59 -0
  99. package/src/style-contract.ts +132 -0
  100. package/src/types/core.ts +46 -0
  101. package/src/types/notification.ts +35 -0
  102. package/src/workflow/api-client.ts +7 -0
  103. package/src/workflow/backend.ts +53 -0
  104. package/src/workflow/crud-client.ts +43 -0
  105. package/src/workflow/notif7.ts +28 -0
  106. package/src/workflow/starter.ts +153 -0
  107. package/src/workflow/use-crud-form.ts +176 -0
  108. package/src/workflow/use-workflow-tracker.ts +109 -0
@@ -0,0 +1,624 @@
1
+ "use client";
2
+
3
+ import { useState, type ReactNode } from "react";
4
+ import {
5
+ Checkbox,
6
+ DatePicker,
7
+ DatePickerInput,
8
+ Select,
9
+ SelectItem,
10
+ TextArea,
11
+ TextInput,
12
+ Toggle,
13
+ } from "@carbon/react";
14
+ import { LookupInput } from "@isi-ui7/lookup-input";
15
+ import { useI18n } from "@isi-ui7/i18n";
16
+ import type { Ui7Locale } from "@isi-ui7/i18n";
17
+ import {
18
+ formatCurrencyAmount,
19
+ getCurrencyPrecision,
20
+ normalizePhoneInput,
21
+ parseDecimalStrict,
22
+ sanitizeDecimalInput,
23
+ } from "./form-numeric";
24
+ import {
25
+ getUi7FormContractClassName,
26
+ UI7_FORM_DENSITY_TOKENS,
27
+ UI7_FORM_VISUAL_CLASSNAMES,
28
+ UI7_FORM_VISUAL_TOKENS,
29
+ } from "./style-contract";
30
+ import type { Ui7FormDensity } from "./style-contract";
31
+ import { useBosSharedI18n } from "./i18n";
32
+ import type { FormField, FormMode, FormSection } from "./form-types";
33
+
34
+ export type SchemaFormRendererProps<TData extends Record<string, unknown>> = {
35
+ mode: FormMode;
36
+ value: TData;
37
+ sections: FormSection<TData>[];
38
+ errors?: Partial<Record<keyof TData, string>>;
39
+ disabled?: boolean;
40
+ onChange?: (patch: Partial<TData>) => void;
41
+ className?: string;
42
+ showSectionHeader?: boolean;
43
+ /** Override the root scope className (includes density + readonly classes). */
44
+ scopeClassName?: string;
45
+ /** Form layout density. Defaults to "compact". */
46
+ density?: Ui7FormDensity;
47
+ };
48
+
49
+ function clampSpan(span: number | undefined, defaultSpan: number): number {
50
+ const s = span ?? defaultSpan;
51
+ if (!Number.isFinite(s)) return defaultSpan;
52
+ return Math.max(1, Math.min(12, Math.trunc(s)));
53
+ }
54
+
55
+ function asString(value: unknown): string {
56
+ if (value === null || value === undefined) return "";
57
+ return String(value);
58
+ }
59
+
60
+ /** Wraps a field label with an optional required asterisk for Carbon's labelText prop. */
61
+ function mkLabel(label: ReactNode, required?: boolean) {
62
+ return (
63
+ <>
64
+ {label}
65
+ {required ? (
66
+ <span aria-hidden="true" style={{ color: "var(--cds-support-error, #da1e28)", marginLeft: "0.125rem"}}>*</span>
67
+ ) : null}
68
+ </>
69
+ );
70
+ }
71
+
72
+ function makeDisplayRenderer(
73
+ labels: { boolYes: string; boolNo: string; toggleOn: string; toggleOff: string; emptyValue: string },
74
+ locale: Ui7Locale,
75
+ ) {
76
+ return function renderDisplayValue(
77
+ field: FormField<Record<string, unknown>>,
78
+ value: unknown,
79
+ data: Record<string, unknown>,
80
+ ): ReactNode {
81
+ if (field.format) return field.format(value as never, data as never);
82
+ if (field.type === "checkbox") return value ? labels.boolYes : labels.boolNo;
83
+ if (field.type === "toggle") return value ? labels.toggleOn : labels.toggleOff;
84
+ if (field.type === "number" && typeof value === "number") {
85
+ const nloc = locale === "en" ? "en-US" : "id-ID";
86
+ const numeric = field.numeric;
87
+ if (!numeric) return asString(value) || labels.emptyValue;
88
+ if (numeric.kind === "integer") {
89
+ return new Intl.NumberFormat(nloc, { maximumFractionDigits: 0 }).format(value);
90
+ }
91
+ if (numeric.kind === "currency") {
92
+ const code = String(data[String(numeric.currencyField)] ?? "");
93
+ return formatCurrencyAmount(
94
+ value,
95
+ code,
96
+ { precisionByCurrency: numeric.precisionByCurrency, defaultPrecision: numeric.defaultPrecision },
97
+ locale,
98
+ );
99
+ }
100
+ if (numeric.kind === "percent") {
101
+ return (
102
+ new Intl.NumberFormat(nloc, {
103
+ minimumFractionDigits: 0,
104
+ maximumFractionDigits: numeric.maxFractionDigits,
105
+ }).format(value) + "%"
106
+ );
107
+ }
108
+ }
109
+ return asString(value) || labels.emptyValue;
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Used for readonly display fields and for components whose labelText prop only accepts string
115
+ * (Toggle, LookupInput). Pass htmlFor when the component has a focusable id.
116
+ */
117
+ function FieldShell({
118
+ label,
119
+ required,
120
+ children,
121
+ helperText,
122
+ htmlFor,
123
+ }: {
124
+ label: ReactNode;
125
+ required?: boolean;
126
+ children: ReactNode;
127
+ helperText?: ReactNode;
128
+ htmlFor?: string;
129
+ }) {
130
+ const labelStyle = { color: "var(--cds-text-secondary, #525252)", display: "flex", alignItems: "center", gap: "0.25rem" } as const;
131
+ const labelContent = (
132
+ <>
133
+ <span>{label}</span>
134
+ {required ? (
135
+ <span aria-hidden="true" style={{ color: "var(--cds-support-error, #da1e28)" }}>*</span>
136
+ ) : null}
137
+ </>
138
+ );
139
+ return (
140
+ <div style={{ minWidth: 0, display: "flex", flexDirection: "column" }}>
141
+ {htmlFor ? (
142
+ <label htmlFor={htmlFor} className={UI7_FORM_VISUAL_CLASSNAMES.label} style={labelStyle}>{labelContent}</label>
143
+ ) : (
144
+ <div className={UI7_FORM_VISUAL_CLASSNAMES.label} style={labelStyle}>{labelContent}</div>
145
+ )}
146
+ {children}
147
+ {helperText ? (
148
+ <div className={UI7_FORM_VISUAL_CLASSNAMES.helper}>{helperText}</div>
149
+ ) : null}
150
+ </div>
151
+ );
152
+ }
153
+
154
+ function NumericField({
155
+ id,
156
+ labelText,
157
+ helperText,
158
+ value,
159
+ numericKind,
160
+ prec,
161
+ locale,
162
+ size,
163
+ disabled,
164
+ invalid,
165
+ invalidText,
166
+ placeholder,
167
+ onChange,
168
+ }: {
169
+ id: string;
170
+ labelText: ReactNode;
171
+ helperText?: ReactNode;
172
+ value: number;
173
+ numericKind: "currency" | "percent" | "integer" | "decimal";
174
+ prec?: number;
175
+ locale: Ui7Locale;
176
+ size?: "sm" | "md" | "lg";
177
+ disabled?: boolean;
178
+ invalid?: boolean;
179
+ invalidText?: string;
180
+ placeholder?: string;
181
+ onChange: (v: number) => void;
182
+ }) {
183
+ const [raw, setRaw] = useState<string | null>(null);
184
+ const decimalSep = locale === "en" ? "." : ",";
185
+ const thousandSep = locale === "en" ? "," : ".";
186
+ const nloc = locale === "en" ? "en-US" : "id-ID";
187
+
188
+ const formatDisplay = (v: number): string => {
189
+ if (numericKind === "integer") {
190
+ return new Intl.NumberFormat(nloc, { maximumFractionDigits: 0 }).format(v);
191
+ }
192
+ return new Intl.NumberFormat(nloc, {
193
+ minimumFractionDigits: 0,
194
+ maximumFractionDigits: prec ?? 8,
195
+ }).format(v);
196
+ };
197
+
198
+ const parseRaw = (r: string): number => {
199
+ if (!r.trim()) return 0;
200
+ // strip thousands separator, then normalize decimal separator to "."
201
+ let cleaned = r.split(thousandSep).join("");
202
+ if (decimalSep !== ".") cleaned = cleaned.replace(decimalSep, ".");
203
+ if (numericKind === "integer") {
204
+ const n = parseInt(cleaned.replace(/[^0-9-]/g, ""), 10);
205
+ return Number.isFinite(n) ? n : 0;
206
+ }
207
+ const sanitized = sanitizeDecimalInput(cleaned, { maxFractionDigits: prec });
208
+ const parsed = parseDecimalStrict(sanitized, { maxFractionDigits: prec });
209
+ return parsed.ok ? parsed.value : 0;
210
+ };
211
+
212
+ const displayValue = raw !== null ? raw : formatDisplay(value);
213
+
214
+ return (
215
+ <TextInput
216
+ id={id}
217
+ labelText={labelText}
218
+ helperText={helperText}
219
+ value={displayValue}
220
+ onFocus={() => {
221
+ setRaw(value === 0 ? "" : String(value).replace(".", decimalSep));
222
+ }}
223
+ onChange={(e) => {
224
+ const next = e.target.value;
225
+ setRaw(next);
226
+ onChange(parseRaw(next));
227
+ }}
228
+ onBlur={() => {
229
+ onChange(parseRaw(raw ?? ""));
230
+ setRaw(null);
231
+ }}
232
+ size={size}
233
+ disabled={disabled}
234
+ inputMode={numericKind === "integer" ? "numeric" : "decimal"}
235
+ light
236
+ invalid={invalid}
237
+ invalidText={invalidText}
238
+ placeholder={placeholder}
239
+ />
240
+ );
241
+ }
242
+
243
+ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
244
+ mode,
245
+ value,
246
+ sections,
247
+ errors,
248
+ disabled = false,
249
+ onChange,
250
+ className,
251
+ showSectionHeader = true,
252
+ scopeClassName,
253
+ density = "compact",
254
+ }: SchemaFormRendererProps<TData>) {
255
+ const isView = mode === "view";
256
+ const dt = UI7_FORM_DENSITY_TOKENS[density];
257
+ const carbonSize = density === "compact" ? "sm" : "md";
258
+ const rootClassName =
259
+ scopeClassName ?? getUi7FormContractClassName({ density, readonly: isView });
260
+ const labels = useBosSharedI18n();
261
+ const { locale } = useI18n();
262
+ const renderDisplayValue = makeDisplayRenderer(labels, locale);
263
+
264
+ const setValue = <K extends keyof TData>(key: K, next: TData[K]) => {
265
+ onChange?.(({ [key]: next } as unknown) as Partial<TData>);
266
+ };
267
+
268
+ return (
269
+ <div
270
+ className={`${rootClassName}${className ? ` ${className}` : ""}`}
271
+ style={{ display: "grid", gap: dt.sectionGap }}
272
+ >
273
+ {sections.map((section, sectionIndex) => {
274
+ const cols = section.columns ?? 1;
275
+ const defaultSpan = Math.floor(12 / cols);
276
+
277
+ return (
278
+ <section
279
+ key={`${String(section.title ?? "section")}-${sectionIndex}`}
280
+ className={UI7_FORM_VISUAL_CLASSNAMES.section}
281
+ style={{
282
+ padding: "1rem 1.125rem",
283
+ border: "1px solid var(--cds-border-subtle, #e0e0e0)",
284
+ background: "var(--cds-layer-01, #ffffff)",
285
+ }}
286
+ >
287
+ {showSectionHeader && (section.title || section.description) ? (
288
+ <div style={{ display: "grid", gap: "0.25rem" }}>
289
+ {section.title ? (
290
+ <div
291
+ className={UI7_FORM_VISUAL_CLASSNAMES.label}
292
+ style={{ fontWeight: 600, color: "var(--cds-text-primary, #161616)" }}
293
+ >
294
+ <span>{section.title}</span>
295
+ </div>
296
+ ) : null}
297
+ {section.description ? (
298
+ <div
299
+ className={UI7_FORM_VISUAL_CLASSNAMES.helper}
300
+ style={{ marginBlockStart: 0, color: "var(--cds-text-primary, #161616)" }}
301
+ >
302
+ {section.description}
303
+ </div>
304
+ ) : null}
305
+ </div>
306
+ ) : null}
307
+
308
+ <div
309
+ className={UI7_FORM_VISUAL_CLASSNAMES.row}
310
+ style={{
311
+ display: "grid",
312
+ gridTemplateColumns: "repeat(12, minmax(0, 1fr))",
313
+ alignItems: "start",
314
+ rowGap: dt.rowGap,
315
+ columnGap: dt.columnGap,
316
+ }}
317
+ >
318
+ {section.fields.flatMap((field) => {
319
+ if (field.mode) {
320
+ const allowed = Array.isArray(field.mode) ? field.mode : [field.mode];
321
+ if (!allowed.includes(mode)) return [];
322
+ }
323
+
324
+ const isHidden =
325
+ typeof field.hidden === "function"
326
+ ? field.hidden(mode, value)
327
+ : Boolean(field.hidden);
328
+ if (isHidden) return [];
329
+
330
+ const key = String(field.key);
331
+ const rawValue = value[field.key];
332
+ const effectiveReadonly =
333
+ isView ||
334
+ (typeof field.readonly === "function"
335
+ ? field.readonly(mode, value)
336
+ : Boolean(field.readonly));
337
+ const effectiveRequired =
338
+ Boolean(field.validation?.required) && !effectiveReadonly;
339
+ const invalidText = errors?.[field.key];
340
+ const span = clampSpan(field.span, defaultSpan);
341
+ const wrapperStyle = {
342
+ gridColumn: `span ${span} / span ${span}`,
343
+ minWidth: 0,
344
+ } as const;
345
+
346
+ // ── View / readonly ─────────────────────────────────────────
347
+ if (effectiveReadonly) {
348
+ return [
349
+ <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
350
+ <FieldShell label={field.label} helperText={isView ? undefined : field.helperText}>
351
+ <div
352
+ className={`${UI7_FORM_VISUAL_CLASSNAMES.control} ${UI7_FORM_VISUAL_CLASSNAMES.controlReadonly}`}
353
+ style={{
354
+ minHeight: UI7_FORM_VISUAL_TOKENS.controlHeight,
355
+ display: "flex",
356
+ alignItems: "center",
357
+ fontSize: UI7_FORM_VISUAL_TOKENS.inputFontSize,
358
+ lineHeight: UI7_FORM_VISUAL_TOKENS.inputLineHeight,
359
+ background: "var(--ui7-form-readonly-background, #f4f4f4)",
360
+ cursor: "var(--ui7-form-readonly-cursor, default)",
361
+ width: "100%",
362
+ padding: "0 0.75rem",
363
+ wordBreak: "break-word",
364
+ overflowWrap: "anywhere",
365
+ }}
366
+ >
367
+ <div className={UI7_FORM_VISUAL_CLASSNAMES.readonlyValue} style={{ width: "100%" }}>
368
+ {renderDisplayValue(field as never, rawValue, value)}
369
+ </div>
370
+ </div>
371
+ </FieldShell>
372
+ </div>,
373
+ ];
374
+ }
375
+
376
+ // ── Lookup ──────────────────────────────────────────────────
377
+ if (field.type === "lookup") {
378
+ const lookup = field.lookup;
379
+ return [
380
+ <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
381
+ <LookupInput
382
+ id={key}
383
+ labelText={mkLabel(field.label, effectiveRequired)}
384
+ value={asString(rawValue)}
385
+ onChange={(next: string) => setValue(field.key, next as TData[keyof TData])}
386
+ onDataSelected={(row: Record<string, unknown>) => {
387
+ if (lookup?.onDataPatch) {
388
+ const patch = lookup.onDataPatch(row as never, value);
389
+ onChange?.(patch);
390
+ }
391
+ }}
392
+ dataSource={lookup?.dataSource ?? "api"}
393
+ lookupAPI={lookup?.lookupAPI ?? ""}
394
+ lookupDataStructure={lookup?.lookupDataStructure ?? {}}
395
+ lookupFieldTitles={lookup?.lookupFieldTitles ?? {}}
396
+ displayFieldNames={lookup?.displayFieldNames ?? []}
397
+ popupWidthPx={lookup?.popupWidthPx}
398
+ placeholder={lookup?.placeholder}
399
+ disabled={disabled}
400
+ />
401
+ {field.helperText ? (
402
+ <div className={UI7_FORM_VISUAL_CLASSNAMES.helper}>{field.helperText}</div>
403
+ ) : null}
404
+ </div>,
405
+ ];
406
+ }
407
+
408
+ // ── Date ────────────────────────────────────────────────────
409
+ if (field.type === "date") {
410
+ return [
411
+ <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
412
+ <DatePicker
413
+ datePickerType="single"
414
+ dateFormat="Y-m-d"
415
+ value={asString(rawValue)}
416
+ onChange={([date]) =>
417
+ setValue(
418
+ field.key,
419
+ (date ? date.toISOString().slice(0, 10) : "") as TData[keyof TData],
420
+ )
421
+ }
422
+ >
423
+ <DatePickerInput
424
+ id={key}
425
+ labelText={mkLabel(field.label, effectiveRequired)}
426
+ size={carbonSize}
427
+ placeholder={field.placeholder ?? "YYYY-MM-DD"}
428
+ disabled={disabled}
429
+ invalid={Boolean(invalidText)}
430
+ invalidText={invalidText}
431
+ />
432
+ </DatePicker>
433
+ {field.helperText ? (
434
+ <div className={UI7_FORM_VISUAL_CLASSNAMES.helper}>{field.helperText}</div>
435
+ ) : null}
436
+ </div>,
437
+ ];
438
+ }
439
+
440
+ // ── Select ──────────────────────────────────────────────────
441
+ if (field.type === "select") {
442
+ return [
443
+ <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
444
+ <Select
445
+ id={key}
446
+ labelText={mkLabel(field.label, effectiveRequired)}
447
+ helperText={field.helperText}
448
+ size={carbonSize}
449
+ value={asString(rawValue)}
450
+ onChange={(e) => setValue(field.key, e.target.value as TData[keyof TData])}
451
+ disabled={disabled}
452
+ light
453
+ invalid={Boolean(invalidText)}
454
+ invalidText={invalidText}
455
+ >
456
+ {(field.options ?? []).map((opt) => (
457
+ <SelectItem key={String(opt.value)} value={opt.value} text={opt.label} />
458
+ ))}
459
+ </Select>
460
+ </div>,
461
+ ];
462
+ }
463
+
464
+ // ── Number ──────────────────────────────────────────────────
465
+ if (field.type === "number") {
466
+ const numeric = field.numeric;
467
+ let numericKind: "currency" | "percent" | "integer" | "decimal" = "decimal";
468
+ let prec: number | undefined;
469
+
470
+ if (numeric?.kind === "currency") {
471
+ numericKind = "currency";
472
+ const code = String(value[numeric.currencyField] ?? "");
473
+ prec = getCurrencyPrecision(code, {
474
+ precisionByCurrency: numeric.precisionByCurrency,
475
+ defaultPrecision: numeric.defaultPrecision,
476
+ });
477
+ } else if (numeric?.kind === "percent") {
478
+ numericKind = "percent";
479
+ prec = numeric.maxFractionDigits;
480
+ } else if (numeric?.kind === "integer") {
481
+ numericKind = "integer";
482
+ }
483
+
484
+ return [
485
+ <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
486
+ <NumericField
487
+ id={key}
488
+ labelText={mkLabel(field.label, effectiveRequired)}
489
+ helperText={field.helperText}
490
+ value={typeof rawValue === "number" ? rawValue : 0}
491
+ numericKind={numericKind}
492
+ prec={prec}
493
+ locale={locale}
494
+ size={carbonSize}
495
+ disabled={disabled}
496
+ invalid={Boolean(invalidText)}
497
+ invalidText={invalidText}
498
+ placeholder={field.placeholder}
499
+ onChange={(v) => setValue(field.key, v as TData[keyof TData])}
500
+ />
501
+ </div>,
502
+ ];
503
+ }
504
+
505
+ // ── Checkbox ────────────────────────────────────────────────
506
+ if (field.type === "checkbox") {
507
+ return [
508
+ <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
509
+ <Checkbox
510
+ id={key}
511
+ labelText={mkLabel(field.label, effectiveRequired)}
512
+ checked={Boolean(rawValue)}
513
+ onChange={(_e, { checked }) =>
514
+ setValue(field.key, Boolean(checked) as TData[keyof TData])
515
+ }
516
+ disabled={disabled}
517
+ />
518
+ </div>,
519
+ ];
520
+ }
521
+
522
+ // ── Toggle ──────────────────────────────────────────────────
523
+ if (field.type === "toggle") {
524
+ return [
525
+ <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
526
+ <FieldShell label={field.label} required={effectiveRequired} helperText={field.helperText}>
527
+ <div style={{ minHeight: dt.controlHeight, display: "flex", alignItems: "center" }}>
528
+ <Toggle
529
+ id={key}
530
+ labelText=""
531
+ labelA={labels.toggleOff}
532
+ labelB={labels.toggleOn}
533
+ toggled={Boolean(rawValue)}
534
+ onToggle={(checked) =>
535
+ setValue(field.key, Boolean(checked) as TData[keyof TData])
536
+ }
537
+ disabled={disabled}
538
+ />
539
+ </div>
540
+ </FieldShell>
541
+ </div>,
542
+ ];
543
+ }
544
+
545
+ // ── Textarea ────────────────────────────────────────────────
546
+ if (field.type === "textarea") {
547
+ return [
548
+ <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
549
+ <TextArea
550
+ id={key}
551
+ labelText={mkLabel(field.label, effectiveRequired)}
552
+ helperText={field.helperText}
553
+ rows={field.rows ?? 3}
554
+ value={asString(rawValue)}
555
+ onChange={(e) =>
556
+ setValue(field.key, e.target.value as TData[keyof TData])
557
+ }
558
+ disabled={disabled}
559
+ maxLength={field.maxLength}
560
+ invalid={Boolean(invalidText)}
561
+ invalidText={invalidText}
562
+ />
563
+ </div>,
564
+ ];
565
+ }
566
+
567
+ // ── Phone (text with numeric phone normalization) ────────────
568
+ if (field.numeric?.kind === "phone") {
569
+ return [
570
+ <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
571
+ <TextInput
572
+ id={key}
573
+ labelText={mkLabel(field.label, effectiveRequired)}
574
+ helperText={field.helperText}
575
+ size={carbonSize}
576
+ value={asString(rawValue)}
577
+ onChange={(e) =>
578
+ setValue(
579
+ field.key,
580
+ normalizePhoneInput(e.target.value) as TData[keyof TData],
581
+ )
582
+ }
583
+ disabled={disabled}
584
+ maxLength={field.maxLength}
585
+ inputMode="tel"
586
+ light
587
+ invalid={Boolean(invalidText)}
588
+ invalidText={invalidText}
589
+ placeholder={field.placeholder}
590
+ />
591
+ </div>,
592
+ ];
593
+ }
594
+
595
+ // ── Text (default) ──────────────────────────────────────────
596
+ return [
597
+ <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
598
+ <TextInput
599
+ id={key}
600
+ labelText={mkLabel(field.label, effectiveRequired)}
601
+ helperText={field.helperText}
602
+ size={carbonSize}
603
+ value={asString(rawValue)}
604
+ onChange={(e) =>
605
+ setValue(field.key, e.target.value as TData[keyof TData])
606
+ }
607
+ disabled={disabled}
608
+ maxLength={field.maxLength}
609
+ inputMode={field.inputMode}
610
+ light
611
+ invalid={Boolean(invalidText)}
612
+ invalidText={invalidText}
613
+ placeholder={field.placeholder}
614
+ />
615
+ </div>,
616
+ ];
617
+ })}
618
+ </div>
619
+ </section>
620
+ );
621
+ })}
622
+ </div>
623
+ );
624
+ }
@@ -0,0 +1,54 @@
1
+ import { isValidPhoneInput } from "./form-numeric";
2
+ import type { PhoneRule } from "./form-numeric";
3
+
4
+ export type FieldValidatorFn<TData = Record<string, unknown>> = (
5
+ value: unknown,
6
+ data: TData,
7
+ ) => string | undefined;
8
+
9
+ // Rule factories — pass a custom msg to override the built-in fallback.
10
+ // The fallback string is used when no i18n context is available (e.g. SSR utility code).
11
+ // In React components the actual message comes from useBosSharedI18n() via field.validation.validators[].
12
+
13
+ export const Rules = {
14
+ email(msg?: string): FieldValidatorFn {
15
+ const m = msg ?? "Format email tidak valid";
16
+ return (v) =>
17
+ v && typeof v === "string" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? m : undefined;
18
+ },
19
+
20
+ url(msg?: string): FieldValidatorFn {
21
+ const m = msg ?? "URL harus diawali http:// atau https://";
22
+ return (v) =>
23
+ v && typeof v === "string" && !/^https?:\/\/.+/.test(v) ? m : undefined;
24
+ },
25
+
26
+ nik(msg?: string): FieldValidatorFn {
27
+ const m = msg ?? "NIK harus 16 digit angka";
28
+ return (v) =>
29
+ v && typeof v === "string" && !/^\d{16}$/.test(v) ? m : undefined;
30
+ },
31
+
32
+ kodePos(msg?: string): FieldValidatorFn {
33
+ const m = msg ?? "Kode pos harus 5 digit";
34
+ return (v) =>
35
+ v && typeof v === "string" && !/^\d{5}$/.test(v) ? m : undefined;
36
+ },
37
+
38
+ phone(rule: PhoneRule, msg?: string): FieldValidatorFn {
39
+ const m = msg ?? "No HP tidak valid";
40
+ return (v) =>
41
+ v && typeof v === "string" && !isValidPhoneInput(v, rule) ? m : undefined;
42
+ },
43
+
44
+ regex(pattern: RegExp, msg: string): FieldValidatorFn {
45
+ return (v) =>
46
+ v && typeof v === "string" && !pattern.test(v) ? msg : undefined;
47
+ },
48
+ } as const;
49
+
50
+ export function buildValidator<TData extends Record<string, unknown>>(
51
+ crossField: (form: TData) => Partial<Record<keyof TData, string>>,
52
+ ): (form: TData) => Partial<Record<keyof TData, string>> {
53
+ return crossField;
54
+ }