Sections across the top, and a choice between the two shells
The old web UI put everything in a sidebar, and a reskin of it would still read as the old web UI. The modern shell splits navigation in two instead: the layout switcher already in the top bar is tier one, and a new bar under it carries that layout's own top-level items, each group opening its children in a menu. Nothing on the left, so a queue table or a dashboard gets the whole window. - Management's nine entries and Account's eleven suit a menu bar; the bar measures its items once per layout and folds whatever doesn't fit into "More", recomputing on resize from the cached widths. - Settings keeps the sidebar. Nineteen groups is a configuration browser, not a set of destinations, and a menu bar stops helping however wide the window; the threshold is on the count, not on the name, so a changed schema can't strand anyone. - Which shell to use is the reader's, beside the theme: user menu, Layout, Modern or Legacy. Modern is the default, and the choice is remembered in the browser rather than with the account — settings.json is shared with the webmail, which has no notion of this. - A phone is unchanged. The section bar is hidden below md and the sidebar stays as the slide-over behind the hamburger. - The tree walking both shells need moves to lib/navTree.ts, so the sidebar and the section bar agree on what is visible, what is locked and what is active.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: walking the server's menu tree, shared by the two shells that draw it
|
||||
* — the sidebar (legacy) and the section bar (modern). The tree itself is the
|
||||
* schema's `layouts`, so neither shell may assume a depth or a fan-out.
|
||||
*/
|
||||
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { isLinkEnterprise, isLinkVisible } from '@/lib/layout';
|
||||
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
|
||||
|
||||
/**
|
||||
* Past this many top-level entries a layout is a configuration browser rather
|
||||
* than a set of destinations, and a menu bar stops helping however wide the
|
||||
* window: on the stock schema that is Settings, with its nineteen groups, which
|
||||
* keeps the sidebar under either shell. Management (nine) and Account (eleven)
|
||||
* fit, and anything left over folds into the section bar's "More".
|
||||
*/
|
||||
export const SECTION_NAV_MAX_ITEMS = 12;
|
||||
|
||||
export function resolveViewPath(sectionName: string, viewName: string): string {
|
||||
return `/${sectionName}/${viewName}`;
|
||||
}
|
||||
|
||||
export function pathMatchesView(currentPath: string, sectionName: string, viewName: string): boolean {
|
||||
const base = `/${sectionName}/${viewName}`;
|
||||
if (currentPath === base || currentPath.startsWith(`${base}/`)) return true;
|
||||
if (viewName === 'CustomComponent/Dashboard') {
|
||||
const dashBase = `/${sectionName}/Dashboard/`;
|
||||
return currentPath.startsWith(dashBase);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkLinkVisible(viewName: string): boolean {
|
||||
const schema = useSchemaStore.getState().schema;
|
||||
if (!schema) return true;
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
return isLinkVisible(
|
||||
schema,
|
||||
viewName,
|
||||
accountStore.edition,
|
||||
(prefix: string) => accountStore.hasObjectPermission(prefix, 'Get'),
|
||||
(perm: string) => accountStore.hasPermission(perm),
|
||||
);
|
||||
}
|
||||
|
||||
export function checkIsEnterprise(viewName: string): boolean {
|
||||
const schema = useSchemaStore.getState().schema;
|
||||
if (!schema) return false;
|
||||
const edition = useAccountStore.getState().edition;
|
||||
return isLinkEnterprise(schema, viewName, edition);
|
||||
}
|
||||
|
||||
export function subtreeContainsActive(items: LayoutSubItem[], currentPath: string, sectionName: string): boolean {
|
||||
for (const item of items) {
|
||||
if (item.type === 'link') {
|
||||
if (pathMatchesView(currentPath, sectionName, item.viewName)) return true;
|
||||
} else if (item.type === 'container') {
|
||||
if (subtreeContainsActive(item.items, currentPath, sectionName)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean {
|
||||
for (const item of items) {
|
||||
if (item.type === 'link') {
|
||||
if (!checkLinkVisible(item.viewName)) continue;
|
||||
const enterprise = checkIsEnterprise(item.viewName);
|
||||
if (enterprise && edition === 'oss') continue;
|
||||
return true;
|
||||
} else if (item.type === 'container') {
|
||||
if (subtreeHasVisibleLink(item.items, edition)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Every visible link under a container, flattened, deeper names prefixed with their group. */
|
||||
export function visibleLinks(
|
||||
items: LayoutSubItem[],
|
||||
edition: string,
|
||||
prefix = '',
|
||||
): { name: string; viewName: string }[] {
|
||||
const out: { name: string; viewName: string }[] = [];
|
||||
for (const it of items) {
|
||||
if (it.type === 'link') {
|
||||
if (!checkLinkVisible(it.viewName)) continue;
|
||||
const enterprise = checkIsEnterprise(it.viewName);
|
||||
if (enterprise && edition === 'oss') continue;
|
||||
out.push({ name: `${prefix}${it.name || 'Overview'}`, viewName: it.viewName });
|
||||
} else if (subtreeHasVisibleLink(it.items, edition)) {
|
||||
out.push(...visibleLinks(it.items, edition, `${prefix}${it.name} › `));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a top-level entry has anything left to show once edition and permissions are applied. */
|
||||
export function topItemVisible(item: LayoutItem, edition: string): boolean {
|
||||
if ('link' in item) {
|
||||
if (!checkLinkVisible(item.link.viewName)) return false;
|
||||
return !(checkIsEnterprise(item.link.viewName) && edition === 'oss');
|
||||
}
|
||||
return subtreeHasVisibleLink(item.container.items, edition);
|
||||
}
|
||||
|
||||
/** A stable key for a top-level entry, for React lists. */
|
||||
export function topItemKey(item: LayoutItem): string {
|
||||
return 'link' in item ? item.link.viewName : item.container.name;
|
||||
}
|
||||
|
||||
/** Whether this layout is shallow enough to be drawn as a menu bar at all. */
|
||||
export function fitsSectionNav(layout: Layout, edition: string): boolean {
|
||||
return layout.items.filter((item) => topItemVisible(item, edition)).length <= SECTION_NAV_MAX_ITEMS;
|
||||
}
|
||||
Reference in New Issue
Block a user