mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
web/router: path-segment tabs via a routed base context
Move tabs from ?page= search params to real path segments:
/settings/sessions, /identity/users/6/credentials/tokens/all-roles. The
page stays mounted across tab changes, and nesting composes with no
per-page wiring.
How the base is delivered:
- The outlet publishes the current page's mount path (the matched path
minus any tab tail) on a routedTabBaseContext.
- Each <ak-tabs routed> consumes that context as its base, and in turn
provides its active panel's path to its own subtree. So a nested tab
group derives its base automatically, and a dispatcher page
(provider/source/connector view) is transparent to it -- the value
flows through to the type-specific view's tabs.
Router core:
- sameRouteMatch ignores the wildcard tail (unnamed URLPattern groups),
so a subtree route (/settings{/*}?) stays mounted while its tabs move
through the tail; a named-param change (:id) still remounts.
- strip/join are segment-aware, so a base without a trailing slash never
captures a sibling that shares its text (.../users/22 vs .../users/220).
ak-tabs:
- With "routed" (or an explicit "base"), the active tab is the path
segment past the base; tabs render as real in-interface links and
switching navigates the router. Without it, the legacy ?page= behavior
is retained so not-yet-converted pages keep working during the rollout.
Slotted panels, lazy-tabs, the command palette, and focus delegation
are unchanged.
- Pure slot<->segment/href/active-slot logic lives in tabs/tab-path.ts,
unit-tested independently of the DOM.
Converted so far: user settings (flat); admin user view and its nested
credentials and roles tab groups, plus the RBAC object-permission tabs.
Remaining tabbed pages still use ?page= until converted.
This commit is contained in:
committed by
Teffen Ellis
parent
9d8db91e05
commit
2de635f839
@@ -248,7 +248,9 @@ export const ROUTES: RouteLike[] = [
|
||||
"users",
|
||||
),
|
||||
new Route<{ id: string }>(
|
||||
"/identity/users/:id",
|
||||
// The `{/*}?` tail carries the tab path (`/identity/users/22/credentials`)
|
||||
// to this route while `ak-user-view` stays mounted across tab changes.
|
||||
"/identity/users/:id{/*}?",
|
||||
async (args) => {
|
||||
await import("#admin/users/UserViewPage");
|
||||
return html`<ak-user-view .userId=${parseInt(args.id, 10)}></ak-user-view>`;
|
||||
|
||||
@@ -42,7 +42,7 @@ export class ObjectPermissionPage extends AKElement {
|
||||
|
||||
render() {
|
||||
return this.model === ModelEnum.AuthentikRbacRole
|
||||
? html`<ak-tabs pageIdentifier="permissionPage" ?vertical=${!this.embedded}>
|
||||
? html`<ak-tabs routed ?vertical=${!this.embedded}>
|
||||
${this.renderPermissionsAssignedToRole()}
|
||||
</ak-tabs>`
|
||||
: html`<div class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||
|
||||
@@ -38,7 +38,7 @@ export class UserCredentialsTab extends WithLazyTabs(WithLicenseSummary(AKElemen
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<ak-tabs pageIdentifier="userCredentialsTokens" vertical>
|
||||
return html`<ak-tabs routed vertical>
|
||||
<div
|
||||
role="tabpanel"
|
||||
tabindex="0"
|
||||
|
||||
@@ -27,7 +27,7 @@ export class UserRolesTab extends WithLazyTabs(AKElement) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<ak-tabs pageIdentifier="userRoles" vertical>
|
||||
return html`<ak-tabs routed vertical>
|
||||
<div
|
||||
role="tabpanel"
|
||||
tabindex="0"
|
||||
|
||||
@@ -95,7 +95,7 @@ export class UserViewPage extends WithLazyTabs(
|
||||
}
|
||||
|
||||
return html`<main>
|
||||
<ak-tabs>
|
||||
<ak-tabs routed>
|
||||
<div
|
||||
role="tabpanel"
|
||||
tabindex="0"
|
||||
|
||||
@@ -7,13 +7,17 @@ import {
|
||||
PaletteCommandDefinitionInit,
|
||||
} from "#elements/commands/shared";
|
||||
import { intersectionObserver } from "#elements/decorators/intersection-observer";
|
||||
import { navigate, RouterNavigateEvent } from "#elements/router/core/navigation";
|
||||
import { getSearchParams, updateSearchParams } from "#elements/router/core/search-params";
|
||||
import Styles from "#elements/Tabs.css" with { type: "bundled-text" };
|
||||
import { routedTabBaseContext } from "#elements/tabs/tab-context";
|
||||
import { activeSlotForPath, tabHref } from "#elements/tabs/tab-path";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
import { isFocusable } from "#elements/utils/focus";
|
||||
|
||||
import { capitalCase } from "change-case";
|
||||
|
||||
import { ContextConsumer, ContextProvider } from "@lit/context";
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { CSSResult, html, LitElement, PropertyValues, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
@@ -27,6 +31,29 @@ export class Tabs extends AKElement {
|
||||
};
|
||||
static styles: CSSResult[] = [Styles];
|
||||
|
||||
/**
|
||||
* Opt into path routing: the active tab becomes a path segment
|
||||
* (`/if/user/settings/sessions`) and switching tabs navigates the router.
|
||||
* The base comes from {@linkcode routedTabBaseContext} — provided by the
|
||||
* router outlet for the page, then refined by each nested group — so no
|
||||
* page wiring is needed. Without this (and without {@linkcode base}), the
|
||||
* group falls back to the legacy `?page=` search parameter.
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
public routed = false;
|
||||
|
||||
/**
|
||||
* An explicit mount path override, e.g. `/if/user/settings`. Rarely needed:
|
||||
* prefer {@linkcode routed} and let the context supply the base. Setting it
|
||||
* implies {@linkcode routed}.
|
||||
*/
|
||||
@property({ type: String })
|
||||
public base = "";
|
||||
|
||||
/**
|
||||
* The search parameter used to persist the active tab in the legacy
|
||||
* (non-{@linkcode base}) mode.
|
||||
*/
|
||||
@property({ type: String })
|
||||
public pageIdentifier = "page";
|
||||
|
||||
@@ -49,6 +76,56 @@ export class Tabs extends AKElement {
|
||||
|
||||
#commands = new CommandPaletteState<string>();
|
||||
|
||||
//#region Routed base
|
||||
|
||||
/**
|
||||
* The base supplied by the nearest routed ancestor (the outlet, or a parent
|
||||
* tab group), consumed reactively.
|
||||
*/
|
||||
#baseConsumer = new ContextConsumer(this, {
|
||||
context: routedTabBaseContext,
|
||||
subscribe: true,
|
||||
callback: () => {
|
||||
if (!this.#pathMode) return;
|
||||
|
||||
this.activeTabName = this.#slotFromLocation();
|
||||
this.#publishChildBase();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides this group's active-panel path to its subtree, so a nested
|
||||
* `<ak-tabs routed>` derives its base with no wiring.
|
||||
*/
|
||||
#childBaseProvider = new ContextProvider(this, {
|
||||
context: routedTabBaseContext,
|
||||
initialValue: "",
|
||||
});
|
||||
|
||||
#publishChildBase(): void {
|
||||
this.#childBaseProvider.setValue(
|
||||
this.activeTabName ? this.#hrefForSlot(this.activeTabName) : this.#effectiveBase,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mount path this group tracks against: an explicit {@linkcode base}
|
||||
* wins, else the context value from the nearest routed ancestor.
|
||||
*/
|
||||
get #effectiveBase(): string {
|
||||
return this.base || this.#baseConsumer.value || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the active tab is tracked as a path segment rather than a search
|
||||
* parameter.
|
||||
*/
|
||||
get #pathMode(): boolean {
|
||||
return this.routed || Boolean(this.base);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
#updateTabs = (): void => {
|
||||
this.tabs = new Map(
|
||||
Array.from(this.querySelectorAll(":scope > [slot^='page-']"), (element) => {
|
||||
@@ -93,6 +170,36 @@ export class Tabs extends AKElement {
|
||||
this.#commands.set(commands);
|
||||
};
|
||||
|
||||
//#region Navigation
|
||||
|
||||
/**
|
||||
* The active slot for the current location, or `null` when the tabs are not
|
||||
* yet known. Falls back to the first tab when the path names no known tab.
|
||||
*/
|
||||
#slotFromLocation(): string | null {
|
||||
return activeSlotForPath(this.#effectiveBase, window.location.pathname, [
|
||||
...this.tabs.keys(),
|
||||
]);
|
||||
}
|
||||
|
||||
#hrefForSlot(slotName: string): string {
|
||||
return tabHref(this.#effectiveBase, slotName, this.tabs.keys().next().value ?? null);
|
||||
}
|
||||
|
||||
#onNavigate = (): void => {
|
||||
if (!this.#pathMode) return;
|
||||
|
||||
const nextSlot = this.#slotFromLocation();
|
||||
|
||||
if (!nextSlot || nextSlot === this.activeTabName) return;
|
||||
|
||||
this.activeTabName = nextSlot;
|
||||
this.#publishChildBase();
|
||||
this.dispatchActivateEvent();
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
public override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
@@ -100,20 +207,30 @@ export class Tabs extends AKElement {
|
||||
|
||||
this.addEventListener("focus", this.#delegateFocusListener);
|
||||
|
||||
if (!this.activeTabName) {
|
||||
const params = getSearchParams();
|
||||
const tabParam = params[this.pageIdentifier];
|
||||
window.addEventListener("popstate", this.#onNavigate);
|
||||
window.addEventListener(RouterNavigateEvent.eventName, this.#onNavigate);
|
||||
|
||||
if (
|
||||
tabParam &&
|
||||
typeof tabParam === "string" &&
|
||||
this.querySelector(`[slot='${tabParam}']`)
|
||||
) {
|
||||
this.activeTabName = tabParam;
|
||||
} else {
|
||||
this.#updateTabs();
|
||||
this.activeTabName = this.tabs.keys().next().value || null;
|
||||
}
|
||||
if (this.activeTabName) return;
|
||||
|
||||
this.#updateTabs();
|
||||
|
||||
if (this.#pathMode) {
|
||||
this.activeTabName = this.#slotFromLocation();
|
||||
this.#publishChildBase();
|
||||
return;
|
||||
}
|
||||
|
||||
const params = getSearchParams();
|
||||
const tabParam = params[this.pageIdentifier];
|
||||
|
||||
if (
|
||||
tabParam &&
|
||||
typeof tabParam === "string" &&
|
||||
this.querySelector(`[slot='${tabParam}']`)
|
||||
) {
|
||||
this.activeTabName = tabParam;
|
||||
} else {
|
||||
this.activeTabName = this.tabs.keys().next().value || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +247,10 @@ export class Tabs extends AKElement {
|
||||
public override disconnectedCallback(): void {
|
||||
this.#observer?.disconnect();
|
||||
this.#commands.clear();
|
||||
|
||||
window.removeEventListener("popstate", this.#onNavigate);
|
||||
window.removeEventListener(RouterNavigateEvent.eventName, this.#onNavigate);
|
||||
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -156,18 +277,26 @@ export class Tabs extends AKElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTab = this.tabs.keys().next().value || null;
|
||||
if (this.#pathMode) {
|
||||
navigate(this.#hrefForSlot(nextTabName));
|
||||
} else {
|
||||
const firstTab = this.tabs.keys().next().value || null;
|
||||
|
||||
// We avoid adding the tab parameter to the URL if it's the first tab
|
||||
// to both reduce URL length and ensure that tests do not have to deal with
|
||||
// unnecessary URL parameters.
|
||||
// We avoid adding the tab parameter to the URL if it's the first tab
|
||||
// to both reduce URL length and ensure that tests do not have to deal with
|
||||
// unnecessary URL parameters.
|
||||
|
||||
updateSearchParams({
|
||||
[this.pageIdentifier]: nextTabName === firstTab ? null : nextTabName,
|
||||
});
|
||||
updateSearchParams({
|
||||
[this.pageIdentifier]: nextTabName === firstTab ? null : nextTabName,
|
||||
});
|
||||
}
|
||||
|
||||
this.activeTabName = nextTabName;
|
||||
|
||||
if (this.#pathMode) {
|
||||
this.#publishChildBase();
|
||||
}
|
||||
|
||||
this.dispatchActivateEvent();
|
||||
}
|
||||
|
||||
@@ -198,24 +327,52 @@ export class Tabs extends AKElement {
|
||||
}
|
||||
};
|
||||
|
||||
#onTabClick(event: MouseEvent, slotName: string): void {
|
||||
// A modified click (new tab, download) or a non-primary button is left
|
||||
// to the browser so real links keep working; the top outlet's anchor
|
||||
// interceptor claims the rest, but activate directly as a fallback.
|
||||
if (event.button !== 0) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
|
||||
event.preventDefault();
|
||||
this.activateTab(slotName);
|
||||
}
|
||||
|
||||
renderTab(slotName: string, tabPanel: Element): TemplateResult {
|
||||
return html` <li
|
||||
part="tab-item"
|
||||
class="pf-c-tabs__item ${slotName === this.activeTabName ? CURRENT_CLASS : ""}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
part="tab-button"
|
||||
id=${`${slotName}-tab`}
|
||||
name=${slotName}
|
||||
aria-selected=${slotName === this.activeTabName ? "true" : "false"}
|
||||
aria-controls=${ifPresent(slotName)}
|
||||
class="pf-c-tabs__link"
|
||||
@click=${() => this.activateTab(slotName)}
|
||||
>
|
||||
<span class="pf-c-tabs__item-text">${tabPanel.getAttribute("aria-label")}</span>
|
||||
</button>
|
||||
const current = slotName === this.activeTabName;
|
||||
const label = tabPanel.getAttribute("aria-label");
|
||||
|
||||
// Path mode renders a real in-interface link (accessible, middle-click
|
||||
// opens the tab's URL); legacy mode keeps the button-driven behavior.
|
||||
const control = this.#pathMode
|
||||
? html`<a
|
||||
href=${this.#hrefForSlot(slotName)}
|
||||
role="tab"
|
||||
part="tab-button"
|
||||
id=${`${slotName}-tab`}
|
||||
aria-selected=${current ? "true" : "false"}
|
||||
aria-controls=${ifPresent(slotName)}
|
||||
class="pf-c-tabs__link"
|
||||
@click=${(event: MouseEvent) => this.#onTabClick(event, slotName)}
|
||||
>
|
||||
<span class="pf-c-tabs__item-text">${label}</span>
|
||||
</a>`
|
||||
: html`<button
|
||||
type="button"
|
||||
role="tab"
|
||||
part="tab-button"
|
||||
id=${`${slotName}-tab`}
|
||||
name=${slotName}
|
||||
aria-selected=${current ? "true" : "false"}
|
||||
aria-controls=${ifPresent(slotName)}
|
||||
class="pf-c-tabs__link"
|
||||
@click=${() => this.activateTab(slotName)}
|
||||
>
|
||||
<span class="pf-c-tabs__item-text">${label}</span>
|
||||
</button>`;
|
||||
|
||||
return html` <li part="tab-item" class="pf-c-tabs__item ${current ? CURRENT_CLASS : ""}">
|
||||
${control}
|
||||
</li>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
RouterNavigateEvent,
|
||||
} from "#elements/router/core/navigation";
|
||||
import { type RouteLike } from "#elements/router/core/Route";
|
||||
import { routedTabBaseContext } from "#elements/tabs/tab-context";
|
||||
import { type SlottedTemplateResult } from "#elements/types";
|
||||
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
startBrowserTracingPageLoadSpan,
|
||||
} from "@sentry/browser";
|
||||
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
@@ -87,6 +89,14 @@ export class RouterView extends AKElement {
|
||||
#sentryClient = getClient();
|
||||
#pageLoadSpan: Span | null = null;
|
||||
|
||||
/**
|
||||
* Publishes the current page's mount path to routed `<ak-tabs>` descendants.
|
||||
*/
|
||||
#tabBaseProvider = new ContextProvider(this, {
|
||||
context: routedTabBaseContext,
|
||||
initialValue: "",
|
||||
});
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -147,22 +157,46 @@ export class RouterView extends AKElement {
|
||||
//#region Matching
|
||||
|
||||
/**
|
||||
* Strip the interface prefix, preserving the leading slash the matcher
|
||||
* requires: `/if/user/settings` → `/settings`, `/if/user/` → `/`. A
|
||||
* pathname outside the prefix is returned unchanged so it falls through to
|
||||
* the 404 branch.
|
||||
* Strip the prefix, preserving the leading slash the matcher requires:
|
||||
* `/if/user/settings` → `/settings`, `/if/user/` → `/`. Matching is
|
||||
* segment-aware so a prefix without a trailing slash (a nested outlet's
|
||||
* base, e.g. `…/users/22`) never captures a sibling that merely shares its
|
||||
* text (`…/users/220`). A pathname outside the prefix is returned unchanged
|
||||
* so it falls through to the 404 branch.
|
||||
*/
|
||||
#strip(pathname: string): string {
|
||||
if (!pathname.startsWith(this.prefix)) return pathname;
|
||||
const base = this.prefix.replace(/\/+$/, "");
|
||||
|
||||
return `/${pathname.slice(this.prefix.length).replace(/^\/+/, "")}`;
|
||||
if (pathname === base) return "/";
|
||||
if (pathname.startsWith(`${base}/`)) {
|
||||
return `/${pathname.slice(base.length + 1).replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a route-relative path onto the prefix for navigation.
|
||||
* Join a route-relative path onto the prefix for navigation, normalizing to
|
||||
* exactly one separator regardless of whether the prefix ends in a slash.
|
||||
*/
|
||||
#join(path: string): string {
|
||||
return `${this.prefix}${path.replace(/^\/+/, "")}`;
|
||||
return `${this.prefix.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The absolute path consumed to reach a match, excluding its wildcard tail —
|
||||
* the mount path a tabbed page hands to its nested outlet. `/settings` and
|
||||
* `/settings/sessions` (a subtree route) both resolve to base
|
||||
* `/if/user/settings`; the tail (`sessions`) belongs to the nested outlet.
|
||||
*/
|
||||
#basePath(match: RouteMatch<RouteLike>): string {
|
||||
const tail = match.parameters["0"];
|
||||
|
||||
if (tail == null) return this.#join(match.pathname);
|
||||
|
||||
const consumed = match.pathname.slice(0, match.pathname.length - tail.length);
|
||||
|
||||
return this.#join(consumed.replace(/\/+$/, "") || "/");
|
||||
}
|
||||
|
||||
#syncRoute = (): void => {
|
||||
@@ -177,6 +211,11 @@ export class RouterView extends AKElement {
|
||||
|
||||
const next = matchRoute(stripped, this.routes);
|
||||
|
||||
// Publish the mount path (minus any tab tail) so routed `<ak-tabs>` under
|
||||
// this outlet can consume it as their base. Refreshed even when the route
|
||||
// is unchanged, so re-entering the same page restores a correct base.
|
||||
this.#tabBaseProvider.setValue(next ? this.#basePath(next) : "");
|
||||
|
||||
// Skip re-resolving when the route and its path parameters are unchanged
|
||||
// (a search-param-only change — a tab, a table filter). Reassigning would
|
||||
// hand `until()` a new promise and flash the loading state over a view
|
||||
|
||||
@@ -51,10 +51,28 @@ export function matchRoute<R extends RoutePatternLike>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two matches resolve to the same rendered view: the same route with the
|
||||
* same path parameters. {@linkcode matchRoute} returns a fresh object every call,
|
||||
* so a search-param-only navigation (a tab, a table filter) yields an equal-but-
|
||||
* new match — comparing structurally lets a consumer skip re-rendering it.
|
||||
* `URLPattern` names unnamed groups — `*` and `(.*)` — with sequential integer
|
||||
* keys. A route that matches a subtree (`/users/:id{/*}?`) captures the tail
|
||||
* this way, and that tail is sub-navigation the *mounted* view owns (its tabs),
|
||||
* not the identity of the mount. Named groups (`:id`) identify the mount.
|
||||
*/
|
||||
const WILDCARD_GROUP_KEY = /^\d+$/;
|
||||
|
||||
function identifyingKeys(parameters: Record<string, string | undefined>): string[] {
|
||||
return Object.keys(parameters).filter((key) => !WILDCARD_GROUP_KEY.test(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two matches resolve to the same mounted view: the same route with the
|
||||
* same *identifying* (named) path parameters. {@linkcode matchRoute} returns a
|
||||
* fresh object every call, so a search-only navigation (a table filter) or a
|
||||
* wildcard-tail change (a tab, in a subtree route) yields an equal-but-new match
|
||||
* — comparing structurally lets the outlet skip re-resolving it, which would
|
||||
* otherwise tear down and reload an already-mounted view.
|
||||
*
|
||||
* The wildcard tail is deliberately excluded: a subtree route stays mounted
|
||||
* while its tabs move through the tail, and the nested outlet inside it handles
|
||||
* the tail. A change to a named parameter (a different `:id`) still remounts.
|
||||
*/
|
||||
export function sameRouteMatch<R extends RoutePatternLike>(
|
||||
a: RouteMatch<R> | null,
|
||||
@@ -64,8 +82,8 @@ export function sameRouteMatch<R extends RoutePatternLike>(
|
||||
if (a === null || b === null) return false;
|
||||
if (a.route !== b.route) return false;
|
||||
|
||||
const aKeys = Object.keys(a.parameters);
|
||||
const bKeys = Object.keys(b.parameters);
|
||||
const aKeys = identifyingKeys(a.parameters);
|
||||
const bKeys = identifyingKeys(b.parameters);
|
||||
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
|
||||
|
||||
@@ -68,4 +68,33 @@ describe("sameRouteMatch", () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores the wildcard tail so a subtree route stays mounted across its tabs", () => {
|
||||
const subtree = route("/users/:id{/*}?");
|
||||
|
||||
// Same `:id`, different tail (a tab change) — same mount.
|
||||
expect(
|
||||
sameRouteMatch(
|
||||
matchRoute("/users/22", [subtree]),
|
||||
matchRoute("/users/22/credentials", [subtree]),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
sameRouteMatch(
|
||||
matchRoute("/users/22/credentials", [subtree]),
|
||||
matchRoute("/users/22/credentials/tokens", [subtree]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still remounts a subtree route when its named parameter changes", () => {
|
||||
const subtree = route("/users/:id{/*}?");
|
||||
|
||||
expect(
|
||||
sameRouteMatch(
|
||||
matchRoute("/users/22/credentials", [subtree]),
|
||||
matchRoute("/users/23/credentials", [subtree]),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
13
web/src/elements/tabs/tab-context.ts
Normal file
13
web/src/elements/tabs/tab-context.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createContext } from "@lit/context";
|
||||
|
||||
/**
|
||||
* The absolute mount path a routed tab group lives under, e.g.
|
||||
* `/if/user/settings`.
|
||||
*
|
||||
* The router's outlet provides it for the current page; each `<ak-tabs routed>`
|
||||
* consumes it as its base and, in turn, provides its active panel's path to its
|
||||
* own subtree — so a nested tab group derives its base with no page wiring. A
|
||||
* dispatcher page (provider/source/connector view) is transparent to this: the
|
||||
* value flows through it to the type-specific view's tabs.
|
||||
*/
|
||||
export const routedTabBaseContext = createContext<string>(Symbol("authentik-routed-tab-base"));
|
||||
69
web/src/elements/tabs/tab-path.ts
Normal file
69
web/src/elements/tabs/tab-path.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file Pure path helpers for path-routed tabs.
|
||||
*
|
||||
* A tab panel is a slotted child named `page-<segment>`; its URL is the tab
|
||||
* group's mount path plus that segment (`/if/user/settings` + `sessions` →
|
||||
* `/if/user/settings/sessions`). These functions map between the two and pick
|
||||
* the active tab from a location. No DOM, no globals — unit-testable in Node.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The `slot` prefix every tab panel carries. The URL segment is the slot name
|
||||
* with this removed.
|
||||
*/
|
||||
export const SLOT_PREFIX = "page-";
|
||||
|
||||
export const slotToSegment = (slot: string): string => slot.slice(SLOT_PREFIX.length);
|
||||
export const segmentToSlot = (segment: string): string => `${SLOT_PREFIX}${segment}`;
|
||||
|
||||
/**
|
||||
* The active tab slot for a location, or `null` when the group has no tabs.
|
||||
*
|
||||
* The first tab is the default: the bare base (and any path outside the group's
|
||||
* subtree) resolves to it. A path whose first segment past the base names a
|
||||
* known tab selects that tab; an unknown segment falls back to the first.
|
||||
*
|
||||
* @param base The group's mount path, e.g. `/if/user/settings`.
|
||||
* @param pathname The current `location.pathname`.
|
||||
* @param slots The group's slot names, in tab order.
|
||||
*/
|
||||
export function activeSlotForPath(
|
||||
base: string,
|
||||
pathname: string,
|
||||
slots: readonly string[],
|
||||
): string | null {
|
||||
const first = slots[0] ?? null;
|
||||
const normalizedBase = base.replace(/\/+$/, "");
|
||||
|
||||
if (pathname.startsWith(`${normalizedBase}/`)) {
|
||||
const [segment] = pathname
|
||||
.slice(normalizedBase.length + 1)
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
|
||||
if (segment) {
|
||||
const slot = segmentToSlot(segment);
|
||||
|
||||
if (slots.includes(slot)) return slot;
|
||||
}
|
||||
}
|
||||
|
||||
return first;
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL a tab links to: the bare `base` for the first (default) tab,
|
||||
* `base/segment` otherwise. Keeping the default at the bare base mirrors the
|
||||
* legacy behavior of omitting the first tab's parameter.
|
||||
*
|
||||
* @param base The group's mount path.
|
||||
* @param slotName The tab's slot name.
|
||||
* @param firstSlot The group's first slot, the default tab.
|
||||
*/
|
||||
export function tabHref(base: string, slotName: string, firstSlot: string | null): string {
|
||||
const normalizedBase = base.replace(/\/+$/, "");
|
||||
|
||||
if (slotName === firstSlot) return normalizedBase;
|
||||
|
||||
return `${normalizedBase}/${slotToSegment(slotName)}`;
|
||||
}
|
||||
102
web/src/elements/tabs/tab-path.unit.test.ts
Normal file
102
web/src/elements/tabs/tab-path.unit.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { activeSlotForPath, segmentToSlot, slotToSegment, tabHref } from "./tab-path.js";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("slot ↔ segment", () => {
|
||||
it("round-trips a slot through its segment", () => {
|
||||
expect(slotToSegment("page-sessions")).toBe("sessions");
|
||||
expect(segmentToSlot("sessions")).toBe("page-sessions");
|
||||
expect(slotToSegment(segmentToSlot("oauth-access"))).toBe("oauth-access");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeSlotForPath", () => {
|
||||
const settings = ["page-details", "page-sessions", "page-consents", "page-sources"];
|
||||
|
||||
it("returns null when there are no tabs", () => {
|
||||
expect(activeSlotForPath("/if/user/settings", "/if/user/settings/sessions", [])).toBeNull();
|
||||
});
|
||||
|
||||
it("selects the first tab at the bare base", () => {
|
||||
expect(activeSlotForPath("/if/user/settings", "/if/user/settings", settings)).toBe(
|
||||
"page-details",
|
||||
);
|
||||
});
|
||||
|
||||
it("tolerates a trailing slash on the base", () => {
|
||||
expect(activeSlotForPath("/if/user/settings/", "/if/user/settings", settings)).toBe(
|
||||
"page-details",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects the tab named by the first segment past the base", () => {
|
||||
expect(activeSlotForPath("/if/user/settings", "/if/user/settings/sessions", settings)).toBe(
|
||||
"page-sessions",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the first tab for an unknown segment", () => {
|
||||
expect(activeSlotForPath("/if/user/settings", "/if/user/settings/nope", settings)).toBe(
|
||||
"page-details",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the first tab for a path outside the group's subtree", () => {
|
||||
expect(activeSlotForPath("/if/user/settings", "/if/user/library", settings)).toBe(
|
||||
"page-details",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses only the first segment, so a nested group owns the rest", () => {
|
||||
// The outer group at `/…/users/22` sees `credentials/tokens` and selects
|
||||
// credentials; the `tokens` segment belongs to the nested group.
|
||||
const outer = ["page-overview", "page-credentials", "page-roles"];
|
||||
|
||||
expect(
|
||||
activeSlotForPath(
|
||||
"/if/admin/identity/users/22",
|
||||
"/if/admin/identity/users/22/credentials/tokens",
|
||||
outer,
|
||||
),
|
||||
).toBe("page-credentials");
|
||||
});
|
||||
|
||||
it("resolves the nested group from its own deeper base", () => {
|
||||
const inner = ["page-sessions", "page-tokens", "page-consent"];
|
||||
|
||||
expect(
|
||||
activeSlotForPath(
|
||||
"/if/admin/identity/users/22/credentials",
|
||||
"/if/admin/identity/users/22/credentials/tokens",
|
||||
inner,
|
||||
),
|
||||
).toBe("page-tokens");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tabHref", () => {
|
||||
const settings = ["page-details", "page-sessions"];
|
||||
const [first] = settings;
|
||||
|
||||
it("links the first (default) tab to the bare base", () => {
|
||||
expect(tabHref("/if/user/settings", "page-details", first)).toBe("/if/user/settings");
|
||||
});
|
||||
|
||||
it("links a non-default tab to base/segment", () => {
|
||||
expect(tabHref("/if/user/settings", "page-sessions", first)).toBe(
|
||||
"/if/user/settings/sessions",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes a trailing slash on the base", () => {
|
||||
expect(tabHref("/if/user/settings/", "page-sessions", first)).toBe(
|
||||
"/if/user/settings/sessions",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a nested tab href from the deeper base", () => {
|
||||
expect(
|
||||
tabHref("/if/admin/identity/users/22/credentials", "page-tokens", "page-sessions"),
|
||||
).toBe("/if/admin/identity/users/22/credentials/tokens");
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,9 @@ export const ROUTES: RouteLike[] = [
|
||||
"requests.fulfill",
|
||||
),
|
||||
new Route(
|
||||
"/settings",
|
||||
// The `{/*}?` tail lets the tab segment (`/settings/sessions`) resolve to
|
||||
// this route while the page stays mounted across tab changes.
|
||||
"/settings{/*}?",
|
||||
async () => {
|
||||
await import("#user/user-settings/UserSettingsPage");
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ export class UserSettingsPage extends WithLicenseSummary(WithSession(AKElement))
|
||||
return html`<div class="pf-c-page">
|
||||
<div class="pf-c-page__main">
|
||||
<ak-tabs
|
||||
routed
|
||||
vertical
|
||||
role="main"
|
||||
aria-label=${msg("User settings")}
|
||||
|
||||
Reference in New Issue
Block a user