feat(core): add layered side rail preferences
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { ArrowDown, ArrowUp, LockKeyhole } from "lucide-react";
|
||||
import type { NavigationPreferences, PlatformNavItem } from "../types";
|
||||
import Button from "./Button";
|
||||
import IconButton from "./IconButton";
|
||||
import ToggleSwitch from "./ToggleSwitch";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
type Scope = "system" | "tenant" | "user";
|
||||
|
||||
export default function NavigationPreferenceEditor({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
scope,
|
||||
disabled = false
|
||||
}: {
|
||||
items: PlatformNavItem[];
|
||||
value: NavigationPreferences | null;
|
||||
onChange: (value: NavigationPreferences | null) => void;
|
||||
scope: Scope;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const inheritedScope = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant";
|
||||
const inherited = preferenceFromLayer(items, inheritedScope);
|
||||
const editable = value ?? inherited;
|
||||
const byId = new Map(items.map((item) => [navigationId(item), item]));
|
||||
const inheritedIds = [...items]
|
||||
.sort((left, right) => layerOrder(left, inheritedScope) - layerOrder(right, inheritedScope))
|
||||
.map(navigationId);
|
||||
const orderedIds = [
|
||||
...editable.order.filter((id) => byId.has(id)),
|
||||
...inheritedIds.filter((id) => !editable.order.includes(id))
|
||||
];
|
||||
const hidden = new Set(editable.hidden);
|
||||
const localLocks = new Set(editable.locked ?? []);
|
||||
|
||||
function update(patch: Partial<NavigationPreferences>) {
|
||||
onChange({ ...editable, ...patch, contract_version: "1" });
|
||||
}
|
||||
|
||||
function move(id: string, offset: -1 | 1) {
|
||||
const index = orderedIds.indexOf(id);
|
||||
const target = index + offset;
|
||||
if (index < 0 || target < 0 || target >= orderedIds.length) return;
|
||||
const next = [...orderedIds];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
update({ order: next });
|
||||
}
|
||||
|
||||
function setVisible(id: string, visible: boolean) {
|
||||
const next = new Set(hidden);
|
||||
if (visible) next.delete(id);
|
||||
else next.add(id);
|
||||
update({ order: orderedIds, hidden: [...next] });
|
||||
}
|
||||
|
||||
function setLocked(id: string, locked: boolean) {
|
||||
const next = new Set(localLocks);
|
||||
if (locked) next.add(id);
|
||||
else next.delete(id);
|
||||
const nextHidden = new Set(hidden);
|
||||
if (locked) nextHidden.delete(id);
|
||||
update({ order: orderedIds, hidden: [...nextHidden], locked: [...next] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="navigation-preference-editor" data-navigation-preference-scope={scope}>
|
||||
<div className="navigation-preference-toolbar">
|
||||
<p className="muted small-note">
|
||||
Higher personal settings take precedence over tenant and system order. Locked entries remain visible.
|
||||
</p>
|
||||
<Button onClick={() => onChange(null)} disabled={disabled || value === null}>
|
||||
Use inherited order
|
||||
</Button>
|
||||
</div>
|
||||
<ol className="navigation-preference-list">
|
||||
{orderedIds.map((id, index) => {
|
||||
const item = byId.get(id);
|
||||
if (!item) return null;
|
||||
const inheritedState = item.navigationLayers?.[inheritedScope];
|
||||
const ancestorLocked = Boolean(inheritedState?.locked);
|
||||
const locked = ancestorLocked || localLocks.has(id);
|
||||
const label = translateText(item.label);
|
||||
return (
|
||||
<li key={id} data-navigation-id={id} data-navigation-locked={locked ? "true" : "false"}>
|
||||
<div className="navigation-preference-order-actions">
|
||||
<IconButton label={`Move ${label} up`} icon={<ArrowUp size={16} />} onClick={() => move(id, -1)} disabled={disabled || index === 0} />
|
||||
<IconButton label={`Move ${label} down`} icon={<ArrowDown size={16} />} onClick={() => move(id, 1)} disabled={disabled || index === orderedIds.length - 1} />
|
||||
</div>
|
||||
<div className="navigation-preference-label">
|
||||
<strong>{label}</strong>
|
||||
<span>{id}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
label="Visible"
|
||||
checked={locked || !hidden.has(id)}
|
||||
disabled={disabled || locked}
|
||||
help={locked ? `Locked by ${inheritedState?.lock_source ?? scope}` : undefined}
|
||||
onChange={(visible) => setVisible(id, visible)}
|
||||
/>
|
||||
{scope !== "user" && (
|
||||
<ToggleSwitch
|
||||
label={<><LockKeyhole size={14} aria-hidden="true" /> Locked</>}
|
||||
checked={locked}
|
||||
disabled={disabled || ancestorLocked}
|
||||
help={ancestorLocked ? `Locked by ${inheritedState?.lock_source}` : "Lower scopes cannot hide this entry."}
|
||||
onChange={(next) => setLocked(id, next)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function navigationId(item: PlatformNavItem): string {
|
||||
return item.navigationId ?? item.surfaceId ?? item.to;
|
||||
}
|
||||
|
||||
function preferenceFromLayer(
|
||||
items: PlatformNavItem[],
|
||||
layer: "module" | "system" | "tenant"
|
||||
): NavigationPreferences {
|
||||
const ordered = [...items].sort((left, right) => layerOrder(left, layer) - layerOrder(right, layer));
|
||||
return {
|
||||
contract_version: "1",
|
||||
order: ordered.map(navigationId),
|
||||
hidden: ordered.filter((item) => item.navigationLayers?.[layer]?.visible === false).map(navigationId),
|
||||
locked: []
|
||||
};
|
||||
}
|
||||
|
||||
function layerOrder(item: PlatformNavItem, layer: "module" | "system" | "tenant"): number {
|
||||
return item.navigationLayers?.[layer]?.order ?? item.order ?? 100;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import DescriptionList from "../../components/DescriptionList";
|
||||
import ContentGrid, { FormGrid } from "../../components/ContentGrid";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
|
||||
import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, NavigationPreferences, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
@@ -12,11 +12,14 @@ import PageActionBar from "../../components/PageActionBar";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { apiFetch } from "../../api/client";
|
||||
import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth";
|
||||
import { dispatchPlatformModulesChanged } from "../../platform/moduleEvents";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import SegmentedControl from "../../components/SegmentedControl";
|
||||
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { usePlatformModules, usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { configurableNavigationItemsForModules } from "../../platform/modules";
|
||||
import NavigationPreferenceEditor from "../../components/NavigationPreferenceEditor";
|
||||
import { useEffectiveView, useViewSurfaces } from "../../platform/ViewContext";
|
||||
import { isViewSurfaceVisible } from "../../platform/views";
|
||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
@@ -103,6 +106,8 @@ export default function SettingsPage({
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
|
||||
const platformModules = usePlatformModules();
|
||||
const navigationItems = useMemo(() => configurableNavigationItemsForModules(platformModules), [platformModules]);
|
||||
const effectiveView = useEffectiveView();
|
||||
const viewSurfaces = useViewSurfaces();
|
||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||
@@ -150,6 +155,7 @@ export default function SettingsPage({
|
||||
const [reduceMotion, setReduceMotion] = useState(currentUiPreferences.reduce_motion);
|
||||
const [stickySections, setStickySections] = useState(currentUiPreferences.sticky_section_sidebars);
|
||||
const [theme, setTheme] = useState<UserUiTheme>(currentUiPreferences.theme);
|
||||
const [navigation, setNavigation] = useState<NavigationPreferences | null>(currentUiPreferences.navigation ?? null);
|
||||
const [uiBusy, setUiBusy] = useState(false);
|
||||
const [uiResult, setUiResult] = useState("");
|
||||
const [uiResultTone, setUiResultTone] = useState<"success" | "warning">("success");
|
||||
@@ -164,7 +170,8 @@ export default function SettingsPage({
|
||||
showHelpHints !== currentUiPreferences.show_inline_help_hints ||
|
||||
reduceMotion !== currentUiPreferences.reduce_motion ||
|
||||
stickySections !== currentUiPreferences.sticky_section_sidebars ||
|
||||
theme !== currentUiPreferences.theme;
|
||||
theme !== currentUiPreferences.theme ||
|
||||
JSON.stringify(navigation) !== JSON.stringify(currentUiPreferences.navigation ?? null);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: active === "profile" && profileDirty,
|
||||
@@ -218,12 +225,14 @@ export default function SettingsPage({
|
||||
setReduceMotion(currentUiPreferences.reduce_motion);
|
||||
setStickySections(currentUiPreferences.sticky_section_sidebars);
|
||||
setTheme(currentUiPreferences.theme);
|
||||
setNavigation(currentUiPreferences.navigation ?? null);
|
||||
}, [
|
||||
currentUiPreferences.compact_tables,
|
||||
currentUiPreferences.show_inline_help_hints,
|
||||
currentUiPreferences.reduce_motion,
|
||||
currentUiPreferences.sticky_section_sidebars,
|
||||
currentUiPreferences.theme
|
||||
currentUiPreferences.theme,
|
||||
currentUiPreferences.navigation
|
||||
]);
|
||||
|
||||
function selectSection(section: SettingsSection) {
|
||||
@@ -262,6 +271,7 @@ export default function SettingsPage({
|
||||
setReduceMotion(currentUiPreferences.reduce_motion);
|
||||
setStickySections(currentUiPreferences.sticky_section_sidebars);
|
||||
setTheme(currentUiPreferences.theme);
|
||||
setNavigation(currentUiPreferences.navigation ?? null);
|
||||
}
|
||||
|
||||
function uiPreferencePayload(): UserUiPreferences {
|
||||
@@ -270,7 +280,8 @@ export default function SettingsPage({
|
||||
show_inline_help_hints: showHelpHints,
|
||||
reduce_motion: reduceMotion,
|
||||
sticky_section_sidebars: stickySections,
|
||||
theme
|
||||
theme,
|
||||
navigation
|
||||
};
|
||||
}
|
||||
|
||||
@@ -280,6 +291,7 @@ export default function SettingsPage({
|
||||
try {
|
||||
const next = await updateProfile(settings, { ui_preferences: uiPreferencePayload() });
|
||||
onAuthChange(next);
|
||||
dispatchPlatformModulesChanged();
|
||||
setUiResultTone("success");
|
||||
setUiResult("i18n:govoplan-core.preferences_saved.c8cd3501");
|
||||
return true;
|
||||
@@ -503,6 +515,15 @@ export default function SettingsPage({
|
||||
<span>i18n:govoplan-core.template_placeholder_chips_and_preview_overlays.11634d55</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Navigation order">
|
||||
<NavigationPreferenceEditor
|
||||
items={navigationItems}
|
||||
value={navigation}
|
||||
onChange={setNavigation}
|
||||
scope="user"
|
||||
disabled={uiBusy}
|
||||
/>
|
||||
</Card>
|
||||
</ContentGrid>
|
||||
}
|
||||
|
||||
@@ -594,7 +615,8 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
|
||||
show_inline_help_hints: Boolean(value?.show_inline_help_hints ?? DEFAULT_UI_PREFERENCES.show_inline_help_hints),
|
||||
reduce_motion: Boolean(value?.reduce_motion ?? DEFAULT_UI_PREFERENCES.reduce_motion),
|
||||
sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
|
||||
theme
|
||||
theme,
|
||||
navigation: value?.navigation ?? null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -240,6 +240,7 @@ export { default as AppShell } from "./layout/AppShell";
|
||||
export { default as BreadcrumbBar } from "./layout/BreadcrumbBar";
|
||||
export { default as HelpMenu } from "./layout/HelpMenu";
|
||||
export { default as IconRail } from "./layout/IconRail";
|
||||
export { default as NavigationPreferenceEditor } from "./components/NavigationPreferenceEditor";
|
||||
export { default as LanguageMenu } from "./layout/LanguageMenu";
|
||||
export { default as ModuleSubnav } from "./layout/ModuleSubnav";
|
||||
export type { ModuleSubnavGroup, ModuleSubnavItem } from "./layout/ModuleSubnav";
|
||||
|
||||
@@ -110,7 +110,14 @@ function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavIt
|
||||
allOf: item.required_all,
|
||||
anyOf: item.required_any,
|
||||
order: item.order,
|
||||
surfaceId: item.surface_id ?? undefined
|
||||
surfaceId: item.surface_id ?? undefined,
|
||||
navigationId: item.navigation_id ?? item.surface_id ?? undefined,
|
||||
navigationVisible: item.navigation_visible,
|
||||
navigationLocked: item.navigation_locked,
|
||||
navigationOrderSource: item.navigation_order_source,
|
||||
navigationVisibilitySource: item.navigation_visibility_source,
|
||||
navigationLockSource: item.navigation_lock_source,
|
||||
navigationLayers: item.navigation_layers
|
||||
};
|
||||
}
|
||||
|
||||
@@ -541,10 +548,30 @@ export function navItemsForModules(
|
||||
);
|
||||
return [...shellNavItemsForModules(modules), ...moduleItems].
|
||||
map(resolveNavItemIcon).
|
||||
filter((item) => item.navigationVisible !== false).
|
||||
filter((item) => isViewSurfaceVisible(projection, item.surfaceId, catalogue)).
|
||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function configurableNavigationItemsForModules(
|
||||
modules: PlatformWebModule[]
|
||||
): PlatformNavItem[] {
|
||||
return modules
|
||||
.flatMap((module) =>
|
||||
(module.navItems ?? []).map((item) => ({
|
||||
...item,
|
||||
surfaceId: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to),
|
||||
navigationId: item.navigationId ?? item.surfaceId ?? navigationViewSurfaceId(module.id, item.to)
|
||||
}))
|
||||
)
|
||||
.map(resolveNavItemIcon)
|
||||
.sort((left, right) =>
|
||||
(left.navigationLayers?.module?.order ?? left.order ?? 100)
|
||||
- (right.navigationLayers?.module?.order ?? right.order ?? 100)
|
||||
|| left.label.localeCompare(right.label)
|
||||
);
|
||||
}
|
||||
|
||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): PlatformNavItem[] {
|
||||
return navItemsForModules(modules, projection).filter((item) => {
|
||||
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||
|
||||
@@ -87,5 +87,13 @@ export function groupNavigationItems(
|
||||
items: remaining
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
const overview = groups.filter((group) => group.id === "overview");
|
||||
const configurable = groups
|
||||
.filter((group) => group.id !== "overview")
|
||||
.sort((left, right) => minimumOrder(left.items) - minimumOrder(right.items));
|
||||
return [...overview, ...configurable];
|
||||
}
|
||||
|
||||
function minimumOrder(items: PlatformNavItem[]): number {
|
||||
return Math.min(...items.map((item) => item.order ?? 100), 10_000);
|
||||
}
|
||||
|
||||
@@ -1010,6 +1010,69 @@
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-preference-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.navigation-preference-toolbar p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navigation-preference-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.navigation-preference-list > li {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(12rem, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: var(--border-soft-line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.navigation-preference-order-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.navigation-preference-label {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.navigation-preference-label span {
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.navigation-preference-toolbar,
|
||||
.navigation-preference-list > li {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.navigation-preference-toolbar {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-section-page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
@@ -48,6 +48,7 @@ export type UserUiPreferences = {
|
||||
reduce_motion: boolean;
|
||||
sticky_section_sidebars: boolean;
|
||||
theme: UserUiTheme;
|
||||
navigation?: NavigationPreferences | null;
|
||||
};
|
||||
|
||||
export type AuthTenant = {
|
||||
@@ -271,6 +272,27 @@ export type PlatformNavItem = {
|
||||
allOf?: string[];
|
||||
order?: number;
|
||||
surfaceId?: string;
|
||||
navigationId?: string;
|
||||
navigationVisible?: boolean;
|
||||
navigationLocked?: boolean;
|
||||
navigationOrderSource?: string;
|
||||
navigationVisibilitySource?: string;
|
||||
navigationLockSource?: string | null;
|
||||
navigationLayers?: Partial<Record<"module" | "system" | "tenant", NavigationLayerState>>;
|
||||
};
|
||||
|
||||
export type NavigationLayerState = {
|
||||
order: number;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
lock_source?: string | null;
|
||||
};
|
||||
|
||||
export type NavigationPreferences = {
|
||||
contract_version: "1";
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
locked?: string[];
|
||||
};
|
||||
|
||||
export type ProductAreaContribution = {
|
||||
@@ -1167,6 +1189,13 @@ export type PlatformFrontendModuleInfo = {
|
||||
required_any: string[];
|
||||
order: number;
|
||||
surface_id?: string | null;
|
||||
navigation_id?: string | null;
|
||||
navigation_visible?: boolean;
|
||||
navigation_locked?: boolean;
|
||||
navigation_order_source?: string;
|
||||
navigation_visibility_source?: string;
|
||||
navigation_lock_source?: string | null;
|
||||
navigation_layers?: Partial<Record<"module" | "system" | "tenant", NavigationLayerState>>;
|
||||
}>;
|
||||
settings_routes: PlatformFrontendRouteInfo[];
|
||||
view_surface_contract_version?: string | null;
|
||||
@@ -1318,6 +1347,13 @@ export type PlatformModuleInfo = {
|
||||
required_any: string[];
|
||||
order: number;
|
||||
surface_id?: string | null;
|
||||
navigation_id?: string | null;
|
||||
navigation_visible?: boolean;
|
||||
navigation_locked?: boolean;
|
||||
navigation_order_source?: string;
|
||||
navigation_visibility_source?: string;
|
||||
navigation_lock_source?: string | null;
|
||||
navigation_layers?: Partial<Record<"module" | "system" | "tenant", NavigationLayerState>>;
|
||||
}>;
|
||||
frontend?: PlatformFrontendModuleInfo | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user