From 7c7957f160af98108c7d803cdfeb9a40d49e0d3c Mon Sep 17 00:00:00 2001 From: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com> Date: Mon, 22 May 2023 14:35:26 -0700 Subject: [PATCH] web/user: refactor LibraryPage for testing, add CTA (#5665) * 5171: Fixed README to comply with Prettier rules. I'm pretty impressed that that worked. Good on Jens for having that in the prettier rules. * web: revised package.json Removed the migration and web/README.md file. The former should not have been included; the latter is currently unprofessional in tone. * web: revise LibraryPage, add CTA TL;DR: - Separated LibraryPage into a bunch of different, independent parts, none of which require Authentik running to be testable or viewable. - This made adding the "Add an Application" CTA easier. - This sets the stage for unit and view testing of the UI This commit revises the LibraryPage, devolving it into a couple of independent components that have to asynchronous dependencies, with a single asynchronous master: - LibraryPage: Loads the UIConfig, UserConfig, and CoreApi, and once those are loaded, launches the LibraryPageImpl. - LibraryPageImpl: the ListView of applications available, and updates the ListView according to search criteria it receives via an event listener. - LibraryPageImpl.css: The stylesheet. Put here because it's visual clutter. - LibraryPageImpl.utils: defines static functions used to filter the view. Here because, again, it would otherwise be visual clutter of the LibraryPageImpl. - ApplicationEmptyState: Shows the "You have no applications" and, if the user is a superuser, the "Add an application" button. - ApplicationSearch: Contains the Fuse implementation and, as the search result is updated, sends the selected and filtered app list to the LibraryPage via an event. Also controls the "Choose an application by pressing Enter" event. - ApplicationList: Displays the list of applications. All of these components are _responsive_ to changes in the Apps collection via the LibraryPage itself, but none of them invoke the Apps collection, UIConfig, and CoreApi directly, so it should be possible to create Storybook implementations that view the LibraryPageImpl itself without having to have an instance of Authentik running. If the user is a superuser, the "You have no applications" panel now shows the "Add an Application" button and a link to the documentation on how to add an application. * web: lint and prettier updates \#\# Details - Resolves #5171 \#\# Changes This just updates the prettier and eslint passes. * \#\# Details - Resolves #5171 \#\# Changes Removed unused declarations. * \#\# Details - web: refactor LibraryPage, resolves #5171 \#\# Changes Some changes found in code review, including an embarassing failure to both remove the old internal accessor and propagate the new one for "isAdmin". A pattern is emerging that a LitComponent class should consist of: - styles - properties - states - queries - other object fields - constructor() - connectedCallBack() - disconnectedCallBack() - event listeners - callback helpers - render helpers - render() ... in that order. * actually remove LibraryPage that got re-added in the rebase Signed-off-by: Jens Langhammer * fix router import Signed-off-by: Jens Langhammer * use pf-c-button for CTA Signed-off-by: Jens Langhammer * fix different alignment compared to old version Signed-off-by: Jens Langhammer * use docLink() for documentation link Signed-off-by: Jens Langhammer * also open docs in new tab Signed-off-by: Jens Langhammer * web: minor language changes As requested by @Tana. --------- Signed-off-by: Jens Langhammer Co-authored-by: Jens Langhammer --- web/src/user/LibraryPage.ts | 248 ------------------ .../user/LibraryPage/ApplicationEmptyState.ts | 77 ++++++ web/src/user/LibraryPage/ApplicationList.ts | 95 +++++++ web/src/user/LibraryPage/ApplicationSearch.ts | 127 +++++++++ web/src/user/LibraryPage/LibraryPage.ts | 96 +++++++ .../user/LibraryPage/LibraryPageImpl.css.ts | 39 +++ web/src/user/LibraryPage/LibraryPageImpl.ts | 147 +++++++++++ .../user/LibraryPage/LibraryPageImpl.utils.ts | 11 + web/src/user/LibraryPage/constants.ts | 2 + web/src/user/LibraryPage/helpers.ts | 23 ++ web/src/user/LibraryPage/types.ts | 12 + web/src/user/Routes.ts | 2 +- 12 files changed, 630 insertions(+), 249 deletions(-) delete mode 100644 web/src/user/LibraryPage.ts create mode 100644 web/src/user/LibraryPage/ApplicationEmptyState.ts create mode 100644 web/src/user/LibraryPage/ApplicationList.ts create mode 100644 web/src/user/LibraryPage/ApplicationSearch.ts create mode 100644 web/src/user/LibraryPage/LibraryPage.ts create mode 100644 web/src/user/LibraryPage/LibraryPageImpl.css.ts create mode 100644 web/src/user/LibraryPage/LibraryPageImpl.ts create mode 100644 web/src/user/LibraryPage/LibraryPageImpl.utils.ts create mode 100644 web/src/user/LibraryPage/constants.ts create mode 100644 web/src/user/LibraryPage/helpers.ts create mode 100644 web/src/user/LibraryPage/types.ts diff --git a/web/src/user/LibraryPage.ts b/web/src/user/LibraryPage.ts deleted file mode 100644 index 878c6248a..000000000 --- a/web/src/user/LibraryPage.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { LayoutType } from "@goauthentik/common/ui/config"; -import { groupBy } from "@goauthentik/common/utils"; -import { AKElement, rootInterface } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/EmptyState"; -import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; -import { PaginatedResponse } from "@goauthentik/elements/table/Table"; -import "@goauthentik/user/LibraryApplication"; -import Fuse from "fuse.js"; - -import { t } from "@lingui/macro"; - -import { CSSResult, TemplateResult, css, html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; - -import PFContent from "@patternfly/patternfly/components/Content/content.css"; -import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css"; -import PFPage from "@patternfly/patternfly/components/Page/page.css"; -import PFGallery from "@patternfly/patternfly/layouts/Gallery/gallery.css"; -import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; - -import { Application, CoreApi } from "@goauthentik/api"; - -export function loading(v: T, actual: TemplateResult): TemplateResult { - if (!v) { - return html` `; - } - return actual; -} - -@customElement("ak-library") -export class LibraryPage extends AKElement { - @property({ attribute: false }) - apps?: PaginatedResponse; - - @state() - selectedApp?: Application; - - @state() - filteredApps: Application[] = []; - - @property() - query = getURLParam("search", undefined); - - fuse: Fuse; - - constructor() { - super(); - this.fuse = new Fuse([], { - keys: [ - { name: "name", weight: 3 }, - "slug", - "group", - { name: "metaDescription", weight: 0.5 }, - { name: "metaPublisher", weight: 0.5 }, - ], - findAllMatches: true, - includeScore: true, - shouldSort: true, - ignoreFieldNorm: true, - useExtendedSearch: true, - threshold: 0.5, - }); - new CoreApi(DEFAULT_CONFIG).coreApplicationsList({}).then((apps) => { - this.apps = apps; - this.filteredApps = apps.results; - this.fuse.setCollection(apps.results); - if (!this.query) return; - const matchingApps = this.fuse.search(this.query); - if (matchingApps.length < 1) return; - this.selectedApp = matchingApps[0].item; - this.filteredApps = matchingApps.map((a) => a.item); - }); - } - - pageTitle(): string { - return t`My Applications`; - } - - static get styles(): CSSResult[] { - return [PFBase, PFDisplay, PFEmptyState, PFPage, PFContent, PFGrid, PFGallery].concat(css` - :host, - main { - padding: 3% 5%; - } - .header { - display: flex; - flex-direction: row; - justify-content: space-between; - } - .header input { - width: 30ch; - box-sizing: border-box; - border: 0; - border-bottom: 1px solid; - border-bottom-color: #fd4b2d; - background-color: transparent; - font-size: 1.5rem; - } - .header input:focus { - outline: 0; - } - .pf-c-page__main { - overflow: hidden; - } - .pf-c-page__main-section { - background-color: transparent; - } - .app-group-header { - margin-bottom: 1em; - margin-top: 1.2em; - } - `); - } - - renderEmptyState(): TemplateResult { - return html`
-
- -

${t`No Applications available.`}

-
- ${t`Either no applications are defined, or you don't have access to any.`} -
-
-
`; - } - - filterApps(apps: Application[]): Application[] { - return apps.filter((app) => { - if (app.launchUrl && app.launchUrl !== "") { - // If the launch URL is a full URL, only show with http or https - if (app.launchUrl.indexOf("://") !== -1) { - return app.launchUrl.startsWith("http"); - } - // If the URL doesn't include a protocol, assume its a relative path - return true; - } - return false; - }); - } - - getApps(): [string, Application[]][] { - return groupBy(this.filterApps(this.filteredApps), (app) => app.group || ""); - } - - renderApps(): TemplateResult { - let groupClass = ""; - let groupGrid = ""; - const uiConfig = rootInterface()?.uiConfig; - switch (uiConfig?.layout.type) { - case LayoutType.row: - groupClass = "pf-m-12-col"; - groupGrid = - "pf-m-all-6-col-on-sm pf-m-all-4-col-on-md pf-m-all-5-col-on-lg pf-m-all-2-col-on-xl"; - break; - case LayoutType.column_2: - groupClass = "pf-m-6-col"; - groupGrid = - "pf-m-all-12-col-on-sm pf-m-all-12-col-on-md pf-m-all-4-col-on-lg pf-m-all-4-col-on-xl"; - break; - case LayoutType.column_3: - groupClass = "pf-m-4-col"; - groupGrid = - "pf-m-all-12-col-on-sm pf-m-all-12-col-on-md pf-m-all-6-col-on-lg pf-m-all-6-col-on-xl"; - break; - } - return html`
- ${this.getApps().map(([group, apps]) => { - return html`
-
-

${group}

-
-
- ${apps.map((app) => { - return html``; - })} -
-
`; - })} -
`; - } - - resetSearch(): void { - const searchInput = this.shadowRoot?.querySelector("input"); - if (searchInput) { - searchInput.value = ""; - } - this.query = ""; - updateURLParams({ - search: this.query, - }); - this.selectedApp = undefined; - this.filteredApps = this.apps?.results || []; - } - - render(): TemplateResult { - return html`
-
-

${t`My applications`}

- ${rootInterface()?.uiConfig?.enabledFeatures.search - ? html` { - this.query = (ev.target as HTMLInputElement).value; - if (this.query === "") { - return this.resetSearch(); - } - updateURLParams({ - search: this.query, - }); - const apps = this.fuse.search(this.query); - if (apps.length < 1) return; - this.selectedApp = apps[0].item; - this.filteredApps = apps.map((a) => a.item); - }} - @keydown=${(ev: KeyboardEvent) => { - if (ev.key === "Enter" && this.selectedApp?.launchUrl) { - window.location.assign(this.selectedApp.launchUrl); - } else if (ev.key === "Escape") { - this.resetSearch(); - } - }} - type="text" - class="pf-u-display-none pf-u-display-block-on-md" - autofocus - placeholder=${t`Search...`} - value=${ifDefined(this.query)} - />` - : html``} -
-
- ${loading( - this.apps, - html`${this.filterApps(this.filteredApps).length > 0 - ? this.renderApps() - : this.renderEmptyState()}`, - )} -
-
`; - } -} diff --git a/web/src/user/LibraryPage/ApplicationEmptyState.ts b/web/src/user/LibraryPage/ApplicationEmptyState.ts new file mode 100644 index 000000000..d6d774059 --- /dev/null +++ b/web/src/user/LibraryPage/ApplicationEmptyState.ts @@ -0,0 +1,77 @@ +import { docLink } from "@goauthentik/common/global"; +import { AKElement } from "@goauthentik/elements/Base"; +import { paramURL } from "@goauthentik/elements/router/RouterOutlet"; + +import { t } from "@lingui/macro"; + +import { css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +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. + */ + +const styles = [ + PFBase, + PFEmptyState, + PFButton, + PFContent, + PFSpacing, + css` + .cta { + display: inline-block; + font-weight: bold; + } + `, +]; + +@customElement("ak-library-application-empty-list") +export class LibraryPageApplicationEmptyList extends AKElement { + static styles = styles; + + @property({ attribute: "isadmin", type: Boolean }) + isAdmin = false; + + renderNewAppButton() { + const href = paramURL("/core/applications", { + createForm: true, + }); + return html` + + + `; + } + + render() { + return html`
+
+ +

${t`No Applications available.`}

+
+ ${t`Either no applications are defined, or you don’t have access to any.`} +
+ ${this.isAdmin ? this.renderNewAppButton() : html``} +
+
`; + } +} diff --git a/web/src/user/LibraryPage/ApplicationList.ts b/web/src/user/LibraryPage/ApplicationList.ts new file mode 100644 index 000000000..3df49c2fe --- /dev/null +++ b/web/src/user/LibraryPage/ApplicationList.ts @@ -0,0 +1,95 @@ +import { LayoutType } from "@goauthentik/common/ui/config"; +import { AKElement } from "@goauthentik/elements/Base"; + +import { css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css"; +import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import type { Application } from "@goauthentik/api"; + +import type { AppGroupEntry, AppGroupList } from "./types"; + +type Pair = [string, string]; + +// prettier-ignore +const LAYOUTS = new Map([ + [ + "row", + ["pf-m-12-col", "pf-m-all-6-col-on-sm pf-m-all-4-col-on-md pf-m-all-5-col-on-lg pf-m-all-2-col-on-xl"]], + [ + "2-column", + ["pf-m-6-col", "pf-m-all-12-col-on-sm pf-m-all-12-col-on-md pf-m-all-4-col-on-lg pf-m-all-4-col-on-xl"], + ], + [ + "3-column", + ["pf-m-4-col", "pf-m-all-12-col-on-sm pf-m-all-12-col-on-md pf-m-all-6-col-on-lg pf-m-all-6-col-on-xl"], + ], +]); + +const styles = [ + PFBase, + PFEmptyState, + PFContent, + PFGrid, + css` + .app-group-header { + margin-bottom: 1em; + margin-top: 1.2em; + } + `, +]; + +@customElement("ak-library-application-list") +export class LibraryPageApplicationList extends AKElement { + static styles = styles; + + @property({ attribute: true }) + layout = "row" as LayoutType; + + @property({ attribute: true }) + background: string | undefined = undefined; + + @property({ attribute: true }) + selected = ""; + + @property() + apps: AppGroupList = []; + + get currentLayout(): Pair { + const layout = LAYOUTS.get(this.layout); + if (!layout) { + console.warn(`Unrecognized layout: ${this.layout || "-undefined-"}`); + return LAYOUTS.get("row") as Pair; + } + return layout; + } + + render() { + const [groupClass, groupGrid] = this.currentLayout; + + return html`
+ ${this.apps.map(([group, apps]: AppGroupEntry) => { + return html`
+
+

${group}

+
+
+ ${apps.map((app: Application) => { + return html``; + })} +
+
`; + })} +
`; + } +} diff --git a/web/src/user/LibraryPage/ApplicationSearch.ts b/web/src/user/LibraryPage/ApplicationSearch.ts new file mode 100644 index 000000000..a37975f67 --- /dev/null +++ b/web/src/user/LibraryPage/ApplicationSearch.ts @@ -0,0 +1,127 @@ +import { AKElement } from "@goauthentik/elements/Base"; +import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; +import Fuse from "fuse.js"; + +import { t } from "@lingui/macro"; + +import { html } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +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"; + +function fuseToApps(apps: Fuse.FuseResult[]): Application[] { + return apps.map((item) => item.item); +} + +@customElement("ak-library-list-search") +export class LibraryPageApplicationList extends AKElement { + static styles = [PFBase, PFDisplay]; + + @property() + apps: Application[] = []; + + @property() + query = getURLParam("search", undefined); + + @query("input") + searchInput?: HTMLInputElement; + + fuse: Fuse; + + constructor() { + super(); + this.fuse = new Fuse([], { + keys: [ + { name: "name", weight: 3 }, + "slug", + "group", + { name: "metaDescription", weight: 0.5 }, + { name: "metaPublisher", weight: 0.5 }, + ], + findAllMatches: true, + includeScore: true, + shouldSort: true, + ignoreFieldNorm: true, + useExtendedSearch: true, + threshold: 0.5, + }); + } + + onSelected(apps: Fuse.FuseResult[]) { + const items = fuseToApps(apps); + this.dispatchEvent( + customEvent(SEARCH_UPDATED, { + selectedApp: items[0], + filteredApps: items, + }), + ); + } + + connectedCallback() { + this.fuse.setCollection(this.apps); + if (!this.query) { + return; + } + const matchingApps = this.fuse.search(this.query); + if (matchingApps.length < 1) { + return; + } + this.onSelected(matchingApps); + } + + resetSearch(): void { + if (this.searchInput) { + this.searchInput.value = ""; + } + this.query = ""; + updateURLParams({ + search: this.query, + }); + this.onSelected([]); + } + + onInput(ev: InputEvent) { + this.query = (ev.target as HTMLInputElement).value; + if (this.query === "") { + return this.resetSearch(); + } + updateURLParams({ + search: this.query, + }); + const apps = this.fuse.search(this.query); + if (apps.length < 1) return; + this.onSelected(apps); + } + + onKeyDown(ev: KeyboardEvent) { + switch (ev.key) { + case "Escape": { + this.resetSearch(); + return; + } + case "Enter": { + this.dispatchEvent(customEvent(SEARCH_ITEM_SELECTED)); + return; + } + } + } + + render() { + return html``; + } +} diff --git a/web/src/user/LibraryPage/LibraryPage.ts b/web/src/user/LibraryPage/LibraryPage.ts new file mode 100644 index 000000000..e0fbfc83b --- /dev/null +++ b/web/src/user/LibraryPage/LibraryPage.ts @@ -0,0 +1,96 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { me } from "@goauthentik/common/users"; +import { AKElement, rootInterface } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/EmptyState"; +import { PaginatedResponse } from "@goauthentik/elements/table/Table"; + +import { t } from "@lingui/macro"; + +import { html } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +import { Application, CoreApi } from "@goauthentik/api"; + +import "./LibraryPageImpl"; +import type { 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") +export class LibraryPage extends AKElement { + @state() + ready = false; + + @state() + isAdmin = false; + + @state() + apps!: PaginatedResponse; + + @state() + uiConfig: PageUIConfig; + + constructor() { + super(); + const applicationListFetch = new CoreApi(DEFAULT_CONFIG).coreApplicationsList({}); + const meFetch = me(); + const uiConfig = rootInterface()?.uiConfig; + if (!uiConfig) { + throw new Error("Could not retrieve uiConfig. Reason: unknown. Check logs."); + } + + this.uiConfig = { + layout: uiConfig.layout.type, + background: uiConfig.theme.cardBackground, + searchEnabled: uiConfig.enabledFeatures.search, + }; + + Promise.allSettled([applicationListFetch, meFetch]).then( + ([applicationListStatus, meStatus]) => { + if (meStatus.status === "rejected") { + throw new Error( + `Could not determine status of user. Reason: ${meStatus.reason}`, + ); + } + if (applicationListStatus.status === "rejected") { + throw new Error( + `Could not retrieve list of applications. Reason: ${applicationListStatus.reason}`, + ); + } + this.isAdmin = meStatus.value.user.isSuperuser; + this.apps = applicationListStatus.value; + this.ready = true; + }, + ); + } + + pageTitle(): string { + return t`My Applications`; + } + + loading() { + return html` `; + } + + running() { + return html``; + } + + render() { + return this.ready ? this.running() : this.loading(); + } +} diff --git a/web/src/user/LibraryPage/LibraryPageImpl.css.ts b/web/src/user/LibraryPage/LibraryPageImpl.css.ts new file mode 100644 index 000000000..821eb7bfc --- /dev/null +++ b/web/src/user/LibraryPage/LibraryPageImpl.css.ts @@ -0,0 +1,39 @@ +import { css } from "lit"; + +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css"; +import PFPage from "@patternfly/patternfly/components/Page/page.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; + +export const styles = [PFBase, PFDisplay, PFEmptyState, PFPage, PFContent].concat(css` + :host { + display: block; + padding: 3% 5%; + } + .header { + display: flex; + flex-direction: row; + justify-content: space-between; + } + .header input { + width: 30ch; + box-sizing: border-box; + border: 0; + border-bottom: 1px solid; + border-bottom-color: #fd4b2d; + background-color: transparent; + font-size: 1.5rem; + } + .header input:focus { + outline: 0; + } + .pf-c-page__main { + overflow: hidden; + } + .pf-c-page__main-section { + background-color: transparent; + } +`); + +export default styles; diff --git a/web/src/user/LibraryPage/LibraryPageImpl.ts b/web/src/user/LibraryPage/LibraryPageImpl.ts new file mode 100644 index 000000000..ce75cc9f3 --- /dev/null +++ b/web/src/user/LibraryPage/LibraryPageImpl.ts @@ -0,0 +1,147 @@ +import { groupBy } from "@goauthentik/common/utils"; +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/EmptyState"; +import { PaginatedResponse } from "@goauthentik/elements/table/Table"; +import "@goauthentik/user/LibraryApplication"; + +import { t } from "@lingui/macro"; + +import { html } from "lit"; +import { customElement, property, state } from "lit/decorators.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 styles = styles; + + @property() + apps!: PaginatedResponse; + + @property({ attribute: "isadmin", type: Boolean }) + isAdmin = false; + + @property() + 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 t`My Applications`; + } + + connectedCallback() { + super.connectedCallback(); + this.filteredApps = this.apps?.results; + 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(); + this.selectedApp = event.detail.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`
+
+

${t`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/LibraryPageImpl.utils.ts b/web/src/user/LibraryPage/LibraryPageImpl.utils.ts new file mode 100644 index 000000000..bac9186e8 --- /dev/null +++ b/web/src/user/LibraryPage/LibraryPageImpl.utils.ts @@ -0,0 +1,11 @@ +import type { Application } from "@goauthentik/api"; + +const isFullUrlRe = new RegExp("://"); +const isHttpRe = new RegExp("http(s?)://"); +const isNotFullUrl = (url: string) => !isFullUrlRe.test(url); +const isHttp = (url: string) => isHttpRe.test(url); + +export const appHasLaunchUrl = (app: Application) => { + const url = app.launchUrl; + return !!(typeof url === "string" && url !== "" && (isHttp(url) || isNotFullUrl(url))); +}; diff --git a/web/src/user/LibraryPage/constants.ts b/web/src/user/LibraryPage/constants.ts new file mode 100644 index 000000000..cfb650c7e --- /dev/null +++ b/web/src/user/LibraryPage/constants.ts @@ -0,0 +1,2 @@ +export const SEARCH_UPDATED = "authentik.search-updated"; +export const SEARCH_ITEM_SELECTED = "authentik.search-item-selected"; diff --git a/web/src/user/LibraryPage/helpers.ts b/web/src/user/LibraryPage/helpers.ts new file mode 100644 index 000000000..35b0f9561 --- /dev/null +++ b/web/src/user/LibraryPage/helpers.ts @@ -0,0 +1,23 @@ +import "@goauthentik/elements/EmptyState"; + +import { t } from "@lingui/macro"; + +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/LibraryPage/types.ts b/web/src/user/LibraryPage/types.ts new file mode 100644 index 000000000..1fecd584c --- /dev/null +++ b/web/src/user/LibraryPage/types.ts @@ -0,0 +1,12 @@ +import type { LayoutType } from "@goauthentik/common/ui/config"; + +import type { Application } from "@goauthentik/api"; + +export type AppGroupEntry = [string, Application[]]; +export type AppGroupList = AppGroupEntry[]; + +export type PageUIConfig = { + layout: LayoutType; + background?: string; + searchEnabled: boolean; +}; diff --git a/web/src/user/Routes.ts b/web/src/user/Routes.ts index 8908f4ecb..72738e182 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"; +import "@goauthentik/user/LibraryPage/LibraryPage"; import { html } from "lit";