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";