From 76ca2fbf7719e7ea549822bb87a406e1670dbf8f Mon Sep 17 00:00:00 2001 From: "authentik-automation[bot]" <135050075+authentik-automation[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:55:31 +0000 Subject: [PATCH] web: Fix stale table rows (cherry-pick #17940 to version-2025.10) (#18408) * 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 --- web/src/elements/table/Table.ts | 165 ++++++++++++------ web/src/elements/utils/pointer.ts | 29 +++ .../RACLaunchEndpointModal.ts | 17 +- 3 files changed, 150 insertions(+), 61 deletions(-) create mode 100644 web/src/elements/utils/pointer.ts diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index df0e402e1b..fb3b8f5f2f 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -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 } } + /** + * 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[] = []; + #pageParam = `${this.tagName.toLowerCase()}-page`; #searchParam = `${this.tagName.toLowerCase()}-search`; @@ -309,15 +326,15 @@ export abstract class Table @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(); @property({ attribute: false }) @@ -394,11 +411,18 @@ export abstract class Table } protected willUpdate(changedProperties: PropertyValues): 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 this.data = data; this.error = null; + this.groups = this.groupBy(this.data.results); + this.page = this.data.pagination.current; const nextExpanded = new Set(); @@ -459,7 +485,17 @@ export abstract class Table 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 //#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 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` ${groupItems.map((item, itemIndex) => - this.#renderRowGroupItem(item, itemIndex, groupItems, 0, groups), + this.#renderRowGroupItem(item, itemIndex, groupItems, 0), )} `; } } - return groups.map(([group, items], groupIndex) => { + return this.groups.map(([groupName, items], groupIndex) => { const groupHeaderID = `table-group-${groupIndex}`; return html` - ${group} + ${groupName} ${items.map((item, itemIndex) => - this.#renderRowGroupItem(item, itemIndex, items, groupIndex, groups), + this.#renderRowGroupItem(item, itemIndex, items, groupIndex), )} `; }); } - //#region Grouping - - protected groupBy(items: T[]): GroupResult[] { - 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 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 return; } + event?.stopPropagation(); + event?.preventDefault(); + if (itemKey) { if (checked) { this.#selectedElements.set(itemKey, item); @@ -655,14 +708,14 @@ export abstract class Table this.requestUpdate(); } - #renderRowGroupItem( - item: T, - rowIndex: number, - items: T[], - groupIndex: number, - groups: GroupResult[], - ): TemplateResult { - const groupHeaderID = groups.length > 1 ? `table-group-${groupIndex}` : null; + //#region Grouping + + protected groupBy(items: T[]): GroupResult[] { + 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 const rowLabel = this.rowLabel(item) || `#${rowIndex + 1}`; - const renderCheckbox = () => - html` -