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 <jens@goauthentik.io>

* fix router import

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* use pf-c-button for CTA

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix different alignment compared to old version

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* use docLink() for documentation link

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* also open docs in new tab

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web: minor language changes

As requested by @Tana.

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Ken Sternberg 2023-05-22 14:35:26 -07:00 committed by GitHub
parent 36340d0960
commit 7c7957f160
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 630 additions and 249 deletions

View file

@ -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<T>(v: T, actual: TemplateResult): TemplateResult {
if (!v) {
return html`<ak-empty-state ?loading="${true}" header=${t`Loading`}> </ak-empty-state>`;
}
return actual;
}
@customElement("ak-library")
export class LibraryPage extends AKElement {
@property({ attribute: false })
apps?: PaginatedResponse<Application>;
@state()
selectedApp?: Application;
@state()
filteredApps: Application[] = [];
@property()
query = getURLParam<string | undefined>("search", undefined);
fuse: Fuse<Application>;
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` <div class="pf-c-empty-state pf-m-full-height">
<div class="pf-c-empty-state__content">
<i class="fas fa-cubes pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">${t`No Applications available.`}</h1>
<div class="pf-c-empty-state__body">
${t`Either no applications are defined, or you don't have access to any.`}
</div>
</div>
</div>`;
}
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`<div class="pf-l-grid pf-m-gutter">
${this.getApps().map(([group, apps]) => {
return html`<div class="pf-l-grid__item ${groupClass}">
<div class="pf-c-content app-group-header">
<h2>${group}</h2>
</div>
<div class="pf-l-grid pf-m-gutter ${groupGrid}">
${apps.map((app) => {
return html`<ak-library-app
class="pf-l-grid__item"
.application=${app}
background=${ifDefined(uiConfig?.theme.cardBackground)}
?selected=${app.slug === this.selectedApp?.slug}
></ak-library-app>`;
})}
</div>
</div> `;
})}
</div>`;
}
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`<main role="main" class="pf-c-page__main" tabindex="-1" id="main-content">
<div class="pf-c-content header">
<h1>${t`My applications`}</h1>
${rootInterface()?.uiConfig?.enabledFeatures.search
? html`<input
@input=${(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.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``}
</div>
<section class="pf-c-page__main-section">
${loading(
this.apps,
html`${this.filterApps(this.filteredApps).length > 0
? this.renderApps()
: this.renderEmptyState()}`,
)}
</section>
</main>`;
}
}

View file

@ -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`
<div class="pf-u-pt-lg">
<a
aria-disabled="false"
class="cta pf-c-button pf-m-secondary"
href="/if/admin/${href}"
>${t`Create a new application`}</a
>
</div>
<div class="pf-c-empty-state__body">
<a href="${docLink("/docs/applications")}" target="_blank"
>${t`Refer to documentation`}</a
>
</div>
`;
}
render() {
return html` <div class="pf-c-empty-state pf-m-full-height">
<div class="pf-c-empty-state__content">
<i class="fas fa-cubes pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">${t`No Applications available.`}</h1>
<div class="pf-c-empty-state__body">
${t`Either no applications are defined, or you dont have access to any.`}
</div>
${this.isAdmin ? this.renderNewAppButton() : html``}
</div>
</div>`;
}
}

View file

@ -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<string, [string, string]>([
[
"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`<div class="pf-l-grid pf-m-gutter">
${this.apps.map(([group, apps]: AppGroupEntry) => {
return html`<div class="pf-l-grid__item ${groupClass}">
<div class="pf-c-content app-group-header">
<h2>${group}</h2>
</div>
<div class="pf-l-grid pf-m-gutter ${groupGrid}">
${apps.map((app: Application) => {
return html`<ak-library-app
class="pf-l-grid__item"
.application=${app}
background=${ifDefined(this.background)}
?selected=${app.slug === this.selected}
></ak-library-app>`;
})}
</div>
</div> `;
})}
</div>`;
}
}

View file

@ -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>[]): 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<string | undefined>("search", undefined);
@query("input")
searchInput?: HTMLInputElement;
fuse: Fuse<Application>;
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<Application>[]) {
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`<input
@input=${this.onInput}
@keydown=${this.onKeyDown}
type="text"
class="pf-u-display-none pf-u-display-block-on-md"
autofocus
placeholder=${t`Search...`}
value=${ifDefined(this.query)}
/>`;
}
}

View file

@ -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<Application>;
@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`<ak-empty-state ?loading="${true}" header=${t`Loading`}> </ak-empty-state>`;
}
running() {
return html`<ak-library-impl
?isadmin=${this.isAdmin}
.apps=${this.apps}
.uiConfig=${this.uiConfig}
></ak-library-impl>`;
}
render() {
return this.ready ? this.running() : this.loading();
}
}

View file

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

View file

@ -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<Application>;
@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`<ak-library-application-empty-list
?isadmin=${this.isAdmin}
></ak-library-application-empty-list>`;
}
renderApps() {
const selected = this.selectedApp?.slug;
const apps = this.getApps();
const layout = this.uiConfig.layout as string;
const background = this.uiConfig.background;
return html`<ak-library-application-list
layout="${layout}"
background="${background}"
selected="${selected}"
.apps=${apps}
></ak-library-application-list>`;
}
renderSearch() {
return html`<ak-library-list-search .apps="{this.apps.results}"></ak-library-list-search>`;
}
render() {
return html`<main role="main" class="pf-c-page__main" tabindex="-1" id="main-content">
<div class="pf-c-content header">
<h1>${t`My applications`}</h1>
${this.uiConfig.searchEnabled ? this.renderSearch() : html``}
</div>
<section class="pf-c-page__main-section">
${loading(
this.apps,
html`${this.filteredApps.find(appHasLaunchUrl)
? this.renderApps()
: this.renderEmptyState()}`,
)}
</section>
</main>`;
}
}

View file

@ -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)));
};

View file

@ -0,0 +1,2 @@
export const SEARCH_UPDATED = "authentik.search-updated";
export const SEARCH_ITEM_SELECTED = "authentik.search-item-selected";

View file

@ -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 = <T>(v: T, actual: TemplateResult) =>
v ? actual : html`<ak-empty-state ?loading="${true}" header=${t`Loading`}> </ak-empty-state>`;

View file

@ -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;
};

View file

@ -1,5 +1,5 @@
import { Route } from "@goauthentik/elements/router/Route";
import "@goauthentik/user/LibraryPage";
import "@goauthentik/user/LibraryPage/LibraryPage";
import { html } from "lit";