web: Fix user list default paths. (#23062)

This commit is contained in:
Teffen Ellis
2026-06-16 15:57:08 +02:00
committed by GitHub
parent 4104af4a45
commit 40caedfbd0
3 changed files with 165 additions and 117 deletions
+47 -14
View File
@@ -98,9 +98,12 @@ export class UserListPage extends WithLicenseSummary(
@property({ type: String }) @property({ type: String })
public order = "-last_login"; public order = "-last_login";
@property({ type: String, useDefault: true }) @property({ type: String, attribute: "active-path", useDefault: true })
public activePath: string = DefaultUIConfig.defaults.userPath; public activePath: string = DefaultUIConfig.defaults.userPath;
@property({ type: String, attribute: "default-active-path", useDefault: true })
public defaultActivePath: string = DefaultUIConfig.defaults.userPath;
@state() @state()
protected hideDeactivated = getURLParam<boolean>("hideDeactivated", false); protected hideDeactivated = getURLParam<boolean>("hideDeactivated", false);
@@ -109,18 +112,39 @@ export class UserListPage extends WithLicenseSummary(
protected canImpersonate = false; protected canImpersonate = false;
public override connectedCallback(): void { //#region Lifecycle
super.connectedCallback();
/**
* Synchronizes `activePath` and `defaultActivePath` from three sources in priority order:
*
* 1. URL param (explicit navigation)
* 2. Brand default user path (admin-configured override)
* 3. Compiled-in `DefaultUIConfig` default (fallback)
*
* `activePath` is set to `""` (show all users) when neither a URL param nor a
* brand-level override is present, avoiding silent list filtering.
* `defaultActivePath` always resolves to a value via the fallback chain.
*/
protected synchronizeUserPaths(): void {
this.canImpersonate = this.can(CapabilitiesEnum.CanImpersonate); this.canImpersonate = this.can(CapabilitiesEnum.CanImpersonate);
const initialDefaultUserPath = DefaultUIConfig.defaults.userPath; const initialDefaultUserPath = DefaultUIConfig.defaults.userPath;
const brandDefaultUserPath = this.uiConfig.defaults.userPath; const brandDefaultUserPath = this.uiConfig.defaults.userPath;
const defaultUserPath = brandDefaultUserPath || initialDefaultUserPath;
const userPathParam = getURLParam<string>("path", "");
this.activePath = getURLParam<string>( const pathPresent =
"path", (userPathParam && userPathParam !== "") || defaultUserPath !== initialDefaultUserPath;
brandDefaultUserPath || initialDefaultUserPath,
); const resolvedUserPath = pathPresent ? userPathParam || defaultUserPath : "";
this.activePath = resolvedUserPath;
this.defaultActivePath = userPathParam || defaultUserPath;
}
public override connectedCallback(): void {
super.connectedCallback();
this.synchronizeUserPaths();
} }
protected override async apiEndpoint(): Promise<PaginatedResponse<User>> { protected override async apiEndpoint(): Promise<PaginatedResponse<User>> {
@@ -138,6 +162,17 @@ export class UserListPage extends WithLicenseSummary(
return users; return users;
} }
//#endregion
//#region Event Listeners
protected treeViewRefreshListener = (ev: CustomEvent<{ path: string }>) => {
this.activePath = ev.detail.path;
this.defaultActivePath = ev.detail.path;
};
//#endregion
protected buildExportParams = async (): Promise<CoreUsersExportCreateRequest> => { protected buildExportParams = async (): Promise<CoreUsersExportCreateRequest> => {
return { return {
...(await this.defaultEndpointConfig()), ...(await this.defaultEndpointConfig()),
@@ -346,15 +381,15 @@ export class UserListPage extends WithLicenseSummary(
} }
protected renderObjectCreate(): SlottedTemplateResult { protected renderObjectCreate(): SlottedTemplateResult {
const { activePath } = this; const { defaultActivePath } = this;
return guard([activePath], () => { return guard([defaultActivePath], () => {
return [ return [
html`<button html`<button
class="pf-c-button pf-m-primary" class="pf-c-button pf-m-primary"
type="button" type="button"
${modalInvoker(AKUserWizard, { ${modalInvoker(AKUserWizard, {
defaultPath: activePath, defaultPath: defaultActivePath,
})} })}
aria-description=${msg("Open the new user wizard")} aria-description=${msg("Open the new user wizard")}
> >
@@ -383,10 +418,8 @@ export class UserListPage extends WithLicenseSummary(
<ak-treeview <ak-treeview
label=${msg("User paths")} label=${msg("User paths")}
.items=${this.userPaths?.paths || []} .items=${this.userPaths?.paths || []}
activePath=${this.activePath} default-active-path=${this.activePath}
@ak-refresh=${(ev: CustomEvent<{ path: string }>) => { @ak-refresh=${this.treeViewRefreshListener}
this.activePath = ev.detail.path;
}}
></ak-treeview> ></ak-treeview>
</div> </div>
</div> </div>
+1 -1
View File
@@ -93,7 +93,7 @@ export const DefaultUIConfig = {
}, },
locale: "", locale: "",
defaults: { defaults: {
userPath: "", userPath: "users",
}, },
} as const satisfies UIConfig; } as const satisfies UIConfig;
+117 -102
View File
@@ -2,16 +2,19 @@ import { EVENT_REFRESH } from "#common/constants";
import { AKElement } from "#elements/Base"; import { AKElement } from "#elements/Base";
import { setURLParams } from "#elements/router/RouteMatch"; import { setURLParams } from "#elements/router/RouteMatch";
import { SlottedTemplateResult } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes"; import { ifPresent } from "#elements/utils/attributes";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit"; import { CSSResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import PFTreeView from "@patternfly/patternfly/components/TreeView/tree-view.css"; import PFTreeView from "@patternfly/patternfly/components/TreeView/tree-view.css";
//#region Tree View Node
export interface TreeViewItem { export interface TreeViewItem {
id?: string; id: string | null;
label: string; label: string;
childItems: TreeViewItem[]; childItems: TreeViewItem[];
parent?: TreeViewItem; parent?: TreeViewItem;
@@ -21,33 +24,35 @@ export interface TreeViewItem {
@customElement("ak-treeview-node") @customElement("ak-treeview-node")
export class TreeViewNode extends AKElement { export class TreeViewNode extends AKElement {
@property({ attribute: false }) @property({ attribute: false })
item?: TreeViewItem; public item: TreeViewItem | null = null;
@property({ type: Boolean }) @property({ type: Boolean })
open = false; public open = false;
@property({ attribute: false }) @property({ attribute: false })
host?: TreeView; public host: TreeView | null = null;
@property() @property({ type: String, attribute: "active-path" })
activePath = ""; public activePath = "";
@property() @property({ type: String })
separator = ""; public separator = "";
get openable(): boolean { public get openable(): boolean {
return (this.item?.childItems || []).length > 0; return (this.item?.childItems || []).length > 0;
} }
get fullPath(): string { public get fullPath(): string {
const pathItems = []; const pathItems = [];
let item = this.item; let item = this.item;
while (item) { while (item) {
if (item.id) { if (item.id) {
pathItems.push(item.id); pathItems.push(item.id);
} }
item = item.parent; item = item.parent || null;
} }
return pathItems.reverse().join(this.separator); return pathItems.reverse().join(this.separator);
} }
@@ -55,7 +60,7 @@ export class TreeViewNode extends AKElement {
return this; return this;
} }
firstUpdated(): void { protected override firstUpdated(): void {
const pathSegments = this.activePath.split(this.separator); const pathSegments = this.activePath.split(this.separator);
const level = this.item?.level || 0; const level = this.item?.level || 0;
// Ignore the last item as that shouldn't be expanded // Ignore the last item as that shouldn't be expanded
@@ -63,7 +68,7 @@ export class TreeViewNode extends AKElement {
if (pathSegments[level] === this.item?.id) { if (pathSegments[level] === this.item?.id) {
this.open = true; this.open = true;
} }
if (this.activePath === this.fullPath && this.host !== undefined) { if (this.activePath === this.fullPath && this.host) {
this.host.activeNode = this; this.host.activeNode = this;
} }
} }
@@ -84,85 +89,87 @@ export class TreeViewNode extends AKElement {
); );
}; };
render(): TemplateResult { protected override render(): SlottedTemplateResult {
const shouldRenderChildren = (this.item?.childItems || []).length > 0 && this.open; const shouldRenderChildren = (this.item?.childItems || []).length > 0 && this.open;
const itemLabel = this.item?.label || msg("Unnamed"); const itemLabel = this.item?.label || msg("Unnamed");
const current = this.host?.activeNode === this; const current = this.host?.activeNode === this;
return html` return html`<li
<li class="pf-c-tree-view__list-item ${this.open ? "pf-m-expanded" : ""}"
class="pf-c-tree-view__list-item ${this.open ? "pf-m-expanded" : ""}" role="treeitem"
role="treeitem" aria-expanded=${ifPresent(this.openable, this.open ? "true" : "false")}
aria-expanded=${ifPresent(this.openable, this.open ? "true" : "false")} aria-label=${itemLabel}
aria-label=${itemLabel} aria-selected=${current ? "true" : "false"}
aria-selected=${current ? "true" : "false"} tabindex="0"
tabindex="0" >
> <div class="pf-c-tree-view__content">
<div class="pf-c-tree-view__content"> <div
<div class="pf-c-tree-view__node ${current ? "pf-m-current" : ""}"
class="pf-c-tree-view__node ${current ? "pf-m-current" : ""}" @click=${this.#selectionListener}
@click=${this.#selectionListener} >
> <div class="pf-c-tree-view__node-container">
<div class="pf-c-tree-view__node-container"> ${this.openable
${this.openable ? html` <button
? html` <button type="button"
type="button" aria-label=${ifPresent(
aria-label=${ifPresent( this.openable,
this.openable, this.open
this.open ? msg(str`Collapse "${itemLabel}"`)
? msg(str`Collapse "${itemLabel}"`) : msg(str`Expand "${itemLabel}"`),
: msg(str`Expand "${itemLabel}"`), )}
)} class="pf-c-tree-view__node-toggle"
class="pf-c-tree-view__node-toggle" @click=${(e: Event) => {
@click=${(e: Event) => { if (this.openable) {
if (this.openable) { this.open = !this.open;
this.open = !this.open; e.stopPropagation();
e.stopPropagation(); }
} }}
}} >
> <span class="pf-c-tree-view__node-toggle-icon">
<span class="pf-c-tree-view__node-toggle-icon"> <i class="fas fa-angle-right" aria-hidden="true"></i>
<i class="fas fa-angle-right" aria-hidden="true"></i> </span>
</span> </button>`
</button>` : null}
: nothing} <span class="pf-c-tree-view__node-icon">
<span class="pf-c-tree-view__node-icon"> <i
<i class="fas ${this.open ? "fa-folder-open" : "fa-folder"}"
class="fas ${this.open ? "fa-folder-open" : "fa-folder"}" aria-hidden="true"
aria-hidden="true" ></i>
></i> </span>
</span> <button
<button type="button"
type="button" aria-label=${msg(str`Select "${itemLabel}"`)}
aria-label=${msg(str`Select "${itemLabel}"`)} @click=${this.#selectionListener}
@click=${this.#selectionListener} class="pf-c-tree-view__node-text"
class="pf-c-tree-view__node-text" >
> ${itemLabel}
${itemLabel} </button>
</button>
</div>
</div> </div>
</div> </div>
<ul </div>
class="pf-c-tree-view__list" <ul
?hidden=${!shouldRenderChildren} class="pf-c-tree-view__list"
role="group" ?hidden=${!shouldRenderChildren}
aria-label=${msg(str`Items of "${itemLabel}"`)} role="group"
> aria-label=${msg(str`Items of "${itemLabel}"`)}
${this.item?.childItems.map((item) => { >
return html`<ak-treeview-node ${this.item?.childItems.map((item) => {
.item=${item} return html`<ak-treeview-node
activePath=${this.activePath} .item=${item}
separator=${this.separator} active-path=${this.activePath}
.host=${this.host} separator=${this.separator}
></ak-treeview-node>`; .host=${this.host}
})} ></ak-treeview-node>`;
</ul> })}
</li> </ul>
`; </li> `;
} }
} }
//#endregion
//#region Tree View
@customElement("ak-treeview") @customElement("ak-treeview")
export class TreeView extends AKElement { export class TreeView extends AKElement {
static styles: CSSResult[] = [PFTreeView]; static styles: CSSResult[] = [PFTreeView];
@@ -171,61 +178,67 @@ export class TreeView extends AKElement {
public label: string | null = null; public label: string | null = null;
@property({ type: Array }) @property({ type: Array })
items: string[] = []; public items: string[] = [];
@property() @property({ type: String, attribute: "default-active-path", useDefault: true })
activePath = ""; public defaultActivePath = "";
@state() @property({ attribute: false })
activeNode?: TreeViewNode; public activeNode: TreeViewNode | null = null;
separator = "/"; protected separator = "/";
public createNode(path: string[], parentItem: TreeViewItem, level: number): TreeViewItem {
const id = path.shift() || null;
const idx = parentItem.childItems.findIndex((item) => item.id === id);
createNode(path: string[], parentItem: TreeViewItem, level: number): TreeViewItem {
const id = path.shift();
const idx = parentItem.childItems.findIndex((e: TreeViewItem) => {
return e.id === id;
});
if (idx < 0) { if (idx < 0) {
const item: TreeViewItem = { const item: TreeViewItem = {
id: id, id,
label: id || "", label: id || "",
childItems: [], childItems: [],
level: level, level: level,
parent: parentItem, parent: parentItem,
}; };
parentItem.childItems.push(item); parentItem.childItems.push(item);
if (path.length !== 0) {
if (path.length) {
const child = this.createNode(path, item, level + 1); const child = this.createNode(path, item, level + 1);
child.parent = item; child.parent = item;
} }
return item; return item;
} }
return this.createNode(path, parentItem.childItems[idx], level + 1); return this.createNode(path, parentItem.childItems[idx], level + 1);
} }
parse(data: string[]): TreeViewItem { protected parse(data: string[]): TreeViewItem {
const rootItem: TreeViewItem = { const rootItem: TreeViewItem = {
id: undefined, id: null,
label: msg("Root"), label: msg("Root"),
childItems: [], childItems: [],
level: -1, level: -1,
}; };
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
const path: string = data[i]; const path: string = data[i];
const split: string[] = path.split(this.separator); const split: string[] = path.split(this.separator);
this.createNode(split, rootItem, 0); this.createNode(split, rootItem, 0);
} }
return rootItem; return rootItem;
} }
render(): TemplateResult { protected override render(): SlottedTemplateResult {
const rootItem = this.parse(this.items); const rootItem = this.parse(this.items);
return html`<div class="pf-c-tree-view pf-m-guides"> return html`<div class="pf-c-tree-view pf-m-guides">
<ul class="pf-c-tree-view__list" role="tree" aria-label=${ifPresent(this.label)}> <ul class="pf-c-tree-view__list" role="tree" aria-label=${ifPresent(this.label)}>
<ak-treeview-node <ak-treeview-node
.item=${rootItem} .item=${rootItem}
activePath=${this.activePath} active-path=${this.defaultActivePath}
open open
separator=${this.separator} separator=${this.separator}
.host=${this} .host=${this}
@@ -235,6 +248,8 @@ export class TreeView extends AKElement {
} }
} }
//#endregion
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ak-treeview": TreeView; "ak-treeview": TreeView;