@isi-ui7/bos7-shared 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,495 @@
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
+ return { type: "select", options: buildOptions(schema, xui, t) ?? [] };
254
+ }
255
+ if (type === "date") return { type: "date" };
256
+ return { type: "text", maxLength: schema.maxLength, placeholder: xui?.placeholder };
257
+ }
258
+
259
+ function buildDetailRows(schema: JSONSchema, t: T): DetailRowsConfig | undefined {
260
+ const items = schema.items;
261
+ if (!items?.properties) return undefined;
262
+ const props = items.properties;
263
+ const required = new Set(items.required ?? []);
264
+ const entries = Object.entries(props);
265
+
266
+ const columns: EditableColumnDef[] = entries.map(([name, propSchema]) => {
267
+ const xui = propSchema["x-ui"];
268
+ return {
269
+ field: name,
270
+ header: resolveLabel(xui, name, t),
271
+ columnType: buildColumnType(propSchema, xui, t),
272
+ required: required.has(name),
273
+ };
274
+ });
275
+
276
+ const newRowFactory = (): Record<string, unknown> => {
277
+ const row: Record<string, unknown> = {};
278
+ for (const [name, propSchema] of entries) row[name] = defaultForColumn(propSchema);
279
+ return row;
280
+ };
281
+
282
+ const validateRow = (row: Record<string, unknown>): Record<string, string> => {
283
+ const errs: Record<string, string> = {};
284
+ for (const name of required) {
285
+ const val = row[name];
286
+ if (val === undefined || val === null || val === "") {
287
+ errs[name] = t("bos7.validRequired", "Wajib diisi");
288
+ }
289
+ }
290
+ return errs;
291
+ };
292
+
293
+ const xuiArr = schema["x-ui"];
294
+ return { columns, newRowFactory, validateRow, maxRows: xuiArr?.maxRows };
295
+ }
296
+
297
+ // ── Field builder ──────────────────────────────────────────────────────────────
298
+
299
+ function buildField(name: string, schema: JSONSchema, isRequired: boolean, t: T): FormField<ValueData> {
300
+ const xui = schema["x-ui"];
301
+ const type = inferFieldType(schema, xui);
302
+ const field: FormField<ValueData> = {
303
+ key: name,
304
+ label: resolveLabel(xui, name, t),
305
+ type,
306
+ span: xui?.span ?? (type === "detail-rows" ? 12 : DEFAULT_SPAN),
307
+ };
308
+
309
+ const help = resolveHelp(xui, t);
310
+ if (help) field.helperText = help;
311
+ if (xui?.placeholder) field.placeholder = xui.placeholder;
312
+ if (typeof schema.maxLength === "number") field.maxLength = schema.maxLength;
313
+
314
+ if (xui?.readonlyOn?.length) {
315
+ const modes = xui.readonlyOn;
316
+ field.readonly = (mode) => modes.includes(mode);
317
+ }
318
+
319
+ if (type === "select") {
320
+ const options = buildOptions(schema, xui, t);
321
+ if (options) field.options = options;
322
+ }
323
+
324
+ if (type === "number") {
325
+ const numeric = buildNumeric(schema, xui);
326
+ if (numeric) field.numeric = numeric;
327
+ field.inputMode = numeric?.kind === "integer" ? "numeric" : "decimal";
328
+ }
329
+
330
+ if (type === "toggle" && xui?.toggle) {
331
+ field.toggle = { valueOn: xui.toggle.valueOn, valueOff: xui.toggle.valueOff };
332
+ }
333
+
334
+ if (type === "detail-rows") {
335
+ const detail = buildDetailRows(schema, t);
336
+ if (detail) field.detailRows = detail;
337
+ }
338
+
339
+ const validation = buildValidation(schema, isRequired);
340
+ if (validation) field.validation = validation;
341
+
342
+ return field;
343
+ }
344
+
345
+ // ── x-rules → cross-field validator ─────────────────────────────────────────────
346
+
347
+ function toNumber(v: unknown): number | undefined {
348
+ if (v === null || v === undefined || v === "") return undefined;
349
+ const n = typeof v === "number" ? v : Number(v);
350
+ return Number.isFinite(n) ? n : undefined;
351
+ }
352
+
353
+ function isEmpty(v: unknown): boolean {
354
+ return v === null || v === undefined || v === "";
355
+ }
356
+
357
+ function ruleMessage(rule: XRule, t: T, fallback: string): string {
358
+ if (rule.messageKey) return t(rule.messageKey, rule.message ?? fallback);
359
+ return rule.message ?? fallback;
360
+ }
361
+
362
+ /**
363
+ * Build a form-level validator from `x-rules`. Comparison ops skip when either
364
+ * operand is empty/non-numeric (per-field `required` handles emptiness), so the
365
+ * cross-field check only fires on otherwise-complete input. Returns errors keyed
366
+ * by the offending field so the page blocks submit before `startWorkflow`.
367
+ */
368
+ export function buildXRulesValidator(rules: XRule[] | undefined, t: T): ValueValidatorFn {
369
+ if (!rules?.length) return () => ({});
370
+ return (data) => {
371
+ const errors: Partial<Record<string, string>> = {};
372
+ for (const rule of rules) {
373
+ if (errors[rule.left]) continue; // first failing rule per field wins
374
+ switch (rule.op) {
375
+ case "lte":
376
+ case "gte":
377
+ case "lt":
378
+ case "gt": {
379
+ const left = toNumber(data[rule.left]);
380
+ const right = rule.right !== undefined ? toNumber(data[rule.right]) : toNumber(rule.value);
381
+ if (left === undefined || right === undefined) break;
382
+ const ok =
383
+ rule.op === "lte" ? left <= right :
384
+ rule.op === "gte" ? left >= right :
385
+ rule.op === "lt" ? left < right :
386
+ left > right;
387
+ if (!ok) errors[rule.left] = ruleMessage(rule, t, "Nilai tidak valid");
388
+ break;
389
+ }
390
+ case "eq": {
391
+ const expected = rule.right !== undefined ? data[rule.right] : rule.value;
392
+ if (isEmpty(data[rule.left])) break;
393
+ if (data[rule.left] !== expected) {
394
+ errors[rule.left] = ruleMessage(rule, t, "Nilai tidak sesuai");
395
+ }
396
+ break;
397
+ }
398
+ case "required-if": {
399
+ const condField = rule.right !== undefined ? data[rule.right] : undefined;
400
+ const conditionMet =
401
+ rule.value !== undefined ? condField === rule.value : !isEmpty(condField);
402
+ if (conditionMet && isEmpty(data[rule.left])) {
403
+ errors[rule.left] = ruleMessage(rule, t, "Wajib diisi");
404
+ }
405
+ break;
406
+ }
407
+ }
408
+ }
409
+ return errors;
410
+ };
411
+ }
412
+
413
+ // ── Section assembly ─────────────────────────────────────────────────────────
414
+
415
+ function buildSections(
416
+ schema: JSONSchema,
417
+ fieldsByName: Map<string, FormField<ValueData>>,
418
+ orderByName: Map<string, number>,
419
+ t: T,
420
+ ): FormSection<ValueData>[] {
421
+ const layout = schema["x-ui"]?.layout;
422
+
423
+ if (layout?.sections?.length) {
424
+ const used = new Set<string>();
425
+ const sections: FormSection<ValueData>[] = layout.sections.map((sec) => {
426
+ const fields = sec.fields
427
+ .map((name) => {
428
+ used.add(name);
429
+ return fieldsByName.get(name);
430
+ })
431
+ .filter((f): f is FormField<ValueData> => Boolean(f));
432
+ const title = sec.titleKey ? t(sec.titleKey, sec.title ?? sec.titleKey) : sec.title;
433
+ return { title: title || undefined, fields };
434
+ });
435
+
436
+ // Any property not referenced by a layout section falls into a trailing group.
437
+ const leftover = [...fieldsByName.keys()].filter((name) => !used.has(name));
438
+ if (leftover.length) {
439
+ sections.push({
440
+ fields: leftover
441
+ .sort((a, b) => (orderByName.get(a) ?? 0) - (orderByName.get(b) ?? 0))
442
+ .map((name) => fieldsByName.get(name)!),
443
+ });
444
+ }
445
+ return sections;
446
+ }
447
+
448
+ // No layout hint → single "Value" section, fields sorted by x-ui.order.
449
+ const ordered = [...fieldsByName.keys()].sort(
450
+ (a, b) => (orderByName.get(a) ?? 0) - (orderByName.get(b) ?? 0),
451
+ );
452
+ return [
453
+ {
454
+ title: t("policy.section.value", "Value"),
455
+ fields: ordered.map((name) => fieldsByName.get(name)!),
456
+ },
457
+ ];
458
+ }
459
+
460
+ // ── Public adapter ──────────────────────────────────────────────────────────────
461
+
462
+ /**
463
+ * Build the "value" form section(s) + a cross-field validator from a policy7
464
+ * `value_schema`. Pure; no fetch.
465
+ *
466
+ * @example
467
+ * const { sections, validate } = buildValueSections(valueSchema, t);
468
+ * const form: CrudForm<Data> = {
469
+ * ...,
470
+ * layout: { type: "single-page", sections: [scopeSection(t), ...sections] },
471
+ * validate: (data) => ({ ...scopeValidate(data), ...validate(data) }),
472
+ * };
473
+ */
474
+ export function buildValueSections(
475
+ valueSchema: JSONSchema,
476
+ t: T,
477
+ ): { sections: FormSection<ValueData>[]; validate: ValueValidatorFn } {
478
+ const props = valueSchema.properties ?? {};
479
+ const required = new Set(valueSchema.required ?? []);
480
+
481
+ const fieldsByName = new Map<string, FormField<ValueData>>();
482
+ const orderByName = new Map<string, number>();
483
+
484
+ Object.entries(props).forEach(([name, propSchema], index) => {
485
+ fieldsByName.set(name, buildField(name, propSchema, required.has(name), t));
486
+ // Sort key: explicit x-ui.order, else original declaration order.
487
+ const order = propSchema["x-ui"]?.order;
488
+ orderByName.set(name, typeof order === "number" ? order : index);
489
+ });
490
+
491
+ const sections = buildSections(valueSchema, fieldsByName, orderByName, t);
492
+ const validate = buildXRulesValidator(valueSchema["x-rules"], t);
493
+
494
+ return { sections, validate };
495
+ }
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
- export type ShellNavItemLike = any;
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: any;
101
- ModalProvider: any;
102
- UiShell: any;
82
+ GlobalTheme: ElementType;
83
+ ModalProvider: ElementType;
84
+ UiShell: ElementType;
103
85
  useAuth?: () => AppShellAuthState;
104
86
  }
105
87
 
@@ -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,7 +358,6 @@ 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;
@@ -389,7 +366,6 @@ function ShellInner({
389
366
  disableAppsMenu: boolean;
390
367
  sideNavMode: "drilldown" | "overlay";
391
368
  sideNavItems: ShellNavItemLike[];
392
- resolvedSideNavItems: ShellNavItemLike[];
393
369
  notifBadgeCount: number;
394
370
  userName?: string | null;
395
371
  userEmail?: string | null;
@@ -51,6 +51,7 @@ describe("ui7 form visual contract", () => {
51
51
  [
52
52
  UI7_FORM_VISUAL_CLASSNAMES.scope,
53
53
  UI7_FORM_VISUAL_CLASSNAMES.densityComfortable,
54
+ UI7_FORM_VISUAL_CLASSNAMES.widthTwoThirds,
54
55
  UI7_FORM_VISUAL_CLASSNAMES.readonly,
55
56
  "custom-form-scope"
56
57
  ].join(" ")