diff --git a/web/src/elements/ak-progress-bar.ts b/web/src/elements/ak-progress-bar.ts index 1fdcfce9fde8..50cd88009365 100644 --- a/web/src/elements/ak-progress-bar.ts +++ b/web/src/elements/ak-progress-bar.ts @@ -64,19 +64,19 @@ export class ProgressBar extends AKElement { `, ]; - @property({ type: Number }) + @property({ type: Number, reflect: true, useDefault: true }) public min = 0; - @property({ type: Number }) + @property({ type: Number, reflect: true, useDefault: true }) public max = 100; - @property({ type: Number }) + @property({ type: Number, reflect: true, useDefault: true }) public value = 0; - @property({ type: Boolean }) + @property({ type: Boolean, reflect: true, useDefault: true }) public indeterminate = false; - @property({ type: String }) + @property({ type: String, reflect: true, useDefault: true }) public size: PFSize = PFSize.Medium; @property({ type: String }) @@ -105,7 +105,7 @@ export class ProgressBar extends AKElement { ` : nothing}
extends Table { return nothing; } - firstUpdated(): void { + firstUpdated(changedProperties: PropertyValues): void { this.expandable = this.usedBy !== undefined; - super.firstUpdated(); + super.firstUpdated(changedProperties); } renderExpanded(item: T): TemplateResult { diff --git a/web/src/elements/table/Table.css b/web/src/elements/table/Table.css index f82c315b6a6b..093c22fe65d6 100644 --- a/web/src/elements/table/Table.css +++ b/web/src/elements/table/Table.css @@ -146,6 +146,7 @@ time { } :host { + display: block; container-type: inline-size; } diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index 366b6dca07c6..f1615ad2f39b 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -15,6 +15,7 @@ import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/error import { GroupResult } from "#common/utils"; import { AKElement } from "#elements/Base"; +import { intersectionObserver } from "#elements/decorators/intersection-observer"; import { WithLicenseSummary } from "#elements/mixins/license"; import { WithSession } from "#elements/mixins/session"; import { getURLParam, updateURLParams } from "#elements/router/RouteMatch"; @@ -24,6 +25,8 @@ import { ifPresent } from "#elements/utils/attributes"; import { isInteractiveElement } from "#elements/utils/interactivity"; import { isEventTargetingListener } from "#elements/utils/pointer"; +import { ConsoleLogger, Logger } from "#logger/browser"; + import { Pagination } from "@goauthentik/api"; import { kebabCase } from "change-case"; @@ -43,7 +46,6 @@ 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"; export * from "./shared.js"; export * from "./TableColumn.js"; @@ -76,7 +78,6 @@ export abstract class Table implements TableLike { static styles: CSSResult[] = [ - PFBase, PFTable, PFBullseye, PFButton, @@ -87,6 +88,8 @@ export abstract class Table Styles, ]; + //#region Abstract members + protected abstract apiEndpoint(): Promise>; /** * The columns to display in the table. @@ -102,11 +105,50 @@ export abstract class Table */ protected abstract row(item: T): SlottedTemplateResult[]; + //#endregion + + //#region Protected Properties + /** * Customize the "No objects found" message. */ protected emptyStateMessage = msg("No objects found."); + /** + * 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; + + /** + * Logger instance for this table. + */ + protected logger: Logger; + + /** + * A cached grouping of the last fetched results. + * + * @see {@linkcode Table.fetch} + */ + @state() + protected groups: GroupResult[] = []; + + @state() + protected expandedElements = new Set(); + + @state() + protected error: APIError | null = null; + + //#endregion + + //#region Private Members + /** * The total number of defined and additional columns in the table. */ @@ -133,32 +175,40 @@ export abstract class Table } /** - * Whether the table is currently fetching data. + * Timestamp of when a fetch was requested, but deferred due to the table not being visible. */ - @state() - protected loading = false; + #deferredRefreshRequestAt: Date | null = null; - /** - * A timestamp of the last attempt to refresh the table data. - */ - @state() - protected lastRefreshedAt: Date | null = null; + #synchronizeRefreshSchedule(): Promise { + if (!this.visible) { + if (!this.#deferredRefreshRequestAt) { + this.#deferredRefreshRequestAt = new Date(); + } + + return Promise.resolve(); + } + + if (!this.#deferredRefreshRequestAt) { + return Promise.resolve(); + } + + return this.fetch(); + } + + readonly #pageParam: string; + readonly #searchParam: string; /** - * A cached grouping of the last fetched results. - * - * @see {@linkcode Table.fetch} + * A mapping of the current items to their respective identifiers. */ - @state() - protected groups: GroupResult[] = []; + #itemKeys = new WeakMap(); - #pageParam = `${this.tagName.toLowerCase()}-page`; - #searchParam = `${this.tagName.toLowerCase()}-search`; + //#endregion @property({ type: Boolean }) public supportsQL: boolean = false; - //#region Properties + //#region Public Properties @property({ type: String }) public toolbarLabel: string | null = null; @@ -170,7 +220,7 @@ export abstract class Table public data: PaginatedResponse | null = null; @property({ type: Number, useDefault: true }) - public page = getURLParam(this.#pageParam, 1); + public page: number; /** * Set if your `selectedElements` use of the selection box is to enable bulk-delete, @@ -200,9 +250,10 @@ export abstract class Table public checkboxChip = false; /** - * A mapping of the current items to their respective identifiers. + * Whether the table is visible in the viewport. */ - #itemKeys = new WeakMap(); + @intersectionObserver() + public visible = false; /** * A mapping of item keys to selected items. @@ -230,18 +281,23 @@ export abstract class Table //#region Lifecycle - @state() - protected expandedElements = new Set(); - - @state() - protected error: APIError | null = null; - #selectAllCheckboxRef = createRef(); #refreshListener = () => { return this.fetch(); }; + constructor() { + super(); + const tagName = this.tagName.toLowerCase(); + + this.#pageParam = `${tagName}-page`; + this.#searchParam = `${tagName}-search`; + this.page = getURLParam(this.#pageParam, 1); + + this.logger = ConsoleLogger.prefix(tagName); + } + public override connectedCallback(): void { super.connectedCallback(); this.addEventListener(EVENT_REFRESH, this.#refreshListener); @@ -284,15 +340,20 @@ export abstract class Table ) { this.#synchronizeColumnProperties(); } + + if (changedProperties.has("visible") && this.hasUpdated) { + this.#synchronizeRefreshSchedule(); + } } - firstUpdated(): void { - this.fetch(); + protected override firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + this.#synchronizeRefreshSchedule(); } //#endregion - async defaultEndpointConfig(): Promise { + protected async defaultEndpointConfig(): Promise { return { ordering: this.order, page: this.page, @@ -301,7 +362,27 @@ export abstract class Table }; } - public fetch(): Promise { + /** + * Fetch data from the API endpoint. + * + * @see {@linkcode Table.apiEndpoint} + * @todo Make this protected. + */ + public async fetch(): Promise { + if (!this.visible) { + if (this.#deferredRefreshRequestAt) { + this.logger.debug("Skipping fetch; already scheduled"); + + return Promise.resolve(); + } + + this.logger.debug("Scheduling fetch for when table becomes visible"); + + this.#deferredRefreshRequestAt = new Date(); + + return Promise.resolve(); + } + if (this.loading) { return Promise.resolve(); } @@ -351,20 +432,25 @@ export abstract class Table .finally(() => { this.loading = false; this.lastRefreshedAt = new Date(); + this.#deferredRefreshRequestAt = null; this.requestUpdate(); }); } //#region Render - protected renderLoading(): TemplateResult { - return html` - -
- -
- - `; + protected renderLoading(): SlottedTemplateResult { + return guard( + [this.loading, this.#columnCount], + () => + html` + +
+ +
+ + `, + ); } protected renderEmpty(inner?: SlottedTemplateResult): TemplateResult { @@ -447,7 +533,7 @@ export abstract class Table if (this.error) { return this.renderEmpty(this.renderError()); } - if (this.loading && this.data === null) { + if (!this.visible || (this.loading && this.data === null)) { return this.renderLoading(); } @@ -668,13 +754,8 @@ export abstract class Table //#region Toolbar protected renderToolbar(): TemplateResult { - return html` ${this.renderObjectCreate()} - { - return this.fetch(); - }} - class="pf-m-secondary" - > + return html`${this.renderObjectCreate()} + ${msg("Refresh")}`; } @@ -867,19 +948,19 @@ export abstract class Table `; } - protected renderLoadingBar() { - return guard( - [this.loading, this.label], - () => - html``, - ); + protected renderLoadingBar(): SlottedTemplateResult { + return guard([this.loading, this.label], () => { + if (!this.loading) return nothing; + + return html``; + }); } protected renderTable(): TemplateResult { @@ -930,7 +1011,7 @@ export abstract class Table ${guard([this.paginated, this.lastRefreshedAt], renderBottomPagination)}`; } - render(): TemplateResult { + protected override render(): SlottedTemplateResult { return this.renderTable(); } } diff --git a/web/src/styles/authentik/base/common.css b/web/src/styles/authentik/base/common.css index ab65d58c55eb..ab8f12364294 100644 --- a/web/src/styles/authentik/base/common.css +++ b/web/src/styles/authentik/base/common.css @@ -67,6 +67,29 @@ /* #endregion */ +/* #region Animations */ + +@keyframes ak-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.ak-fade-in { + opacity: 0; + animation-fill-mode: forwards; + animation-name: ak-fade-in; + animation-duration: 150ms; + animation-iteration-count: 1; + animation-timing-function: linear; + animation-delay: 500ms; +} + +/* #endregion */ + /* #region Dark Theme */ /**