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 */
/**