@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
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* value_schema → bos7-shared form adapter (Wave C, core7-devroot#569).
|
|
3
|
+
*
|
|
4
|
+
* Turns a policy7 `parameter_categories.value_schema` (plain JSON Schema +
|
|
5
|
+
* the `x-ui` UI-hint extension + `x-rules` cross-field rules) into the
|
|
6
|
+
* `FormSection[]` + cross-field validator consumed by the EXISTING
|
|
7
|
+
* `CrudSchemaPage`. No new renderer.
|
|
8
|
+
*
|
|
9
|
+
* Pure: no fetch, no React. The category page fetches the schema (from #568),
|
|
10
|
+
* then composes `[scopeSection(t), ...buildValueSections(schema, t).sections]`
|
|
11
|
+
* and passes `validate` to the form.
|
|
12
|
+
*
|
|
13
|
+
* Spec: docs/plans/integration/PLAN-WC-XUI-CONVENTION.md
|
|
14
|
+
*/
|
|
15
|
+
import type { EditableColumnDef, EditableColumnType } from "@isi-ui7/editable-table";
|
|
16
|
+
import { INDONESIA_CURRENCY_CONFIG } from "./form-numeric";
|
|
17
|
+
import type {
|
|
18
|
+
DetailRowsConfig,
|
|
19
|
+
FormField,
|
|
20
|
+
FormFieldNumericConfig,
|
|
21
|
+
FormFieldOption,
|
|
22
|
+
FormFieldType,
|
|
23
|
+
FormMode,
|
|
24
|
+
FormSection,
|
|
25
|
+
} from "./form-types";
|
|
26
|
+
|
|
27
|
+
// ── i18n translate function ─────────────────────────────────────────────────
|
|
28
|
+
export type T = (key: string, fallback?: string) => string;
|
|
29
|
+
|
|
30
|
+
// ── JSON Schema + x-ui / x-rules shapes (the subset Wave C interprets) ───────
|
|
31
|
+
|
|
32
|
+
export type XUiNumeric = {
|
|
33
|
+
kind: "currency" | "percent" | "integer" | "phone";
|
|
34
|
+
/** currency: name of the sibling field holding the currency code. */
|
|
35
|
+
currencyField?: string;
|
|
36
|
+
precisionByCurrency?: Record<string, number>;
|
|
37
|
+
defaultPrecision?: number;
|
|
38
|
+
/** percent. */
|
|
39
|
+
maxFractionDigits?: number;
|
|
40
|
+
/** phone. */
|
|
41
|
+
minDigits?: number;
|
|
42
|
+
maxDigits?: number;
|
|
43
|
+
allowedPrefixes?: string[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type XUiOption = { value: string; labelKey?: string; label?: string };
|
|
47
|
+
|
|
48
|
+
export type XUiWidget =
|
|
49
|
+
| "text"
|
|
50
|
+
| "textarea"
|
|
51
|
+
| "number"
|
|
52
|
+
| "select"
|
|
53
|
+
| "date"
|
|
54
|
+
| "toggle"
|
|
55
|
+
| "lookup"
|
|
56
|
+
| "detail-rows";
|
|
57
|
+
|
|
58
|
+
/** Per-property `x-ui` hint object (also array-level for `detail-rows`). */
|
|
59
|
+
export type XUi = {
|
|
60
|
+
widget?: XUiWidget;
|
|
61
|
+
labelKey?: string;
|
|
62
|
+
label?: string;
|
|
63
|
+
helpKey?: string;
|
|
64
|
+
help?: string;
|
|
65
|
+
/** 1–12 grid width. Default 6. */
|
|
66
|
+
span?: number;
|
|
67
|
+
/** Sort order within an inferred section. */
|
|
68
|
+
order?: number;
|
|
69
|
+
/** Modes in which the field renders read-only, e.g. ["edit"]. */
|
|
70
|
+
readonlyOn?: FormMode[];
|
|
71
|
+
placeholder?: string;
|
|
72
|
+
numeric?: XUiNumeric;
|
|
73
|
+
/** `select` options (overrides enum labels). */
|
|
74
|
+
options?: XUiOption[];
|
|
75
|
+
/** Named dynamic-options source — deferred in Wave C (resolved by FE). */
|
|
76
|
+
optionsRef?: string;
|
|
77
|
+
lookup?: { api: string; valueField?: string; displayFields?: string[] };
|
|
78
|
+
toggle?: { valueOn?: unknown; valueOff?: unknown };
|
|
79
|
+
/** array `detail-rows`: max number of rows. */
|
|
80
|
+
maxRows?: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Root-level `x-ui` (layout grouping + density). */
|
|
84
|
+
export type XUiRoot = XUi & {
|
|
85
|
+
layout?: {
|
|
86
|
+
sections?: Array<{ titleKey?: string; title?: string; fields: string[] }>;
|
|
87
|
+
};
|
|
88
|
+
density?: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type JSONSchema = {
|
|
92
|
+
type?: string;
|
|
93
|
+
properties?: Record<string, JSONSchema>;
|
|
94
|
+
required?: string[];
|
|
95
|
+
enum?: unknown[];
|
|
96
|
+
minimum?: number;
|
|
97
|
+
maximum?: number;
|
|
98
|
+
minLength?: number;
|
|
99
|
+
maxLength?: number;
|
|
100
|
+
pattern?: string;
|
|
101
|
+
format?: string;
|
|
102
|
+
default?: unknown;
|
|
103
|
+
items?: JSONSchema;
|
|
104
|
+
"x-ui"?: XUiRoot;
|
|
105
|
+
"x-rules"?: XRule[];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type XRuleOp = "lte" | "gte" | "lt" | "gt" | "eq" | "required-if";
|
|
109
|
+
|
|
110
|
+
export type XRule = {
|
|
111
|
+
op: XRuleOp;
|
|
112
|
+
left: string;
|
|
113
|
+
/** Other field to compare against (lte/gte/lt/gt/eq, or the condition field of required-if). */
|
|
114
|
+
right?: string;
|
|
115
|
+
/** Literal to compare against (eq) or the condition value (required-if). */
|
|
116
|
+
value?: unknown;
|
|
117
|
+
message?: string;
|
|
118
|
+
/** i18n key for the message (preferred over `message`). */
|
|
119
|
+
messageKey?: string;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
type ValueData = Record<string, unknown>;
|
|
123
|
+
export type ValueValidatorFn = (data: ValueData) => Partial<Record<string, string>>;
|
|
124
|
+
|
|
125
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
const DEFAULT_SPAN = 6;
|
|
128
|
+
|
|
129
|
+
function resolveLabel(xui: XUi | undefined, fallback: string, t: T): string {
|
|
130
|
+
if (xui?.labelKey) return t(xui.labelKey, xui.label ?? fallback);
|
|
131
|
+
return xui?.label ?? fallback;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveHelp(xui: XUi | undefined, t: T): string | undefined {
|
|
135
|
+
if (xui?.helpKey) return t(xui.helpKey, xui.help ?? "");
|
|
136
|
+
return xui?.help;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** JSON Schema scalar type → bos7-shared field type, honoring `x-ui.widget`. */
|
|
140
|
+
function inferFieldType(schema: JSONSchema, xui: XUi | undefined): FormFieldType {
|
|
141
|
+
if (xui?.widget) {
|
|
142
|
+
if (xui.widget === "lookup") return "lookup";
|
|
143
|
+
if (xui.widget === "detail-rows") return "detail-rows";
|
|
144
|
+
return xui.widget as FormFieldType;
|
|
145
|
+
}
|
|
146
|
+
switch (schema.type) {
|
|
147
|
+
case "boolean":
|
|
148
|
+
return "toggle";
|
|
149
|
+
case "integer":
|
|
150
|
+
case "number":
|
|
151
|
+
return "number";
|
|
152
|
+
case "array":
|
|
153
|
+
return "detail-rows";
|
|
154
|
+
case "string":
|
|
155
|
+
default:
|
|
156
|
+
if (Array.isArray(schema.enum)) return "select";
|
|
157
|
+
if (schema.format === "date" || schema.format === "date-time") return "date";
|
|
158
|
+
return "text";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildNumeric(
|
|
163
|
+
schema: JSONSchema,
|
|
164
|
+
xui: XUi | undefined,
|
|
165
|
+
): FormFieldNumericConfig<ValueData> | undefined {
|
|
166
|
+
const n = xui?.numeric;
|
|
167
|
+
if (n) {
|
|
168
|
+
switch (n.kind) {
|
|
169
|
+
case "currency":
|
|
170
|
+
return {
|
|
171
|
+
kind: "currency",
|
|
172
|
+
currencyField: (n.currencyField ?? "currency") as keyof ValueData,
|
|
173
|
+
precisionByCurrency: n.precisionByCurrency ?? INDONESIA_CURRENCY_CONFIG.precisionByCurrency,
|
|
174
|
+
defaultPrecision: n.defaultPrecision ?? INDONESIA_CURRENCY_CONFIG.defaultPrecision,
|
|
175
|
+
};
|
|
176
|
+
case "percent":
|
|
177
|
+
return { kind: "percent", maxFractionDigits: n.maxFractionDigits ?? 2 };
|
|
178
|
+
case "integer":
|
|
179
|
+
return { kind: "integer" };
|
|
180
|
+
case "phone":
|
|
181
|
+
return {
|
|
182
|
+
kind: "phone",
|
|
183
|
+
minDigits: n.minDigits ?? 0,
|
|
184
|
+
maxDigits: n.maxDigits ?? 30,
|
|
185
|
+
allowedPrefixes: n.allowedPrefixes,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// No explicit numeric hint: integer JSON type still implies an integer field.
|
|
190
|
+
if (schema.type === "integer") return { kind: "integer" };
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function buildOptions(schema: JSONSchema, xui: XUi | undefined, t: T): FormFieldOption[] | undefined {
|
|
195
|
+
if (xui?.options?.length) {
|
|
196
|
+
return xui.options.map((o) => ({
|
|
197
|
+
value: o.value,
|
|
198
|
+
label: o.labelKey ? t(o.labelKey, o.label ?? o.value) : (o.label ?? o.value),
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
if (Array.isArray(schema.enum)) {
|
|
202
|
+
return schema.enum.map((e) => ({ value: String(e), label: String(e) }));
|
|
203
|
+
}
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** plain JSON Schema constraints → FieldValidation (per-field). */
|
|
208
|
+
function buildValidation(schema: JSONSchema, isRequired: boolean): FormField<ValueData>["validation"] {
|
|
209
|
+
const v: NonNullable<FormField<ValueData>["validation"]> = {};
|
|
210
|
+
if (isRequired) v.required = true;
|
|
211
|
+
if (typeof schema.minimum === "number") v.min = schema.minimum;
|
|
212
|
+
if (typeof schema.maximum === "number") v.max = schema.maximum;
|
|
213
|
+
if (typeof schema.minLength === "number") v.minLength = schema.minLength;
|
|
214
|
+
if (typeof schema.maxLength === "number") v.maxLength = schema.maxLength;
|
|
215
|
+
if (typeof schema.pattern === "string") v.pattern = new RegExp(schema.pattern);
|
|
216
|
+
return Object.keys(v).length ? v : undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── detail-rows (array of object) ─────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
function defaultForColumn(schema: JSONSchema): unknown {
|
|
222
|
+
if (schema.default !== undefined) return schema.default;
|
|
223
|
+
switch (schema.type) {
|
|
224
|
+
case "integer":
|
|
225
|
+
case "number":
|
|
226
|
+
return 0;
|
|
227
|
+
case "boolean":
|
|
228
|
+
return false;
|
|
229
|
+
default:
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function buildColumnType(schema: JSONSchema, xui: XUi | undefined, t: T): EditableColumnType {
|
|
235
|
+
const type = inferFieldType(schema, xui);
|
|
236
|
+
const n = xui?.numeric;
|
|
237
|
+
if (n?.kind === "currency") {
|
|
238
|
+
return {
|
|
239
|
+
type: "currency",
|
|
240
|
+
precision: n.defaultPrecision ?? INDONESIA_CURRENCY_CONFIG.defaultPrecision,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (n?.kind === "percent") {
|
|
244
|
+
return { type: "number", decimals: n.maxFractionDigits ?? 2, min: schema.minimum, max: schema.maximum };
|
|
245
|
+
}
|
|
246
|
+
if (n?.kind === "integer" || schema.type === "integer") {
|
|
247
|
+
return { type: "number", decimals: 0, min: schema.minimum, max: schema.maximum };
|
|
248
|
+
}
|
|
249
|
+
if (type === "number") {
|
|
250
|
+
return { type: "number", min: schema.minimum, max: schema.maximum };
|
|
251
|
+
}
|
|
252
|
+
if (type === "select") {
|
|
253
|
+
// EditableTable select options are value:string only — coerce, since
|
|
254
|
+
// FormFieldOption.value may be a number.
|
|
255
|
+
return {
|
|
256
|
+
type: "select",
|
|
257
|
+
options: (buildOptions(schema, xui, t) ?? []).map((o) => ({ value: String(o.value), label: o.label })),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (type === "date") return { type: "date" };
|
|
261
|
+
return { type: "text", maxLength: schema.maxLength, placeholder: xui?.placeholder };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildDetailRows(schema: JSONSchema, t: T): DetailRowsConfig | undefined {
|
|
265
|
+
const items = schema.items;
|
|
266
|
+
if (!items?.properties) return undefined;
|
|
267
|
+
const props = items.properties;
|
|
268
|
+
const required = new Set(items.required ?? []);
|
|
269
|
+
const entries = Object.entries(props);
|
|
270
|
+
|
|
271
|
+
const columns: EditableColumnDef[] = entries.map(([name, propSchema]) => {
|
|
272
|
+
const xui = propSchema["x-ui"];
|
|
273
|
+
return {
|
|
274
|
+
field: name,
|
|
275
|
+
header: resolveLabel(xui, name, t),
|
|
276
|
+
columnType: buildColumnType(propSchema, xui, t),
|
|
277
|
+
required: required.has(name),
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const newRowFactory = (): Record<string, unknown> => {
|
|
282
|
+
const row: Record<string, unknown> = {};
|
|
283
|
+
for (const [name, propSchema] of entries) row[name] = defaultForColumn(propSchema);
|
|
284
|
+
return row;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const validateRow = (row: Record<string, unknown>): Record<string, string> => {
|
|
288
|
+
const errs: Record<string, string> = {};
|
|
289
|
+
for (const name of required) {
|
|
290
|
+
const val = row[name];
|
|
291
|
+
if (val === undefined || val === null || val === "") {
|
|
292
|
+
errs[name] = t("bos7.validRequired", "Wajib diisi");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return errs;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const xuiArr = schema["x-ui"];
|
|
299
|
+
return { columns, newRowFactory, validateRow, maxRows: xuiArr?.maxRows };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Field builder ──────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
function buildField(name: string, schema: JSONSchema, isRequired: boolean, t: T): FormField<ValueData> {
|
|
305
|
+
const xui = schema["x-ui"];
|
|
306
|
+
const type = inferFieldType(schema, xui);
|
|
307
|
+
const field: FormField<ValueData> = {
|
|
308
|
+
key: name,
|
|
309
|
+
label: resolveLabel(xui, name, t),
|
|
310
|
+
type,
|
|
311
|
+
span: xui?.span ?? (type === "detail-rows" ? 12 : DEFAULT_SPAN),
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const help = resolveHelp(xui, t);
|
|
315
|
+
if (help) field.helperText = help;
|
|
316
|
+
if (xui?.placeholder) field.placeholder = xui.placeholder;
|
|
317
|
+
if (typeof schema.maxLength === "number") field.maxLength = schema.maxLength;
|
|
318
|
+
|
|
319
|
+
if (xui?.readonlyOn?.length) {
|
|
320
|
+
const modes = xui.readonlyOn;
|
|
321
|
+
field.readonly = (mode) => modes.includes(mode);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (type === "select") {
|
|
325
|
+
const options = buildOptions(schema, xui, t);
|
|
326
|
+
if (options) field.options = options;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (type === "number") {
|
|
330
|
+
const numeric = buildNumeric(schema, xui);
|
|
331
|
+
if (numeric) field.numeric = numeric;
|
|
332
|
+
field.inputMode = numeric?.kind === "integer" ? "numeric" : "decimal";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (type === "toggle" && xui?.toggle) {
|
|
336
|
+
field.toggle = { valueOn: xui.toggle.valueOn, valueOff: xui.toggle.valueOff };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (type === "detail-rows") {
|
|
340
|
+
const detail = buildDetailRows(schema, t);
|
|
341
|
+
if (detail) field.detailRows = detail;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const validation = buildValidation(schema, isRequired);
|
|
345
|
+
if (validation) field.validation = validation;
|
|
346
|
+
|
|
347
|
+
return field;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── x-rules → cross-field validator ─────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
function toNumber(v: unknown): number | undefined {
|
|
353
|
+
if (v === null || v === undefined || v === "") return undefined;
|
|
354
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
355
|
+
return Number.isFinite(n) ? n : undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isEmpty(v: unknown): boolean {
|
|
359
|
+
return v === null || v === undefined || v === "";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function ruleMessage(rule: XRule, t: T, fallback: string): string {
|
|
363
|
+
if (rule.messageKey) return t(rule.messageKey, rule.message ?? fallback);
|
|
364
|
+
return rule.message ?? fallback;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Build a form-level validator from `x-rules`. Comparison ops skip when either
|
|
369
|
+
* operand is empty/non-numeric (per-field `required` handles emptiness), so the
|
|
370
|
+
* cross-field check only fires on otherwise-complete input. Returns errors keyed
|
|
371
|
+
* by the offending field so the page blocks submit before `startWorkflow`.
|
|
372
|
+
*/
|
|
373
|
+
export function buildXRulesValidator(rules: XRule[] | undefined, t: T): ValueValidatorFn {
|
|
374
|
+
if (!rules?.length) return () => ({});
|
|
375
|
+
return (data) => {
|
|
376
|
+
const errors: Partial<Record<string, string>> = {};
|
|
377
|
+
for (const rule of rules) {
|
|
378
|
+
if (errors[rule.left]) continue; // first failing rule per field wins
|
|
379
|
+
switch (rule.op) {
|
|
380
|
+
case "lte":
|
|
381
|
+
case "gte":
|
|
382
|
+
case "lt":
|
|
383
|
+
case "gt": {
|
|
384
|
+
const left = toNumber(data[rule.left]);
|
|
385
|
+
const right = rule.right !== undefined ? toNumber(data[rule.right]) : toNumber(rule.value);
|
|
386
|
+
if (left === undefined || right === undefined) break;
|
|
387
|
+
const ok =
|
|
388
|
+
rule.op === "lte" ? left <= right :
|
|
389
|
+
rule.op === "gte" ? left >= right :
|
|
390
|
+
rule.op === "lt" ? left < right :
|
|
391
|
+
left > right;
|
|
392
|
+
if (!ok) errors[rule.left] = ruleMessage(rule, t, "Nilai tidak valid");
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
case "eq": {
|
|
396
|
+
const expected = rule.right !== undefined ? data[rule.right] : rule.value;
|
|
397
|
+
if (isEmpty(data[rule.left])) break;
|
|
398
|
+
if (data[rule.left] !== expected) {
|
|
399
|
+
errors[rule.left] = ruleMessage(rule, t, "Nilai tidak sesuai");
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
case "required-if": {
|
|
404
|
+
const condField = rule.right !== undefined ? data[rule.right] : undefined;
|
|
405
|
+
const conditionMet =
|
|
406
|
+
rule.value !== undefined ? condField === rule.value : !isEmpty(condField);
|
|
407
|
+
if (conditionMet && isEmpty(data[rule.left])) {
|
|
408
|
+
errors[rule.left] = ruleMessage(rule, t, "Wajib diisi");
|
|
409
|
+
}
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return errors;
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Section assembly ─────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
function buildSections(
|
|
421
|
+
schema: JSONSchema,
|
|
422
|
+
fieldsByName: Map<string, FormField<ValueData>>,
|
|
423
|
+
orderByName: Map<string, number>,
|
|
424
|
+
t: T,
|
|
425
|
+
): FormSection<ValueData>[] {
|
|
426
|
+
const layout = schema["x-ui"]?.layout;
|
|
427
|
+
|
|
428
|
+
if (layout?.sections?.length) {
|
|
429
|
+
const used = new Set<string>();
|
|
430
|
+
const sections: FormSection<ValueData>[] = layout.sections.map((sec) => {
|
|
431
|
+
const fields = sec.fields
|
|
432
|
+
.map((name) => {
|
|
433
|
+
used.add(name);
|
|
434
|
+
return fieldsByName.get(name);
|
|
435
|
+
})
|
|
436
|
+
.filter((f): f is FormField<ValueData> => Boolean(f));
|
|
437
|
+
const title = sec.titleKey ? t(sec.titleKey, sec.title ?? sec.titleKey) : sec.title;
|
|
438
|
+
return { title: title || undefined, fields };
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// Any property not referenced by a layout section falls into a trailing group.
|
|
442
|
+
const leftover = [...fieldsByName.keys()].filter((name) => !used.has(name));
|
|
443
|
+
if (leftover.length) {
|
|
444
|
+
sections.push({
|
|
445
|
+
fields: leftover
|
|
446
|
+
.sort((a, b) => (orderByName.get(a) ?? 0) - (orderByName.get(b) ?? 0))
|
|
447
|
+
.map((name) => fieldsByName.get(name)!),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return sections;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// No layout hint → single "Value" section, fields sorted by x-ui.order.
|
|
454
|
+
const ordered = [...fieldsByName.keys()].sort(
|
|
455
|
+
(a, b) => (orderByName.get(a) ?? 0) - (orderByName.get(b) ?? 0),
|
|
456
|
+
);
|
|
457
|
+
return [
|
|
458
|
+
{
|
|
459
|
+
title: t("policy.section.value", "Value"),
|
|
460
|
+
fields: ordered.map((name) => fieldsByName.get(name)!),
|
|
461
|
+
},
|
|
462
|
+
];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── Public adapter ──────────────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Build the "value" form section(s) + a cross-field validator from a policy7
|
|
469
|
+
* `value_schema`. Pure; no fetch.
|
|
470
|
+
*
|
|
471
|
+
* @example
|
|
472
|
+
* const { sections, validate } = buildValueSections(valueSchema, t);
|
|
473
|
+
* const form: CrudForm<Data> = {
|
|
474
|
+
* ...,
|
|
475
|
+
* layout: { type: "single-page", sections: [scopeSection(t), ...sections] },
|
|
476
|
+
* validate: (data) => ({ ...scopeValidate(data), ...validate(data) }),
|
|
477
|
+
* };
|
|
478
|
+
*/
|
|
479
|
+
export function buildValueSections(
|
|
480
|
+
valueSchema: JSONSchema,
|
|
481
|
+
t: T,
|
|
482
|
+
): { sections: FormSection<ValueData>[]; validate: ValueValidatorFn } {
|
|
483
|
+
const props = valueSchema.properties ?? {};
|
|
484
|
+
const required = new Set(valueSchema.required ?? []);
|
|
485
|
+
|
|
486
|
+
const fieldsByName = new Map<string, FormField<ValueData>>();
|
|
487
|
+
const orderByName = new Map<string, number>();
|
|
488
|
+
|
|
489
|
+
Object.entries(props).forEach(([name, propSchema], index) => {
|
|
490
|
+
fieldsByName.set(name, buildField(name, propSchema, required.has(name), t));
|
|
491
|
+
// Sort key: explicit x-ui.order, else original declaration order.
|
|
492
|
+
const order = propSchema["x-ui"]?.order;
|
|
493
|
+
orderByName.set(name, typeof order === "number" ? order : index);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const sections = buildSections(valueSchema, fieldsByName, orderByName, t);
|
|
497
|
+
const validate = buildXRulesValidator(valueSchema["x-rules"], t);
|
|
498
|
+
|
|
499
|
+
return { sections, validate };
|
|
500
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export * from './notifications-bff';
|
|
|
17
17
|
export * from './crud-types';
|
|
18
18
|
export * from './crud-hooks';
|
|
19
19
|
export * from './form-types';
|
|
20
|
+
export * from './form-value-schema';
|
|
20
21
|
export * from './i18n';
|
|
21
22
|
export { proxyBackendPost, proxyQueryRoute } from './data-table/proxy';
|
|
22
23
|
export type { ProxyBackendPostOptions, ProxyQueryRouteOptions } from './data-table/proxy';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import type { ReactNode } from "react";
|
|
3
|
+
import type { ElementType, ReactNode } from "react";
|
|
4
4
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
5
|
import { useI18n, useUi7Labels, type Ui7Locale } from "@isi-ui7/i18n";
|
|
6
6
|
import { useAuth as defaultUseAuth } from "../auth7";
|
|
@@ -20,7 +20,22 @@ export interface AppShellAuthState {
|
|
|
20
20
|
logout: () => Promise<void> | void;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Structural shape of a side-nav item, mirroring ui-shell's `ShellNavItem`.
|
|
25
|
+
* `label`/`href` are required to stay mutually assignable with `ShellNavItem`:
|
|
26
|
+
* this type is used both covariantly (`navItems`) and contravariantly
|
|
27
|
+
* (`markActive`'s parameter), so consumers passing `ShellNavItem`-typed values
|
|
28
|
+
* would otherwise fail under `strict`. The index signature keeps it permissive
|
|
29
|
+
* for extra metadata (e.g. `requiredPermission`).
|
|
30
|
+
*/
|
|
31
|
+
export interface ShellNavItemLike {
|
|
32
|
+
label: string;
|
|
33
|
+
href: string;
|
|
34
|
+
isActive?: boolean;
|
|
35
|
+
icon?: string;
|
|
36
|
+
children?: ShellNavItemLike[];
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
24
39
|
|
|
25
40
|
export interface ShellNotificationLike {
|
|
26
41
|
id?: string;
|
|
@@ -57,39 +72,6 @@ interface NotificationBellOptionsLike {
|
|
|
57
72
|
onNavigate?: (href: string) => void;
|
|
58
73
|
}
|
|
59
74
|
|
|
60
|
-
interface UiShellLikeProps {
|
|
61
|
-
productName: string;
|
|
62
|
-
headerPrefix: string;
|
|
63
|
-
headerThemeVariant: any;
|
|
64
|
-
navItems: any[];
|
|
65
|
-
sideNavItems: ShellNavItemLike[];
|
|
66
|
-
sideNavMode: string;
|
|
67
|
-
onSideNavSelect: (href: string) => void;
|
|
68
|
-
notifications: {
|
|
69
|
-
badgeCount: number;
|
|
70
|
-
/** Bumping this value forces ui-shell to re-fetch the panel list. */
|
|
71
|
-
fetchKey?: number;
|
|
72
|
-
onFetch: () => Promise<ShellNotificationLike[]>;
|
|
73
|
-
onMarkAllRead: () => void;
|
|
74
|
-
onItemClick: (n: ShellNotificationLike) => void;
|
|
75
|
-
viewAllHref: string;
|
|
76
|
-
viewAllTarget: string;
|
|
77
|
-
};
|
|
78
|
-
userProfile: {
|
|
79
|
-
name: string;
|
|
80
|
-
role?: string;
|
|
81
|
-
email: string;
|
|
82
|
-
};
|
|
83
|
-
profileActions: string[];
|
|
84
|
-
onProfileAction: (action: string) => void;
|
|
85
|
-
profileExtra?: ReactNode;
|
|
86
|
-
apps: {
|
|
87
|
-
onFetch: () => Promise<Array<{ href: string }>>;
|
|
88
|
-
onAppSelect: (app: { href: string }) => void;
|
|
89
|
-
};
|
|
90
|
-
children: ReactNode;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
75
|
export type AppSwitchMode = "push" | "location";
|
|
94
76
|
|
|
95
77
|
export interface AppShellLayoutDeps {
|
|
@@ -97,9 +79,9 @@ export interface AppShellLayoutDeps {
|
|
|
97
79
|
useRouter: () => RouterLike;
|
|
98
80
|
useTheme: () => { theme: ThemeLike };
|
|
99
81
|
useNotificationBell: (opts: NotificationBellOptionsLike) => NotificationBellLike;
|
|
100
|
-
GlobalTheme:
|
|
101
|
-
ModalProvider:
|
|
102
|
-
UiShell:
|
|
82
|
+
GlobalTheme: ElementType;
|
|
83
|
+
ModalProvider: ElementType;
|
|
84
|
+
UiShell: ElementType;
|
|
103
85
|
useAuth?: () => AppShellAuthState;
|
|
104
86
|
}
|
|
105
87
|
|
|
@@ -118,7 +100,7 @@ export interface AppShellLayoutProps {
|
|
|
118
100
|
notificationsViewAllTarget?: string;
|
|
119
101
|
hideMenuButton?: boolean;
|
|
120
102
|
disableAppsMenu?: boolean;
|
|
121
|
-
sideNavMode?: "drilldown" | "overlay";
|
|
103
|
+
sideNavMode?: "drilldown" | "overlay" | "expand";
|
|
122
104
|
sideNavItems?: ShellNavItemLike[];
|
|
123
105
|
}
|
|
124
106
|
|
|
@@ -303,7 +285,6 @@ export function AppShellLayout({
|
|
|
303
285
|
corporateId={corporateId}
|
|
304
286
|
appName={appName}
|
|
305
287
|
auth7UiUrl={auth7UiUrl}
|
|
306
|
-
portalUrl={portalUrl}
|
|
307
288
|
roleFallback={roleFallback}
|
|
308
289
|
appSwitchMode={appSwitchMode}
|
|
309
290
|
notificationsViewAllHref={notificationsViewAllHref ?? `${portalUrl}/notifications`}
|
|
@@ -312,7 +293,6 @@ export function AppShellLayout({
|
|
|
312
293
|
disableAppsMenu={disableAppsMenu}
|
|
313
294
|
sideNavMode={sideNavMode}
|
|
314
295
|
sideNavItems={sideNavItems ?? resolvedSideNavItems}
|
|
315
|
-
resolvedSideNavItems={resolvedSideNavItems}
|
|
316
296
|
notifBadgeCount={badgeCount}
|
|
317
297
|
userName={user?.name}
|
|
318
298
|
userEmail={user?.email}
|
|
@@ -356,7 +336,6 @@ function ShellInner({
|
|
|
356
336
|
deps,
|
|
357
337
|
appName,
|
|
358
338
|
auth7UiUrl,
|
|
359
|
-
portalUrl,
|
|
360
339
|
roleFallback,
|
|
361
340
|
appSwitchMode,
|
|
362
341
|
notificationsViewAllHref,
|
|
@@ -365,7 +344,6 @@ function ShellInner({
|
|
|
365
344
|
disableAppsMenu,
|
|
366
345
|
sideNavMode,
|
|
367
346
|
sideNavItems,
|
|
368
|
-
resolvedSideNavItems,
|
|
369
347
|
notifBadgeCount,
|
|
370
348
|
userName,
|
|
371
349
|
userEmail,
|
|
@@ -380,16 +358,14 @@ function ShellInner({
|
|
|
380
358
|
deps: AppShellLayoutDeps;
|
|
381
359
|
appName: string;
|
|
382
360
|
auth7UiUrl: string;
|
|
383
|
-
portalUrl: string;
|
|
384
361
|
roleFallback: string;
|
|
385
362
|
appSwitchMode: AppSwitchMode;
|
|
386
363
|
notificationsViewAllHref: string;
|
|
387
364
|
notificationsViewAllTarget: string;
|
|
388
365
|
hideMenuButton: boolean;
|
|
389
366
|
disableAppsMenu: boolean;
|
|
390
|
-
sideNavMode: "drilldown" | "overlay";
|
|
367
|
+
sideNavMode: "drilldown" | "overlay" | "expand";
|
|
391
368
|
sideNavItems: ShellNavItemLike[];
|
|
392
|
-
resolvedSideNavItems: ShellNavItemLike[];
|
|
393
369
|
notifBadgeCount: number;
|
|
394
370
|
userName?: string | null;
|
|
395
371
|
userEmail?: string | null;
|