From eff85e489cfa2917f7be1ae5c0d7294db85b8530 Mon Sep 17 00:00:00 2001 From: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com> Date: Wed, 26 Jun 2024 15:11:49 -0700 Subject: [PATCH] web: provide better feedback on Application Library page about search results (#9386) * web: fix esbuild issue with style sheets Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious pain. This fix better identifies the value types (instances) being passed from various sources in the repo to the three *different* kinds of style processors we're using (the native one, the polyfill one, and whatever the heck Storybook does internally). Falling back to using older CSS instantiating techniques one era at a time seems to do the trick. It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content (FLoUC), it's the logic with which we're left. In standard mode, the following warning appears on the console when running a Flow: ``` Autofocus processing was blocked because a document already has a focused element. ``` In compatibility mode, the following **error** appears on the console when running a Flow: ``` crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'. at initDomMutationObservers (crawler-inject.js:1106:18) at crawler-inject.js:1114:24 at Array.forEach () at initDomMutationObservers (crawler-inject.js:1114:10) at crawler-inject.js:1549:1 initDomMutationObservers @ crawler-inject.js:1106 (anonymous) @ crawler-inject.js:1114 initDomMutationObservers @ crawler-inject.js:1114 (anonymous) @ crawler-inject.js:1549 ``` Despite this error, nothing seems to be broken and flows work as anticipated. * web: improve state management of Fuze application search This commit rewrites a bit (just a bit, really!) of the relationship between `ak-library-application-impl` and `ak-library-application-search`. The "show only apps with launch URLs filter" has been moved up to the retrieval layer; there was no reason for the renderer to repeatedly call a *required* filter; just call it on the list of applications once and be done. The search component exchanges the two-state guesswork and custom events for a concrete three-state solution and *private* events. The search handler now sends the events "reset," "updated," and the new "updated and empty," which we could not previously track. By limiting the Impl layer to only those apps with launchUrls, we can now distinguish between "all apps," and "filtered apps," and understand that when "all apps" is empty we have no apps, and when "filtered apps" is empty the search has returned nothing. I also tried to add a lot more comments. In keeping with ES2020, I've put `.js` extensions on all the local imports. In keeping with a variety of [best practice recommendations](https://webcomponents.today/best-practices/), I've renamed web component files to match the custom element they deploy: ``` ak-library-application-search-empty.ts 19:@customElement("ak-library-application-search-empty") ak-library-impl.ts 44:@customElement("ak-library-impl") ak-library.ts 30:@customElement("ak-library") ak-library-application-list.ts 34:@customElement("ak-library-application-list") ak-library-application-empty-list.ts 22:@customElement("ak-library-application-empty-list") ak-library-application-search.ts 46:@customElement("ak-library-application-search") ``` The only effect(s) external to the changes in this vertical is that the Route() had to be updated, and I have done that. * web: updated the improved search to Google's Lit standards for events. --- .../ApplicationEmptyState.stories.ts | 4 +- web/src/user/LibraryPage/LibraryPageImpl.ts | 155 --------------- ...s => ak-library-application-empty-list.ts} | 0 ...List.ts => ak-library-application-list.ts} | 9 +- .../ak-library-application-search-empty.ts | 33 ++++ ...ch.ts => ak-library-application-search.ts} | 46 +++-- web/src/user/LibraryPage/ak-library-impl.ts | 182 ++++++++++++++++++ .../{LibraryPage.ts => ak-library.ts} | 8 +- web/src/user/LibraryPage/constants.ts | 2 - web/src/user/LibraryPage/events.ts | 72 +++++++ web/src/user/LibraryPage/helpers.ts | 24 --- web/src/user/Routes.ts | 2 +- 12 files changed, 337 insertions(+), 200 deletions(-) delete mode 100644 web/src/user/LibraryPage/LibraryPageImpl.ts rename web/src/user/LibraryPage/{ApplicationEmptyState.ts => ak-library-application-empty-list.ts} (100%) rename web/src/user/LibraryPage/{ApplicationList.ts => ak-library-application-list.ts} (93%) create mode 100644 web/src/user/LibraryPage/ak-library-application-search-empty.ts rename web/src/user/LibraryPage/{ApplicationSearch.ts => ak-library-application-search.ts} (79%) create mode 100644 web/src/user/LibraryPage/ak-library-impl.ts rename web/src/user/LibraryPage/{LibraryPage.ts => ak-library.ts} (91%) delete mode 100644 web/src/user/LibraryPage/constants.ts create mode 100644 web/src/user/LibraryPage/events.ts delete mode 100644 web/src/user/LibraryPage/helpers.ts diff --git a/web/src/user/LibraryPage/ApplicationEmptyState.stories.ts b/web/src/user/LibraryPage/ApplicationEmptyState.stories.ts index 990b61a96d21..3d54f867cc4b 100644 --- a/web/src/user/LibraryPage/ApplicationEmptyState.stories.ts +++ b/web/src/user/LibraryPage/ApplicationEmptyState.stories.ts @@ -1,9 +1,9 @@ import { html } from "lit"; -import "./ApplicationEmptyState"; +import "./ak-library-application-empty-list"; export default { - title: "ApplicationEmptyState", + title: "Elements / Application Empty State", }; export const OrdinaryUser = () => diff --git a/web/src/user/LibraryPage/LibraryPageImpl.ts b/web/src/user/LibraryPage/LibraryPageImpl.ts deleted file mode 100644 index 1892e499f900..000000000000 --- a/web/src/user/LibraryPage/LibraryPageImpl.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { groupBy } from "@goauthentik/common/utils"; -import { AKElement } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/EmptyState"; -import "@goauthentik/user/LibraryApplication"; - -import { msg } from "@lit/localize"; -import { html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; - -import styles from "./LibraryPageImpl.css"; - -import type { Application } from "@goauthentik/api"; - -import "./ApplicationEmptyState"; -import "./ApplicationList"; -import "./ApplicationSearch"; -import { appHasLaunchUrl } from "./LibraryPageImpl.utils"; -import { SEARCH_ITEM_SELECTED, SEARCH_UPDATED } from "./constants"; -import { isCustomEvent, loading } from "./helpers"; -import type { AppGroupList, PageUIConfig } from "./types"; - -/** - * List of Applications available - * - * Properties: - * apps: a list of the applications available to the user. - * - * Aggregates two functions: - * - Display the list of applications available to the user - * - Filter that list using the search bar - * - */ - -@customElement("ak-library-impl") -export class LibraryPage extends AKElement { - static get styles() { - return styles; - } - - @property({ attribute: "isadmin", type: Boolean }) - isAdmin = false; - - @property({ attribute: false, type: Array }) - apps!: Application[]; - - @property({ attribute: false }) - uiConfig!: PageUIConfig; - - @state() - selectedApp?: Application; - - @state() - filteredApps: Application[] = []; - - constructor() { - super(); - this.searchUpdated = this.searchUpdated.bind(this); - this.launchRequest = this.launchRequest.bind(this); - } - - pageTitle(): string { - return msg("My Applications"); - } - - connectedCallback() { - super.connectedCallback(); - this.filteredApps = this.apps; - if (this.filteredApps === undefined) { - throw new Error( - "Application.results should never be undefined when passed to the Library Page.", - ); - } - this.addEventListener(SEARCH_UPDATED, this.searchUpdated); - this.addEventListener(SEARCH_ITEM_SELECTED, this.launchRequest); - } - - disconnectedCallback() { - this.removeEventListener(SEARCH_UPDATED, this.searchUpdated); - this.removeEventListener(SEARCH_ITEM_SELECTED, this.launchRequest); - super.disconnectedCallback(); - } - - searchUpdated(event: Event) { - if (!isCustomEvent(event)) { - throw new Error("ak-library-search-updated must send a custom event."); - } - event.stopPropagation(); - const apps = event.detail.apps; - this.selectedApp = undefined; - this.filteredApps = this.apps; - if (apps.length > 0) { - this.selectedApp = apps[0]; - this.filteredApps = event.detail.apps; - } - } - - launchRequest(event: Event) { - if (!isCustomEvent(event)) { - throw new Error("ak-library-item-selected must send a custom event"); - } - event.stopPropagation(); - const location = this.selectedApp?.launchUrl; - if (location) { - window.location.assign(location); - } - } - - getApps(): AppGroupList { - return groupBy(this.filteredApps.filter(appHasLaunchUrl), (app) => app.group || ""); - } - - renderEmptyState() { - return html``; - } - - renderApps() { - const selected = this.selectedApp?.slug; - const apps = this.getApps(); - const layout = this.uiConfig.layout as string; - const background = this.uiConfig.background; - - return html``; - } - - renderSearch() { - return html``; - } - - render() { - return html`
-
-

- ${msg("My applications")} -

- ${this.uiConfig.searchEnabled ? this.renderSearch() : html``} -
-
- ${loading( - this.apps, - html`${this.filteredApps.find(appHasLaunchUrl) - ? this.renderApps() - : this.renderEmptyState()}`, - )} -
-
`; - } -} diff --git a/web/src/user/LibraryPage/ApplicationEmptyState.ts b/web/src/user/LibraryPage/ak-library-application-empty-list.ts similarity index 100% rename from web/src/user/LibraryPage/ApplicationEmptyState.ts rename to web/src/user/LibraryPage/ak-library-application-empty-list.ts diff --git a/web/src/user/LibraryPage/ApplicationList.ts b/web/src/user/LibraryPage/ak-library-application-list.ts similarity index 93% rename from web/src/user/LibraryPage/ApplicationList.ts rename to web/src/user/LibraryPage/ak-library-application-list.ts index c51acce659aa..0c3a013ec40f 100644 --- a/web/src/user/LibraryPage/ApplicationList.ts +++ b/web/src/user/LibraryPage/ak-library-application-list.ts @@ -12,7 +12,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import type { Application } from "@goauthentik/api"; -import type { AppGroupEntry, AppGroupList } from "./types"; +import type { AppGroupEntry, AppGroupList } from "./types.js"; type Pair = [string, string]; @@ -31,6 +31,13 @@ const LAYOUTS = new Map([ ], ]); +/** + * @element ak-library-application-list + * @class LibraryPageApplicationList + * + * Renders the current library list of a User's Applications. + * + */ @customElement("ak-library-application-list") export class LibraryPageApplicationList extends AKElement { static get styles() { diff --git a/web/src/user/LibraryPage/ak-library-application-search-empty.ts b/web/src/user/LibraryPage/ak-library-application-search-empty.ts new file mode 100644 index 000000000000..778af88c5eca --- /dev/null +++ b/web/src/user/LibraryPage/ak-library-application-search-empty.ts @@ -0,0 +1,33 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; + +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import PFSpacing from "@patternfly/patternfly/utilities/Spacing/spacing.css"; + +/** + * Library Page Application List Empty + * + * Display a message if there are no applications defined in the current instance. If the user is an + * administrator, provide a link to the "Create a new application" page. + */ + +@customElement("ak-library-application-search-empty") +export class LibraryPageApplicationSearchEmpty extends AKElement { + static get styles() { + return [PFBase, PFEmptyState, PFContent, PFSpacing]; + } + + render() { + return html`
+
+ +

${msg("Search returned no results.")}

+
+
`; + } +} diff --git a/web/src/user/LibraryPage/ApplicationSearch.ts b/web/src/user/LibraryPage/ak-library-application-search.ts similarity index 79% rename from web/src/user/LibraryPage/ApplicationSearch.ts rename to web/src/user/LibraryPage/ak-library-application-search.ts index 7e6f40f53ad8..013c968ad70a 100644 --- a/web/src/user/LibraryPage/ApplicationSearch.ts +++ b/web/src/user/LibraryPage/ak-library-application-search.ts @@ -13,11 +13,30 @@ import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; import type { Application } from "@goauthentik/api"; -import { SEARCH_ITEM_SELECTED, SEARCH_UPDATED } from "./constants"; -import { customEvent } from "./helpers"; - -@customElement("ak-library-list-search") -export class LibraryPageApplicationList extends AKElement { +import { + LibraryPageSearchEmpty, + LibraryPageSearchReset, + LibraryPageSearchSelected, + LibraryPageSearchUpdated, +} from "./events.js"; + +/** + * @element ak-library-list-search + * + * @class LibraryPageApplicationSearch + * + * @classdesc + * + * The interface between our list of applications shown to the user, an input box, and the Fuse + * fuzzy search library. + * + * @fires LibraryPageSearchUpdated + * @fires LibraryPageSearchEmpty + * @fires LibraryPageSearchReset + * + */ +@customElement("ak-library-application-search") +export class LibraryPageApplicationSearch extends AKElement { static get styles() { return [ PFBase, @@ -75,11 +94,7 @@ export class LibraryPageApplicationList extends AKElement { } onSelected(apps: FuseResult[]) { - this.dispatchEvent( - customEvent(SEARCH_UPDATED, { - apps: apps.map((app) => app.item), - }), - ); + this.dispatchEvent(new LibraryPageSearchUpdated(apps.map((app) => app.item))); } connectedCallback() { @@ -102,7 +117,7 @@ export class LibraryPageApplicationList extends AKElement { updateURLParams({ search: this.query, }); - this.onSelected([]); + this.dispatchEvent(new LibraryPageSearchReset()); } onInput(ev: InputEvent) { @@ -113,8 +128,13 @@ export class LibraryPageApplicationList extends AKElement { updateURLParams({ search: this.query, }); + const apps = this.fuse.search(this.query); - if (apps.length < 1) return; + if (apps.length < 1) { + this.dispatchEvent(new LibraryPageSearchEmpty()); + return; + } + this.onSelected(apps); } @@ -125,7 +145,7 @@ export class LibraryPageApplicationList extends AKElement { return; } case "Enter": { - this.dispatchEvent(customEvent(SEARCH_ITEM_SELECTED)); + this.dispatchEvent(new LibraryPageSearchSelected()); return; } } diff --git a/web/src/user/LibraryPage/ak-library-impl.ts b/web/src/user/LibraryPage/ak-library-impl.ts new file mode 100644 index 000000000000..8f41b75119cc --- /dev/null +++ b/web/src/user/LibraryPage/ak-library-impl.ts @@ -0,0 +1,182 @@ +import { groupBy } from "@goauthentik/common/utils"; +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/EmptyState"; +import { bound } from "@goauthentik/elements/decorators/bound.js"; +import "@goauthentik/user/LibraryApplication"; + +import { msg } from "@lit/localize"; +import { html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import styles from "./LibraryPageImpl.css"; + +import type { Application } from "@goauthentik/api"; + +import "./ak-library-application-empty-list.js"; +import "./ak-library-application-list.js"; +import "./ak-library-application-search-empty.js"; +import "./ak-library-application-search.js"; +import { + LibraryPageSearchEmpty, + LibraryPageSearchReset, + LibraryPageSearchSelected, + LibraryPageSearchUpdated, +} from "./events.js"; +import type { PageUIConfig } from "./types.js"; + +/** + * List of Applications available + * + * Properties: + * apps: a list of the applications available to the user. + * + * Aggregates two functions: + * - Display the list of applications available to the user + * - Filter that list using the search bar + * + */ + +@customElement("ak-library-impl") +export class LibraryPage extends AKElement { + static get styles() { + return styles; + } + + /** + * Controls showing the "Switch to Admin" button. + * + * @attr + */ + @property({ attribute: "isadmin", type: Boolean }) + isAdmin = false; + + /** + * The *complete* list of applications for this user. Not paginated. + * + * @attr + */ + @property({ attribute: false, type: Array }) + apps!: Application[]; + + /** + * The aggregate uiConfig, derived from user, brand, and instance data. + * + * @attr + */ + @property({ attribute: false }) + uiConfig!: PageUIConfig; + + @state() + selectedApp?: Application; + + @state() + filteredApps: Application[] = []; + + pageTitle(): string { + return msg("My Applications"); + } + + connectedCallback() { + super.connectedCallback(); + this.filteredApps = this.apps; + if (this.filteredApps === undefined) { + throw new Error( + "Application.results should never be undefined when passed to the Library Page.", + ); + } + this.addEventListener(LibraryPageSearchUpdated.eventName, this.searchUpdated); + this.addEventListener(LibraryPageSearchReset.eventName, this.searchReset); + this.addEventListener(LibraryPageSearchEmpty.eventName, this.searchEmpty); + this.addEventListener(LibraryPageSearchSelected.eventName, this.launchRequest); + } + + disconnectedCallback() { + this.removeEventListener(LibraryPageSearchUpdated.eventName, this.searchUpdated); + this.removeEventListener(LibraryPageSearchReset.eventName, this.searchReset); + this.removeEventListener(LibraryPageSearchEmpty.eventName, this.searchEmpty); + this.removeEventListener(LibraryPageSearchSelected.eventName, this.launchRequest); + super.disconnectedCallback(); + } + + @bound + searchUpdated(event: LibraryPageSearchUpdated) { + event.stopPropagation(); + const apps = event.apps; + if (!(apps.length > 0)) { + throw new Error( + "LibaryPageSearchUpdated had empty results body. This must not happen.", + ); + } + this.filteredApps = apps; + this.selectedApp = apps[0]; + } + + @bound + launchRequest(event: LibraryPageSearchSelected) { + event.stopPropagation(); + this.selectedApp?.launchUrl && window.location.assign(this.selectedApp?.launchUrl); + } + + @bound + searchReset(event: LibraryPageSearchReset) { + event.stopPropagation(); + this.filteredApps = this.apps; + this.selectedApp = undefined; + } + + @bound + searchEmpty(event: LibraryPageSearchEmpty) { + event.stopPropagation(); + this.filteredApps = []; + this.selectedApp = undefined; + } + + renderApps() { + const selected = this.selectedApp?.slug; + const layout = this.uiConfig.layout as string; + const background = this.uiConfig.background; + const groupedApps = groupBy(this.filteredApps, (app) => app.group || ""); + + return html``; + } + + renderSearch() { + return html``; + } + + renderSearchEmpty() { + return nothing; + } + + renderState() { + if (this.apps.length === 0) { + return html``; + } + if (this.filteredApps.length === 0) { + return html``; + } + return this.renderApps(); + } + + render() { + return html`
+
+

+ ${msg("My applications")} +

+ ${this.uiConfig.searchEnabled ? this.renderSearch() : nothing} +
+
${this.renderState()}
+
`; + } +} diff --git a/web/src/user/LibraryPage/LibraryPage.ts b/web/src/user/LibraryPage/ak-library.ts similarity index 91% rename from web/src/user/LibraryPage/LibraryPage.ts rename to web/src/user/LibraryPage/ak-library.ts index 01ab26c718e6..5388b25119d0 100644 --- a/web/src/user/LibraryPage/LibraryPage.ts +++ b/web/src/user/LibraryPage/ak-library.ts @@ -9,8 +9,8 @@ import { customElement, state } from "lit/decorators.js"; import { Application, CoreApi } from "@goauthentik/api"; -import "./LibraryPageImpl"; -import type { PageUIConfig } from "./types"; +import "./ak-library-impl.js"; +import type { PageUIConfig } from "./types.js"; /** * List of Applications available @@ -35,6 +35,10 @@ export class LibraryPage extends AKElement { @state() isAdmin = false; + /** + * The list of applications. This is the *complete* list; the constructor fetches as many pages + * as the server announces when page one is accessed, and then concatenates them all together. + */ @state() apps: Application[] = []; diff --git a/web/src/user/LibraryPage/constants.ts b/web/src/user/LibraryPage/constants.ts deleted file mode 100644 index cfb650c7e070..000000000000 --- a/web/src/user/LibraryPage/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const SEARCH_UPDATED = "authentik.search-updated"; -export const SEARCH_ITEM_SELECTED = "authentik.search-item-selected"; diff --git a/web/src/user/LibraryPage/events.ts b/web/src/user/LibraryPage/events.ts new file mode 100644 index 000000000000..e6b1ed56e642 --- /dev/null +++ b/web/src/user/LibraryPage/events.ts @@ -0,0 +1,72 @@ +import type { Application } from "@goauthentik/api"; + +/** + * @class LibraryPageSearchUpdated + * + * Indicates that the user has made a query that resulted in some + * applications being filtered-for. + * + */ +export class LibraryPageSearchUpdated extends Event { + static readonly eventName = "authentik.library.search-updated"; + /** + * @attr apps: The list of those entries found by the current search. + */ + constructor(public apps: Application[]) { + super(LibraryPageSearchUpdated.eventName, { composed: true, bubbles: true }); + } +} + +/** + * @class LibraryPageSearchReset + * + * Indicates that the user has emptied the search field. Intended to + * signal that all available apps are to be displayed. + * + */ +export class LibraryPageSearchReset extends Event { + static readonly eventName = "authentik.library.search-reset"; + constructor() { + super(LibraryPageSearchReset.eventName, { composed: true, bubbles: true }); + } +} + +/** + * @class LibraryPageSearchEmpty + * + * Indicates that the user has made a query that resulted in an empty + * list being returned. Intended to signal that an alternative "No + * matching applications found" message be displayed. + * + */ +export class LibraryPageSearchEmpty extends Event { + static readonly eventName = "authentik.library.search-empty"; + + constructor() { + super(LibraryPageSearchEmpty.eventName, { composed: true, bubbles: true }); + } +} + +/** + * @class LibraryPageSearchEmpty + * + * Indicates that the user has pressed "Enter" while focused on the + * search box. Intended to signal that the currently highlighted search + * entry (if any) should be activated. + * + */ +export class LibraryPageSearchSelected extends Event { + static readonly eventName = "authentik.library.search-item-selected"; + constructor() { + super(LibraryPageSearchSelected.eventName, { composed: true, bubbles: true }); + } +} + +declare global { + interface GlobalEventHandlersEventMap { + [LibraryPageSearchUpdated.eventName]: LibraryPageSearchUpdated; + [LibraryPageSearchReset.eventName]: LibraryPageSearchReset; + [LibraryPageSearchEmpty.eventName]: LibraryPageSearchEmpty; + [LibraryPageSearchSelected.eventName]: LibraryPageSearchSelected; + } +} diff --git a/web/src/user/LibraryPage/helpers.ts b/web/src/user/LibraryPage/helpers.ts deleted file mode 100644 index 50a7f9ed6934..000000000000 --- a/web/src/user/LibraryPage/helpers.ts +++ /dev/null @@ -1,24 +0,0 @@ -import "@goauthentik/elements/EmptyState"; - -import { msg } from "@lit/localize"; -import { html } from "lit"; -import type { TemplateResult } from "lit"; - -export const customEvent = (name: string, details = {}) => - new CustomEvent(name as string, { - composed: true, - bubbles: true, - detail: details, - }); - -// "Unknown" seems to violate some obscure Typescript rule and doesn't work here, although it -// should. -// -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const isCustomEvent = (v: any): v is CustomEvent => - v instanceof CustomEvent && "detail" in v; - -export const loading = (v: T, actual: TemplateResult) => - v - ? actual - : html` `; diff --git a/web/src/user/Routes.ts b/web/src/user/Routes.ts index 72738e18212a..dd2605c82f13 100644 --- a/web/src/user/Routes.ts +++ b/web/src/user/Routes.ts @@ -1,5 +1,5 @@ import { Route } from "@goauthentik/elements/router/Route"; -import "@goauthentik/user/LibraryPage/LibraryPage"; +import "@goauthentik/user/LibraryPage/ak-library.js"; import { html } from "lit";