mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-18 03:19:51 +03:00
* web: Table row refinements (#17659) * web: Reset selection state after refresh. * web: Only select row when not expandable. * web: Only render expandable content when row is expanded. * web: Use `repeat` directive. * web: Fix nested pointer event detection. * web: Fix issues surrounding stale table rows. * Port row selector fix. --------- Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Co-authored-by: Teffen Ellis <teffen@goauthentik.io>
This commit is contained in:
committed by
GitHub
parent
e4e8bc57f1
commit
76ca2fbf77
+113
-52
@@ -19,6 +19,8 @@ import { WithLicenseSummary } from "#elements/mixins/license";
|
||||
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
import { isInteractiveElement } from "#elements/utils/interactivity";
|
||||
import { isEventTargetingListener } from "#elements/utils/pointer";
|
||||
|
||||
import { Pagination } from "@goauthentik/api";
|
||||
|
||||
@@ -28,6 +30,7 @@ import { msg, str } from "@lit/localize";
|
||||
import { css, CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { guard } from "lit/directives/guard.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
@@ -262,12 +265,26 @@ export abstract class Table<T extends object>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the table is currently fetching data.
|
||||
*/
|
||||
@state()
|
||||
protected loading = false;
|
||||
|
||||
/**
|
||||
* A timestamp of the last attempt to refresh the table data.
|
||||
*/
|
||||
@state()
|
||||
protected lastRefreshedAt: Date | null = null;
|
||||
|
||||
/**
|
||||
* A cached grouping of the last fetched results.
|
||||
*
|
||||
* @see {@linkcode Table.fetch}
|
||||
*/
|
||||
@state()
|
||||
protected groups: GroupResult<T>[] = [];
|
||||
|
||||
#pageParam = `${this.tagName.toLowerCase()}-page`;
|
||||
#searchParam = `${this.tagName.toLowerCase()}-search`;
|
||||
|
||||
@@ -309,15 +326,15 @@ export abstract class Table<T extends object>
|
||||
@property({ type: Boolean })
|
||||
public clickable = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public clickHandler: (item: T) => void = () => {};
|
||||
|
||||
@property({ type: Boolean })
|
||||
public radioSelect = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public checkboxChip = false;
|
||||
|
||||
/**
|
||||
* A mapping of the current items to their respective identifiers.
|
||||
*/
|
||||
#itemKeys = new WeakMap<T, string | number>();
|
||||
|
||||
@property({ attribute: false })
|
||||
@@ -394,11 +411,18 @@ export abstract class Table<T extends object>
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
const interactive = isInteractiveElement(this);
|
||||
|
||||
if (!interactive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (changedProperties.has("page")) {
|
||||
updateURLParams({
|
||||
[this.#pageParam]: this.page === 1 ? null : this.page,
|
||||
});
|
||||
}
|
||||
|
||||
if (changedProperties.has("search")) {
|
||||
updateURLParams({
|
||||
[this.#searchParam]: this.search,
|
||||
@@ -443,6 +467,8 @@ export abstract class Table<T extends object>
|
||||
this.data = data;
|
||||
this.error = null;
|
||||
|
||||
this.groups = this.groupBy(this.data.results);
|
||||
|
||||
this.page = this.data.pagination.current;
|
||||
const nextExpanded = new Set<string | number>();
|
||||
|
||||
@@ -459,7 +485,17 @@ export abstract class Table<T extends object>
|
||||
this.expandedElements = nextExpanded;
|
||||
|
||||
if (this.clearOnRefresh) {
|
||||
this.#selectedElements.clear();
|
||||
if (this.#selectedElements.size) {
|
||||
this.#selectedElements.clear();
|
||||
|
||||
const selectAllCheckbox = this.#selectAllCheckboxRef.value;
|
||||
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
@@ -526,6 +562,30 @@ export abstract class Table<T extends object>
|
||||
|
||||
//#region Rows
|
||||
|
||||
/**
|
||||
* An overridable event listener when a row is clicked.
|
||||
*
|
||||
* @bound
|
||||
* @abstract
|
||||
*/
|
||||
protected rowClickListener(item: T, event?: InputEvent | PointerEvent): void {
|
||||
if (event?.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEventTargetingListener(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.expandable) {
|
||||
const itemKey = this.#itemKeys.get(item);
|
||||
|
||||
return this.#toggleExpansion(itemKey, event);
|
||||
}
|
||||
|
||||
this.#selectItemListener(item, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a row for a given item.
|
||||
*
|
||||
@@ -549,48 +609,42 @@ export abstract class Table<T extends object>
|
||||
return this.renderEmpty();
|
||||
}
|
||||
|
||||
const groups = this.groupBy(this.data.results);
|
||||
|
||||
if (groups.length === 1) {
|
||||
const [firstGroup] = groups;
|
||||
if (this.groups.length === 1) {
|
||||
const [firstGroup] = this.groups;
|
||||
const [groupKey, groupItems] = firstGroup;
|
||||
|
||||
if (!groupKey) {
|
||||
return html`<tbody>
|
||||
${groupItems.map((item, itemIndex) =>
|
||||
this.#renderRowGroupItem(item, itemIndex, groupItems, 0, groups),
|
||||
this.#renderRowGroupItem(item, itemIndex, groupItems, 0),
|
||||
)}
|
||||
</tbody>`;
|
||||
}
|
||||
}
|
||||
|
||||
return groups.map(([group, items], groupIndex) => {
|
||||
return this.groups.map(([groupName, items], groupIndex) => {
|
||||
const groupHeaderID = `table-group-${groupIndex}`;
|
||||
|
||||
return html`<thead>
|
||||
<tr>
|
||||
<th id=${groupHeaderID} scope="colgroup" colspan=${this.#columnCount}>
|
||||
${group}
|
||||
${groupName}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${items.map((item, itemIndex) =>
|
||||
this.#renderRowGroupItem(item, itemIndex, items, groupIndex, groups),
|
||||
this.#renderRowGroupItem(item, itemIndex, items, groupIndex),
|
||||
)}
|
||||
</tbody>`;
|
||||
});
|
||||
}
|
||||
|
||||
//#region Grouping
|
||||
|
||||
protected groupBy(items: T[]): GroupResult<T>[] {
|
||||
return [["", items]];
|
||||
}
|
||||
//#region Expansion
|
||||
|
||||
protected renderExpanded?(item: T): SlottedTemplateResult;
|
||||
|
||||
#toggleExpansion = (itemKey?: string | number, event?: PointerEvent) => {
|
||||
#toggleExpansion = (itemKey?: string | number, event?: PointerEvent | InputEvent) => {
|
||||
// An unlikely scenario but possible if items shift between fetches
|
||||
if (typeof itemKey === "undefined") return;
|
||||
|
||||
@@ -614,12 +668,8 @@ export abstract class Table<T extends object>
|
||||
this.requestUpdate("expandedElements");
|
||||
};
|
||||
|
||||
#selectItemListener(item: T, event: InputEvent | PointerEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (event instanceof PointerEvent && target.classList.contains("ignore-click")) {
|
||||
return;
|
||||
}
|
||||
#selectItemListener(item: T, event?: InputEvent | PointerEvent) {
|
||||
const { target } = event ?? {};
|
||||
|
||||
const itemKey = this.#itemKeys.get(item);
|
||||
const selected = !!(itemKey && this.#selectedElements.has(itemKey));
|
||||
@@ -635,6 +685,9 @@ export abstract class Table<T extends object>
|
||||
return;
|
||||
}
|
||||
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
|
||||
if (itemKey) {
|
||||
if (checked) {
|
||||
this.#selectedElements.set(itemKey, item);
|
||||
@@ -655,14 +708,14 @@ export abstract class Table<T extends object>
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
#renderRowGroupItem(
|
||||
item: T,
|
||||
rowIndex: number,
|
||||
items: T[],
|
||||
groupIndex: number,
|
||||
groups: GroupResult<T>[],
|
||||
): TemplateResult {
|
||||
const groupHeaderID = groups.length > 1 ? `table-group-${groupIndex}` : null;
|
||||
//#region Grouping
|
||||
|
||||
protected groupBy(items: T[]): GroupResult<T>[] {
|
||||
return [["", items]];
|
||||
}
|
||||
|
||||
#renderRowGroupItem(item: T, rowIndex: number, items: T[], groupIndex: number): TemplateResult {
|
||||
const groupHeaderID = this.groups.length > 1 ? `table-group-${groupIndex}` : null;
|
||||
|
||||
const itemKey = this.#itemKeys.get(item);
|
||||
const expanded = !!(itemKey && this.expandedElements.has(itemKey));
|
||||
@@ -670,33 +723,43 @@ export abstract class Table<T extends object>
|
||||
|
||||
const rowLabel = this.rowLabel(item) || `#${rowIndex + 1}`;
|
||||
|
||||
const renderCheckbox = () =>
|
||||
html`<td class="pf-c-table__check" role="presentation">
|
||||
<label aria-label="${msg(str`Select "${rowLabel}" row`)}" class="ignore-click"
|
||||
const selectItem = this.#selectItemListener.bind(this, item);
|
||||
|
||||
const renderCheckbox = () => {
|
||||
if (!this.checkbox) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<td class="pf-c-table__check" role="presentation" @click=${selectItem}>
|
||||
<label aria-label="${msg(str`Select "${rowLabel}" row`)}"
|
||||
><input
|
||||
type="checkbox"
|
||||
class="ignore-click"
|
||||
.checked=${selected}
|
||||
@input=${this.#selectItemListener.bind(this, item)}
|
||||
@click=${(ev: PointerEvent) => {
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
@input=${selectItem}
|
||||
@click=${(event: PointerEvent) => event.stopPropagation()}
|
||||
/></label>
|
||||
</td>`;
|
||||
};
|
||||
|
||||
const expandItem = this.#toggleExpansion.bind(this, itemKey);
|
||||
|
||||
const renderExpansion = () => {
|
||||
if (!this.expandable) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<td
|
||||
class="pf-c-table__toggle pf-m-pressable"
|
||||
role="presentation"
|
||||
@click=${this.#toggleExpansion.bind(this, itemKey)}
|
||||
@click=${expandItem}
|
||||
>
|
||||
<button
|
||||
class="pf-c-button pf-m-plain ${classMap({
|
||||
"pf-m-expanded": expanded,
|
||||
})}"
|
||||
@click=${this.#toggleExpansion.bind(this, itemKey)}
|
||||
@click=${expandItem}
|
||||
aria-label=${expanded ? msg("Collapse row") : msg("Expand row")}
|
||||
aria-expanded=${expanded ? "true" : "false"}
|
||||
aria-expanded=${expanded.toString()}
|
||||
>
|
||||
<div class="pf-c-table__toggle-icon">
|
||||
<i class="fas fa-angle-down" aria-hidden="true"></i>
|
||||
@@ -707,7 +770,7 @@ export abstract class Table<T extends object>
|
||||
|
||||
let expansionContent: SlottedTemplateResult = nothing;
|
||||
|
||||
if (this.expandable) {
|
||||
if (this.expandable && expanded) {
|
||||
if (!this.renderExpanded) {
|
||||
throw new TypeError("Expandable is enabled but renderExpanded is not overridden!");
|
||||
}
|
||||
@@ -728,16 +791,13 @@ export abstract class Table<T extends object>
|
||||
|
||||
return html`
|
||||
<tr
|
||||
aria-selected=${selected ? "true" : "false"}
|
||||
aria-selected=${selected.toString()}
|
||||
class="${classMap({
|
||||
"pf-m-hoverable": this.checkbox || this.clickable,
|
||||
"pf-m-hoverable": this.checkbox || this.expandable || this.clickable,
|
||||
})}"
|
||||
@click=${this.clickable
|
||||
? this.clickHandler.bind(this, item)
|
||||
: this.#selectItemListener.bind(this, item)}
|
||||
>
|
||||
${this.checkbox ? renderCheckbox() : nothing}
|
||||
${this.expandable ? renderExpansion() : nothing}
|
||||
${guard([this.checkbox, selected], renderCheckbox)}
|
||||
${guard([this.expandable, expanded], renderExpansion)}
|
||||
${this.row(item).map((cell, columnIndex) => {
|
||||
const columnID = this.#columnIDs.get(this.columns[columnIndex]);
|
||||
|
||||
@@ -746,6 +806,7 @@ export abstract class Table<T extends object>
|
||||
: columnID;
|
||||
|
||||
return html`<td
|
||||
@click=${this.rowClickListener.bind(this, item)}
|
||||
class=${ifPresent(!columnID, "presentational")}
|
||||
headers=${ifPresent(headers)}
|
||||
>
|
||||
@@ -1000,7 +1061,7 @@ export abstract class Table<T extends object>
|
||||
${this.renderRows()}
|
||||
</table>
|
||||
</div>
|
||||
${this.paginated ? renderBottomPagination() : nothing}`;
|
||||
${guard([this.paginated, this.lastRefreshedAt], renderBottomPagination)}`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
const InteractiveElementsQuery =
|
||||
"[href],input,button,[role='button'],select,[tabindex]:not([tabindex='-1'])";
|
||||
|
||||
/**
|
||||
* Whether a pointer event is targeting the element itself or one of its children.
|
||||
*
|
||||
* @param event The pointer event to check.
|
||||
* @returns Whether the event is targeting the element or one of its children.
|
||||
*/
|
||||
export function isEventTargetingListener(event?: Pick<Event, "target" | "currentTarget">): boolean {
|
||||
const { target: triggerElement, currentTarget: listenerTarget } = event ?? {};
|
||||
|
||||
if (!triggerElement || !listenerTarget) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(triggerElement instanceof HTMLElement) || !(listenerTarget instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (triggerElement === listenerTarget) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!(
|
||||
triggerElement.matches(InteractiveElementsQuery) ||
|
||||
triggerElement.parentElement?.matches(InteractiveElementsQuery)
|
||||
);
|
||||
}
|
||||
@@ -15,16 +15,15 @@ export class RACLaunchEndpointModal extends TableModal<Endpoint> {
|
||||
clickable = true;
|
||||
protected override searchEnabled = true;
|
||||
|
||||
clickHandler = (item: Endpoint) => {
|
||||
protected override rowClickListener(item: Endpoint, event?: InputEvent | PointerEvent) {
|
||||
if (!item.launchUrl) {
|
||||
return;
|
||||
return super.rowClickListener(item, event);
|
||||
}
|
||||
if (this.app?.openInNewTab) {
|
||||
window.open(item.launchUrl);
|
||||
} else {
|
||||
window.location.assign(item.launchUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const target = this.app?.openInNewTab ? `ak-rac-endpoint-${item.name}` : "_self";
|
||||
|
||||
window.open(item.launchUrl, target);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
app?: Application;
|
||||
@@ -35,7 +34,7 @@ export class RACLaunchEndpointModal extends TableModal<Endpoint> {
|
||||
provider: this.app?.provider || 0,
|
||||
});
|
||||
if (this.open && endpoints.pagination.count === 1) {
|
||||
this.clickHandler(endpoints.results[0]);
|
||||
this.rowClickListener(endpoints.results[0]);
|
||||
this.open = false;
|
||||
}
|
||||
return endpoints;
|
||||
|
||||
Reference in New Issue
Block a user