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>
This commit is contained in:
Ken Sternberg
2025-06-24 12:33:07 -07:00
committed by GitHub
parent 88073305eb
commit 27b7b0b0e7
44 changed files with 635 additions and 373 deletions

View File

@ -64,7 +64,7 @@ export const EntryPoint = /** @type {const} */ ({
in: resolve(PackageRoot, "src", "flow", "index.entrypoint.ts"), in: resolve(PackageRoot, "src", "flow", "index.entrypoint.ts"),
out: resolve(DistDirectory, "flow", "FlowInterface"), out: resolve(DistDirectory, "flow", "FlowInterface"),
}, },
Standalone: { StandaloneAPI: {
in: resolve(PackageRoot, "src", "standalone", "api-browser/index.entrypoint.ts"), in: resolve(PackageRoot, "src", "standalone", "api-browser/index.entrypoint.ts"),
out: resolve(DistDirectory, "standalone", "api-browser", "index"), out: resolve(DistDirectory, "standalone", "api-browser", "index"),
}, },

View File

@ -89,7 +89,7 @@ export class RecentEventsCard extends Table<Event> {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state html`<ak-empty-state
><span slot="header">${msg("No Events found.")}</span> ><span>${msg("No Events found.")}</span>
<div slot="body">${msg("No matching events could be found.")}</div> <div slot="body">${msg("No matching events could be found.")}</div>
</ak-empty-state>`, </ak-empty-state>`,
); );

View File

@ -112,7 +112,7 @@ export class ApplicationViewPage extends AKElement {
renderApp(): TemplateResult { renderApp(): TemplateResult {
if (!this.application) { if (!this.application) {
return html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`; return html`<ak-empty-state default-label></ak-empty-state>`;
} }
return html`<ak-tabs> return html`<ak-tabs>
${this.missingOutpost ${this.missingOutpost

View File

@ -118,13 +118,12 @@ export class ApplicationEntitlementsPage extends Table<ApplicationEntitlement> {
renderEmpty(): TemplateResult { renderEmpty(): TemplateResult {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state html`<ak-empty-state icon="pf-icon-module"
header=${msg("No app entitlements created.")} ><span>${msg("No app entitlements created.")}</span>
icon="pf-icon-module"
>
<div slot="body"> <div slot="body">
${msg( ${msg(
"This application does currently not have any application entitlement defined.", "This application does currently not have any application entitlements defined.",
)} )}
</div> </div>
<div slot="primary"></div> <div slot="primary"></div>

View File

@ -116,7 +116,7 @@ export class ApplicationWizardBindingsStep extends ApplicationWizardStep {
.content=${[]} .content=${[]}
></ak-select-table> ></ak-select-table>
<ak-empty-state icon="pf-icon-module" <ak-empty-state icon="pf-icon-module"
><span slot="header">${msg("No bound policies.")} </span> ><span>${msg("No bound policies.")}</span>
<div slot="body">${msg("No policies are currently bound to this object.")}</div> <div slot="body">${msg("No policies are currently bound to this object.")}</div>
<div slot="primary"> <div slot="primary">
<button <button

View File

@ -83,7 +83,7 @@ export class ApplicationWizardProviderChoiceStep extends WithLicenseSummary(Appl
}} }}
></ak-wizard-page-type-create> ></ak-wizard-page-type-create>
</form>` </form>`
: html`<ak-empty-state loading header=${msg("Loading")}></ak-empty-state>`; : html`<ak-empty-state default-label></ak-empty-state>`;
} }
} }

View File

@ -109,10 +109,8 @@ export class EnterpriseLicenseListPage extends TablePage<License> {
return super.renderEmpty(html` return super.renderEmpty(html`
${inner ${inner
? inner ? inner
: html`<ak-empty-state : html`<ak-empty-state icon=${this.pageIcon()}
icon=${this.pageIcon()} ><span>${msg("No licenses found.")}</span>
header="${msg("No licenses found.")}"
>
<div slot="body"> <div slot="body">
${this.searchEnabled() ? this.renderEmptyClearSearch() : html``} ${this.searchEnabled() ? this.renderEmptyClearSearch() : html``}
</div> </div>

View File

@ -136,7 +136,7 @@ export class BoundStagesList extends Table<FlowStageBinding> {
renderEmpty(): TemplateResult { renderEmpty(): TemplateResult {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state icon="pf-icon-module"> html`<ak-empty-state icon="pf-icon-module">
<span slot="header">${msg("No Stages bound")}</span> <span>${msg("No Stages bound")}</span>
<div slot="body">${msg("No stages are currently bound to this flow.")}</div> <div slot="body">${msg("No stages are currently bound to this flow.")}</div>
<div slot="primary"> <div slot="primary">
<ak-stage-wizard <ak-stage-wizard

View File

@ -199,7 +199,7 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
renderEmpty(): TemplateResult { renderEmpty(): TemplateResult {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state icon="pf-icon-module" html`<ak-empty-state icon="pf-icon-module"
><span slot="header">${msg("No Policies bound.")}</span> ><span>${msg("No Policies bound.")}</span>
<div slot="body">${msg("No policies are currently bound to this object.")}</div> <div slot="body">${msg("No policies are currently bound to this object.")}</div>
<div slot="primary"> <div slot="primary">
<ak-policy-wizard <ak-policy-wizard

View File

@ -42,7 +42,7 @@ export class ProviderViewPage extends AKElement {
renderProvider(): TemplateResult { renderProvider(): TemplateResult {
if (!this.provider) { if (!this.provider) {
return html`<ak-empty-state loading fullHeight></ak-empty-state>`; return html`<ak-empty-state loading full-height></ak-empty-state>`;
} }
switch (this.provider?.component) { switch (this.provider?.component) {
case "ak-provider-saml-form": case "ak-provider-saml-form":

View File

@ -34,7 +34,7 @@ export class SourceViewPage extends AKElement {
renderSource(): TemplateResult { renderSource(): TemplateResult {
if (!this.source) { if (!this.source) {
return html`<ak-empty-state loading fullHeight></ak-empty-state>`; return html`<ak-empty-state loading full-height></ak-empty-state>`;
} }
switch (this.source?.component) { switch (this.source?.component) {
case "ak-source-kerberos-form": case "ak-source-kerberos-form":

View File

@ -94,7 +94,7 @@ export class ObjectChangelog extends Table<Event> {
renderEmpty(): TemplateResult { renderEmpty(): TemplateResult {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state html`<ak-empty-state
><span slot="header">${msg("No Events found.")}</span> ><span>${msg("No Events found.")}</span>
<div slot="body">${msg("No matching events could be found.")}</div> <div slot="body">${msg("No matching events could be found.")}</div>
</ak-empty-state>`, </ak-empty-state>`,
); );

View File

@ -67,7 +67,7 @@ export class UserEvents extends Table<Event> {
renderEmpty(): TemplateResult { renderEmpty(): TemplateResult {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state html`<ak-empty-state
><span slot="header">${msg("No Events found.")}</span> ><span>${msg("No Events found.")}</span>
<div slot="body">${msg("No matching events could be found.")}</div> <div slot="body">${msg("No matching events could be found.")}</div>
</ak-empty-state>`, </ak-empty-state>`,
); );

View File

@ -148,5 +148,31 @@ export class AKElement extends LitElement implements AKElementProps {
return this.#styleRoot; return this.#styleRoot;
} }
protected hasSlotted(name: string | null) {
const isNotNestedSlot = (start: Element) => {
let node = start.parentNode;
while (node && node !== this) {
if (node instanceof Element && node.hasAttribute("slot")) {
return false;
}
node = node.parentNode;
}
return true;
};
// All child slots accessible from the component's LightDOM that match the request
const allChildSlotRequests =
typeof name === "string"
? [...this.querySelectorAll(`[slot="${name}"]`)]
: [...this.children].filter((child) => {
const slotAttr = child.getAttribute("slot");
return !slotAttr || slotAttr === "";
});
// All child slots accessible from the LightDom that match the request *and* are not nested
// within another slotted element.
return allChildSlotRequests.filter((node) => isNotNestedSlot(node)).length > 0;
}
//#endregion //#endregion
} }

View File

@ -3,38 +3,63 @@ import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Spinner"; import "@goauthentik/elements/Spinner";
import { type SlottedTemplateResult, type Spread } from "@goauthentik/elements/types"; import { type SlottedTemplateResult, type Spread } from "@goauthentik/elements/types";
import { spread } from "@open-wc/lit-helpers"; import { spread } from "@open-wc/lit-helpers";
import { SlotController } from "@patternfly/pfe-core/controllers/slot-controller.js";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { css, html, nothing } from "lit"; import { css, html, nothing, render } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css"; import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css"; import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
/**
* Props for the EmptyState component
*/
export interface IEmptyState { export interface IEmptyState {
/** Font Awesome icon class (e.g., "fa-user", "fa-folder") to display */
icon?: string; icon?: string;
/** When true, will automatically show the loading spinner. Overrides `icon`. */
loading?: boolean; loading?: boolean;
/**
* When true, will automatically fill the header with the "Loading" message and show the loading
* spinner. Overrides 'loading'.
*/
defaultLabel?: boolean;
/** Whether the empty state should take up the full height of its container */
fullHeight?: boolean; fullHeight?: boolean;
header?: string;
} }
/**
* @element ak-empty-state
* @class EmptyState
*
* A component for displaying empty states with optional icons, headings, body text, and actions.
* Follows PatternFly design patterns for empty state presentations.
*
* ## Slots
*
* @slot - The main heading text for the empty state
* @slot body - Descriptive text explaining the empty state or what the user can do
* @slot primary - Primary action buttons or other interactive elements
*
*/
@customElement("ak-empty-state") @customElement("ak-empty-state")
export class EmptyState extends AKElement implements IEmptyState { export class EmptyState extends AKElement implements IEmptyState {
@property({ type: String }) @property({ type: String })
icon = ""; public icon = "";
@property({ type: Boolean }) @property({ type: Boolean, reflect: true })
loading = false; public loading = false;
@property({ type: Boolean }) @property({ type: Boolean, reflect: true, attribute: "default-label" })
fullHeight = false; public defaultLabel = false;
@property() @property({ type: Boolean, attribute: "full-height" })
header?: string; public fullHeight = false;
slots = new SlotController(this, "header", "body", "primary");
static get styles() { static get styles() {
return [ return [
@ -50,32 +75,49 @@ export class EmptyState extends AKElement implements IEmptyState {
]; ];
} }
render() { willUpdate() {
const showHeader = this.loading || this.slots.hasSlotted("header"); if (this.defaultLabel && this.querySelector("span:not([slot])") === null) {
const header = () => render(html`<span>${msg("Loading")}</span>`, this);
this.slots.hasSlotted("header") }
? html`<slot name="header"></slot>` }
: html`<span>${msg("Loading")}</span>`;
return html`<div class="pf-c-empty-state ${this.fullHeight && "pf-m-full-height"}"> get localAriaLabel() {
<div class="pf-c-empty-state__content"> const result = this.querySelector("span:not([slot])");
${this.loading return result instanceof HTMLElement ? result.innerText || undefined : undefined;
? html`<div class="pf-c-empty-state__icon"> }
render() {
const hasHeading = this.hasSlotted(null);
const loading = this.loading || this.defaultLabel;
const classes = {
"pf-c-empty-state": true,
"pf-m-full-height": this.fullHeight,
};
return html`<div aria-label=${this.localAriaLabel ?? nothing} class="${classMap(classes)}">
<div class="pf-c-empty-state__content" role="progressbar">
${loading
? html`<div part="spinner" class="pf-c-empty-state__icon">
<ak-spinner size=${PFSize.XLarge}></ak-spinner> <ak-spinner size=${PFSize.XLarge}></ak-spinner>
</div>` </div>`
: html`<i : html`<i
part="icon"
class="pf-icon fa ${this.icon || class="pf-icon fa ${this.icon ||
"fa-question-circle"} pf-c-empty-state__icon" "fa-question-circle"} pf-c-empty-state__icon"
aria-hidden="true" aria-hidden="true"
></i>`} ></i>`}
${showHeader ? html` <h1 class="pf-c-title pf-m-lg">${header()}</h1>` : nothing} ${hasHeading
${this.slots.hasSlotted("body") ? html` <h1 part="heading" class="pf-c-title pf-m-lg" id="empty-state-heading">
? html` <div class="pf-c-empty-state__body"> <slot></slot>
</h1>`
: nothing}
${this.hasSlotted("body")
? html` <div part="body" class="pf-c-empty-state__body">
<slot name="body"></slot> <slot name="body"></slot>
</div>` </div>`
: nothing} : nothing}
${this.slots.hasSlotted("primary") ${this.hasSlotted("primary")
? html` <div class="pf-c-empty-state__primary"> ? html` <div part="primary" class="pf-c-empty-state__primary">
<slot name="primary"></slot> <slot name="primary"></slot>
</div>` </div>`
: nothing} : nothing}
@ -84,10 +126,37 @@ export class EmptyState extends AKElement implements IEmptyState {
} }
} }
export function akEmptyState(properties: IEmptyState, content: SlottedTemplateResult = nothing) { interface IEmptyStateContent {
const message = heading?: SlottedTemplateResult;
typeof content === "string" ? html`<span slot="body">${content}</span>` : content; body?: SlottedTemplateResult;
return html`<ak-empty-state ${spread(properties as Spread)}>${message}</ak-empty-state>`; primary?: SlottedTemplateResult;
}
type ContentKey = keyof IEmptyStateContent;
type ContentValue = SlottedTemplateResult | undefined;
/**
* Generate `<ak-empty-state>` programmatically
*
* @param properties - properties to apply to the component.
* @param content - strings or TemplateResults for the slots in `<ak-empty-state>`
* @returns TemplateResult for the ak-empty-state element
*
*/
export function akEmptyState(properties: IEmptyState = {}, content: IEmptyStateContent = {}) {
// `heading` here is an Object.key of ILoadingOverlayContent, not the obsolete
// slot-name.
const stringToSlot = (name: string, c: ContentValue) =>
name === "heading" ? html`<span>${c}</span>` : html`<span slot=${name}>${c}</span>`;
const stringToTemplate = (name: string, c: ContentValue) =>
typeof c === "string" ? stringToSlot(name, c) : c;
const items = Object.entries(content)
.map(([name, content]) => stringToTemplate(name, content))
.filter(Boolean);
return html`<ak-empty-state ${spread(properties as Spread)}>${items}</ak-empty-state>`;
} }
declare global { declare global {

View File

@ -5,30 +5,59 @@ import { spread } from "@open-wc/lit-helpers";
import { css, html, nothing } from "lit"; import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
export interface ILoadingOverlay { export interface ILoadingOverlay {
/**
* Whether this overlay should appear above all other overlays (z-index: 999)
*/
topmost?: boolean; topmost?: boolean;
/**
* Whether to show the loading spinner animation
*/
noSpinner?: boolean;
/**
* Icon name to display instead of the default loading spinner
*/
icon?: string;
} }
/**
* @element ak-loading-overlay
* @class LoadingOverlay
*
* A component for for showing a loading message above a darkening background, in order
* to pause interaction while dynamically importing a major component.
*
* ## Slots
*
* @slot - The main heading text for the loading state
* @slot body - Descriptive text explaining the loading state
*
*/
@customElement("ak-loading-overlay") @customElement("ak-loading-overlay")
export class LoadingOverlay extends AKElement implements ILoadingOverlay { export class LoadingOverlay extends AKElement implements ILoadingOverlay {
// Do not camelize: https://www.merriam-webster.com/dictionary/topmost // Do not camelize: https://www.merriam-webster.com/dictionary/topmost
@property({ type: Boolean, attribute: "topmost" }) @property({ type: Boolean, attribute: "topmost" })
topmost = false; topmost = false;
@property({ type: Boolean }) @property({ type: Boolean, attribute: "no-spinner" })
loading = true; noSpinner = false;
@property({ type: String }) @property({ type: String })
icon = ""; icon?: string;
static get styles() { static get styles() {
return [ return [
PFBase, PFBase,
css` css`
:host { :host {
top: 0;
left: 0;
display: flex; display: flex;
height: 100%; height: 100%;
width: 100%; width: 100%;
@ -46,20 +75,49 @@ export class LoadingOverlay extends AKElement implements ILoadingOverlay {
} }
render() { render() {
return html`<ak-empty-state ?loading=${this.loading} header="" icon=${this.icon}> // Nested slots. Can get a little cognitively heavy, so be careful if you're editing here...
<span slot="body"><slot></slot></span> return html`<ak-empty-state ?loading=${!this.noSpinner} icon=${ifDefined(this.icon)}>
${this.hasSlotted(null) ? html`<span><slot></slot></span>` : nothing}
${this.hasSlotted("body")
? html`<span slot="body"><slot name="body"></slot></span>`
: nothing}
</ak-empty-state>`; </ak-empty-state>`;
} }
} }
interface ILoadingOverlayContent {
heading?: SlottedTemplateResult;
body?: SlottedTemplateResult;
}
type ContentKey = keyof ILoadingOverlayContent;
type ContentValue = SlottedTemplateResult | undefined;
/**
* Function to create `<ak-loading-overlay>` programmatically
*
* @param properties - properties to apply to the component.
* @param content - strings or TemplateResults for the slots in `<ak-loading-overlay>`
* @returns TemplateResult for the ak-loading-overlay element
*
*/
export function akLoadingOverlay( export function akLoadingOverlay(
properties: ILoadingOverlay, properties: ILoadingOverlay = {},
content: SlottedTemplateResult = nothing, content: ILoadingOverlayContent = {},
) { ) {
const message = typeof content === "string" ? html`<span>${content}</span>` : content; // `heading` here is an Object.key of ILoadingOverlayContent, not the obsolete
return html`<ak-loading-overlay ${spread(properties as Spread)} // slot-name.
>${message}</ak-loading-overlay const stringToSlot = (name: string, c: ContentValue) =>
>`; name === "heading" ? html`<span>${c}</span>` : html`<span slot=${name}>${c}</span>`;
const stringToTemplate = (name: string, c: ContentValue) =>
typeof c === "string" ? stringToSlot(name, c) : c;
const items = Object.entries(content)
.map(([name, content]) => stringToTemplate(name, content))
.filter(Boolean);
return html`<ak-loading-overlay ${spread(properties as Spread)}>${items}</ak-loading-overlay>`;
} }
declare global { declare global {

View File

@ -201,7 +201,7 @@ export abstract class AKChart<T> extends AKElement {
${this.error ${this.error
? html` ? html`
<ak-empty-state icon="fa-times" <ak-empty-state icon="fa-times"
><span slot="header">${msg("Failed to fetch data.")}</span> ><span>${msg("Failed to fetch data.")}</span>
<p slot="body">${pluckErrorDetail(this.error)}</p> <p slot="body">${pluckErrorDetail(this.error)}</p>
</ak-empty-state> </ak-empty-state>
` `

View File

@ -40,9 +40,7 @@ export class LogViewer extends Table<LogEvent> {
renderEmpty(): TemplateResult { renderEmpty(): TemplateResult {
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state html`<ak-empty-state><span>${msg("No log messages.")}</span> </ak-empty-state>`,
><span slot="header">${msg("No log messages.")}</span>
</ak-empty-state>`,
); );
} }

View File

@ -164,7 +164,7 @@ export class NotificationDrawer extends AKElement {
renderEmpty() { renderEmpty() {
return html`<ak-empty-state return html`<ak-empty-state
><span slot="header">${msg("No notifications found.")}</span> ><span>${msg("No notifications found.")}</span>
<div slot="body">${msg("You don't have any notifications currently.")}</div> <div slot="body">${msg("You don't have any notifications currently.")}</div>
</ak-empty-state>`; </ak-empty-state>`;
} }

View File

@ -1,59 +0,0 @@
import { Canvas, Description, Meta, Story, Title } from "@storybook/blocks";
import * as EmptyStateStories from "./EmptyState.stories";
<Meta of={EmptyStateStories} />
# EmptyState
The EmptyState is an in-page element to indicate that something is either loading or unavailable.
When "loading" is true it displays a spinner, otherwise it displays a static icon. The default
icon is a question mark in a circle.
It has two named slots, `body` and `primary`, to communicate further details about the current state
this element is meant to display.
## Usage
```Typescript
import "@goauthentik/elements/EmptyState.js";
```
Note that the content of an alert _must_ be a valid HTML component; plain text does not work here.
```html
<ak-empty-state icon="fa-eject"
><span slot="primary">This would display in the "primary" slot</span></ak-empty-state
>
```
## Demo
### Default: Loading
The default state is _loading_
<Story of={EmptyStateStories.DefaultStory} />
### Done
<Story of={EmptyStateStories.DefaultAndLoadingDone} />
### Alternative "Done" Icon
This also shows the "header" attribute filled, which is rendered in a large, dark typeface.
<Story of={EmptyStateStories.DoneWithAlternativeIcon} />
### The Body Slot Filled
The body content slot is rendered in a lighter typeface at default size.
<Story of={EmptyStateStories.WithBodySlotFilled} />
### The Body and Primary Slot Filled
The primary content is rendered in the normal dark typeface at default size. It is also spaced
significantly below the spinner itself.
<Story of={EmptyStateStories.WithBodyAndPrimarySlotsFilled} />

View File

@ -1,108 +1,254 @@
import type { Meta, StoryObj } from "@storybook/web-components"; import type { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit"; import { TemplateResult, html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
import { EmptyState, type IEmptyState } from "../EmptyState.js";
import "../EmptyState.js"; import "../EmptyState.js";
import { type EmptyState, type IEmptyState, akEmptyState } from "../EmptyState.js";
const metadata: Meta<EmptyState> = { type StoryArgs = IEmptyState & {
title: "Elements/<ak-empty-state>", headingText?: string | TemplateResult;
bodyText?: string | TemplateResult;
primaryButtonText?: string | TemplateResult;
};
const metadata: Meta<StoryArgs> = {
title: "Elements / <ak-empty-state>",
component: "ak-empty-state", component: "ak-empty-state",
tags: ["autodocs"],
parameters: { parameters: {
docs: { docs: {
description: "Our empty state spinner", description: {
component: `
# Empty State Component
The EmptyState is an in-page element to indicate that something is either loading or unavailable.
When "loading" is true it displays a spinner, otherwise it displays a static icon. The default
icon is a question mark in a circle.
It has three named slots:
- The default slot: The heading (renders larger and more bold)
- **body**: Any text to describe the state
- **primary**: Action buttons or other interactive elements
For the loading attributes:
- The attribute \`loading\` will show the spinner
- The attribute \`default\` will show the spinner and the default header of "Loading"
If either of these attributes is active and the element contains content not assigned to one of the
named slots, it will be shown in the header. This overrides the default text of \`default\`. You
do not need both attributes for \`default\` to work; it assumes loading.
`,
}, },
}, },
layout: "padded",
},
argTypes: { argTypes: {
icon: { control: "text" }, icon: {
loading: { control: "boolean" }, control: "text",
fullHeight: { control: "boolean" }, description: "Font Awesome icon class (without 'fa-' prefix)",
header: { control: "text" }, },
loading: {
control: "boolean",
description: "Show loading spinner instead of icon",
},
defaultLabel: {
control: "boolean",
description: "Show loading spinner instead of icon",
},
fullHeight: {
control: "boolean",
description: "Fill the full height of container",
},
headingText: {
control: "text",
description: "Text for heading slot (for demo purposes)",
},
bodyText: {
control: "text",
description: "Text for body slot (for demo purposes)",
},
primaryButtonText: {
control: "text",
description: "Text for primary button (for demo purposes)",
},
}, },
}; };
export default metadata; export default metadata;
const container = (content: TemplateResult) => type Story = StoryObj<StoryArgs>;
html` <div style="background-color: #f0f0f0; padding: 1rem;">
<style>
ak-divider {
display: inline-block;
width: 32rem;
max-width: 32rem;
}</style
>${content}
</div>`;
export const DefaultStory: StoryObj = { const Template: Story = {
args: { args: {
icon: undefined, icon: "fa-circle-radiation",
loading: true, loading: false,
defaultLabel: false,
fullHeight: false, fullHeight: false,
header: undefined,
}, },
render: (args) => html`
render: ({ icon, loading, fullHeight, header }: IEmptyState) =>
container(
html` <ak-empty-state
?loading=${loading}
?fullHeight=${fullHeight}
icon=${ifDefined(icon)}
header=${ifDefined(header)}
>
</ak-empty-state>`,
),
};
export const DefaultAndLoadingDone = {
...DefaultStory,
args: { ...DefaultStory, ...{ loading: false } },
};
export const DoneWithAlternativeIcon = {
...DefaultStory,
args: {
...DefaultStory,
...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" },
},
};
export const WithBodySlotFilled = {
...DefaultStory,
args: {
...DefaultStory,
...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" },
},
render: ({ icon, loading, fullHeight, header }: IEmptyState) =>
container(html`
<ak-empty-state <ak-empty-state
?loading=${loading} icon=${ifDefined(args.icon)}
?fullHeight=${fullHeight} ?loading=${args.loading}
icon=${ifDefined(icon)} ?default=${args.defaultLabel}
header=${ifDefined(header)} ?full-height=${args.fullHeight}
> >
<span slot="body">This is the body content</span> ${args.headingText ? html`<span>${args.headingText}</span>` : nothing}
${args.bodyText ? html`<span slot="body">${args.bodyText}</span>` : nothing}
${args.primaryButtonText
? html`
<button slot="primary" class="pf-c-button pf-m-primary">
${args.primaryButtonText}
</button>
`
: nothing}
</ak-empty-state> </ak-empty-state>
`), `,
}; };
export const WithBodyAndPrimarySlotsFilled = { export const Basic: Story = {
...DefaultStory, ...Template,
args: { args: {
...DefaultStory, icon: "fa-folder-open",
...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" }, headingText: "No files found",
bodyText: "This folder is empty. Upload some files to get started.",
},
};
export const Empty: Story = {
...Template,
args: {
icon: "",
},
render: () =>
html`<p>Note that a completely empty &lt;ak-empty-state&gt; is just that: empty.</p>
<ak-empty-state></ak-empty-state>`,
};
export const WithAction: Story = {
...Template,
args: {
icon: "fa-users",
headingText: "No users yet",
bodyText: "Get started by creating your first user account.",
primaryButtonText: html`<button>Create User</button>`,
},
};
export const Loading: Story = {
...Template,
args: {
loading: true,
},
};
export const LoadingWithCustomMessage: Story = {
...Template,
args: {
loading: true,
headingText: html`<span>I <em>know</em> it's here, somewhere...</span>`,
},
};
export const LoadingWithDefaultMessage: Story = {
...Template,
args: {
defaultLabel: true,
},
};
export const LoadingDefaultWithOverride: Story = {
...Template,
args: {
defaultLabel: true,
headingText: html`<span>Have they got a chance? Eh. It would take a miracle.</span>`,
},
};
export const LoadingDefaultWithButton: Story = {
...Template,
args: {
defaultLabel: true,
primaryButtonText: html`<button>Cancel</button>`,
},
};
export const FullHeight: Story = {
...Template,
args: {
icon: "fa-search",
headingText: "No search results",
bodyText: "Try adjusting your search criteria or browse our categories.",
fullHeight: true,
primaryButtonText: html`<button>Go back</button>`,
},
};
export const ProgrammaticUsage: Story = {
...Template,
args: {
icon: "fa-beer",
headingText: "Hold My Beer",
bodyText: "I saw this in a cartoon once. I'm sure I can pull it off.",
primaryButtonText: html`<button>Leave The Scene Immediately</button>`,
},
render: (args) =>
akEmptyState(
{
icon: args.icon,
},
{
heading: args.headingText,
body: args.bodyText,
primary: args.primaryButtonText
? html`
<button slot="primary" class="pf-c-button pf-m-primary">
${args.primaryButtonText}
</button>
`
: undefined,
}, },
render: ({ icon, loading, fullHeight, header }: IEmptyState) =>
container(
html` <ak-empty-state
?loading=${loading}
?fullHeight=${fullHeight}
icon=${ifDefined(icon)}
header=${ifDefined(header)}
>
<span slot="body">This is the body content slot</span>
<span slot="primary">This is the primary content slot</span>
</ak-empty-state>`,
), ),
}; };
export const IconShowcase: Story = {
args: {},
render: () => html`
<div
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem;"
>
<ak-empty-state icon="fa-users">
<span>Users</span>
<span slot="body">No users found</span>
</ak-empty-state>
<ak-empty-state icon="fa-database">
<span>Database</span>
<span slot="body">No records</span>
</ak-empty-state>
<ak-empty-state icon="fa-envelope">
<span>Messages</span>
<span slot="body">No messages</span>
</ak-empty-state>
<ak-empty-state icon="fa-chart-bar">
<span>Analytics</span>
<span slot="body">No data to display</span>
</ak-empty-state>
<ak-empty-state icon="fa-cog">
<span>Settings</span>
<span slot="body">No configuration</span>
</ak-empty-state>
<ak-empty-state icon="fa-shield-alt">
<span>Security</span>
<span slot="body">No alerts</span>
</ak-empty-state>
</div>
`,
};

View File

@ -1,36 +0,0 @@
import { Canvas, Description, Meta, Story, Title } from "@storybook/blocks";
import * as LoadingOverlayStories from "./LoadingOverlay.stories";
<Meta of={LoadingOverlayStories} />
# LoadingOverlay
The LoadingOverlay is meant to cover the container element completely, hiding the content behind a
dimming filter, while content loads.
It has a single named slot, "body" into which messages about the loading process can be included.
## Usage
```Typescript
import "@goauthentik/elements/LoadingOverlay.js";
```
Note that the content of an alert _must_ be a valid HTML component; plain text does not work here.
```html
<ak-loading-overlay topmost>
<span>This would display below the loading spinner</span>
</ak-loading-overlay>
```
## Demo
### Default
<Story of={LoadingOverlayStories.DefaultStory} />
### With a message
<Story of={LoadingOverlayStories.WithAMessage} />

View File

@ -1,74 +1,154 @@
import type { Meta, StoryObj } from "@storybook/web-components"; import type { Meta, StoryObj } from "@storybook/web-components";
import { LitElement, TemplateResult, css, html } from "lit"; import { html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js";
import { type ILoadingOverlay, LoadingOverlay } from "../LoadingOverlay.js";
import "../LoadingOverlay.js"; import "../LoadingOverlay.js";
import { type ILoadingOverlay, LoadingOverlay, akLoadingOverlay } from "../LoadingOverlay.js";
const metadata: Meta<LoadingOverlay> = { type StoryArgs = ILoadingOverlay & {
title: "Elements/<ak-loading-overlay>", headingText?: string;
bodyText?: string;
noSpinner: boolean;
};
const metadata: Meta<StoryArgs> = {
title: "Elements/ <ak-loading-overlay>",
component: "ak-loading-overlay", component: "ak-loading-overlay",
tags: ["autodocs"],
parameters: { parameters: {
docs: { docs: {
description: "Our empty state spinner", description: {
component: `
# Loading Overlay Component
A full-screen overlay component that displays a loading state with optional heading and body content.
A variant of the EmptyState component that includes a protective background for load or import
operations during which the user should be prevented from interacting with the page.
It has two named slots, both optional:
- **heading**: Main title (renders in an \`<h1>\`)
- **body**: Any text to describe the state
`,
},
}, },
}, },
argTypes: { argTypes: {
topmost: { control: "boolean" }, topmost: {
// @ts-ignore control: "boolean",
message: { control: "text" }, description:
"Whether this overlay should appear above all other overlays (z-index: 999)",
defaultValue: false,
}, },
noSpinner: {
control: "boolean",
description: "Disable the loading spinner animation",
defaultValue: false,
},
icon: {
control: "text",
description: "Icon name to display instead of the default loading spinner",
},
headingText: {
control: "text",
description: "Heading text displayed above the loading indicator",
},
bodyText: {
control: "text",
description: "Body text displayed below the loading indicator",
},
},
decorators: [
(story) => html`
<div
style="position: relative; height: 400px; width: 100%; border: 1px solid #ccc; background: #f5f5f5;"
>
<div style="padding: 20px;">
<h3>Content Behind Overlay</h3>
<p>authentik is awesome (or will be if something were actually loading)</p>
<button>Sample Button</button>
</div>
${story()}
</div>
`,
],
}; };
export default metadata; export default metadata;
@customElement("ak-storybook-demo-container") type Story = StoryObj<StoryArgs>;
export class Container extends LitElement {
static get styles() {
return css`
:host {
display: block;
position: relative;
height: 25vh;
width: 75vw;
}
#main-container {
position: relative;
width: 100%;
height: 100%;
}
`;
}
@property({ type: Object, attribute: false }) export const Default: Story = {
content!: TemplateResult; render: () => html`<ak-loading-overlay></ak-loading-overlay>`,
};
render() { export const WithHeading: Story = {
return html` <div id="main-container">${this.content}</div>`;
}
}
export const DefaultStory: StoryObj = {
args: { args: {
topmost: undefined, headingText: "Loading Data",
// @ts-ignore
message: undefined,
},
// @ts-ignore
render: ({ topmost, message }: ILoadingOverlay) => {
message = typeof message === "string" ? html`<span>${message}</span>` : message;
const content = html` <ak-loading-overlay ?topmost=${topmost}
>${message ?? ""}
</ak-loading-overlay>`;
return html`<ak-storybook-demo-container
.content=${content}
></ak-storybook-demo-container>`;
}, },
render: (args) =>
html`<ak-loading-overlay>
<span>${args.headingText}</span>
</ak-loading-overlay>`,
}; };
export const WithAMessage: StoryObj = { export const WithHeadingAndBody: Story = {
...DefaultStory, args: {
args: { ...DefaultStory.args, message: html`<p>Overlay with a message</p>` }, headingText: "Loading Data",
bodyText: "Please wait while we fetch your information...",
},
render: (args) =>
html`<ak-loading-overlay>
<span>${args.headingText}</span>
<span slot="body">${args.bodyText}</span>
</ak-loading-overlay>`,
};
export const NoSpinner: Story = {
args: {
headingText: "Static Message",
bodyText: "This overlay shows without a spinner animation.",
},
render: (args) =>
html`<ak-loading-overlay no-spinner>
<span>${args.headingText}</span>
<span slot="body">${args.bodyText}</span>
</ak-loading-overlay>`,
};
export const WithCustomIcon: Story = {
args: {
icon: "fa-info-circle",
headingText: "Processing",
bodyText: "Your request is being processed...",
},
render: (args) =>
html`<ak-loading-overlay no-spinner icon=${ifDefined(args.icon)}>
<span>${args.headingText}</span>
<span slot="body">${args.bodyText}</span>
</ak-loading-overlay>`,
};
export const ProgrammaticUsage: Story = {
args: {
topmost: false,
noSpinner: false,
icon: "",
headingText: "Programmatic Loading",
bodyText: "This overlay was created using the akLoadingOverlay function.",
},
render: (args) =>
akLoadingOverlay(
{
topmost: args.topmost,
noSpinner: args.noSpinner,
icon: args.icon || undefined,
},
{
heading: args.headingText,
body: args.bodyText,
},
),
}; };

View File

@ -299,9 +299,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
return html`<tr role="row"> return html`<tr role="row">
<td role="cell" colspan="25"> <td role="cell" colspan="25">
<div class="pf-l-bullseye"> <div class="pf-l-bullseye">
<ak-empty-state loading <ak-empty-state default-label></ak-empty-state>
><span slot="header">${msg("Loading")}</span></ak-empty-state
>
</div> </div>
</td> </td>
</tr>`; </tr>`;
@ -314,7 +312,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
<div class="pf-l-bullseye"> <div class="pf-l-bullseye">
${inner ?? ${inner ??
html`<ak-empty-state html`<ak-empty-state
><span slot="header">${msg("No objects found.")}</span> > ><span>${msg("No objects found.")}</span>
<div slot="primary">${this.renderObjectCreate()}</div> <div slot="primary">${this.renderObjectCreate()}</div>
</ak-empty-state>`} </ak-empty-state>`}
</div> </div>
@ -331,7 +329,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
if (!this.error) return nothing; if (!this.error) return nothing;
return html`<ak-empty-state icon="fa-ban" return html`<ak-empty-state icon="fa-ban"
><span slot="header">${msg("Failed to fetch objects.")}</span> ><span>${msg("Failed to fetch objects.")}</span>
<div slot="body">${pluckErrorDetail(this.error)}</div> <div slot="body">${pluckErrorDetail(this.error)}</div>
</ak-empty-state>`; </ak-empty-state>`;
} }

View File

@ -42,7 +42,8 @@ export abstract class TablePage<T> extends Table<T> {
return super.renderEmpty(html` return super.renderEmpty(html`
${inner ${inner
? inner ? inner
: html`<ak-empty-state icon=${this.pageIcon()} header="${msg("No objects found.")}"> : html`<ak-empty-state icon=${this.pageIcon()}
><span>${msg("No objects found.")}</span>
<div slot="body"> <div slot="body">
${this.searchEnabled() ? this.renderEmptyClearSearch() : html``} ${this.searchEnabled() ? this.renderEmptyClearSearch() : html``}
</div> </div>

View File

@ -19,11 +19,7 @@ describe("ak-empty-state", () => {
}); });
it("should render the default loader", async () => { it("should render the default loader", async () => {
render( render(html`<ak-empty-state default-label></ak-empty-state>`);
html`<ak-empty-state loading
><span slot="header">${msg("Loading")}</span>
</ak-empty-state>`,
);
const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon"); const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon");
await expect(empty).toExist(); await expect(empty).toExist();
@ -33,25 +29,17 @@ describe("ak-empty-state", () => {
}); });
it("should handle standard boolean", async () => { it("should handle standard boolean", async () => {
render( render(html`<ak-empty-state loading>Waiting</ak-empty-state>`);
html`<ak-empty-state loading
><span slot="header">${msg("Loading")}</span>
</ak-empty-state>`,
);
const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon"); const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon");
await expect(empty).toExist(); await expect(empty).toExist();
const header = await $("ak-empty-state").$(">>>.pf-c-title"); const header = await $("ak-empty-state").$(">>>.pf-c-title");
await expect(header).toHaveText("Loading"); await expect(header).toHaveText("Waiting");
}); });
it("should render a static empty state", async () => { it("should render a static empty state", async () => {
render( render(html`<ak-empty-state><span>${msg("No messages found")}</span> </ak-empty-state>`);
html`<ak-empty-state
><span slot="header">${msg("No messages found")}</span>
</ak-empty-state>`,
);
const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon"); const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon");
await expect(empty).toExist(); await expect(empty).toExist();
@ -64,7 +52,7 @@ describe("ak-empty-state", () => {
it("should render a slotted message", async () => { it("should render a slotted message", async () => {
render( render(
html`<ak-empty-state html`<ak-empty-state
><span slot="header">${msg("No messages found")}</span> ><span>${msg("No messages found")}</span>
<p slot="body">Try again with a different filter</p> <p slot="body">Try again with a different filter</p>
</ak-empty-state>`, </ak-empty-state>`,
); );

View File

@ -115,9 +115,9 @@ export class UserSourceSettingsPage extends AKElement {
${this.sourceSettings ${this.sourceSettings
? html` ? html`
${this.sourceSettings.length < 1 ${this.sourceSettings.length < 1
? html`<ak-empty-state ? html`<ak-empty-state>
header=${msg("No services available.")} <span>${msg("No services available.")}</span></ak-empty-state
></ak-empty-state>` >`
: html` : html`
${this.sourceSettings.map((source) => { ${this.sourceSettings.map((source) => {
return html`<li class="pf-c-data-list__item"> return html`<li class="pf-c-data-list__item">
@ -139,7 +139,7 @@ export class UserSourceSettingsPage extends AKElement {
})} })}
`} `}
` `
: html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`} : html`<ak-empty-state default-label></ak-empty-state>`}
</ul>`; </ul>`;
} }
} }

View File

@ -304,7 +304,7 @@ export class FlowExecutor
async renderChallenge(): Promise<TemplateResult> { async renderChallenge(): Promise<TemplateResult> {
if (!this.challenge) { if (!this.challenge) {
return html`<ak-empty-state loading> </ak-empty-state>`; return html`<ak-empty-state loading default-label> </ak-empty-state>`;
} }
switch (this.challenge?.component) { switch (this.challenge?.component) {
case "ak-stage-access-denied": case "ak-stage-access-denied":

View File

@ -24,7 +24,7 @@ export class SessionEnd extends BaseStage<SessionEndChallenge, unknown> {
render(): TemplateResult { render(): TemplateResult {
if (!this.challenge) { if (!this.challenge) {
return html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`; return html`<ak-empty-state default-label></ak-empty-state>`;
} }
return html`<header class="pf-c-login__main-header"> return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1> <h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>

View File

@ -17,10 +17,8 @@ export class DeviceCodeFinish extends BaseStage<
if (!this.challenge) { if (!this.challenge) {
return html`<ak-empty-state loading> </ak-empty-state>`; return html`<ak-empty-state loading> </ak-empty-state>`;
} }
return html`<ak-empty-state return html`<ak-empty-state icon="fas fa-check">
icon="fas fa-check" <span>${msg("You may close this page now.")}</span>
header=${msg("You may close this page now.")}
>
<span slot="body"> ${msg("You've successfully authenticated your device.")} </span> <span slot="body"> ${msg("You've successfully authenticated your device.")} </span>
</ak-empty-state>`; </ak-empty-state>`;
} }

View File

@ -69,7 +69,8 @@ export class PlexLoginInit extends BaseStage<
</header> </header>
<div class="pf-c-login__main-body"> <div class="pf-c-login__main-body">
<form class="pf-c-form"> <form class="pf-c-form">
<ak-empty-state loading header=${msg("Waiting for authentication...")}> <ak-empty-state loading
><span>${msg("Waiting for authentication...")}></span>
</ak-empty-state> </ak-empty-state>
<hr class="pf-c-divider" /> <hr class="pf-c-divider" />
<p>${msg("If no Plex popup opens, click the button below.")}</p> <p>${msg("If no Plex popup opens, click the button below.")}</p>

View File

@ -45,11 +45,11 @@ export class FlowErrorStage extends BaseStage<FlowErrorChallenge, FlowChallengeR
</header> </header>
<div class="pf-c-login__main-body"> <div class="pf-c-login__main-body">
<form class="pf-c-form"> <form class="pf-c-form">
<ak-empty-state <ak-empty-state icon="fa-times"
icon="fa-times" ><span>
header=${this.challenge.error ${this.challenge.error
? this.challenge.error ? this.challenge.error
: msg("Something went wrong! Please try again later.")} : msg("Something went wrong! Please try again later.")}</span
> >
<div slot="body"> <div slot="body">
${this.challenge?.traceback ${this.challenge?.traceback

View File

@ -28,10 +28,10 @@ export class FlowFrameStage extends BaseStage<FrameChallenge, FrameChallengeResp
</header> </header>
<div class="pf-c-login__main-body"> <div class="pf-c-login__main-body">
${this.challenge.loadingOverlay ${this.challenge.loadingOverlay
? html`<ak-empty-state ? html`<ak-empty-state loading
loading >${this.challenge.loadingText
header=${this.challenge.loadingText ?? undefined} ? html`<span>${this.challenge.loadingText}}</span>`
> : nothing}
</ak-empty-state>` </ak-empty-state>`
: nothing} : nothing}
<iframe <iframe

View File

@ -69,13 +69,11 @@ export class RedirectStage extends BaseStage<RedirectChallenge, FlowChallengeRes
// As this wouldn't really be a redirect, show a message that the page can be closed // As this wouldn't really be a redirect, show a message that the page can be closed
// and try to close it ourselves // and try to close it ourselves
if (!url.protocol.startsWith("http")) { if (!url.protocol.startsWith("http")) {
return html`<ak-empty-state return html`<ak-empty-state icon="fas fa-check"
icon="fas fa-check" ><span>${msg("You may close this page now.")}</span>
header=${msg("You may close this page now.")}
>
</ak-empty-state>`; </ak-empty-state>`;
} }
return html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`; return html`<ak-empty-state default-label></ak-empty-state>`;
} }
render(): TemplateResult { render(): TemplateResult {

View File

@ -44,7 +44,8 @@ export class AccessDeniedStage extends BaseStage<
> >
</div> </div>
</ak-form-static> </ak-form-static>
<ak-empty-state icon="fa-times" header=${msg("Request has been denied.")}> <ak-empty-state icon="fa-times"
><span>${msg("Request has been denied.")}</span>
${this.challenge.errorMessage ${this.challenge.errorMessage
? html` ? html`
<div slot="body"> <div slot="body">

View File

@ -59,12 +59,11 @@ export class AuthenticatorValidateStageWebDuo extends BaseDeviceStage<
}} }}
> >
${this.renderUserInfo()} ${this.renderUserInfo()}
<ak-empty-state <ak-empty-state ?loading="${this.authenticating}" icon="fas fa-times"
?loading="${this.authenticating}" ><span
header=${this.authenticating >${this.authenticating
? msg("Sending Duo push notification...") ? msg("Sending Duo push notification...")
: errorMessage.join(", ") || msg("Failed to authenticate")} : errorMessage.join(", ") || msg("Failed to authenticate")}</span
icon="fas fa-times"
> >
</ak-empty-state> </ak-empty-state>
<div class="pf-c-form__group pf-m-action">${this.renderReturnToDevicePicker()}</div> <div class="pf-c-form__group pf-m-action">${this.renderReturnToDevicePicker()}</div>

View File

@ -106,12 +106,11 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
return html`<div class="pf-c-login__main-body"> return html`<div class="pf-c-login__main-body">
<form class="pf-c-form"> <form class="pf-c-form">
${this.renderUserInfo()} ${this.renderUserInfo()}
<ak-empty-state <ak-empty-state ?loading="${this.authenticating}" icon="fa-times">
?loading="${this.authenticating}" <span
header=${this.authenticating >${this.authenticating
? msg("Authenticating...") ? msg("Authenticating...")
: this.errorMessage || msg("Loading")} : this.errorMessage || msg("Loading")}</span
icon="fa-times"
> >
</ak-empty-state> </ak-empty-state>
<div class="pf-c-form__group pf-m-action"> <div class="pf-c-form__group pf-m-action">

View File

@ -145,13 +145,12 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
> >
</div> </div>
</ak-form-static> </ak-form-static>
<ak-empty-state <ak-empty-state ?loading="${this.registerRunning}" icon="fa-times">
?loading="${this.registerRunning}" <span
header=${this.registerRunning >${this.registerRunning
? msg("Registering...") ? msg("Registering...")
: this.registerMessage || msg("Failed to register")} : this.registerMessage || msg("Failed to register")}
icon="fa-times" </span>
>
</ak-empty-state> </ak-empty-state>
${this.challenge?.responseErrors ${this.challenge?.responseErrors
? html`<p class="pf-m-block"> ? html`<p class="pf-m-block">

View File

@ -327,9 +327,9 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
// [hasError, isInteractive] // [hasError, isInteractive]
// prettier-ignore // prettier-ignore
return match([Boolean(this.error), Boolean(this.challenge?.interactive)]) return match([Boolean(this.error), Boolean(this.challenge?.interactive)])
.with([true, P.any], () => akEmptyState({ icon: "fa-times", header: this.error })) .with([true, P.any], () => akEmptyState({ icon: "fa-times" }, { heading: this.error }))
.with([false, true], () => html`${this.captchaFrame}`) .with([false, true], () => html`${this.captchaFrame}`)
.with([false, false], () => akEmptyState({ loading: true, header: msg("Verifying...") })) .with([false, false], () => akEmptyState({ loading: true }, { heading: msg("Verifying...") }))
.exhaustive(); .exhaustive();
} }

View File

@ -356,8 +356,8 @@ export class RacInterface extends WithBrandConfig(Interface) {
GuacClientState.WAITING, GuacClientState.WAITING,
].includes(this.clientState); ].includes(this.clientState);
return html` return html`
<ak-loading-overlay ?loading=${isLoading} icon="fa fa-times"> <ak-loading-overlay ?no-spinner=${!isLoading} icon="fa fa-times">
<span> ${message} </span> <span>${message}</span>
</ak-loading-overlay> </ak-loading-overlay>
`; `;
} }

View File

@ -32,11 +32,12 @@ export class Loading extends AKElement {
} }
public connectedCallback(): void { public connectedCallback(): void {
super.connectedCallback();
this.dataset.akInterfaceRoot = this.tagName.toLowerCase(); this.dataset.akInterfaceRoot = this.tagName.toLowerCase();
} }
render(): TemplateResult { render(): TemplateResult {
return html` <section return html`<section
class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl" class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"
> >
<div class="pf-c-empty-state" style="height: 100vh;"> <div class="pf-c-empty-state" style="height: 100vh;">

View File

@ -105,7 +105,7 @@ export class LibraryPage extends AKElement {
} }
loading() { loading() {
return html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`; return html`<ak-empty-state default-label></ak-empty-state>`;
} }
running() { running() {

View File

@ -172,7 +172,7 @@ export class UserSettingsFlowExecutor
level: MessageLevel.success, level: MessageLevel.success,
message: msg("Successfully updated details"), message: msg("Successfully updated details"),
}); });
return html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`; return html`<ak-empty-state default-label></ak-empty-state>`;
default: default:
console.debug( console.debug(
`authentik/user/flows: unsupported stage type ${this.challenge.component}`, `authentik/user/flows: unsupported stage type ${this.challenge.component}`,
@ -193,7 +193,7 @@ export class UserSettingsFlowExecutor
return html`<p>${msg("No settings flow configured.")}</p> `; return html`<p>${msg("No settings flow configured.")}</p> `;
} }
if (!this.challenge || this.loading) { if (!this.challenge || this.loading) {
return html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`; return html`<ak-empty-state default-label></ak-empty-state>`;
} }
return html` ${this.renderChallenge()} `; return html` ${this.renderChallenge()} `;
} }

View File

@ -64,7 +64,7 @@ export class UserSettingsPromptStage extends PromptStage {
render(): TemplateResult { render(): TemplateResult {
if (!this.challenge) { if (!this.challenge) {
return html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`; return html`<ak-empty-state default-label></ak-empty-state>`;
} }
return html`<div class="pf-c-login__main-body"> return html`<div class="pf-c-login__main-body">
<form <form