Files
authentik/web/src/elements/table/Table.ts
Ken Sternberg 27b7b0b0e7 web/elements/empty-state: Fix issues with EmptyState and Loading Overlay (#15152)
* web: Add InvalidationFlow to Radius Provider dialogues

## What

- Bugfix: adds the InvalidationFlow to the Radius Provider dialogues
  - Repairs: `{"invalidation_flow":["This field is required."]}` message, which was *not* propagated
    to the Notification.
- Nitpick: Pretties `?foo=${true}` expressions: `s/\?([^=]+)=\$\{true\}/\1/`

## Note

Yes, I know I'm going to have to do more magic when we harmonize the forms, and no, I didn't add the
Property Mappings to the wizard, and yes, I know I'm going to have pain with the *new* version of
the wizard. But this is a serious bug; you can't make Radius servers with *either* of the current
dialogues at the moment.

* This (temporary) change is needed to prevent the unit tests from failing.

\# What

\# Why

\# How

\# Designs

\# Test Steps

\# Other Notes

* Revert "This (temporary) change is needed to prevent the unit tests from failing."

This reverts commit dddde09be5.

* web/element: empty-state should not have a default label when used as a loading indicator

* .

* web/bug/empty-state: Fix issues with EmptyState and Loading Overlay

- Add a method, `hasSlotted()`, to the Base component.
- Revise `EmptyState` to use `hasSlotted()`.
- Revise `LoadingOverlay` to use `hasSlotted()`.
- Provide (hopefully complete) Storybook stories for both
- Revise use of these components throughout the codebase.

The essential problem here was mine: I misunderstood what the Patternfly `SlotController` does (and,
yikes, how it does it). Slots aren't magical; they're just named containers, in which lightDOM
elements that appear between the opening and closing tags of a web component can be strategically
placed, shown or hidden, and to some extent styled, within the rendered and visible results of the
shadowDOM component that will fill the browser's RECT allocated to that component.

SlotController tries to associate the template with slots by creating the shadowDOM *first*, then
working backwards to see if there are lightDOM components to put into those slots.  That's not what
we want; we want to see if there are lightDOM components that meet our slot requirements and, if
there are, create corresponding slots for them.

That's what `hasSlotted()` does: it returns true or false to the question, "Is there currently in
the lightDOM for this component an entry requesting a known slot name?"  Components are free to do
what they want with that knowledge.

`<ak-empty-state>` now has several modes, all well-documented in the Storybook story.  But in short,
the Title is now a default slot; any HTML Element not sent to one of the named slots are put into
the Title.  The two named slots are `body` and `primary`.  The header is bold and large; body is
just text, and primary is boxed to indicate that one or more buttons should be placed there, to
allow interaction.

The extra modes are controlled by boolean attributes:

- `loading`: Shows the loading spinner, overriding the `icon` attribute
- `default`: Shows the loading spinner *and* the word "Loading" (i18n-aware).

The priority for all of these is:

- Has something in the default (header) slot: That text will be shown. Overrides both
- `default` overrides `loading`
- `loading`

q`<ak-loading-overlay>` is a specialized variant of `<ak-empty-state>` over what will become
`<ak-backdrop>`, but for now is just internal.  It allows only for the heading and primary slots,
forwarding them `<ak-empty-state>`.  Since this is literally the *Loading*Overlay, showing the
`loading` spinner is the default; to prevent it, pass `no-spinner` as an attribute.

* Grammatical error.

* Prettier had opinions that shouldn't have been aired in public.

* Prettier had opinions that shouldn't have been aired in public.

* Collapsing unnecessary boolean nest.

* fix typo

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* always render icon

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* missing default in flow exec

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* unrelated: fix loading interface

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rename default attr

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix jsdoc

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-06-24 21:33:07 +02:00

605 lines
21 KiB
TypeScript

import { WithLicenseSummary } from "#elements/mixins/license";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import {
APIError,
parseAPIResponseError,
pluckErrorDetail,
} from "@goauthentik/common/errors/network";
import { uiConfig } from "@goauthentik/common/ui/config";
import { groupBy } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/chips/Chip";
import "@goauthentik/elements/chips/ChipGroup";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import "@goauthentik/elements/table/TablePagination";
import "@goauthentik/elements/table/TableSearch";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
import PFPagination from "@patternfly/patternfly/components/Pagination/pagination.css";
import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css";
import PFTable from "@patternfly/patternfly/components/Table/table.css";
import PFToolbar from "@patternfly/patternfly/components/Toolbar/toolbar.css";
import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { LicenseSummaryStatusEnum, Pagination } from "@goauthentik/api";
export interface TableLike {
order?: string;
fetch: () => void;
}
export interface PaginatedResponse<T> {
pagination: Pagination;
autocomplete?: { [key: string]: string };
results: Array<T>;
}
export class TableColumn {
title: string;
orderBy?: string;
onClick?: () => void;
constructor(title: string, orderBy?: string) {
this.title = title;
this.orderBy = orderBy;
}
headerClickHandler(table: TableLike): void {
if (!this.orderBy) {
return;
}
table.order = table.order === this.orderBy ? `-${this.orderBy}` : this.orderBy;
table.fetch();
}
private getSortIndicator(table: TableLike): string {
switch (table.order) {
case this.orderBy:
return "fa-long-arrow-alt-down";
case `-${this.orderBy}`:
return "fa-long-arrow-alt-up";
default:
return "fa-arrows-alt-v";
}
}
renderSortable(table: TableLike): TemplateResult {
return html` <button
class="pf-c-table__button"
@click=${() => this.headerClickHandler(table)}
>
<div class="pf-c-table__button-content">
<span class="pf-c-table__text">${this.title}</span>
<span class="pf-c-table__sort-indicator">
<i class="fas ${this.getSortIndicator(table)}"></i>
</span>
</div>
</button>`;
}
render(table: TableLike): TemplateResult {
const classes = {
"pf-c-table__sort": !!this.orderBy,
"pf-m-selected": table.order === this.orderBy || table.order === `-${this.orderBy}`,
};
return html`<th role="columnheader" scope="col" class="${classMap(classes)}">
${this.orderBy ? this.renderSortable(table) : html`${this.title}`}
</th>`;
}
}
export abstract class Table<T> extends WithLicenseSummary(AKElement) implements TableLike {
abstract apiEndpoint(): Promise<PaginatedResponse<T>>;
abstract columns(): TableColumn[];
abstract row(item: T): SlottedTemplateResult[];
private isLoading = false;
@property({ type: Boolean })
supportsQL: boolean = false;
searchEnabled(): boolean {
return false;
}
renderExpanded(_item: T): SlottedTemplateResult {
if (this.expandable) {
throw new Error("Expandable is enabled but renderExpanded is not overridden!");
}
return nothing;
}
@property({ attribute: false })
data?: PaginatedResponse<T>;
@property({ type: Number })
page = getURLParam("tablePage", 1);
/**
* Set if your `selectedElements` use of the selection box is to enable bulk-delete,
* so that stale data is cleared out when the API returns a new list minus the deleted entries.
*
* @prop
*/
@property({ attribute: "clear-on-refresh", type: Boolean, reflect: true })
clearOnRefresh = false;
@property({ type: String })
order?: string;
@property({ type: String })
search: string = "";
@property({ type: Boolean })
checkbox = false;
@property({ type: Boolean })
clickable = false;
@property({ attribute: false })
clickHandler: (item: T) => void = () => {};
@property({ type: Boolean })
radioSelect = false;
@property({ type: Boolean })
checkboxChip = false;
@property({ attribute: false })
selectedElements: T[] = [];
@property({ type: Boolean })
paginated = true;
@property({ type: Boolean })
expandable = false;
@property({ attribute: false })
expandedElements: T[] = [];
@state()
error?: APIError;
static get styles(): CSSResult[] {
return [
PFBase,
PFTable,
PFBullseye,
PFButton,
PFSwitch,
PFToolbar,
PFDropdown,
PFPagination,
css`
.pf-c-toolbar__group.pf-m-search-filter.ql {
flex-grow: 1;
}
ak-table-search.ql {
width: 100% !important;
}
.pf-c-table thead .pf-c-table__check {
min-width: 3rem;
}
.pf-c-table tbody .pf-c-table__check input {
margin-top: calc(var(--pf-c-table__check--input--MarginTop) + 1px);
}
.pf-c-toolbar__content {
row-gap: var(--pf-global--spacer--sm);
}
.pf-c-toolbar__item .pf-c-input-group {
padding: 0 var(--pf-global--spacer--sm);
}
.pf-c-table {
--pf-c-table--m-striped__tr--BackgroundColor: var(
--pf-global--BackgroundColor--dark-300
);
}
`,
];
}
constructor() {
super();
this.addEventListener(EVENT_REFRESH, async () => {
await this.fetch();
});
if (this.searchEnabled()) {
this.search = getURLParam("search", "");
}
}
async defaultEndpointConfig() {
return {
ordering: this.order,
page: this.page,
pageSize: (await uiConfig()).pagination.perPage,
search: this.searchEnabled() ? this.search || "" : undefined,
};
}
public groupBy(items: T[]): [SlottedTemplateResult, T[]][] {
return groupBy(items, () => {
return "";
});
}
public async fetch(): Promise<void> {
if (this.isLoading) return;
this.isLoading = true;
return this.apiEndpoint()
.then((data) => {
this.data = data;
this.error = undefined;
this.page = this.data.pagination.current;
const newExpanded: T[] = [];
this.data.results.forEach((res) => {
const jsonRes = JSON.stringify(res);
// So because we're dealing with complex objects here, we can't use indexOf
// since it checks strict equality, and we also can't easily check in findIndex()
// Instead we default to comparing the JSON of both objects, which is quite slow
// Hence we check if the objects have `pk` attributes set (as most models do)
// and compare that instead, which will be much faster.
let comp = (item: T) => {
return JSON.stringify(item) === jsonRes;
};
if (Object.hasOwn(res as object, "pk")) {
comp = (item: T) => {
return (
(item as unknown as { pk: string | number }).pk ===
(res as unknown as { pk: string | number }).pk
);
};
}
const expandedIndex = this.expandedElements.findIndex(comp);
if (expandedIndex > -1) {
newExpanded.push(res);
}
});
this.expandedElements = newExpanded;
// Clear selections after fetch if clearOnRefresh is true
if (this.clearOnRefresh) {
this.selectedElements = [];
}
})
.catch(async (error: unknown) => {
this.error = await parseAPIResponseError(error);
})
.finally(() => {
this.isLoading = false;
this.requestUpdate();
});
}
private renderLoading(): TemplateResult {
return html`<tr role="row">
<td role="cell" colspan="25">
<div class="pf-l-bullseye">
<ak-empty-state default-label></ak-empty-state>
</div>
</td>
</tr>`;
}
renderEmpty(inner?: SlottedTemplateResult): TemplateResult {
return html`<tbody role="rowgroup">
<tr role="row">
<td role="cell" colspan="8">
<div class="pf-l-bullseye">
${inner ??
html`<ak-empty-state
><span>${msg("No objects found.")}</span>
<div slot="primary">${this.renderObjectCreate()}</div>
</ak-empty-state>`}
</div>
</td>
</tr>
</tbody>`;
}
renderObjectCreate(): SlottedTemplateResult {
return nothing;
}
renderError(): SlottedTemplateResult {
if (!this.error) return nothing;
return html`<ak-empty-state icon="fa-ban"
><span>${msg("Failed to fetch objects.")}</span>
<div slot="body">${pluckErrorDetail(this.error)}</div>
</ak-empty-state>`;
}
private renderRows(): TemplateResult[] | undefined {
if (this.error) {
return [this.renderEmpty(this.renderError())];
}
if (!this.data || this.isLoading) {
return [this.renderLoading()];
}
if (this.data.pagination.count === 0) {
return [this.renderEmpty()];
}
const groupedResults = this.groupBy(this.data.results);
if (groupedResults.length === 1 && groupedResults[0][0] === "") {
return this.renderRowGroup(groupedResults[0][1]);
}
return groupedResults.map(([group, items]) => {
return html`<thead>
<tr role="row">
<th role="columnheader" scope="row" colspan="200">${group}</th>
</tr>
</thead>
${this.renderRowGroup(items)}`;
});
}
private renderRowGroup(items: T[]): TemplateResult[] {
return items.map((item) => {
const itemSelectHandler = (ev: InputEvent | PointerEvent) => {
const target = ev.target as HTMLElement;
if (ev instanceof PointerEvent && target.classList.contains("ignore-click")) {
return;
}
const selected = this.selectedElements.includes(item);
const checked =
ev instanceof PointerEvent ? !selected : (target as HTMLInputElement).checked;
if ((checked && selected) || !(checked || selected)) {
return;
}
this.selectedElements = this.selectedElements.filter((i) => i !== item);
if (checked) {
this.selectedElements.push(item);
}
const selectAllCheckbox =
this.shadowRoot?.querySelector<HTMLInputElement>("[name=select-all]");
if (selectAllCheckbox && this.selectedElements.length < 1) {
selectAllCheckbox.checked = false;
}
this.requestUpdate();
};
const renderCheckbox = () =>
html`<td class="pf-c-table__check" role="cell">
<label class="ignore-click"
><input
type="checkbox"
class="ignore-click"
.checked=${this.selectedElements.includes(item)}
@input=${itemSelectHandler}
@click=${(ev: Event) => {
ev.stopPropagation();
}}
/></label>
</td>`;
const handleExpansion = (ev: Event) => {
ev.stopPropagation();
const expanded = this.expandedElements.includes(item);
this.expandedElements = this.expandedElements.filter((i) => i !== item);
if (!expanded) {
this.expandedElements.push(item);
}
this.requestUpdate();
};
const expandedClass = {
"pf-m-expanded": this.expandedElements.includes(item),
};
const renderExpansion = () => {
return html`<td class="pf-c-table__toggle" role="cell">
<button
class="pf-c-button pf-m-plain ${classMap(expandedClass)}"
@click=${handleExpansion}
>
<div class="pf-c-table__toggle-icon">
&nbsp;<i class="fas fa-angle-down" aria-hidden="true"></i>&nbsp;
</div>
</button>
</td>`;
};
return html`<tbody role="rowgroup" class="${classMap(expandedClass)}">
<tr
role="row"
class="${this.checkbox || this.clickable ? "pf-m-hoverable" : ""}"
@click=${this.clickable
? () => {
this.clickHandler(item);
}
: itemSelectHandler}
>
${this.checkbox ? renderCheckbox() : nothing}
${this.expandable ? renderExpansion() : nothing}
${this.row(item).map((column, columnIndex) => {
return html`<td data-column-index="${columnIndex}" role="cell">
${column}
</td>`;
})}
</tr>
<tr class="pf-c-table__expandable-row ${classMap(expandedClass)}" role="row">
<td></td>
${this.expandedElements.includes(item) ? this.renderExpanded(item) : nothing}
</tr>
</tbody>`;
});
}
renderToolbar(): TemplateResult {
return html` ${this.renderObjectCreate()}
<ak-spinner-button
.callAction=${() => {
return this.fetch();
}}
class="pf-m-secondary"
>
${msg("Refresh")}</ak-spinner-button
>`;
}
renderToolbarSelected(): SlottedTemplateResult {
return nothing;
}
renderToolbarAfter(): SlottedTemplateResult {
return nothing;
}
renderSearch(): TemplateResult {
const runSearch = (value: string) => {
this.search = value;
updateURLParams({
search: value,
});
this.fetch();
};
const isQL =
this.supportsQL && this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed;
return !this.searchEnabled()
? html``
: html`<div class="pf-c-toolbar__group pf-m-search-filter ${isQL ? "ql" : ""}">
<ak-table-search
?supportsQL=${this.supportsQL}
class="pf-c-toolbar__item pf-m-search-filter ${isQL ? "ql" : ""}"
value=${ifDefined(this.search)}
.onSearch=${runSearch}
.apiResponse=${this.data}
>
</ak-table-search>
</div>`;
}
renderToolbarContainer(): TemplateResult {
return html`<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
${this.renderSearch()}
<div class="pf-c-toolbar__bulk-select">${this.renderToolbar()}</div>
<div class="pf-c-toolbar__group">${this.renderToolbarAfter()}</div>
<div class="pf-c-toolbar__group">${this.renderToolbarSelected()}</div>
${this.paginated ? this.renderTablePagination() : html``}
</div>
</div>`;
}
firstUpdated(): void {
this.fetch();
}
/* The checkbox on the table header row that allows the user to "activate all on this page,"
* "deactivate all on this page" with a single click.
*/
renderAllOnThisPageCheckbox(): TemplateResult {
const checked =
this.selectedElements.length === this.data?.results.length &&
this.selectedElements.length > 0;
const onInput = (ev: InputEvent) => {
this.selectedElements = (ev.target as HTMLInputElement).checked
? this.data?.results.slice(0) || []
: [];
};
return html`<td class="pf-c-table__check" role="cell">
<input
name="select-all"
type="checkbox"
aria-label=${msg("Select all rows")}
.checked=${checked}
@input=${onInput}
/>
</td>`;
}
/* For very large tables where the user is selecting a limited number of entries, we provide a
* chip-based subtable at the top that shows the list of selected entries. Long text result in
* ellipsized chips, which is sub-optimal.
*/
renderSelectedChip(_item: T): SlottedTemplateResult {
// Override this for chip-based displays
return nothing;
}
get needChipGroup() {
return this.checkbox && this.checkboxChip;
}
renderChipGroup(): TemplateResult {
return html`<ak-chip-group>
${this.selectedElements.map((el) => {
return html`<ak-chip>${this.renderSelectedChip(el)}</ak-chip>`;
})}
</ak-chip-group>`;
}
/* A simple pagination display, shown at both the top and bottom of the page. */
renderTablePagination(): TemplateResult {
const handler = (page: number) => {
updateURLParams({ tablePage: page });
this.page = page;
this.fetch();
};
return html`
<ak-table-pagination
class="pf-c-toolbar__item pf-m-pagination"
.pages=${this.data?.pagination}
.pageChangeHandler=${handler}
>
</ak-table-pagination>
`;
}
renderTable(): TemplateResult {
const renderBottomPagination = () =>
html`<div class="pf-c-pagination pf-m-bottom">${this.renderTablePagination()}</div>`;
return html`${this.needChipGroup ? this.renderChipGroup() : html``}
${this.renderToolbarContainer()}
<table class="pf-c-table pf-m-compact pf-m-grid-md pf-m-expandable">
<thead>
<tr role="row" class="pf-c-table__header-row">
${this.checkbox ? this.renderAllOnThisPageCheckbox() : html``}
${this.expandable ? html`<td role="cell"></td>` : html``}
${this.columns().map((col) => col.render(this))}
</tr>
</thead>
${this.renderRows()}
</table>
${this.paginated ? renderBottomPagination() : html``}`;
}
render(): TemplateResult {
return this.renderTable();
}
}