From 27b7b0b0e7dbbb6f21321322c5a5740481d9c8b3 Mon Sep 17 00:00:00 2001 From: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:33:07 -0700 Subject: [PATCH] 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 dddde09be571a639ecd041569dd3a282aab3f9be. * 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. `` 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`` is a specialized variant of `` over what will become ``, but for now is just internal. It allows only for the heading and primary slots, forwarding them ``. 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 * always render icon Signed-off-by: Jens Langhammer * missing default in flow exec Signed-off-by: Jens Langhammer * unrelated: fix loading interface Signed-off-by: Jens Langhammer * rename default attr Signed-off-by: Jens Langhammer * fix jsdoc Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer Co-authored-by: Jens Langhammer --- web/paths/node.js | 2 +- .../admin-overview/cards/RecentEventsCard.ts | 2 +- .../admin/applications/ApplicationViewPage.ts | 2 +- .../ApplicationEntitlementPage.ts | 9 +- .../ak-application-wizard-bindings-step.ts | 2 +- ...application-wizard-provider-choice-step.ts | 2 +- .../enterprise/EnterpriseLicenseListPage.ts | 6 +- web/src/admin/flows/BoundStagesList.ts | 2 +- web/src/admin/policies/BoundPoliciesList.ts | 2 +- web/src/admin/providers/ProviderViewPage.ts | 2 +- web/src/admin/sources/SourceViewPage.ts | 2 +- web/src/components/events/ObjectChangelog.ts | 2 +- web/src/components/events/UserEvents.ts | 2 +- web/src/elements/Base.ts | 26 ++ web/src/elements/EmptyState.ts | 131 ++++++-- web/src/elements/LoadingOverlay.ts | 80 ++++- web/src/elements/charts/Chart.ts | 2 +- web/src/elements/events/LogViewer.ts | 4 +- .../notifications/NotificationDrawer.ts | 2 +- web/src/elements/stories/EmptyState.docs.mdx | 59 ---- .../elements/stories/EmptyState.stories.ts | 306 +++++++++++++----- .../elements/stories/LoadingOverlay.docs.mdx | 36 --- .../stories/LoadingOverlay.stories.ts | 182 ++++++++--- web/src/elements/table/Table.ts | 8 +- web/src/elements/table/TablePage.ts | 3 +- web/src/elements/tests/EmptyState.test.ts | 22 +- .../elements/user/sources/SourceSettings.ts | 8 +- web/src/flow/FlowExecutor.ts | 2 +- web/src/flow/providers/SessionEnd.ts | 2 +- .../flow/providers/oauth2/DeviceCodeFinish.ts | 6 +- web/src/flow/sources/plex/PlexLoginInit.ts | 3 +- web/src/flow/stages/FlowErrorStage.ts | 12 +- web/src/flow/stages/FlowFrameStage.ts | 8 +- web/src/flow/stages/RedirectStage.ts | 8 +- .../stages/access_denied/AccessDeniedStage.ts | 3 +- .../AuthenticatorValidateStageDuo.ts | 13 +- .../AuthenticatorValidateStageWebAuthn.ts | 13 +- .../WebAuthnAuthenticatorRegisterStage.ts | 13 +- web/src/flow/stages/captcha/CaptchaStage.ts | 4 +- web/src/rac/index.entrypoint.ts | 4 +- .../standalone/loading/index.entrypoint.ts | 3 +- web/src/user/LibraryPage/ak-library.ts | 2 +- .../details/UserSettingsFlowExecutor.ts | 4 +- .../details/stages/prompt/PromptStage.ts | 2 +- 44 files changed, 635 insertions(+), 373 deletions(-) delete mode 100644 web/src/elements/stories/EmptyState.docs.mdx delete mode 100644 web/src/elements/stories/LoadingOverlay.docs.mdx diff --git a/web/paths/node.js b/web/paths/node.js index 14eb449b98..85f3d1718c 100644 --- a/web/paths/node.js +++ b/web/paths/node.js @@ -64,7 +64,7 @@ export const EntryPoint = /** @type {const} */ ({ in: resolve(PackageRoot, "src", "flow", "index.entrypoint.ts"), out: resolve(DistDirectory, "flow", "FlowInterface"), }, - Standalone: { + StandaloneAPI: { in: resolve(PackageRoot, "src", "standalone", "api-browser/index.entrypoint.ts"), out: resolve(DistDirectory, "standalone", "api-browser", "index"), }, diff --git a/web/src/admin/admin-overview/cards/RecentEventsCard.ts b/web/src/admin/admin-overview/cards/RecentEventsCard.ts index df9ceeb786..c66d89dec6 100644 --- a/web/src/admin/admin-overview/cards/RecentEventsCard.ts +++ b/web/src/admin/admin-overview/cards/RecentEventsCard.ts @@ -89,7 +89,7 @@ export class RecentEventsCard extends Table { return super.renderEmpty( html`${msg("No Events found.")} + >${msg("No Events found.")}
${msg("No matching events could be found.")}
`, ); diff --git a/web/src/admin/applications/ApplicationViewPage.ts b/web/src/admin/applications/ApplicationViewPage.ts index 480e231ff8..5f810200cc 100644 --- a/web/src/admin/applications/ApplicationViewPage.ts +++ b/web/src/admin/applications/ApplicationViewPage.ts @@ -112,7 +112,7 @@ export class ApplicationViewPage extends AKElement { renderApp(): TemplateResult { if (!this.application) { - return html` `; + return html``; } return html` ${this.missingOutpost diff --git a/web/src/admin/applications/entitlements/ApplicationEntitlementPage.ts b/web/src/admin/applications/entitlements/ApplicationEntitlementPage.ts index 5dcace54a6..816b5306a6 100644 --- a/web/src/admin/applications/entitlements/ApplicationEntitlementPage.ts +++ b/web/src/admin/applications/entitlements/ApplicationEntitlementPage.ts @@ -118,13 +118,12 @@ export class ApplicationEntitlementsPage extends Table { renderEmpty(): TemplateResult { return super.renderEmpty( - html` + html`${msg("No app entitlements created.")} +
${msg( - "This application does currently not have any application entitlement defined.", + "This application does currently not have any application entitlements defined.", )}
diff --git a/web/src/admin/applications/wizard/steps/ak-application-wizard-bindings-step.ts b/web/src/admin/applications/wizard/steps/ak-application-wizard-bindings-step.ts index e7285bce1c..877f0d2ff3 100644 --- a/web/src/admin/applications/wizard/steps/ak-application-wizard-bindings-step.ts +++ b/web/src/admin/applications/wizard/steps/ak-application-wizard-bindings-step.ts @@ -116,7 +116,7 @@ export class ApplicationWizardBindingsStep extends ApplicationWizardStep { .content=${[]} > ${msg("No bound policies.")} + >${msg("No bound policies.")}
${msg("No policies are currently bound to this object.")}
+ ` + : nothing} + + `, +}; - render: ({ icon, loading, fullHeight, header }: IEmptyState) => - container( - html` - `, +export const Basic: Story = { + ...Template, + args: { + icon: "fa-folder-open", + headingText: "No files found", + bodyText: "This folder is empty. Upload some files to get started.", + }, +}; + +export const Empty: Story = { + ...Template, + args: { + icon: "", + }, + render: () => + html`

Note that a completely empty <ak-empty-state> is just that: empty.

+ `, +}; + +export const WithAction: Story = { + ...Template, + args: { + icon: "fa-users", + headingText: "No users yet", + bodyText: "Get started by creating your first user account.", + primaryButtonText: html``, + }, +}; + +export const Loading: Story = { + ...Template, + args: { + loading: true, + }, +}; + +export const LoadingWithCustomMessage: Story = { + ...Template, + args: { + loading: true, + headingText: html`I know it's here, somewhere...`, + }, +}; + +export const LoadingWithDefaultMessage: Story = { + ...Template, + args: { + defaultLabel: true, + }, +}; + +export const LoadingDefaultWithOverride: Story = { + ...Template, + args: { + defaultLabel: true, + headingText: html`Have they got a chance? Eh. It would take a miracle.`, + }, +}; + +export const LoadingDefaultWithButton: Story = { + ...Template, + args: { + defaultLabel: true, + primaryButtonText: html``, + }, +}; + +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``, + }, +}; + +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``, + }, + render: (args) => + akEmptyState( + { + icon: args.icon, + }, + { + heading: args.headingText, + body: args.bodyText, + primary: args.primaryButtonText + ? html` + + ` + : undefined, + }, ), }; -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` - - This is the body content +export const IconShowcase: Story = { + args: {}, + render: () => html` +
+ + Users + No users found - `), -}; -export const WithBodyAndPrimarySlotsFilled = { - ...DefaultStory, - args: { - ...DefaultStory, - ...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" }, - }, - render: ({ icon, loading, fullHeight, header }: IEmptyState) => - container( - html` - This is the body content slot - This is the primary content slot - `, - ), + + Database + No records + + + + Messages + No messages + + + + Analytics + No data to display + + + + Settings + No configuration + + + + Security + No alerts + +
+ `, }; diff --git a/web/src/elements/stories/LoadingOverlay.docs.mdx b/web/src/elements/stories/LoadingOverlay.docs.mdx deleted file mode 100644 index 65d010fc72..0000000000 --- a/web/src/elements/stories/LoadingOverlay.docs.mdx +++ /dev/null @@ -1,36 +0,0 @@ -import { Canvas, Description, Meta, Story, Title } from "@storybook/blocks"; - -import * as LoadingOverlayStories from "./LoadingOverlay.stories"; - - - -# 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 - - This would display below the loading spinner - -``` - -## Demo - -### Default - - - -### With a message - - diff --git a/web/src/elements/stories/LoadingOverlay.stories.ts b/web/src/elements/stories/LoadingOverlay.stories.ts index aac589c6aa..02d129effc 100644 --- a/web/src/elements/stories/LoadingOverlay.stories.ts +++ b/web/src/elements/stories/LoadingOverlay.stories.ts @@ -1,74 +1,154 @@ import type { Meta, StoryObj } from "@storybook/web-components"; -import { LitElement, TemplateResult, css, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; -import { type ILoadingOverlay, LoadingOverlay } from "../LoadingOverlay.js"; import "../LoadingOverlay.js"; +import { type ILoadingOverlay, LoadingOverlay, akLoadingOverlay } from "../LoadingOverlay.js"; -const metadata: Meta = { - title: "Elements/", +type StoryArgs = ILoadingOverlay & { + headingText?: string; + bodyText?: string; + noSpinner: boolean; +}; + +const metadata: Meta = { + title: "Elements/ ", component: "ak-loading-overlay", + tags: ["autodocs"], parameters: { 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 \`

\`) +- **body**: Any text to describe the state +`, + }, }, }, argTypes: { - topmost: { control: "boolean" }, - // @ts-ignore - message: { control: "text" }, + topmost: { + control: "boolean", + 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` +
+
+

Content Behind Overlay

+

authentik is awesome (or will be if something were actually loading)

+ +
+ ${story()} +
+ `, + ], }; export default metadata; -@customElement("ak-storybook-demo-container") -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%; - } - `; - } +type Story = StoryObj; - @property({ type: Object, attribute: false }) - content!: TemplateResult; +export const Default: Story = { + render: () => html``, +}; - render() { - return html`
${this.content}
`; - } -} - -export const DefaultStory: StoryObj = { +export const WithHeading: Story = { args: { - topmost: undefined, - // @ts-ignore - message: undefined, - }, - - // @ts-ignore - render: ({ topmost, message }: ILoadingOverlay) => { - message = typeof message === "string" ? html`${message}` : message; - const content = html` ${message ?? ""} - `; - return html``; + headingText: "Loading Data", }, + render: (args) => + html` + ${args.headingText} + `, }; -export const WithAMessage: StoryObj = { - ...DefaultStory, - args: { ...DefaultStory.args, message: html`

Overlay with a message

` }, +export const WithHeadingAndBody: Story = { + args: { + headingText: "Loading Data", + bodyText: "Please wait while we fetch your information...", + }, + render: (args) => + html` + ${args.headingText} + ${args.bodyText} + `, +}; + +export const NoSpinner: Story = { + args: { + headingText: "Static Message", + bodyText: "This overlay shows without a spinner animation.", + }, + render: (args) => + html` + ${args.headingText} + ${args.bodyText} + `, +}; + +export const WithCustomIcon: Story = { + args: { + icon: "fa-info-circle", + headingText: "Processing", + bodyText: "Your request is being processed...", + }, + render: (args) => + html` + ${args.headingText} + ${args.bodyText} + `, +}; + +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, + }, + ), }; diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index 4a5a6b3f04..1afabd0d5a 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -299,9 +299,7 @@ export abstract class Table extends WithLicenseSummary(AKElement) implements return html`
- ${msg("Loading")} +
`; @@ -314,7 +312,7 @@ export abstract class Table extends WithLicenseSummary(AKElement) implements
${inner ?? html`${msg("No objects found.")} > + >${msg("No objects found.")}
${this.renderObjectCreate()}
`}
@@ -331,7 +329,7 @@ export abstract class Table extends WithLicenseSummary(AKElement) implements if (!this.error) return nothing; return html`${msg("Failed to fetch objects.")} + >${msg("Failed to fetch objects.")}
${pluckErrorDetail(this.error)}
`; } diff --git a/web/src/elements/table/TablePage.ts b/web/src/elements/table/TablePage.ts index b89a215fd0..0f42c65bf3 100644 --- a/web/src/elements/table/TablePage.ts +++ b/web/src/elements/table/TablePage.ts @@ -42,7 +42,8 @@ export abstract class TablePage extends Table { return super.renderEmpty(html` ${inner ? inner - : html` + : html`${msg("No objects found.")}
${this.searchEnabled() ? this.renderEmptyClearSearch() : html``}
diff --git a/web/src/elements/tests/EmptyState.test.ts b/web/src/elements/tests/EmptyState.test.ts index 542bcdf301..9660d9ab2d 100644 --- a/web/src/elements/tests/EmptyState.test.ts +++ b/web/src/elements/tests/EmptyState.test.ts @@ -19,11 +19,7 @@ describe("ak-empty-state", () => { }); it("should render the default loader", async () => { - render( - html`${msg("Loading")} - `, - ); + render(html``); const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon"); await expect(empty).toExist(); @@ -33,25 +29,17 @@ describe("ak-empty-state", () => { }); it("should handle standard boolean", async () => { - render( - html`${msg("Loading")} - `, - ); + render(html`Waiting`); const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon"); await expect(empty).toExist(); 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 () => { - render( - html`${msg("No messages found")} - `, - ); + render(html`${msg("No messages found")} `); const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon"); await expect(empty).toExist(); @@ -64,7 +52,7 @@ describe("ak-empty-state", () => { it("should render a slotted message", async () => { render( html`${msg("No messages found")} + >${msg("No messages found")}

Try again with a different filter

`, ); diff --git a/web/src/elements/user/sources/SourceSettings.ts b/web/src/elements/user/sources/SourceSettings.ts index b7d368e4d2..f749fbc487 100644 --- a/web/src/elements/user/sources/SourceSettings.ts +++ b/web/src/elements/user/sources/SourceSettings.ts @@ -115,9 +115,9 @@ export class UserSourceSettingsPage extends AKElement { ${this.sourceSettings ? html` ${this.sourceSettings.length < 1 - ? html`` + ? html` + ${msg("No services available.")}` : html` ${this.sourceSettings.map((source) => { return html`
  • @@ -139,7 +139,7 @@ export class UserSourceSettingsPage extends AKElement { })} `} ` - : html` `} + : html``} `; } } diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index efba4f8bb5..4254ae49c4 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -304,7 +304,7 @@ export class FlowExecutor async renderChallenge(): Promise { if (!this.challenge) { - return html` `; + return html` `; } switch (this.challenge?.component) { case "ak-stage-access-denied": diff --git a/web/src/flow/providers/SessionEnd.ts b/web/src/flow/providers/SessionEnd.ts index 33ebecc185..edd526258d 100644 --- a/web/src/flow/providers/SessionEnd.ts +++ b/web/src/flow/providers/SessionEnd.ts @@ -24,7 +24,7 @@ export class SessionEnd extends BaseStage { render(): TemplateResult { if (!this.challenge) { - return html` `; + return html``; } return html`