@open-mercato/ui 0.6.7-develop.6814.1.0627c7e9f1 → 0.6.7-develop.6825.1.85bbf320ad
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.
|
@@ -34,6 +34,9 @@ async function fetchFeatureGrants(requestFeatures) {
|
|
|
34
34
|
}
|
|
35
35
|
return granted;
|
|
36
36
|
}
|
|
37
|
+
function legacySettingsSectionSlug(groupLabel) {
|
|
38
|
+
return groupLabel.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
|
39
|
+
}
|
|
37
40
|
function buildSettingsSections(entries, sectionOrder) {
|
|
38
41
|
const settingsItems = entries.filter((e) => e.pageContext === "settings");
|
|
39
42
|
const sectionMap = /* @__PURE__ */ new Map();
|
|
@@ -51,8 +54,8 @@ function buildSettingsSections(entries, sectionOrder) {
|
|
|
51
54
|
};
|
|
52
55
|
};
|
|
53
56
|
for (const item of settingsItems) {
|
|
54
|
-
const sectionId = item.
|
|
55
|
-
const order = sectionOrder[sectionId] ?? 999;
|
|
57
|
+
const sectionId = item.groupId;
|
|
58
|
+
const order = sectionOrder[sectionId] ?? sectionOrder[legacySettingsSectionSlug(item.group)] ?? 999;
|
|
56
59
|
if (!sectionMap.has(sectionId)) {
|
|
57
60
|
sectionMap.set(sectionId, {
|
|
58
61
|
id: sectionId,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/utils/nav.ts"],
|
|
4
|
-
"sourcesContent": ["import type { ReactNode } from 'react'\nimport React from 'react'\nimport type { Module, ModuleRoute, PageMetadata } from '@open-mercato/shared/modules/registry'\nimport { hasAllFeatures as checkFeatures } from '@open-mercato/shared/security/features'\n\n/** Route with optional page-metadata aliases that may be merged during generation. */\ntype NavRoute = ModuleRoute & Partial<Pick<PageMetadata, 'pageTitleKey' | 'pageGroupKey'>>\n\nexport type AdminNavItem = {\n group: string\n groupId: string\n groupKey?: string\n groupDefaultName: string\n title: string\n defaultTitle: string\n titleKey?: string\n href: string\n enabled: boolean\n hidden?: boolean\n order?: number\n priority?: number\n icon?: ReactNode\n children?: AdminNavItem[]\n pageContext?: 'main' | 'admin' | 'settings' | 'profile'\n}\n\nexport type AdminNavFeatureChecker = (features: string[]) => Promise<Iterable<string> | null | undefined>\n\nexport type BuildAdminNavOptions = {\n checkFeatures?: AdminNavFeatureChecker\n}\n\n/**\n * @deprecated The internal fetch-based feature check will be removed.\n * Provide `options.checkFeatures` so buildAdminNav can reuse your RBAC context.\n */\nasync function fetchFeatureGrants(requestFeatures: string[]): Promise<Set<string>> { // NOSONAR \u2014 mutable accumulator pattern; Set is populated between early return and final return\n const granted = new Set<string>()\n if (!requestFeatures.length) return granted\n let url = '/api/auth/feature-check'\n let headersInit: Record<string, string> | undefined\n if (typeof window === 'undefined') {\n // On the server, build absolute URL and forward cookies so auth is available\n try {\n const { headers: getHeaders } = await import('next/headers')\n const h = await getHeaders()\n const host = h.get('x-forwarded-host') || h.get('host') || ''\n const proto = h.get('x-forwarded-proto') || 'http'\n const cookie = h.get('cookie') || ''\n if (host) url = `${proto}://${host}/api/auth/feature-check`\n headersInit = { cookie }\n } catch {\n // ignore; fall back to relative URL without forwarded cookies\n }\n }\n try {\n const res = await fetch(url, {\n method: 'POST',\n credentials: 'include' as any,\n headers: { 'content-type': 'application/json', ...(headersInit || {}) },\n body: JSON.stringify({ features: requestFeatures }),\n } as any)\n if (res.ok) {\n const data = await res.json().catch(() => ({ granted: [] }))\n if (Array.isArray(data?.granted)) {\n data.granted.forEach((f: string) => granted.add(f))\n }\n }\n } catch {\n // ignore fetch failures and keep feature set empty\n }\n return granted\n}\n\n/**\n * @deprecated Use number directly in sectionOrder config instead\n */\nexport type SettingsSectionConfig = {\n label: string\n labelKey?: string\n order: number\n}\n\nexport type SettingsSection = {\n id: string\n label: string\n labelKey?: string\n order: number\n items: SettingsSectionItem[]\n}\n\nexport type SettingsSectionItem = {\n id: string\n label: string\n labelKey?: string\n href: string\n icon?: ReactNode\n requireFeatures?: string[]\n order: number\n children?: SettingsSectionItem[]\n}\n\nexport function buildSettingsSections(\n entries: AdminNavItem[],\n sectionOrder: Record<string, number>\n): SettingsSection[] {\n const settingsItems = entries.filter(e => e.pageContext === 'settings')\n\n const sectionMap = new Map<string, SettingsSection>()\n\n const mapSectionItem = (item: AdminNavItem): SettingsSectionItem => {\n const itemId = item.href.replace(/\\//g, '-').slice(1)\n return {\n id: itemId,\n label: item.title,\n labelKey: item.titleKey,\n href: item.href,\n icon: item.icon,\n requireFeatures: undefined,\n order: item.order ?? item.priority ?? 100,\n children: item.children?.map(mapSectionItem),\n }\n }\n\n for (const item of settingsItems) {\n const sectionId = item.group.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '')\n const order = sectionOrder[sectionId] ?? 999\n\n if (!sectionMap.has(sectionId)) {\n sectionMap.set(sectionId, {\n id: sectionId,\n label: item.group,\n labelKey: item.groupKey,\n order,\n items: []\n })\n }\n\n const section = sectionMap.get(sectionId)!\n section.items.push(mapSectionItem(item))\n }\n\n const sections = Array.from(sectionMap.values())\n const sortItems = (items: SettingsSectionItem[]) => {\n items.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))\n for (const item of items) {\n if (item.children?.length) sortItems(item.children)\n }\n }\n sections.sort((a, b) => a.order - b.order)\n for (const section of sections) {\n sortItems(section.items)\n }\n\n return sections\n}\n\nexport function computeSettingsPathPrefixes(sections: SettingsSection[]): string[] {\n const prefixes = new Set<string>()\n const visitItem = (item: SettingsSectionItem) => {\n const parts = item.href.split('/')\n const lastSegment = parts[parts.length - 1]\n if (parts.length > 3 && lastSegment !== 'settings') {\n prefixes.add(parts.slice(0, -1).join('/'))\n }\n prefixes.add(item.href)\n if (item.children?.length) {\n for (const child of item.children) visitItem(child)\n }\n }\n for (const section of sections) {\n for (const item of section.items) {\n visitItem(item)\n }\n }\n return Array.from(prefixes)\n}\n\nexport function convertToSectionNavGroups(\n sections: SettingsSection[],\n translate?: (key: string | undefined, fallback: string) => string\n): Array<{\n id: string\n label: string\n labelKey?: string\n order?: number\n items: ConvertedSectionNavItem[]\n}> {\n const t = translate || ((key, fallback) => fallback)\n const mapSectionItem = (item: SettingsSectionItem): ConvertedSectionNavItem => ({\n id: item.id,\n label: t(item.labelKey, item.label),\n labelKey: item.labelKey,\n href: item.href,\n icon: item.icon,\n order: item.order,\n children: item.children?.map(mapSectionItem),\n })\n\n return sections.map(section => ({\n id: section.id,\n label: t(section.labelKey, section.label),\n labelKey: section.labelKey,\n order: section.order,\n items: section.items.map(mapSectionItem),\n }))\n}\n\ntype ConvertedSectionNavItem = {\n id: string\n label: string\n labelKey?: string\n href: string\n icon?: ReactNode\n requireFeatures?: string[]\n order?: number\n children?: ConvertedSectionNavItem[]\n}\n\ntype BuildAdminNavModule = Pick<Module, 'id'> & { backendRoutes?: (ModuleRoute | Omit<ModuleRoute, 'Component'>)[] }\n\nexport async function buildAdminNav(\n modules: BuildAdminNavModule[],\n ctx: { auth?: { roles?: string[]; sub?: string; orgId?: string | null; tenantId?: string | null }; path?: string },\n userEntities?: Array<{ entityId: string; label: string; href: string }>,\n translate?: (key: string | undefined, fallback: string) => string,\n options?: BuildAdminNavOptions\n): Promise<AdminNavItem[]> {\n function capitalize(s: string) {\n return s.charAt(0).toUpperCase() + s.slice(1)\n }\n function deriveTitleFromPath(p: string) {\n const seg = p.split('/').filter(Boolean).pop() || ''\n return seg ? seg.split('-').map(capitalize).join(' ') : 'Home'\n }\n const entries: AdminNavItem[] = []\n\n // Collect all unique features needed across all routes first\n const allRequiredFeatures = new Set<string>()\n for (const m of modules) {\n for (const r of (m.backendRoutes ?? []) as NavRoute[]) {\n const features = r.requireFeatures\n if (features && features.length) {\n features.forEach(f => allRequiredFeatures.add(f))\n }\n }\n }\n\n // Batch check all features in a single API call\n let userFeatures: string[] = []\n if (allRequiredFeatures.size > 0) {\n const requestFeatures = Array.from(allRequiredFeatures)\n if (options?.checkFeatures) {\n try {\n const resolved = await options.checkFeatures(requestFeatures)\n if (resolved) {\n userFeatures = Array.from(resolved).filter((feature): feature is string => typeof feature === 'string' && feature.length > 0)\n }\n } catch {\n // ignore and fall back to empty feature set\n }\n } else {\n userFeatures = Array.from(await fetchFeatureGrants(requestFeatures))\n }\n }\n\n // Helper: check if user has all required features (from cache)\n function hasAllFeatures(required: string[]): boolean {\n if (!required || required.length === 0) return true\n return checkFeatures(userFeatures, required)\n }\n\n // Icons are defined per-page in metadata; no heuristic derivation here.\n for (const m of modules) {\n const groupDefault = capitalize(m.id)\n for (const r of (m.backendRoutes ?? []) as NavRoute[]) {\n const href = r.pattern ?? r.path ?? ''\n if (!href || href.includes('[')) continue\n if (r.navHidden) continue\n const title = r.title || deriveTitleFromPath(href)\n const titleKey = r.pageTitleKey ?? r.titleKey\n const group = r.group || groupDefault\n const groupKey = r.pageGroupKey ?? r.groupKey\n const groupId = groupKey ?? group\n const displayGroup = translate ? translate(groupKey, group) : group\n const displayTitle = translate ? translate(titleKey, title) : title\n const visible = r.visible ? await Promise.resolve(r.visible(ctx)) : true\n if (!visible) continue\n const enabled = r.enabled ? await Promise.resolve(r.enabled(ctx)) : true\n // If roles are required, check; otherwise include\n const required = r.requireRoles || []\n if (required.length) {\n const roles = ctx.auth?.roles || []\n const ok = required.some((role) => roles.includes(role))\n if (!ok) continue\n }\n // If features are required, check from cached batch result\n const features = r.requireFeatures\n if (features && features.length) {\n const ok = hasAllFeatures(features)\n if (!ok) continue\n }\n const order = r.order\n const priority = r.priority ?? order\n const icon = r.icon\n const pageContext = r.pageContext\n entries.push({\n group: displayGroup,\n groupId,\n groupKey,\n groupDefaultName: displayGroup,\n title: displayTitle,\n defaultTitle: displayTitle,\n titleKey,\n href,\n enabled,\n order,\n priority,\n icon,\n pageContext,\n })\n }\n }\n // Build hierarchy: treat routes whose href starts with a parent href + '/'\n // Sort by href length (shortest first) so potential parents are processed before children\n const sorted = [...entries].sort((a, b) => a.href.length - b.href.length)\n const byHref = new Map<string, AdminNavItem>()\n const roots: AdminNavItem[] = []\n for (const e of sorted) {\n // Walk up the href segments to find the longest matching parent in the same group\n let parent: AdminNavItem | undefined\n const segments = e.href.split('/')\n for (let i = segments.length - 1; i >= 2; i--) {\n const candidate = byHref.get(segments.slice(0, i).join('/'))\n if (candidate && candidate !== e && candidate.groupId === e.groupId) {\n parent = candidate\n break\n }\n }\n byHref.set(e.href, e)\n if (parent) {\n parent.children = parent.children || []\n parent.children.push(e)\n } else {\n roots.push(e)\n }\n }\n\n // Add dynamic user entities to the navigation\n if (userEntities && userEntities.length > 0) {\n const tableIcon = React.createElement(\n 'svg',\n { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 },\n React.createElement('rect', { x: 3, y: 4, width: 18, height: 16, rx: 2 }),\n React.createElement('path', { d: 'M3 10h18M9 4v16M15 4v16' }),\n )\n const userEntitiesLegacyGroupKeys = new Set(['settings.sections.dataDesigner', 'entities.nav.group'])\n const userEntitiesItem = entries.find((entry) => entry.href === '/backend/entities/user')\n ?? entries.find((entry) =>\n entry.titleKey === 'entities.nav.userEntities' &&\n typeof entry.groupKey === 'string' &&\n userEntitiesLegacyGroupKeys.has(entry.groupKey),\n )\n if (userEntitiesItem) {\n const existingChildren = userEntitiesItem.children || []\n const dynamicUserEntities = userEntities.map((entity) => ({\n group: userEntitiesItem.group,\n groupId: userEntitiesItem.groupId,\n groupKey: userEntitiesItem.groupKey,\n groupDefaultName: userEntitiesItem.groupDefaultName,\n title: entity.label,\n defaultTitle: entity.label,\n href: entity.href,\n enabled: true,\n order: 1000, // High order to appear at the end\n priority: 1000,\n icon: tableIcon,\n }))\n // Merge and deduplicate by href to avoid duplicates coming from server or generator\n const merged = [...existingChildren, ...dynamicUserEntities]\n const byHref = new Map<string, AdminNavItem>()\n for (const it of merged) {\n if (!byHref.has(it.href)) byHref.set(it.href, it)\n }\n userEntitiesItem.children = Array.from(byHref.values())\n }\n }\n\n // Sorting: group, then priority/order, then title. Apply within children too.\n const sortItems = (arr: AdminNavItem[]) => {\n arr.sort((a, b) => {\n if (a.groupId !== b.groupId) return a.groupId.localeCompare(b.groupId)\n const ap = a.priority ?? a.order ?? 10_000\n const bp = b.priority ?? b.order ?? 10_000\n if (ap !== bp) return ap - bp\n return a.title.localeCompare(b.title)\n })\n for (const it of arr) if (it.children?.length) sortItems(it.children)\n }\n sortItems(roots)\n return roots\n}\n"],
|
|
5
|
-
"mappings": "AACA,OAAO,WAAW;AAElB,SAAS,kBAAkB,qBAAqB;AAiChD,eAAe,mBAAmB,iBAAiD;AACjF,QAAM,UAAU,oBAAI,IAAY;AAChC,MAAI,CAAC,gBAAgB,OAAQ,QAAO;AACpC,MAAI,MAAM;AACV,MAAI;AACJ,MAAI,OAAO,WAAW,aAAa;AAEjC,QAAI;AACF,YAAM,EAAE,SAAS,WAAW,IAAI,MAAM,OAAO,cAAc;AAC3D,YAAM,IAAI,MAAM,WAAW;AAC3B,YAAM,OAAO,EAAE,IAAI,kBAAkB,KAAK,EAAE,IAAI,MAAM,KAAK;AAC3D,YAAM,QAAQ,EAAE,IAAI,mBAAmB,KAAK;AAC5C,YAAM,SAAS,EAAE,IAAI,QAAQ,KAAK;AAClC,UAAI,KAAM,OAAM,GAAG,KAAK,MAAM,IAAI;AAClC,oBAAc,EAAE,OAAO;AAAA,IACzB,QAAQ;AAAA,IAER;AAAA,EACF;AACA,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,KAAK;AAAA,MAC3B,QAAQ;AAAA,MACR,aAAa;AAAA,MACb,SAAS,EAAE,gBAAgB,oBAAoB,GAAI,eAAe,CAAC,EAAG;AAAA,MACtE,MAAM,KAAK,UAAU,EAAE,UAAU,gBAAgB,CAAC;AAAA,IACpD,CAAQ;AACR,QAAI,IAAI,IAAI;AACV,YAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,OAAO,EAAE,SAAS,CAAC,EAAE,EAAE;AAC3D,UAAI,MAAM,QAAQ,MAAM,OAAO,GAAG;AAChC,aAAK,QAAQ,QAAQ,CAAC,MAAc,QAAQ,IAAI,CAAC,CAAC;AAAA,MACpD;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO;AACT;
|
|
4
|
+
"sourcesContent": ["import type { ReactNode } from 'react'\nimport React from 'react'\nimport type { Module, ModuleRoute, PageMetadata } from '@open-mercato/shared/modules/registry'\nimport { hasAllFeatures as checkFeatures } from '@open-mercato/shared/security/features'\n\n/** Route with optional page-metadata aliases that may be merged during generation. */\ntype NavRoute = ModuleRoute & Partial<Pick<PageMetadata, 'pageTitleKey' | 'pageGroupKey'>>\n\nexport type AdminNavItem = {\n group: string\n groupId: string\n groupKey?: string\n groupDefaultName: string\n title: string\n defaultTitle: string\n titleKey?: string\n href: string\n enabled: boolean\n hidden?: boolean\n order?: number\n priority?: number\n icon?: ReactNode\n children?: AdminNavItem[]\n pageContext?: 'main' | 'admin' | 'settings' | 'profile'\n}\n\nexport type AdminNavFeatureChecker = (features: string[]) => Promise<Iterable<string> | null | undefined>\n\nexport type BuildAdminNavOptions = {\n checkFeatures?: AdminNavFeatureChecker\n}\n\n/**\n * @deprecated The internal fetch-based feature check will be removed.\n * Provide `options.checkFeatures` so buildAdminNav can reuse your RBAC context.\n */\nasync function fetchFeatureGrants(requestFeatures: string[]): Promise<Set<string>> { // NOSONAR \u2014 mutable accumulator pattern; Set is populated between early return and final return\n const granted = new Set<string>()\n if (!requestFeatures.length) return granted\n let url = '/api/auth/feature-check'\n let headersInit: Record<string, string> | undefined\n if (typeof window === 'undefined') {\n // On the server, build absolute URL and forward cookies so auth is available\n try {\n const { headers: getHeaders } = await import('next/headers')\n const h = await getHeaders()\n const host = h.get('x-forwarded-host') || h.get('host') || ''\n const proto = h.get('x-forwarded-proto') || 'http'\n const cookie = h.get('cookie') || ''\n if (host) url = `${proto}://${host}/api/auth/feature-check`\n headersInit = { cookie }\n } catch {\n // ignore; fall back to relative URL without forwarded cookies\n }\n }\n try {\n const res = await fetch(url, {\n method: 'POST',\n credentials: 'include' as any,\n headers: { 'content-type': 'application/json', ...(headersInit || {}) },\n body: JSON.stringify({ features: requestFeatures }),\n } as any)\n if (res.ok) {\n const data = await res.json().catch(() => ({ granted: [] }))\n if (Array.isArray(data?.granted)) {\n data.granted.forEach((f: string) => granted.add(f))\n }\n }\n } catch {\n // ignore fetch failures and keep feature set empty\n }\n return granted\n}\n\n/**\n * @deprecated Use number directly in sectionOrder config instead\n */\nexport type SettingsSectionConfig = {\n label: string\n labelKey?: string\n order: number\n}\n\nexport type SettingsSection = {\n id: string\n label: string\n labelKey?: string\n order: number\n items: SettingsSectionItem[]\n}\n\nexport type SettingsSectionItem = {\n id: string\n label: string\n labelKey?: string\n href: string\n icon?: ReactNode\n requireFeatures?: string[]\n order: number\n children?: SettingsSectionItem[]\n}\n\n/**\n * Slug of a rendered group label, used as the pre-#4843 settings section id.\n *\n * `sectionOrder` used to be keyed by these slugs, which made ordering depend on the active locale.\n * Kept only as a lookup fallback so third-party callers passing a legacy map keep their weights.\n *\n * @deprecated Key `sectionOrder` by the untranslated group id (`AdminNavItem.groupId`) instead.\n */\nfunction legacySettingsSectionSlug(groupLabel: string): string {\n return groupLabel.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '')\n}\n\n/**\n * Groups the settings-context nav entries into ordered sections.\n *\n * `sectionOrder` is keyed by the untranslated group id (`AdminNavItem.groupId`, i.e. the page's\n * `pageGroupKey` when it declares one) \u2014 the same convention the main sidebar's `defaultGroupOrder`\n * follows. Keying off the rendered label instead made every non-English deployment miss its weights\n * and fall back to the catch-all bucket (#4843).\n */\nexport function buildSettingsSections(\n entries: AdminNavItem[],\n sectionOrder: Record<string, number>\n): SettingsSection[] {\n const settingsItems = entries.filter(e => e.pageContext === 'settings')\n\n const sectionMap = new Map<string, SettingsSection>()\n\n const mapSectionItem = (item: AdminNavItem): SettingsSectionItem => {\n const itemId = item.href.replace(/\\//g, '-').slice(1)\n return {\n id: itemId,\n label: item.title,\n labelKey: item.titleKey,\n href: item.href,\n icon: item.icon,\n requireFeatures: undefined,\n order: item.order ?? item.priority ?? 100,\n children: item.children?.map(mapSectionItem),\n }\n }\n\n for (const item of settingsItems) {\n const sectionId = item.groupId\n const order = sectionOrder[sectionId] ?? sectionOrder[legacySettingsSectionSlug(item.group)] ?? 999\n\n if (!sectionMap.has(sectionId)) {\n sectionMap.set(sectionId, {\n id: sectionId,\n label: item.group,\n labelKey: item.groupKey,\n order,\n items: []\n })\n }\n\n const section = sectionMap.get(sectionId)!\n section.items.push(mapSectionItem(item))\n }\n\n const sections = Array.from(sectionMap.values())\n const sortItems = (items: SettingsSectionItem[]) => {\n items.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))\n for (const item of items) {\n if (item.children?.length) sortItems(item.children)\n }\n }\n sections.sort((a, b) => a.order - b.order)\n for (const section of sections) {\n sortItems(section.items)\n }\n\n return sections\n}\n\nexport function computeSettingsPathPrefixes(sections: SettingsSection[]): string[] {\n const prefixes = new Set<string>()\n const visitItem = (item: SettingsSectionItem) => {\n const parts = item.href.split('/')\n const lastSegment = parts[parts.length - 1]\n if (parts.length > 3 && lastSegment !== 'settings') {\n prefixes.add(parts.slice(0, -1).join('/'))\n }\n prefixes.add(item.href)\n if (item.children?.length) {\n for (const child of item.children) visitItem(child)\n }\n }\n for (const section of sections) {\n for (const item of section.items) {\n visitItem(item)\n }\n }\n return Array.from(prefixes)\n}\n\nexport function convertToSectionNavGroups(\n sections: SettingsSection[],\n translate?: (key: string | undefined, fallback: string) => string\n): Array<{\n id: string\n label: string\n labelKey?: string\n order?: number\n items: ConvertedSectionNavItem[]\n}> {\n const t = translate || ((key, fallback) => fallback)\n const mapSectionItem = (item: SettingsSectionItem): ConvertedSectionNavItem => ({\n id: item.id,\n label: t(item.labelKey, item.label),\n labelKey: item.labelKey,\n href: item.href,\n icon: item.icon,\n order: item.order,\n children: item.children?.map(mapSectionItem),\n })\n\n return sections.map(section => ({\n id: section.id,\n label: t(section.labelKey, section.label),\n labelKey: section.labelKey,\n order: section.order,\n items: section.items.map(mapSectionItem),\n }))\n}\n\ntype ConvertedSectionNavItem = {\n id: string\n label: string\n labelKey?: string\n href: string\n icon?: ReactNode\n requireFeatures?: string[]\n order?: number\n children?: ConvertedSectionNavItem[]\n}\n\ntype BuildAdminNavModule = Pick<Module, 'id'> & { backendRoutes?: (ModuleRoute | Omit<ModuleRoute, 'Component'>)[] }\n\nexport async function buildAdminNav(\n modules: BuildAdminNavModule[],\n ctx: { auth?: { roles?: string[]; sub?: string; orgId?: string | null; tenantId?: string | null }; path?: string },\n userEntities?: Array<{ entityId: string; label: string; href: string }>,\n translate?: (key: string | undefined, fallback: string) => string,\n options?: BuildAdminNavOptions\n): Promise<AdminNavItem[]> {\n function capitalize(s: string) {\n return s.charAt(0).toUpperCase() + s.slice(1)\n }\n function deriveTitleFromPath(p: string) {\n const seg = p.split('/').filter(Boolean).pop() || ''\n return seg ? seg.split('-').map(capitalize).join(' ') : 'Home'\n }\n const entries: AdminNavItem[] = []\n\n // Collect all unique features needed across all routes first\n const allRequiredFeatures = new Set<string>()\n for (const m of modules) {\n for (const r of (m.backendRoutes ?? []) as NavRoute[]) {\n const features = r.requireFeatures\n if (features && features.length) {\n features.forEach(f => allRequiredFeatures.add(f))\n }\n }\n }\n\n // Batch check all features in a single API call\n let userFeatures: string[] = []\n if (allRequiredFeatures.size > 0) {\n const requestFeatures = Array.from(allRequiredFeatures)\n if (options?.checkFeatures) {\n try {\n const resolved = await options.checkFeatures(requestFeatures)\n if (resolved) {\n userFeatures = Array.from(resolved).filter((feature): feature is string => typeof feature === 'string' && feature.length > 0)\n }\n } catch {\n // ignore and fall back to empty feature set\n }\n } else {\n userFeatures = Array.from(await fetchFeatureGrants(requestFeatures))\n }\n }\n\n // Helper: check if user has all required features (from cache)\n function hasAllFeatures(required: string[]): boolean {\n if (!required || required.length === 0) return true\n return checkFeatures(userFeatures, required)\n }\n\n // Icons are defined per-page in metadata; no heuristic derivation here.\n for (const m of modules) {\n const groupDefault = capitalize(m.id)\n for (const r of (m.backendRoutes ?? []) as NavRoute[]) {\n const href = r.pattern ?? r.path ?? ''\n if (!href || href.includes('[')) continue\n if (r.navHidden) continue\n const title = r.title || deriveTitleFromPath(href)\n const titleKey = r.pageTitleKey ?? r.titleKey\n const group = r.group || groupDefault\n const groupKey = r.pageGroupKey ?? r.groupKey\n const groupId = groupKey ?? group\n const displayGroup = translate ? translate(groupKey, group) : group\n const displayTitle = translate ? translate(titleKey, title) : title\n const visible = r.visible ? await Promise.resolve(r.visible(ctx)) : true\n if (!visible) continue\n const enabled = r.enabled ? await Promise.resolve(r.enabled(ctx)) : true\n // If roles are required, check; otherwise include\n const required = r.requireRoles || []\n if (required.length) {\n const roles = ctx.auth?.roles || []\n const ok = required.some((role) => roles.includes(role))\n if (!ok) continue\n }\n // If features are required, check from cached batch result\n const features = r.requireFeatures\n if (features && features.length) {\n const ok = hasAllFeatures(features)\n if (!ok) continue\n }\n const order = r.order\n const priority = r.priority ?? order\n const icon = r.icon\n const pageContext = r.pageContext\n entries.push({\n group: displayGroup,\n groupId,\n groupKey,\n groupDefaultName: displayGroup,\n title: displayTitle,\n defaultTitle: displayTitle,\n titleKey,\n href,\n enabled,\n order,\n priority,\n icon,\n pageContext,\n })\n }\n }\n // Build hierarchy: treat routes whose href starts with a parent href + '/'\n // Sort by href length (shortest first) so potential parents are processed before children\n const sorted = [...entries].sort((a, b) => a.href.length - b.href.length)\n const byHref = new Map<string, AdminNavItem>()\n const roots: AdminNavItem[] = []\n for (const e of sorted) {\n // Walk up the href segments to find the longest matching parent in the same group\n let parent: AdminNavItem | undefined\n const segments = e.href.split('/')\n for (let i = segments.length - 1; i >= 2; i--) {\n const candidate = byHref.get(segments.slice(0, i).join('/'))\n if (candidate && candidate !== e && candidate.groupId === e.groupId) {\n parent = candidate\n break\n }\n }\n byHref.set(e.href, e)\n if (parent) {\n parent.children = parent.children || []\n parent.children.push(e)\n } else {\n roots.push(e)\n }\n }\n\n // Add dynamic user entities to the navigation\n if (userEntities && userEntities.length > 0) {\n const tableIcon = React.createElement(\n 'svg',\n { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 },\n React.createElement('rect', { x: 3, y: 4, width: 18, height: 16, rx: 2 }),\n React.createElement('path', { d: 'M3 10h18M9 4v16M15 4v16' }),\n )\n const userEntitiesLegacyGroupKeys = new Set(['settings.sections.dataDesigner', 'entities.nav.group'])\n const userEntitiesItem = entries.find((entry) => entry.href === '/backend/entities/user')\n ?? entries.find((entry) =>\n entry.titleKey === 'entities.nav.userEntities' &&\n typeof entry.groupKey === 'string' &&\n userEntitiesLegacyGroupKeys.has(entry.groupKey),\n )\n if (userEntitiesItem) {\n const existingChildren = userEntitiesItem.children || []\n const dynamicUserEntities = userEntities.map((entity) => ({\n group: userEntitiesItem.group,\n groupId: userEntitiesItem.groupId,\n groupKey: userEntitiesItem.groupKey,\n groupDefaultName: userEntitiesItem.groupDefaultName,\n title: entity.label,\n defaultTitle: entity.label,\n href: entity.href,\n enabled: true,\n order: 1000, // High order to appear at the end\n priority: 1000,\n icon: tableIcon,\n }))\n // Merge and deduplicate by href to avoid duplicates coming from server or generator\n const merged = [...existingChildren, ...dynamicUserEntities]\n const byHref = new Map<string, AdminNavItem>()\n for (const it of merged) {\n if (!byHref.has(it.href)) byHref.set(it.href, it)\n }\n userEntitiesItem.children = Array.from(byHref.values())\n }\n }\n\n // Sorting: group, then priority/order, then title. Apply within children too.\n const sortItems = (arr: AdminNavItem[]) => {\n arr.sort((a, b) => {\n if (a.groupId !== b.groupId) return a.groupId.localeCompare(b.groupId)\n const ap = a.priority ?? a.order ?? 10_000\n const bp = b.priority ?? b.order ?? 10_000\n if (ap !== bp) return ap - bp\n return a.title.localeCompare(b.title)\n })\n for (const it of arr) if (it.children?.length) sortItems(it.children)\n }\n sortItems(roots)\n return roots\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["byHref"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6825.1.85bbf320ad",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.7-develop.6825.1.85bbf320ad",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
165
|
+
"@open-mercato/shared": "0.6.7-develop.6825.1.85bbf320ad",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^6.9.1",
|
|
168
168
|
"@testing-library/react": "^16.3.1",
|
|
@@ -202,4 +202,143 @@ describe('settings navigation helpers', () => {
|
|
|
202
202
|
)
|
|
203
203
|
expect(allSettingsHrefs).not.toContain('/backend/config/system-status')
|
|
204
204
|
})
|
|
205
|
+
|
|
206
|
+
it('orders settings sections by the untranslated group id, not the rendered label', () => {
|
|
207
|
+
const polishEntry = (
|
|
208
|
+
group: string,
|
|
209
|
+
groupKey: string,
|
|
210
|
+
href: string,
|
|
211
|
+
): AdminNavItem => ({
|
|
212
|
+
group,
|
|
213
|
+
groupId: groupKey,
|
|
214
|
+
groupKey,
|
|
215
|
+
groupDefaultName: group,
|
|
216
|
+
title: href,
|
|
217
|
+
defaultTitle: href,
|
|
218
|
+
href,
|
|
219
|
+
enabled: true,
|
|
220
|
+
order: 10,
|
|
221
|
+
pageContext: 'settings',
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const entries: AdminNavItem[] = [
|
|
225
|
+
polishEntry('System', 'settings.sections.system', '/backend/config/system-status'),
|
|
226
|
+
polishEntry('Konfiguracja modułów', 'settings.sections.moduleConfigs', '/backend/config/wms'),
|
|
227
|
+
polishEntry('Autoryzacja', 'settings.sections.auth', '/backend/users'),
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
const sections = buildSettingsSections(entries, {
|
|
231
|
+
'settings.sections.system': 1,
|
|
232
|
+
'settings.sections.auth': 2,
|
|
233
|
+
'settings.sections.moduleConfigs': 5,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
expect(sections.map((section) => section.id)).toEqual([
|
|
237
|
+
'settings.sections.system',
|
|
238
|
+
'settings.sections.auth',
|
|
239
|
+
'settings.sections.moduleConfigs',
|
|
240
|
+
])
|
|
241
|
+
expect(sections.map((section) => section.order)).toEqual([1, 2, 5])
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('keeps the section label and labelKey of the rendered group', () => {
|
|
245
|
+
const entries: AdminNavItem[] = [
|
|
246
|
+
{
|
|
247
|
+
group: 'Konfiguracja modułów',
|
|
248
|
+
groupId: 'settings.sections.moduleConfigs',
|
|
249
|
+
groupKey: 'settings.sections.moduleConfigs',
|
|
250
|
+
groupDefaultName: 'Konfiguracja modułów',
|
|
251
|
+
title: 'WMS',
|
|
252
|
+
defaultTitle: 'WMS',
|
|
253
|
+
href: '/backend/config/wms',
|
|
254
|
+
enabled: true,
|
|
255
|
+
pageContext: 'settings',
|
|
256
|
+
},
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
const [section] = buildSettingsSections(entries, { 'settings.sections.moduleConfigs': 5 })
|
|
260
|
+
|
|
261
|
+
expect(section.label).toBe('Konfiguracja modułów')
|
|
262
|
+
expect(section.labelKey).toBe('settings.sections.moduleConfigs')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('still honors legacy sectionOrder maps keyed by the English label slug', () => {
|
|
266
|
+
const entries: AdminNavItem[] = [
|
|
267
|
+
{
|
|
268
|
+
group: 'Module Configs',
|
|
269
|
+
groupId: 'settings.sections.moduleConfigs',
|
|
270
|
+
groupKey: 'settings.sections.moduleConfigs',
|
|
271
|
+
groupDefaultName: 'Module Configs',
|
|
272
|
+
title: 'WMS',
|
|
273
|
+
defaultTitle: 'WMS',
|
|
274
|
+
href: '/backend/config/wms',
|
|
275
|
+
enabled: true,
|
|
276
|
+
pageContext: 'settings',
|
|
277
|
+
},
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
const [section] = buildSettingsSections(entries, { 'module-configs': 5 })
|
|
281
|
+
|
|
282
|
+
expect(section.order).toBe(5)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('falls back to the catch-all weight for groups the order map does not name', () => {
|
|
286
|
+
const entries: AdminNavItem[] = [
|
|
287
|
+
{
|
|
288
|
+
group: 'Bezpieczeństwo',
|
|
289
|
+
groupId: 'settings.sections.security',
|
|
290
|
+
groupKey: 'settings.sections.security',
|
|
291
|
+
groupDefaultName: 'Bezpieczeństwo',
|
|
292
|
+
title: 'Audit Logs',
|
|
293
|
+
defaultTitle: 'Audit Logs',
|
|
294
|
+
href: '/backend/audit-logs',
|
|
295
|
+
enabled: true,
|
|
296
|
+
pageContext: 'settings',
|
|
297
|
+
},
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
const [section] = buildSettingsSections(entries, { 'settings.sections.system': 1 })
|
|
301
|
+
|
|
302
|
+
expect(section.order).toBe(999)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// A partially translated locale renders `translate(groupKey, group)` as the localized label for
|
|
306
|
+
// pages whose key is covered and as the raw English fallback for the rest, so one group can reach
|
|
307
|
+
// buildSettingsSections under two different labels.
|
|
308
|
+
it('groups entries that share a group id even when labels differ', () => {
|
|
309
|
+
const entries: AdminNavItem[] = [
|
|
310
|
+
{
|
|
311
|
+
group: 'Konfiguracja modułów',
|
|
312
|
+
groupId: 'settings.sections.moduleConfigs',
|
|
313
|
+
groupKey: 'settings.sections.moduleConfigs',
|
|
314
|
+
groupDefaultName: 'Konfiguracja modułów',
|
|
315
|
+
title: 'WMS',
|
|
316
|
+
defaultTitle: 'WMS',
|
|
317
|
+
href: '/backend/config/wms',
|
|
318
|
+
enabled: true,
|
|
319
|
+
order: 20,
|
|
320
|
+
pageContext: 'settings',
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
group: 'Module Configs',
|
|
324
|
+
groupId: 'settings.sections.moduleConfigs',
|
|
325
|
+
groupKey: 'settings.sections.moduleConfigs',
|
|
326
|
+
groupDefaultName: 'Module Configs',
|
|
327
|
+
title: 'Sales',
|
|
328
|
+
defaultTitle: 'Sales',
|
|
329
|
+
href: '/backend/config/sales',
|
|
330
|
+
enabled: true,
|
|
331
|
+
order: 10,
|
|
332
|
+
pageContext: 'settings',
|
|
333
|
+
},
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
const sections = buildSettingsSections(entries, { 'settings.sections.moduleConfigs': 5 })
|
|
337
|
+
|
|
338
|
+
expect(sections).toHaveLength(1)
|
|
339
|
+
expect(sections[0].items.map((item) => item.href)).toEqual([
|
|
340
|
+
'/backend/config/sales',
|
|
341
|
+
'/backend/config/wms',
|
|
342
|
+
])
|
|
343
|
+
})
|
|
205
344
|
})
|
package/src/backend/utils/nav.ts
CHANGED
|
@@ -100,6 +100,26 @@ export type SettingsSectionItem = {
|
|
|
100
100
|
children?: SettingsSectionItem[]
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Slug of a rendered group label, used as the pre-#4843 settings section id.
|
|
105
|
+
*
|
|
106
|
+
* `sectionOrder` used to be keyed by these slugs, which made ordering depend on the active locale.
|
|
107
|
+
* Kept only as a lookup fallback so third-party callers passing a legacy map keep their weights.
|
|
108
|
+
*
|
|
109
|
+
* @deprecated Key `sectionOrder` by the untranslated group id (`AdminNavItem.groupId`) instead.
|
|
110
|
+
*/
|
|
111
|
+
function legacySettingsSectionSlug(groupLabel: string): string {
|
|
112
|
+
return groupLabel.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Groups the settings-context nav entries into ordered sections.
|
|
117
|
+
*
|
|
118
|
+
* `sectionOrder` is keyed by the untranslated group id (`AdminNavItem.groupId`, i.e. the page's
|
|
119
|
+
* `pageGroupKey` when it declares one) — the same convention the main sidebar's `defaultGroupOrder`
|
|
120
|
+
* follows. Keying off the rendered label instead made every non-English deployment miss its weights
|
|
121
|
+
* and fall back to the catch-all bucket (#4843).
|
|
122
|
+
*/
|
|
103
123
|
export function buildSettingsSections(
|
|
104
124
|
entries: AdminNavItem[],
|
|
105
125
|
sectionOrder: Record<string, number>
|
|
@@ -123,8 +143,8 @@ export function buildSettingsSections(
|
|
|
123
143
|
}
|
|
124
144
|
|
|
125
145
|
for (const item of settingsItems) {
|
|
126
|
-
const sectionId = item.
|
|
127
|
-
const order = sectionOrder[sectionId] ?? 999
|
|
146
|
+
const sectionId = item.groupId
|
|
147
|
+
const order = sectionOrder[sectionId] ?? sectionOrder[legacySettingsSectionSlug(item.group)] ?? 999
|
|
128
148
|
|
|
129
149
|
if (!sectionMap.has(sectionId)) {
|
|
130
150
|
sectionMap.set(sectionId, {
|