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;
|
||||
}
|
||||
Reference in New Issue
Block a user