@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,580 @@
1
+ "use client";
2
+
3
+ import { type ReactNode, useCallback, useState } from "react";
4
+ import { Add } from "@carbon/icons-react";
5
+ import {
6
+ Button,
7
+ ComposedModal,
8
+ InlineLoading,
9
+ InlineNotification,
10
+ ModalBody,
11
+ ModalFooter,
12
+ ModalHeader,
13
+ ProgressIndicator,
14
+ ProgressStep,
15
+ ToastNotification,
16
+ } from "@carbon/react";
17
+ import { ServerDataTable } from "@isi-ui7/data-table";
18
+ import type { CrudCustomActionSchema, CrudDeleteSchema, CrudListSchema } from "./crud-types";
19
+ import type { CrudForm, FormMode } from "./form-types";
20
+ import type { Ui7FormDensity } from "./style-contract";
21
+ import { SchemaFormRenderer } from "./form-renderer";
22
+ import { PageBody, PageFooter, PageHeader, PageLayout } from "./page-layout";
23
+ import { submitWorkflowDelete, submitWorkflowForm } from "./workflow/crud-client";
24
+ import { useCrudForm } from "./workflow/use-crud-form";
25
+ import { useWorkflowTracker } from "./workflow/use-workflow-tracker";
26
+ import { useBosSharedI18n } from "./i18n";
27
+
28
+ // ── List page (bare) ──────────────────────────────────────────────────────────
29
+
30
+ export function SharedCrudListPage({
31
+ schema,
32
+ tableKey,
33
+ onAdd,
34
+ onPopupClick
35
+ }: {
36
+ schema: CrudListSchema;
37
+ tableKey?: number;
38
+ onAdd: () => void;
39
+ onPopupClick: (row: Record<string, unknown>, actionId: string) => void;
40
+ }) {
41
+ const labels = useBosSharedI18n();
42
+ return (
43
+ <PageLayout>
44
+ <PageHeader title={schema.title} subtitle={schema.subtitle} />
45
+ <PageBody>
46
+ <ServerDataTable
47
+ key={tableKey}
48
+ apiPath={schema.apiPath}
49
+ columns={schema.columns as any}
50
+ showSearch={schema.showSearch ?? true}
51
+ showSort={schema.showSort ?? true}
52
+ popupMenu={{ src: "local", items: schema.popupMenuItems as any, onClick: onPopupClick }}
53
+ toolbarActions={
54
+ <Button renderIcon={Add} size="sm" onClick={onAdd}>
55
+ {schema.addButtonLabel || labels.next}
56
+ </Button>
57
+ }
58
+ />
59
+ </PageBody>
60
+ </PageLayout>
61
+ );
62
+ }
63
+
64
+ // ── Schema-driven list page ───────────────────────────────────────────────────
65
+ // Handles popup routing + delete modal (built-in) + custom action modal (L2) internally.
66
+ // Unknown action IDs fall through to onCustomAction (L1).
67
+
68
+ export function CrudSchemaListPage<TData extends Record<string, unknown>>({
69
+ listSchema,
70
+ deleteSchema,
71
+ onAdd,
72
+ onView,
73
+ onEdit,
74
+ deleteApiPath,
75
+ onCustomAction,
76
+ }: {
77
+ listSchema: CrudListSchema;
78
+ deleteSchema?: CrudDeleteSchema<TData>;
79
+ onAdd: () => void;
80
+ onView?: (id: string) => void;
81
+ onEdit?: (id: string) => void;
82
+ deleteApiPath?: (id: string) => string;
83
+ /**
84
+ * Level 1: called for any popup action id not handled by built-in logic
85
+ * (view / edit / delete) and not in listSchema.customActionSchemas (Level 2).
86
+ */
87
+ onCustomAction?: (id: string, row: Record<string, unknown>, actionId: string) => void;
88
+ }) {
89
+ const labels = useBosSharedI18n();
90
+ const [tableKey, setTableKey] = useState(0);
91
+ const [deleteRow, setDeleteRow] = useState<Record<string, unknown> | null>(null);
92
+ const [deleting, setDeleting] = useState(false);
93
+
94
+ // Level 2 custom action state
95
+ const [customActionId, setCustomActionId] = useState<string | null>(null);
96
+ const [customActionRow, setCustomActionRow] = useState<Record<string, unknown> | null>(null);
97
+ const [customActioning, setCustomActioning] = useState(false);
98
+ const [customActionError, setCustomActionError] = useState<string | null>(null);
99
+
100
+ // Workflow completion toast
101
+ const [wfToast, setWfToast] = useState<{ kind: 'success' | 'error'; title: string; subtitle?: string } | null>(null);
102
+
103
+ const tracker = useWorkflowTracker({
104
+ onComplete: useCallback((_id: string, result: { status: 'success' | 'error'; message?: string }) => {
105
+ if (result.status === 'success') {
106
+ setWfToast({ kind: 'success', title: 'Proses berhasil', subtitle: result.message });
107
+ setTableKey((v) => v + 1);
108
+ } else {
109
+ const title = listSchema.title ? `${listSchema.title}: proses gagal` : 'Proses gagal';
110
+ setWfToast({ kind: 'error', title, subtitle: result.message });
111
+ }
112
+ }, [listSchema.title]),
113
+ });
114
+
115
+ const onPopupClick = useCallback(
116
+ (row: Record<string, unknown>, actionId: string) => {
117
+ const rid = encodeURIComponent(String(row.id ?? ""));
118
+ if (!rid) return;
119
+ if (actionId === "view") { onView?.(rid); return; }
120
+ if (actionId === "edit") { onEdit?.(rid); return; }
121
+ if (actionId === "delete") { setDeleteRow(row); return; }
122
+
123
+ // Level 2: schema-driven confirmation modal
124
+ if (listSchema.customActionSchemas?.[actionId]) {
125
+ setCustomActionId(actionId);
126
+ setCustomActionRow(row);
127
+ setCustomActionError(null);
128
+ return;
129
+ }
130
+
131
+ // Level 1: delegate to caller
132
+ onCustomAction?.(rid, row, actionId);
133
+ },
134
+ [onView, onEdit, listSchema.customActionSchemas, onCustomAction],
135
+ );
136
+
137
+ const doDelete = useCallback(async () => {
138
+ const id = String(deleteRow?.id ?? "");
139
+ if (!id || !deleteApiPath) return;
140
+ setDeleting(true);
141
+ try {
142
+ const instanceId = await submitWorkflowDelete(deleteApiPath(id));
143
+ setDeleteRow(null);
144
+ setTableKey((v) => v + 1);
145
+ tracker.track(instanceId);
146
+ } catch {
147
+ // error shown inline by modal
148
+ } finally {
149
+ setDeleting(false);
150
+ }
151
+ }, [deleteRow, deleteApiPath, tracker]);
152
+
153
+ const doCustomAction = useCallback(async () => {
154
+ if (!customActionId || !customActionRow) return;
155
+ const schema: CrudCustomActionSchema | undefined =
156
+ listSchema.customActionSchemas?.[customActionId];
157
+ if (!schema) return;
158
+ const id = String(customActionRow.id ?? "");
159
+ const path = typeof schema.apiPath === "function" ? schema.apiPath(id) : schema.apiPath;
160
+ setCustomActioning(true);
161
+ setCustomActionError(null);
162
+ try {
163
+ const instanceId = await submitWorkflowForm(path, schema.method ?? "POST", {});
164
+ setCustomActionId(null);
165
+ setCustomActionRow(null);
166
+ setTableKey((v) => v + 1);
167
+ tracker.track(instanceId);
168
+ } catch (e: unknown) {
169
+ setCustomActionError(e instanceof Error ? e.message : String(e));
170
+ } finally {
171
+ setCustomActioning(false);
172
+ }
173
+ }, [customActionId, customActionRow, listSchema.customActionSchemas, tracker]);
174
+
175
+ const activeCustomSchema =
176
+ customActionId ? listSchema.customActionSchemas?.[customActionId] : undefined;
177
+ const customDescription = activeCustomSchema
178
+ ? (typeof activeCustomSchema.description === "function"
179
+ ? activeCustomSchema.description(customActionRow ?? {})
180
+ : activeCustomSchema.description)
181
+ : "";
182
+
183
+ return (
184
+ <>
185
+ <SharedCrudListPage
186
+ schema={listSchema}
187
+ tableKey={tableKey}
188
+ onAdd={onAdd}
189
+ onPopupClick={onPopupClick}
190
+ />
191
+
192
+ {wfToast ? (
193
+ <div style={{ position: 'fixed', bottom: '1rem', right: '1rem', zIndex: 9000 }}>
194
+ <ToastNotification
195
+ kind={wfToast.kind}
196
+ title={wfToast.title}
197
+ subtitle={wfToast.subtitle ?? ''}
198
+ timeout={5000}
199
+ onClose={() => setWfToast(null)}
200
+ />
201
+ </div>
202
+ ) : null}
203
+
204
+ {/* Built-in delete modal */}
205
+ {deleteSchema ? (
206
+ <SharedCrudDeleteModal
207
+ open={Boolean(deleteRow)}
208
+ row={deleteRow as never}
209
+ schema={deleteSchema as never}
210
+ deleting={deleting}
211
+ onClose={() => !deleting && setDeleteRow(null)}
212
+ onConfirm={() => void doDelete()}
213
+ />
214
+ ) : null}
215
+
216
+ {/* Level 2: generic custom action confirmation modal */}
217
+ {activeCustomSchema ? (
218
+ <ComposedModal
219
+ open={Boolean(customActionRow)}
220
+ onClose={() => !customActioning && setCustomActionId(null)}
221
+ size="sm"
222
+ >
223
+ <ModalHeader title={activeCustomSchema.title} />
224
+ <ModalBody hasScrollingContent={false}>
225
+ <div style={{ display: "grid", gap: "0.5rem", fontSize: "0.875rem" }}>
226
+ <p>{customDescription}</p>
227
+ {customActionError ? (
228
+ <InlineNotification
229
+ kind="error"
230
+ title={labels.errorTitle}
231
+ subtitle={customActionError}
232
+ lowContrast
233
+ />
234
+ ) : null}
235
+ </div>
236
+ </ModalBody>
237
+ <ModalFooter>
238
+ <Button
239
+ kind="secondary"
240
+ onClick={() => setCustomActionId(null)}
241
+ disabled={customActioning}
242
+ >
243
+ {activeCustomSchema.cancelLabel ?? labels.cancel}
244
+ </Button>
245
+ <Button
246
+ kind="primary"
247
+ onClick={() => void doCustomAction()}
248
+ disabled={customActioning || !customActionRow}
249
+ >
250
+ {customActioning ? labels.saving : (activeCustomSchema.confirmLabel ?? labels.next)}
251
+ </Button>
252
+ </ModalFooter>
253
+ </ComposedModal>
254
+ ) : null}
255
+ </>
256
+ );
257
+ }
258
+
259
+ // ── Form page shell ───────────────────────────────────────────────────────────
260
+
261
+ export function SharedCrudFormPage({
262
+ title,
263
+ subtitle,
264
+ children,
265
+ footer
266
+ }: {
267
+ title: string;
268
+ subtitle?: string;
269
+ children: ReactNode;
270
+ footer: ReactNode;
271
+ }) {
272
+ return (
273
+ <PageLayout>
274
+ <PageHeader title={title} subtitle={subtitle} />
275
+ <PageBody>{children}</PageBody>
276
+ {footer ? <PageFooter>{footer}</PageFooter> : null}
277
+ </PageLayout>
278
+ );
279
+ }
280
+
281
+ // ── Schema-driven full page (single-page / tabs / wizard) ─────────────────────
282
+
283
+ export function CrudSchemaPage<TData extends Record<string, unknown>>({
284
+ schema,
285
+ mode,
286
+ params = {},
287
+ onBack,
288
+ onSuccess,
289
+ density: densityProp,
290
+ }: {
291
+ schema: CrudForm<TData>;
292
+ mode: FormMode;
293
+ params?: Record<string, string>;
294
+ onBack?: () => void;
295
+ onSuccess?: (instanceId: string) => void;
296
+ /** Override schema.density. Both default to "compact". */
297
+ density?: Ui7FormDensity;
298
+ }) {
299
+ const labels = useBosSharedI18n();
300
+ const density = densityProp ?? schema.density ?? "compact";
301
+ const {
302
+ form,
303
+ setForm,
304
+ loading,
305
+ saving,
306
+ error,
307
+ fieldErrors,
308
+ isDirty,
309
+ page,
310
+ isLastPage,
311
+ canGoPrev,
312
+ goNext,
313
+ goPrev,
314
+ handleSave,
315
+ } = useCrudForm({ schema, mode, params, onSuccess });
316
+
317
+ const [activeTab, setActiveTab] = useState(0);
318
+
319
+ const title = schema.title[mode];
320
+ const subtitleDefaults = { create: labels.subtitleCreate, edit: labels.subtitleEdit, view: labels.subtitleView };
321
+ const subtitle =
322
+ schema.subtitle === undefined
323
+ ? subtitleDefaults[mode]
324
+ : typeof schema.subtitle === "string"
325
+ ? schema.subtitle
326
+ : schema.subtitle[mode];
327
+ const isView = mode === "view";
328
+ const submitLabel =
329
+ mode === "create"
330
+ ? (schema.submitLabel?.create ?? labels.submitCreate)
331
+ : (schema.submitLabel?.edit ?? labels.submitEdit);
332
+ const backLabel =
333
+ isView
334
+ ? (schema.backLabel?.view ?? labels.close)
335
+ : (schema.backLabel?.others ?? labels.cancel);
336
+
337
+ if (loading) {
338
+ return (
339
+ <PageLayout>
340
+ <PageHeader title={title} subtitle={subtitle} />
341
+ <PageBody>
342
+ <InlineLoading description={labels.loading} />
343
+ </PageBody>
344
+ </PageLayout>
345
+ );
346
+ }
347
+
348
+ const errBanner = error ? (
349
+ <InlineNotification
350
+ kind="error"
351
+ title={labels.errorTitle}
352
+ subtitle={error}
353
+ lowContrast
354
+ style={{ marginBottom: "1rem" }}
355
+ />
356
+ ) : null;
357
+
358
+ const layout = schema.layout;
359
+
360
+ const renderBody = () => {
361
+ if (layout.type === "single-page") {
362
+ return (
363
+ <PageBody>
364
+ {errBanner}
365
+ <SchemaFormRenderer
366
+ mode={mode}
367
+ value={form}
368
+ sections={layout.sections}
369
+ errors={fieldErrors}
370
+ disabled={saving}
371
+ onChange={setForm}
372
+ density={density}
373
+ />
374
+ </PageBody>
375
+ );
376
+ }
377
+
378
+ if (layout.type === "tabs") {
379
+ return (
380
+ <PageBody>
381
+ {errBanner}
382
+ <div>
383
+ <div
384
+ role="tablist"
385
+ style={{
386
+ display: "flex",
387
+ borderBottom: "1px solid var(--cds-border-subtle-01, #e0e0e0)",
388
+ marginBottom: 0,
389
+ }}
390
+ >
391
+ {layout.pages.map((pg, i) => (
392
+ <button
393
+ key={i}
394
+ type="button"
395
+ role="tab"
396
+ aria-selected={i === activeTab}
397
+ onClick={() => setActiveTab(i)}
398
+ style={{
399
+ padding: "0.75rem 1rem",
400
+ background: "none",
401
+ border: "none",
402
+ borderBottom:
403
+ i === activeTab
404
+ ? "2px solid var(--cds-interactive-01, #0f62fe)"
405
+ : "2px solid transparent",
406
+ marginBottom: "-1px",
407
+ cursor: "pointer",
408
+ fontSize: "0.875rem",
409
+ lineHeight: "1.25rem",
410
+ fontWeight: i === activeTab ? 600 : 400,
411
+ color:
412
+ i === activeTab
413
+ ? "var(--cds-text-primary, #161616)"
414
+ : "var(--cds-text-secondary, #525252)",
415
+ }}
416
+ >
417
+ {pg.label}
418
+ </button>
419
+ ))}
420
+ </div>
421
+ {layout.pages.map((pg, i) => (
422
+ <div key={i} style={{ display: i === activeTab ? "block" : "none" }}>
423
+ <SchemaFormRenderer
424
+ mode={mode}
425
+ value={form}
426
+ sections={pg.sections}
427
+ errors={fieldErrors}
428
+ disabled={saving}
429
+ onChange={setForm}
430
+ density={density}
431
+ />
432
+ </div>
433
+ ))}
434
+ </div>
435
+ </PageBody>
436
+ );
437
+ }
438
+
439
+ const currentPage = layout.pages[page];
440
+ return (
441
+ <PageBody>
442
+ {errBanner}
443
+ <ProgressIndicator currentIndex={page} style={{ marginBottom: "1.5rem" }}>
444
+ {layout.pages.map((pg, i) => (
445
+ <ProgressStep key={i} label={pg.label} />
446
+ ))}
447
+ </ProgressIndicator>
448
+ {currentPage ? (
449
+ <SchemaFormRenderer
450
+ mode={mode}
451
+ value={form}
452
+ sections={currentPage.sections}
453
+ errors={fieldErrors}
454
+ disabled={saving}
455
+ onChange={setForm}
456
+ density={density}
457
+ />
458
+ ) : null}
459
+ </PageBody>
460
+ );
461
+ };
462
+
463
+ const renderFooter = () => {
464
+ if (isView) {
465
+ return (
466
+ <PageFooter>
467
+ <Button kind="secondary" size="sm" onClick={onBack}>
468
+ {backLabel}
469
+ </Button>
470
+ </PageFooter>
471
+ );
472
+ }
473
+
474
+ if (layout.type === "wizard") {
475
+ return (
476
+ <PageFooter spread>
477
+ <Button kind="secondary" size="sm" onClick={onBack} disabled={saving}>
478
+ {backLabel}
479
+ </Button>
480
+ <div style={{ display: "flex", gap: "0.5rem" }}>
481
+ {canGoPrev ? (
482
+ <Button kind="secondary" size="sm" onClick={goPrev} disabled={saving}>
483
+ {labels.prev}
484
+ </Button>
485
+ ) : null}
486
+ {isLastPage ? (
487
+ <Button
488
+ kind="primary"
489
+ size="sm"
490
+ onClick={handleSave}
491
+ disabled={saving || (mode === "edit" && !isDirty)}
492
+ >
493
+ {saving ? labels.saving : submitLabel}
494
+ </Button>
495
+ ) : (
496
+ <Button kind="primary" size="sm" onClick={() => void goNext()} disabled={saving}>
497
+ {labels.next}
498
+ </Button>
499
+ )}
500
+ </div>
501
+ </PageFooter>
502
+ );
503
+ }
504
+
505
+ return (
506
+ <PageFooter>
507
+ <Button kind="secondary" size="sm" onClick={onBack} disabled={saving}>
508
+ {backLabel}
509
+ </Button>
510
+ <Button
511
+ kind="primary"
512
+ size="sm"
513
+ onClick={handleSave}
514
+ disabled={saving || (mode === "edit" && !isDirty)}
515
+ >
516
+ {saving ? labels.saving : submitLabel}
517
+ </Button>
518
+ </PageFooter>
519
+ );
520
+ };
521
+
522
+ return (
523
+ <PageLayout>
524
+ <PageHeader title={title} subtitle={subtitle} />
525
+ {renderBody()}
526
+ {renderFooter()}
527
+ </PageLayout>
528
+ );
529
+ }
530
+
531
+ // ── Delete modal ──────────────────────────────────────────────────────────────
532
+
533
+ function fmt(value: unknown): string {
534
+ return value === null || value === undefined || value === "" ? "-" : String(value);
535
+ }
536
+
537
+ export function SharedCrudDeleteModal<TData extends Record<string, unknown>>({
538
+ open,
539
+ row,
540
+ schema,
541
+ deleting,
542
+ onClose,
543
+ onConfirm
544
+ }: {
545
+ open: boolean;
546
+ row: TData | null;
547
+ schema: CrudDeleteSchema<TData>;
548
+ deleting?: boolean;
549
+ onClose: () => void;
550
+ onConfirm: () => void;
551
+ }) {
552
+ const labels = useBosSharedI18n();
553
+ return (
554
+ <ComposedModal open={open} onClose={onClose} size="sm">
555
+ <ModalHeader title={schema.title ?? labels.deleteTitle} />
556
+ <ModalBody hasScrollingContent={false}>
557
+ <div style={{ display: "grid", gap: "0.5rem", fontSize: "0.875rem", lineHeight: "1.25rem" }}>
558
+ <p>{schema.description ?? labels.deleteDescription}</p>
559
+ {schema.summary.map((item) => {
560
+ const raw = row ? row[item.key as keyof TData] : undefined;
561
+ const value = row ? (item.format ? item.format(raw, row) : fmt(raw)) : "-";
562
+ return (
563
+ <div key={String(item.key)}>
564
+ <b>{item.label}:</b> {value}
565
+ </div>
566
+ );
567
+ })}
568
+ </div>
569
+ </ModalBody>
570
+ <ModalFooter>
571
+ <Button kind="secondary" onClick={onClose} disabled={deleting}>
572
+ {schema.cancelLabel ?? labels.cancel}
573
+ </Button>
574
+ <Button kind="danger" onClick={onConfirm} disabled={deleting || !row}>
575
+ {deleting ? labels.saving : (schema.confirmLabel ?? labels.deleteConfirm)}
576
+ </Button>
577
+ </ModalFooter>
578
+ </ComposedModal>
579
+ );
580
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyCrudFieldOverrides } from "./crud-hooks";
3
+
4
+ describe("applyCrudFieldOverrides", () => {
5
+ it("applies readonly/required/helper override", () => {
6
+ const fields = [{ key: "a", readonly: false, required: true, helperText: "old" }];
7
+ const out = applyCrudFieldOverrides(fields, {
8
+ fieldOverrides: { a: { readonly: true, required: false, helperText: "new" } },
9
+ });
10
+
11
+ expect(out).toHaveLength(1);
12
+ expect(out[0].readonly).toBe(true);
13
+ expect(out[0].required).toBe(false);
14
+ expect(out[0].helperText).toBe("new");
15
+ });
16
+
17
+ it("drops hidden field", () => {
18
+ const fields = [{ key: "a" }, { key: "b" }];
19
+ const out = applyCrudFieldOverrides(fields, {
20
+ fieldOverrides: { b: { hidden: true } },
21
+ });
22
+ expect(out.map((f) => f.key)).toEqual(["a"]);
23
+ });
24
+
25
+ it("keeps original when override absent", () => {
26
+ const fields = [{ key: "a", required: true }];
27
+ const out = applyCrudFieldOverrides(fields, {
28
+ fieldOverrides: { b: { required: false } },
29
+ });
30
+ expect(out).toEqual(fields);
31
+ });
32
+ });
@@ -0,0 +1,31 @@
1
+ import type { CrudActionOverrideHooks } from "./crud-types";
2
+
3
+ export async function runCrudHook<TData extends Record<string, unknown>, TResult>(
4
+ hook: ((data: TData, context: { mode: "create" | "edit" | "view" | "delete"; id?: string }) => Promise<TResult> | TResult) | undefined,
5
+ data: TData,
6
+ context: { mode: "create" | "edit" | "view" | "delete"; id?: string },
7
+ ): Promise<TResult | undefined> {
8
+ if (!hook) return undefined;
9
+ return hook(data, context);
10
+ }
11
+
12
+ export function applyCrudFieldOverrides<TField extends { key: string; readonly?: boolean; required?: boolean; helperText?: unknown }>(
13
+ fields: TField[],
14
+ hooks?: CrudActionOverrideHooks<Record<string, unknown>> | CrudActionOverrideHooks<any>,
15
+ ): TField[] {
16
+ if (!hooks?.fieldOverrides) return fields;
17
+
18
+ return fields
19
+ .filter((field) => hooks.fieldOverrides?.[field.key]?.hidden !== true)
20
+ .map((field) => {
21
+ const override = hooks.fieldOverrides?.[field.key];
22
+ if (!override) return field;
23
+
24
+ return {
25
+ ...field,
26
+ readonly: override.readonly ?? field.readonly,
27
+ required: override.required ?? field.required,
28
+ helperText: override.helperText ?? field.helperText,
29
+ };
30
+ });
31
+ }