web: continuing with the Sidebar
I've finally reached a stage where I have a framework I can build upon, but what a pain in the posterior it was to get here. Keeping the entire navigation list within a single DOM is a solid idea, but porting from the original code to this proved to be unreasonably kludegy. Instead, I started from scratch, adding each step along the way, a sort of Transformation Priority Premise, and testing each step to make sure it was all behaving as expected. So far, so good. The remaining details are not trivial: I have to figure out how to express the different classes of actions, and get the third tier working, but at least the React version gives us hints.
This commit is contained in:
parent
476adef4ea
commit
d539884204
|
@ -17,7 +17,6 @@ import "@goauthentik/elements/notifications/NotificationDrawer";
|
||||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||||
import "@goauthentik/elements/router/RouterOutlet";
|
import "@goauthentik/elements/router/RouterOutlet";
|
||||||
import "@goauthentik/elements/sidebar/Sidebar";
|
import "@goauthentik/elements/sidebar/Sidebar";
|
||||||
import "@goauthentik/elements/sidebar/SidebarItem";
|
|
||||||
|
|
||||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
|
|
@ -4,18 +4,40 @@ import { me } from "@goauthentik/common/users";
|
||||||
import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts";
|
import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts";
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
|
import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
|
||||||
|
import "@goauthentik/elements/sidebar/Sidebar";
|
||||||
|
import { SidebarAttributes, SidebarEntry, SidebarEventHandler } from "@goauthentik/elements/sidebar/SidebarItems";
|
||||||
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
|
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
|
||||||
import { spread } from "@open-wc/lit-helpers";
|
|
||||||
|
|
||||||
import { consume } from "@lit-labs/context";
|
import { consume } from "@lit-labs/context";
|
||||||
import { msg, str } from "@lit/localize";
|
import { msg, str } from "@lit/localize";
|
||||||
import { TemplateResult, html, nothing } from "lit";
|
import { html } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import { map } from "lit/directives/map.js";
|
|
||||||
|
|
||||||
import { AdminApi, CapabilitiesEnum, CoreApi, UiThemeEnum, Version } from "@goauthentik/api";
|
import { ProvidersApi, TypeCreate } from "@goauthentik/api";
|
||||||
|
import { AdminApi, CapabilitiesEnum, CoreApi, Version } from "@goauthentik/api";
|
||||||
import type { Config, SessionUser, UserSelf } from "@goauthentik/api";
|
import type { Config, SessionUser, UserSelf } from "@goauthentik/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdminSidebar
|
||||||
|
*
|
||||||
|
* Encapsulates the logic for the administration sidebar: what to show and, initially, when to show
|
||||||
|
* it. Rendering decisions are left to the sidebar itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type LocalSidebarEntry = [
|
||||||
|
string | SidebarEventHandler | null,
|
||||||
|
string,
|
||||||
|
(SidebarAttributes | string[] | null)?, // eslint-disable-line
|
||||||
|
LocalSidebarEntry[]?,
|
||||||
|
];
|
||||||
|
|
||||||
|
const localToSidebarEntry = (l: LocalSidebarEntry): SidebarEntry => ({
|
||||||
|
path: l[0],
|
||||||
|
label: l[1],
|
||||||
|
...(l[2]? { attributes: Array.isArray(l[2]) ? { activeWhen: l[2] } : l[2] } : {}),
|
||||||
|
...(l[3] ? { children: l[3].map(localToSidebarEntry) } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
@customElement("ak-admin-sidebar")
|
@customElement("ak-admin-sidebar")
|
||||||
export class AkAdminSidebar extends AKElement {
|
export class AkAdminSidebar extends AKElement {
|
||||||
@property({ type: Boolean, reflect: true })
|
@property({ type: Boolean, reflect: true })
|
||||||
|
@ -27,6 +49,9 @@ export class AkAdminSidebar extends AKElement {
|
||||||
@state()
|
@state()
|
||||||
impersonation: UserSelf["username"] | null = null;
|
impersonation: UserSelf["username"] | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
providerTypes: TypeCreate[] = [];
|
||||||
|
|
||||||
@consume({ context: authentikConfigContext })
|
@consume({ context: authentikConfigContext })
|
||||||
public config!: Config;
|
public config!: Config;
|
||||||
|
|
||||||
|
@ -38,6 +63,9 @@ export class AkAdminSidebar extends AKElement {
|
||||||
me().then((user: SessionUser) => {
|
me().then((user: SessionUser) => {
|
||||||
this.impersonation = user.original ? user.user.username : null;
|
this.impersonation = user.original ? user.user.username : null;
|
||||||
});
|
});
|
||||||
|
new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList().then((types) => {
|
||||||
|
this.providerTypes = types;
|
||||||
|
});
|
||||||
this.toggleOpen = this.toggleOpen.bind(this);
|
this.toggleOpen = this.toggleOpen.bind(this);
|
||||||
this.checkWidth = this.checkWidth.bind(this);
|
this.checkWidth = this.checkWidth.bind(this);
|
||||||
}
|
}
|
||||||
|
@ -51,9 +79,7 @@ export class AkAdminSidebar extends AKElement {
|
||||||
checkWidth() {
|
checkWidth() {
|
||||||
// This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in
|
// This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in
|
||||||
// REMs. If that changes, this code will have to be adjusted as well.
|
// REMs. If that changes, this code will have to be adjusted as well.
|
||||||
const minWidth =
|
const minWidth = parseFloat(getRootStyle("--ak-sidebar--minimum-auto-width")) * parseFloat(getRootStyle("font-size"));
|
||||||
parseFloat(getRootStyle("--ak-sidebar--minimum-auto-width")) *
|
|
||||||
parseFloat(getRootStyle("font-size"));
|
|
||||||
this.open = window.innerWidth >= minWidth;
|
this.open = window.innerWidth >= minWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,19 +101,6 @@ export class AkAdminSidebar extends AKElement {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
|
||||||
return html`
|
|
||||||
<ak-sidebar
|
|
||||||
class="pf-c-page__sidebar ${this.open ? "pf-m-expanded" : "pf-m-collapsed"} ${this
|
|
||||||
.activeTheme === UiThemeEnum.Light
|
|
||||||
? "pf-m-light"
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
${this.renderSidebarItems()}
|
|
||||||
</ak-sidebar>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updated() {
|
updated() {
|
||||||
// This is permissible as`:host.classList` is not one of the properties Lit uses as a
|
// This is permissible as`:host.classList` is not one of the properties Lit uses as a
|
||||||
// scheduling trigger. This sort of shenanigans can trigger an loop, in that it will trigger
|
// scheduling trigger. This sort of shenanigans can trigger an loop, in that it will trigger
|
||||||
|
@ -98,26 +111,43 @@ export class AkAdminSidebar extends AKElement {
|
||||||
this.classList.add(this.open ? "pf-m-expanded" : "pf-m-collapsed");
|
this.classList.add(this.open ? "pf-m-expanded" : "pf-m-collapsed");
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSidebarItems(): TemplateResult {
|
get sidebarItems(): SidebarEntry[] {
|
||||||
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
|
const reload = () =>
|
||||||
// commonplace and singular enough to merit its own handler.
|
new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => {
|
||||||
type SidebarEntry = [
|
window.location.reload();
|
||||||
path: string | null,
|
});
|
||||||
label: string,
|
|
||||||
attributes?: Record<string, any> | string[] | null, // eslint-disable-line
|
|
||||||
children?: SidebarEntry[],
|
|
||||||
];
|
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
const sidebarContent: SidebarEntry[] = [
|
const newVersionMessage: LocalSidebarEntry[] = this.version && this.version !== VERSION
|
||||||
["/if/user/", msg("User interface"), { "?isAbsoluteLink": true, "?highlight": true }],
|
? [["https://goauthentik.io", msg("A newer version of the frontend is available."), { "?highlight": true }]]
|
||||||
[null, msg("Dashboards"), { "?expanded": true }, [
|
: [];
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
const impersonationMessage: LocalSidebarEntry[] = this.impersonation
|
||||||
|
? [[reload, msg(str`You're currently impersonating ${this.impersonation}. Click to stop.`)]]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
const enterpriseMenu: LocalSidebarEntry[] = this.config?.capabilities.includes(CapabilitiesEnum.IsEnterprise)
|
||||||
|
? [[null, msg("Enterprise"), null, [["/enterprise/licenses", msg("Licenses")]]]]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
const providerTypes: LocalSidebarEntry[] = this.providerTypes.map((ptype) =>
|
||||||
|
([`/core/providers;${encodeURIComponent(JSON.stringify({ search: ptype.modelName.replace(/provider$/, "") }))}`, ptype.name]));
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
const localSidebar: LocalSidebarEntry[] = [
|
||||||
|
...(newVersionMessage),
|
||||||
|
...(impersonationMessage),
|
||||||
|
["/if/user/", msg("User interface"), { isAbsoluteLink: true, highlight: true }],
|
||||||
|
[null, msg("Dashboards"), { expanded: true }, [
|
||||||
["/administration/overview", msg("Overview")],
|
["/administration/overview", msg("Overview")],
|
||||||
["/administration/dashboard/users", msg("User Statistics")],
|
["/administration/dashboard/users", msg("User Statistics")],
|
||||||
["/administration/system-tasks", msg("System Tasks")]]],
|
["/administration/system-tasks", msg("System Tasks")]]],
|
||||||
[null, msg("Applications"), null, [
|
[null, msg("Applications"), null, [
|
||||||
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
|
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
|
||||||
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]],
|
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`], providerTypes],
|
||||||
["/outpost/outposts", msg("Outposts")]]],
|
["/outpost/outposts", msg("Outposts")]]],
|
||||||
[null, msg("Events"), null, [
|
[null, msg("Events"), null, [
|
||||||
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
|
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
|
||||||
|
@ -142,73 +172,14 @@ export class AkAdminSidebar extends AKElement {
|
||||||
[null, msg("System"), null, [
|
[null, msg("System"), null, [
|
||||||
["/core/tenants", msg("Tenants")],
|
["/core/tenants", msg("Tenants")],
|
||||||
["/crypto/certificates", msg("Certificates")],
|
["/crypto/certificates", msg("Certificates")],
|
||||||
["/outpost/integrations", msg("Outpost Integrations")]]]
|
["/outpost/integrations", msg("Outpost Integrations")]]],
|
||||||
|
...(enterpriseMenu)
|
||||||
];
|
];
|
||||||
|
|
||||||
// Typescript requires the type here to correctly type the recursive path
|
return localSidebar.map(localToSidebarEntry);
|
||||||
type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
|
|
||||||
|
|
||||||
const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => {
|
|
||||||
const properties = Array.isArray(attributes)
|
|
||||||
? { ".activeWhen": attributes }
|
|
||||||
: attributes ?? {};
|
|
||||||
if (path) {
|
|
||||||
properties["path"] = path;
|
|
||||||
}
|
|
||||||
return html`<ak-sidebar-item ${spread(properties)}>
|
|
||||||
${label ? html`<span slot="label">${label}</span>` : nothing}
|
|
||||||
${map(children, renderOneSidebarItem)}
|
|
||||||
</ak-sidebar-item>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// prettier-ignore
|
|
||||||
return html`
|
|
||||||
${this.renderNewVersionMessage()}
|
|
||||||
${this.renderImpersonationMessage()}
|
|
||||||
${map(sidebarContent, renderOneSidebarItem)}
|
|
||||||
${this.renderEnterpriseMessage()}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderNewVersionMessage() {
|
render() {
|
||||||
return this.version && this.version !== VERSION
|
return html` <ak-sidebar class="pf-c-page__sidebar" .entries=${this.sidebarItems}></ak-sidebar> `;
|
||||||
? html`
|
|
||||||
<ak-sidebar-item ?highlight=${true}>
|
|
||||||
<span slot="label"
|
|
||||||
>${msg("A newer version of the frontend is available.")}</span
|
|
||||||
>
|
|
||||||
</ak-sidebar-item>
|
|
||||||
`
|
|
||||||
: nothing;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderImpersonationMessage() {
|
|
||||||
const reload = () =>
|
|
||||||
new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => {
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.impersonation
|
|
||||||
? html`<ak-sidebar-item ?highlight=${true} @click=${reload}>
|
|
||||||
<span slot="label"
|
|
||||||
>${msg(
|
|
||||||
str`You're currently impersonating ${this.impersonation}. Click to stop.`,
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
</ak-sidebar-item>`
|
|
||||||
: nothing;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderEnterpriseMessage() {
|
|
||||||
return this.config?.capabilities.includes(CapabilitiesEnum.IsEnterprise)
|
|
||||||
? html`
|
|
||||||
<ak-sidebar-item>
|
|
||||||
<span slot="label">${msg("Enterprise")}</span>
|
|
||||||
<ak-sidebar-item path="/enterprise/licenses">
|
|
||||||
<span slot="label">${msg("Licenses")}</span>
|
|
||||||
</ak-sidebar-item>
|
|
||||||
</ak-sidebar-item>
|
|
||||||
`
|
|
||||||
: nothing;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import "@goauthentik/elements/sidebar/SidebarBrand";
|
import "@goauthentik/elements/sidebar/SidebarBrand";
|
||||||
import "@goauthentik/elements/sidebar/SidebarUser";
|
import "@goauthentik/elements/sidebar/SidebarUser";
|
||||||
|
import "@goauthentik/elements/sidebar/SidebarItems";
|
||||||
|
|
||||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||||
import { customElement } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
|
|
||||||
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
|
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
|
||||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||||
|
@ -11,8 +12,13 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
import { UiThemeEnum } from "@goauthentik/api";
|
import { UiThemeEnum } from "@goauthentik/api";
|
||||||
|
|
||||||
|
import type { SidebarEntry } from "./SidebarItems";
|
||||||
|
|
||||||
@customElement("ak-sidebar")
|
@customElement("ak-sidebar")
|
||||||
export class Sidebar extends AKElement {
|
export class Sidebar extends AKElement {
|
||||||
|
@property({ type: Array })
|
||||||
|
entries: SidebarEntry[] = [];
|
||||||
|
|
||||||
static get styles(): CSSResult[] {
|
static get styles(): CSSResult[] {
|
||||||
return [
|
return [
|
||||||
PFBase,
|
PFBase,
|
||||||
|
@ -45,7 +51,8 @@ export class Sidebar extends AKElement {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
.pf-c-nav__list {
|
|
||||||
|
ak-sidebar-items {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
@ -66,14 +73,10 @@ export class Sidebar extends AKElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
return html`<nav
|
console.log(this.entries);
|
||||||
class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}"
|
return html`<nav class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}" aria-label="Global">
|
||||||
aria-label="Global"
|
|
||||||
>
|
|
||||||
<ak-sidebar-brand></ak-sidebar-brand>
|
<ak-sidebar-brand></ak-sidebar-brand>
|
||||||
<ul class="pf-c-nav__list">
|
<ak-sidebar-items .entries=${this.entries}></ak-sidebar-items>
|
||||||
<slot></slot>
|
|
||||||
</ul>
|
|
||||||
<ak-sidebar-user></ak-sidebar-user>
|
<ak-sidebar-user></ak-sidebar-user>
|
||||||
</nav>`;
|
</nav>`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,227 +0,0 @@
|
||||||
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
|
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
|
||||||
|
|
||||||
import { CSSResult, css } from "lit";
|
|
||||||
import { TemplateResult, html } from "lit";
|
|
||||||
import { customElement, property } from "lit/decorators.js";
|
|
||||||
|
|
||||||
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
|
|
||||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
|
||||||
|
|
||||||
@customElement("ak-sidebar-item")
|
|
||||||
export class SidebarItem extends AKElement {
|
|
||||||
static get styles(): CSSResult[] {
|
|
||||||
return [
|
|
||||||
PFBase,
|
|
||||||
PFPage,
|
|
||||||
PFNav,
|
|
||||||
css`
|
|
||||||
:host {
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
:host([highlight]) .pf-c-nav__item {
|
|
||||||
background-color: var(--ak-accent);
|
|
||||||
margin: 16px;
|
|
||||||
}
|
|
||||||
:host([highlight]) .pf-c-nav__item .pf-c-nav__link {
|
|
||||||
padding-left: 0.5rem;
|
|
||||||
}
|
|
||||||
.pf-c-nav__link.pf-m-current::after,
|
|
||||||
.pf-c-nav__link.pf-m-current:hover::after,
|
|
||||||
.pf-c-nav__item.pf-m-current:not(.pf-m-expanded) .pf-c-nav__link::after {
|
|
||||||
--pf-c-nav__link--m-current--after--BorderColor: #fd4b2d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-c-nav__section + .pf-c-nav__section {
|
|
||||||
--pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm);
|
|
||||||
}
|
|
||||||
.pf-c-nav__list .sidebar-brand {
|
|
||||||
max-height: 82px;
|
|
||||||
margin-bottom: -0.5rem;
|
|
||||||
}
|
|
||||||
nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: 100vh;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: hidden;
|
|
||||||
}
|
|
||||||
.pf-c-nav__list {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-c-nav__link {
|
|
||||||
--pf-c-nav__link--PaddingTop: 0.5rem;
|
|
||||||
--pf-c-nav__link--PaddingRight: 0.5rem;
|
|
||||||
--pf-c-nav__link--PaddingBottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.pf-c-nav__section-title {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.pf-c-nav__item {
|
|
||||||
--pf-c-nav__item--MarginTop: 0px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
@property()
|
|
||||||
path?: string;
|
|
||||||
|
|
||||||
activeMatchers: RegExp[] = [];
|
|
||||||
|
|
||||||
@property({ type: Boolean })
|
|
||||||
expanded = false;
|
|
||||||
|
|
||||||
@property({ type: Boolean })
|
|
||||||
isActive = false;
|
|
||||||
|
|
||||||
@property({ type: Boolean })
|
|
||||||
isAbsoluteLink?: boolean;
|
|
||||||
|
|
||||||
@property({ type: Boolean })
|
|
||||||
highlight?: boolean;
|
|
||||||
|
|
||||||
parent?: SidebarItem;
|
|
||||||
|
|
||||||
get childItems(): SidebarItem[] {
|
|
||||||
const children = Array.from(this.querySelectorAll<SidebarItem>("ak-sidebar-item") || []);
|
|
||||||
children.forEach((child) => (child.parent = this));
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
set activeWhen(regexp: string[]) {
|
|
||||||
regexp.forEach((r) => {
|
|
||||||
this.activeMatchers.push(new RegExp(r));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
firstUpdated(): void {
|
|
||||||
this.onHashChange();
|
|
||||||
window.addEventListener("hashchange", () => this.onHashChange());
|
|
||||||
}
|
|
||||||
|
|
||||||
onHashChange(): void {
|
|
||||||
const activePath = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
|
|
||||||
this.childItems.forEach((item) => {
|
|
||||||
this.expandParentRecursive(activePath, item);
|
|
||||||
});
|
|
||||||
this.isActive = this.matchesPath(activePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private matchesPath(path: string): boolean {
|
|
||||||
if (!this.path) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.path) {
|
|
||||||
const ourPath = this.path.split(";")[0];
|
|
||||||
if (new RegExp(`^${ourPath}$`).exec(path)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.activeMatchers.some((v) => {
|
|
||||||
const match = v.exec(path);
|
|
||||||
if (match !== null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
expandParentRecursive(activePath: string, item: SidebarItem): void {
|
|
||||||
if (item.matchesPath(activePath) && item.parent) {
|
|
||||||
item.parent.expanded = true;
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
item.childItems.forEach((i) => this.expandParentRecursive(activePath, i));
|
|
||||||
}
|
|
||||||
|
|
||||||
render(): TemplateResult {
|
|
||||||
return this.renderInner();
|
|
||||||
}
|
|
||||||
|
|
||||||
renderWithChildren() {
|
|
||||||
return html`<li
|
|
||||||
class="pf-c-nav__item ${this.expanded ? "pf-m-expandable pf-m-expanded" : ""}"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="pf-c-nav__link"
|
|
||||||
aria-expanded="true"
|
|
||||||
@click=${() => {
|
|
||||||
this.expanded = !this.expanded;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<slot name="label"></slot>
|
|
||||||
<span class="pf-c-nav__toggle">
|
|
||||||
<span class="pf-c-nav__toggle-icon">
|
|
||||||
<i class="fas fa-angle-right" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<section class="pf-c-nav__subnav" ?hidden=${!this.expanded}>
|
|
||||||
<ul class="pf-c-nav__list">
|
|
||||||
<slot></slot>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</li>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderWithPathAndChildren() {
|
|
||||||
return html`<li
|
|
||||||
class="pf-c-nav__item ${this.expanded ? "pf-m-expandable pf-m-expanded" : ""}"
|
|
||||||
>
|
|
||||||
<slot name="label"></slot>
|
|
||||||
<button
|
|
||||||
class="pf-c-nav__link"
|
|
||||||
aria-expanded="true"
|
|
||||||
@click=${() => {
|
|
||||||
this.expanded = !this.expanded;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span class="pf-c-nav__toggle">
|
|
||||||
<span class="pf-c-nav__toggle-icon">
|
|
||||||
<i class="fas fa-angle-right" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<section class="pf-c-nav__subnav" ?hidden=${!this.expanded}>
|
|
||||||
<ul class="pf-c-nav__list">
|
|
||||||
<slot></slot>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</li>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderWithPath() {
|
|
||||||
return html`
|
|
||||||
<a
|
|
||||||
href="${this.isAbsoluteLink ? "" : "#"}${this.path}"
|
|
||||||
class="pf-c-nav__link ${this.isActive ? "pf-m-current" : ""}"
|
|
||||||
>
|
|
||||||
<slot name="label"></slot>
|
|
||||||
</a>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderWithLabel() {
|
|
||||||
html`
|
|
||||||
<span class="pf-c-nav__link">
|
|
||||||
<slot name="label"></slot>
|
|
||||||
</span>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderInner() {
|
|
||||||
if (this.childItems.length > 0) {
|
|
||||||
return this.path ? this.renderWithPathAndChildren() : this.renderWithChildren();
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`<li class="pf-c-nav__item">
|
|
||||||
${this.path ? this.renderWithPath() : this.renderWithLabel()}
|
|
||||||
</li>`;
|
|
||||||
}
|
|
||||||
}
|
|
255
web/src/elements/sidebar/SidebarItems.ts
Normal file
255
web/src/elements/sidebar/SidebarItems.ts
Normal file
|
@ -0,0 +1,255 @@
|
||||||
|
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
|
||||||
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
|
|
||||||
|
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
import { classMap } from "lit/directives/class-map.js";
|
||||||
|
import { map } from "lit/directives/map.js";
|
||||||
|
|
||||||
|
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
|
||||||
|
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||||
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
|
import { UiThemeEnum } from "@goauthentik/api";
|
||||||
|
|
||||||
|
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
|
||||||
|
// commonplace and singular enough to merit its own handler.
|
||||||
|
export type SidebarEventHandler = () => void;
|
||||||
|
|
||||||
|
export type SidebarAttributes = {
|
||||||
|
isAbsoluteLink?: boolean | (() => boolean);
|
||||||
|
highlight?: boolean | (() => boolean);
|
||||||
|
expanded?: boolean | (() => boolean);
|
||||||
|
activeWhen?: string[];
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SidebarEntry = {
|
||||||
|
path: string | SidebarEventHandler | null;
|
||||||
|
label: string;
|
||||||
|
attributes?: SidebarAttributes | null; // eslint-disable-line
|
||||||
|
children?: SidebarEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript requires the type here to correctly type the recursive path
|
||||||
|
export type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
|
||||||
|
|
||||||
|
@customElement("ak-sidebar-items")
|
||||||
|
export class SidebarItems extends AKElement {
|
||||||
|
static get styles(): CSSResult[] {
|
||||||
|
return [
|
||||||
|
PFBase,
|
||||||
|
PFPage,
|
||||||
|
PFNav,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlighted {
|
||||||
|
background-color: var(--ak-accent);
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlighted .pf-c-nav__link {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-c-nav__link.pf-m-current::after,
|
||||||
|
.pf-c-nav__link.pf-m-current:hover::after,
|
||||||
|
.pf-c-nav__item.pf-m-current:not(.pf-m-expanded) .pf-c-nav__link::after {
|
||||||
|
--pf-c-nav__link--m-current--after--BorderColor: #fd4b2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-c-nav__section + .pf-c-nav__section {
|
||||||
|
--pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm);
|
||||||
|
}
|
||||||
|
.pf-c-nav__list .sidebar-brand {
|
||||||
|
max-height: 82px;
|
||||||
|
margin-bottom: -0.5rem;
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 100vh;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
.pf-c-nav__list {
|
||||||
|
flex: 1 0 1fr;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-c-nav__link {
|
||||||
|
--pf-c-nav__link--PaddingTop: 0.5rem;
|
||||||
|
--pf-c-nav__link--PaddingRight: 0.5rem;
|
||||||
|
--pf-c-nav__link--PaddingBottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.pf-c-nav__section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.pf-c-nav__item {
|
||||||
|
--pf-c-nav__item--MarginTop: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-c-nav__toggle-icon {
|
||||||
|
padding: var(--pf-global--spacer--sm) var(--pf-global--spacer--md);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@property({ type: Array })
|
||||||
|
entries: SidebarEntry[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
expanded: WeakSet<SidebarEntry> = new WeakSet();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
// this.onToggle = this.onToggle.bind(this);
|
||||||
|
|
||||||
|
this.onHashChange = this.onHashChange.bind(this);
|
||||||
|
this.isActive = this.isActive.bind(this);
|
||||||
|
this.renderItem = this.renderItem.bind(this);
|
||||||
|
this.toggleExpand = this.toggleExpand.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
window.addEventListener("hashchange", this.onHashChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
window.removeEventListener("hashchange", this.onHashChange);
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
firstUpdated(): void {
|
||||||
|
this.onHashChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
onHashChange(): void {
|
||||||
|
/* no op */
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive(path: string) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): TemplateResult {
|
||||||
|
const lightThemed = { "pf-m-light": this.activeTheme === UiThemeEnum.Light };
|
||||||
|
|
||||||
|
return html` <nav class="pf-c-nav ${classMap(lightThemed)}" aria-label="Navigation">
|
||||||
|
<ul class="pf-c-nav__list">
|
||||||
|
${map(this.entries, this.renderItem)}
|
||||||
|
</ul>
|
||||||
|
</nav>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleExpand(entry: SidebarEntry) {
|
||||||
|
console.log(this, entry, this.expanded.has(entry));
|
||||||
|
if (this.expanded.has(entry)) {
|
||||||
|
this.expanded.delete(entry);
|
||||||
|
} else {
|
||||||
|
this.expanded.add(entry);
|
||||||
|
}
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderItem(entry: SidebarEntry) {
|
||||||
|
const { path, label, attributes, children } = entry;
|
||||||
|
// Ensure the attributes are undefined, not null; they can be null in the placeholders, but
|
||||||
|
// not when being forwarded to the correct renderer.
|
||||||
|
const attr = attributes ?? undefined;
|
||||||
|
const hasChildren = !!(children && children.length > 0);
|
||||||
|
|
||||||
|
// This is grossly imperative, in that it HAS to come before the content is rendered
|
||||||
|
// to make sure the content gets the right settings with respect to expansion.
|
||||||
|
if (attr?.expanded) {
|
||||||
|
this.expanded.add(entry);
|
||||||
|
delete attr.expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = path
|
||||||
|
? this.renderLink(label, path, attr)
|
||||||
|
: hasChildren
|
||||||
|
? this.renderLabelAndChildren(entry)
|
||||||
|
: this.renderLabel(label, attr);
|
||||||
|
|
||||||
|
const expanded = {
|
||||||
|
"highlighted": !!attr?.highlight,
|
||||||
|
"pf-m-expanded": this.expanded.has(entry),
|
||||||
|
"pf-m-expandable": hasChildren,
|
||||||
|
};
|
||||||
|
|
||||||
|
return html`<li class="pf-c-nav__item ${classMap(expanded)}">${content}</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toLinkClasses(attr: SidebarAttributes) {
|
||||||
|
return {
|
||||||
|
"pf-m-current": !!attr.isActive,
|
||||||
|
"pf-c-nav__link": true,
|
||||||
|
"highlight": !!(typeof attr.highlight === "function"
|
||||||
|
? attr.highlight()
|
||||||
|
: attr.highlight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLabel(label: string, attr: SidebarAttributes = {}) {
|
||||||
|
return html`<div class=${classMap(this.toLinkClasses(attr))}>${label}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// note the responsibilities pushed up to the caller
|
||||||
|
renderLink(label: string, path: string | SidebarEventHandler, attr: SidebarAttributes = {}) {
|
||||||
|
if (typeof path === "function") {
|
||||||
|
return html` <a @click=${path} class=${classMap(this.toLinkClasses(attr))}>
|
||||||
|
${label}
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
return html` <a
|
||||||
|
href="${attr.isAbsoluteLink ? "" : "#"}${path}"
|
||||||
|
class=${classMap(this.toLinkClasses(attr))}
|
||||||
|
>
|
||||||
|
${label}
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderChildren(children: SidebarEntry[]) {
|
||||||
|
return html`<section class="pf-c-nav__subnav">
|
||||||
|
<ul class="pf-c-nav__list">
|
||||||
|
${map(children, this.renderItem)}
|
||||||
|
</ul>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLabelAndChildren(entry: SidebarEntry): TemplateResult {
|
||||||
|
const handler = () => this.toggleExpand(entry);
|
||||||
|
return html` <div class="pf-c-nav__link">
|
||||||
|
<div class="ak-nav__link">${entry.label}</div>
|
||||||
|
<span class="pf-c-nav__toggle" @click=${handler}>
|
||||||
|
<span class="pf-c-nav__toggle-icon">
|
||||||
|
<i class="fas fa-angle-right" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${this.expanded.has(entry) ? this.renderChildren(entry.children ?? []) : nothing}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLinkAndChildren(
|
||||||
|
label: string,
|
||||||
|
children: SidebarEntry[],
|
||||||
|
attr: SidebarAttributes = {}
|
||||||
|
): TemplateResult {
|
||||||
|
return html` <div class="pf-c-nav__link">
|
||||||
|
<div class="ak-nav__link">${label}</div>
|
||||||
|
<span class="pf-c-nav__toggle">
|
||||||
|
<span class="pf-c-nav__toggle-icon">
|
||||||
|
<i class="fas fa-angle-right" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${attr.expanded ? this.renderChildren(children) : nothing}`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,9 +18,7 @@ import "@goauthentik/elements/notifications/APIDrawer";
|
||||||
import "@goauthentik/elements/notifications/NotificationDrawer";
|
import "@goauthentik/elements/notifications/NotificationDrawer";
|
||||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||||
import "@goauthentik/elements/router/RouterOutlet";
|
import "@goauthentik/elements/router/RouterOutlet";
|
||||||
import "@goauthentik/elements/sidebar/Sidebar";
|
|
||||||
import { DefaultTenant } from "@goauthentik/elements/sidebar/SidebarBrand";
|
import { DefaultTenant } from "@goauthentik/elements/sidebar/SidebarBrand";
|
||||||
import "@goauthentik/elements/sidebar/SidebarItem";
|
|
||||||
import { ROUTES } from "@goauthentik/user/Routes";
|
import { ROUTES } from "@goauthentik/user/Routes";
|
||||||
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
|
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
|
||||||
|
|
||||||
|
|
Reference in a new issue