web: provide three-tier sidebar with subcategories

This commit implements a three-tier sidebar with subcategories.

The first thing is that we've refactored the Sidebar into a holistic entity; rather than be built in
pieces, it's constructed declaratively from a tree of entries, much in the same way routes are, and
for much the same reason<sup>1</sup>.

The AdminSidebar element only provides the list of entries to show and some of the controls
necessary to show/hide the sidebar.  Because the sidebar requires a rich collection of objects
retrieved from the back-end, to avoid cluttering the AdminSidebar each of those sublists of
TypeCreate have been isolated into their own controllers.

The SidebarTypeController isn't even a strongly reactive controller; all it does is fetch the
TypeCreate collection and notify the client object that the fetch has completed. The client can then
call the `.entries()` method on the controller to get the sub-tree of entries for the TypeCreate
object.

The Sidebar has been slightly (!) refactored so that it's emphatic about what it does: it shows the
brand, nav, and user sections of the sidebar. The styling has been moved to a separate file, the
better to emphasize this.

The SidebarItems file is where all the magic-- and a lot of ugly-- is hidden.

The main purpose of the SidebarItems is to render the tree of entries passed to it.  That's it.  But
it also has to be able to read the URL and highlight which entry is currently being shown by the
router, and it has to be able to open up all the parent objects of the "current" link so that the
user gets a clear sense of where they are navigationally.

Most messy: the `reclick()` function intercepts clicks on anchors and, using the same logic as the
router, decides if the router would *not* handle the navigation event.  If the router would not, it
takes on the responsibility for reaching into the currently visible table, modifying the filter and
triggering a new `.fetch()`.

Somewhere along the way I boyscoutted another `switch` statement or two into lookup expressions.

---

<sup>1</sup>&nbsp; One of the reasons for this is that the Administrator Application's sidebar,
routes, and command palette will all get their data from a single source of truth, and that single
source will be independent of any of those.  This is a step in that direction.
This commit is contained in:
Ken Sternberg 2023-11-28 11:11:35 -08:00
parent f2834cc7e2
commit a3673906c7
6 changed files with 200 additions and 163 deletions

View File

@ -192,11 +192,11 @@ export class AkAdminSidebar extends AKElement {
["/administration/dashboard/users", msg("User Statistics")],
["/administration/system-tasks", msg("System Tasks")]]],
[null, msg("Applications"), null, [
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`], this.providerTypes.entries()],
["/core/applications", msg("Applications"), [`^/core/applications(/(?<slug>${SLUG_REGEX}))?$`]],
["/core/providers", msg("Providers"), [`^/core/providers(/(?<id>${ID_REGEX}))?$`], this.providerTypes.entries()],
["/outpost/outposts", msg("Outposts")]]],
[null, msg("Events"), null, [
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`], eventTypes],
["/events/log", msg("Logs"), [`^/events/log(/(?<id>${UUID_REGEX}))?$`], eventTypes],
["/events/rules", msg("Notification Rules")],
["/events/transports", msg("Notification Transports")]]],
[null, msg("Customisation"), null, [
@ -205,14 +205,14 @@ export class AkAdminSidebar extends AKElement {
["/blueprints/instances", msg("Blueprints")],
["/policy/reputation", msg("Reputation scores")]]],
[null, msg("Flows and Stages"), null, [
["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`], flowTypes],
["/flow/flows", msg("Flows"), [`^/flow/flows(/(?<slug>${SLUG_REGEX}))?$`], flowTypes],
["/flow/stages", msg("Stages"), null, this.stageTypes.entries()],
["/flow/stages/prompts", msg("Prompts")]]],
[null, msg("Directory"), null, [
["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]],
["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]],
["/identity/users", msg("Users"), [`^/identity/users(/(?<id>${ID_REGEX}))?$`]],
["/identity/groups", msg("Groups"), [`^/identity/groups(/(?<id>${UUID_REGEX}))?$`]],
["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]],
["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`], this.sourceTypes.entries()],
["/core/sources", msg("Federation and Social login"), [`^/core/sources(/(?<slug>${SLUG_REGEX}))?$`], this.sourceTypes.entries()],
["/core/tokens", msg("Tokens and App passwords")],
["/flow/stages/invitations", msg("Invitations")]]],
[null, msg("System"), null, [

View File

@ -0,0 +1,56 @@
import { css } from "lit";
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";
export const sidebarStyles = [
PFBase,
PFPage,
PFNav,
css`
:host {
z-index: 100;
}
.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;
}
:host([theme="light"]) {
border-right-color: transparent !important;
}
.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;
}
ak-sidebar-items {
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;
}
`,
];

View File

@ -3,15 +3,12 @@ import "@goauthentik/elements/sidebar/SidebarBrand";
import "@goauthentik/elements/sidebar/SidebarItems";
import "@goauthentik/elements/sidebar/SidebarUser";
import { CSSResult, TemplateResult, css, html } from "lit";
import { 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";
import { UiThemeEnum } from "@goauthentik/api";
import { sidebarStyles } from "./Sidebar.css.js";
import type { SidebarEntry } from "./types";
@customElement("ak-sidebar")
@ -19,60 +16,11 @@ export class Sidebar extends AKElement {
@property({ type: Array })
entries: SidebarEntry[] = [];
static get styles(): CSSResult[] {
return [
PFBase,
PFPage,
PFNav,
css`
:host {
z-index: 100;
}
.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;
}
:host([theme="light"]) {
border-right-color: transparent !important;
}
.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;
}
ak-sidebar-items {
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;
}
`,
];
static get styles() {
return sidebarStyles;
}
render(): TemplateResult {
render() {
return html`<nav
class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}"
aria-label="Global"

View File

@ -0,0 +1,86 @@
import { css } from "lit";
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";
export const sidebarItemStyles = [
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__item .pf-c-nav__item::before {
border-bottom-width: 0;
}
.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;
}
.pf-c-nav__toggle {
width: calc(var(--pf-c-nav__toggle--FontSize) + calc(2 * var(--pf-global--spacer--md)));
}
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__link a {
flex: 1 0 max-content;
color: var(--pf-c-nav__link--Color);
}
a.pf-c-nav__link:hover {
color: var(--pf-c-nav__link--Color);
text-decoration: var(--pf-global--link--TextDecoration--hover);
}
.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);
}
`,
];

View File

@ -2,113 +2,40 @@ import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base";
import { findTable } from "@goauthentik/elements/table/TablePage";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { TemplateResult, 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";
import { sidebarItemStyles } from "./SidebarItems.css.js";
import type { SidebarEntry } from "./types";
import { entryKey, findMatchForNavbarUrl, makeParentMap } from "./utils";
/**
* Display the sidebar item tree.
*
* Along with the `reclick()` complaint down below, the other thing I dislike about this design is
* that it's effectively two different programs glued together. The first responds to the `click`
* and performs the navigation, which either triggers the router or triggers a new search on the
* existing view. The second responds to the navigation change event when the URL is changed by the
* navigation event, at which point it figures out which entry to highlight as "current," which
* causes the re-render.
*/
@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__item .pf-c-nav__item::before {
border-bottom-width: 0;
}
.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;
}
.pf-c-nav__toggle {
width: calc(
var(--pf-c-nav__toggle--FontSize) + calc(2 * var(--pf-global--spacer--md))
);
}
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__link a {
flex: 1 0 max-content;
color: var(--pf-c-nav__link--Color);
}
a.pf-c-nav__link:hover {
color: var(--pf-c-nav__link--Color);
text-decoration: var(--pf-global--link--TextDecoration--hover);
}
.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);
}
`,
];
static get styles() {
return sidebarItemStyles;
}
@property({ type: Array })
entries: SidebarEntry[] = [];
@state()
expanded: Set<string> = new Set();
@state()
current = "";
constructor() {
@ -121,6 +48,7 @@ export class SidebarItems extends AKElement {
connectedCallback() {
super.connectedCallback();
this.onHashChange();
window.addEventListener("hashchange", this.onHashChange);
}
@ -198,6 +126,7 @@ export class SidebarItems extends AKElement {
}
render(): TemplateResult {
console.log("C:", this.current);
const lightThemed = { "pf-m-light": this.activeTheme === UiThemeEnum.Light };
return html` <nav class="pf-c-nav ${classMap(lightThemed)}" aria-label="Navigation">
@ -239,8 +168,9 @@ export class SidebarItems extends AKElement {
getLinkClasses(entry: SidebarEntry) {
const a = entry.attributes ?? {};
const key = entryKey(entry);
return {
"pf-m-current": a == this.current,
"pf-m-current": key === this.current,
"pf-c-nav__link": true,
"highlight": !!(typeof a.highlight === "function" ? a.highlight() : a.highlight),
};
@ -277,7 +207,9 @@ export class SidebarItems extends AKElement {
renderLabelAndChildren(entry: SidebarEntry): TemplateResult {
const handler = () => this.toggleExpand(entry);
return html` <div class="pf-c-nav__link">
const current = { "pf-m-current": this.current === entryKey(entry) };
return html` <div class="pf-c-nav__link ${classMap(current)}">
<div class="ak-nav__link">${entry.label}</div>
<span class="pf-c-nav__toggle" @click=${handler}>
<span class="pf-c-nav__toggle-icon">
@ -292,8 +224,9 @@ export class SidebarItems extends AKElement {
renderLinkAndChildren(entry: SidebarEntry): TemplateResult {
const handler = () => this.toggleExpand(entry);
const current = { "pf-m-current": this.current === entryKey(entry) };
const path = `${entry.attributes?.isAbsoluteLink ? "" : "#"}${entry.path}`;
return html` <div class="pf-c-nav__link">
return html` <div class="pf-c-nav__link ${classMap(current)}">
<a
href=${path}
@click=${(ev: Event) => this.reclick(ev, path)}

View File

@ -6,6 +6,13 @@ export function entryKey(entry: SidebarEntry) {
return `${entry.path || "no-path"}:${entry.label}`;
}
// "Never store what you can calculate." (At least, if it's cheap.)
/**
* Takes tree and creates a map where every key is an entry in the tree and every value is that
* entry's parent.
*/
export function makeParentMap(entries: SidebarEntry[]) {
const reverseMap = new WeakMap<SidebarEntry, SidebarEntry>();
function reverse(entry: SidebarEntry) {
@ -18,20 +25,27 @@ export function makeParentMap(entries: SidebarEntry[]) {
return reverseMap;
}
/**
* Given the current path and the collection of entries, identify which entry is currently live.
*
*/
const trailingSlash = new RegExp("/$");
const fixed = (s: string) => s.replace(trailingSlash, "");
function scanner(entry: SidebarEntry, activePath: string): SidebarEntry | undefined {
if (typeof entry.path === "string" && fixed(activePath) === fixed(entry.path)) {
return entry;
}
for (const matcher of entry.attributes?.activeWhen ?? []) {
const matchtest = new RegExp(matcher);
if (matchtest.test(activePath)) {
return entry;
}
const match: SidebarEntry | undefined = (entry.children ?? []).find((e) =>
scanner(e, activePath),
);
if (match) {
return match;
}
}
return undefined;
return (entry.children ?? []).find((e) => scanner(e, activePath));
}
export function findMatchForNavbarUrl(entries: SidebarEntry[]) {