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 { pagination: Pagination; autocomplete?: { [key: string]: string }; results: Array; } 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` `; } 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` ${this.orderBy ? this.renderSortable(table) : html`${this.title}`} `; } } export abstract class Table extends WithLicenseSummary(AKElement) implements TableLike { abstract apiEndpoint(): Promise>; 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; @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 { 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`
`; } renderEmpty(inner?: SlottedTemplateResult): TemplateResult { return html`
${inner ?? html`${msg("No objects found.")}
${this.renderObjectCreate()}
`}
`; } renderObjectCreate(): SlottedTemplateResult { return nothing; } renderError(): SlottedTemplateResult { if (!this.error) return nothing; return html`${msg("Failed to fetch objects.")}
${pluckErrorDetail(this.error)}
`; } 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` ${group} ${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("[name=select-all]"); if (selectAllCheckbox && this.selectedElements.length < 1) { selectAllCheckbox.checked = false; } this.requestUpdate(); }; const renderCheckbox = () => html` `; 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` `; }; return html` { this.clickHandler(item); } : itemSelectHandler} > ${this.checkbox ? renderCheckbox() : nothing} ${this.expandable ? renderExpansion() : nothing} ${this.row(item).map((column, columnIndex) => { return html` ${column} `; })} ${this.expandedElements.includes(item) ? this.renderExpanded(item) : nothing} `; }); } renderToolbar(): TemplateResult { return html` ${this.renderObjectCreate()} { return this.fetch(); }} class="pf-m-secondary" > ${msg("Refresh")}`; } renderToolbarSelected(): SlottedTemplateResult { return nothing; } renderToolbarAfter(): SlottedTemplateResult { return nothing; } renderSearch(): TemplateResult { const runSearch = (value: string) => { this.search = value; this.page = 1; updateURLParams({ search: value, tablePage: 1, }); this.fetch(); }; const isQL = this.supportsQL && this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed; return !this.searchEnabled() ? html`` : html`
`; } renderToolbarContainer(): TemplateResult { return html`
${this.renderSearch()}
${this.renderToolbar()}
${this.renderToolbarAfter()}
${this.renderToolbarSelected()}
${this.paginated ? this.renderTablePagination() : html``}
`; } 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` `; } /* 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` ${this.selectedElements.map((el) => { return html`${this.renderSelectedChip(el)}`; })} `; } /* 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` `; } renderTable(): TemplateResult { const renderBottomPagination = () => html`
${this.renderTablePagination()}
`; return html`${this.needChipGroup ? this.renderChipGroup() : html``} ${this.renderToolbarContainer()} ${this.checkbox ? this.renderAllOnThisPageCheckbox() : html``} ${this.expandable ? html`` : html``} ${this.columns().map((col) => col.render(this))} ${this.renderRows()}
${this.paginated ? renderBottomPagination() : html``}`; } render(): TemplateResult { return this.renderTable(); } }