web: issue 7864, provide a "list view" for the Applications page.
This commit is a quick-and-dirty prototypes for what such a list view would look like. None of the usual bells and whistles have been applied: the styles are ugly, the reveals overly quick, and there's probably a host of other things we could do to pretty it up (like search, or toggle between Alphabetical and ASCIIBetical, although in the context of I18N does that even mean anything anymore?). But it does the job.
This commit is contained in:
2443
tests/wdio/package-lock.json
generated
2443
tests/wdio/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,4 +12,8 @@ export const authentikEnterpriseContext = createContext<LicenseSummary>(
|
||||
|
||||
export const authentikBrandContext = createContext<CurrentBrand>(Symbol("authentik-brand-context"));
|
||||
|
||||
export const authentikLocalStoreContext = createContext<unknown>(
|
||||
Symbol("authentik-local-store-context"),
|
||||
);
|
||||
|
||||
export default authentikConfigContext;
|
||||
|
30
web/src/elements/utils/tryCatch.ts
Normal file
30
web/src/elements/utils/tryCatch.ts
Normal file
@ -0,0 +1,30 @@
|
||||
type TryFn<T> = () => T;
|
||||
type CatchFn<T> = (error: unknown) => T;
|
||||
|
||||
type TryCatchArgs<T> = {
|
||||
tryFn: TryFn<T>;
|
||||
catchFn: CatchFn<T>;
|
||||
};
|
||||
|
||||
export function tryCatch<T>({ tryFn, catchFn }: TryCatchArgs<T>): T;
|
||||
export function tryCatch<T>(tryFn: TryFn<T>, catchFn: CatchFn<T>): T;
|
||||
|
||||
const isTryCatchArgs = <T>(t: any): t is TryCatchArgs<T> =>
|
||||
typeof t === "object" && "tryFn" in t && "catchFn" in t;
|
||||
|
||||
export function tryCatch<T>(tryFn: TryFn<T> | TryCatchProps<T>, catchFn?: CatchFn<T>): T {
|
||||
if (isTryCatchArgs(tryFn)) {
|
||||
catchFn = tryFn.catchFn;
|
||||
tryFn = tryFn.tryFn;
|
||||
}
|
||||
|
||||
if (catchFn === undefined) {
|
||||
catchFn = () => null;
|
||||
}
|
||||
|
||||
try {
|
||||
return tryFn();
|
||||
} catch (error) {
|
||||
return catchFn(error);
|
||||
}
|
||||
}
|
95
web/src/user/LibraryPage/ApplicationCards.ts
Normal file
95
web/src/user/LibraryPage/ApplicationCards.ts
Normal 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"],
|
||||
],
|
||||
]);
|
||||
|
||||
@customElement("ak-library-application-cards")
|
||||
export class LibraryPageApplicationCards extends AKElement {
|
||||
static get styles() {
|
||||
return [
|
||||
PFBase,
|
||||
PFEmptyState,
|
||||
PFContent,
|
||||
PFGrid,
|
||||
css`
|
||||
.app-group-header {
|
||||
margin-bottom: 1em;
|
||||
margin-top: 1.2em;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@property({ attribute: true })
|
||||
layout = "row" as LayoutType;
|
||||
|
||||
@property({ attribute: true })
|
||||
background: string | undefined = undefined;
|
||||
|
||||
@property({ attribute: true })
|
||||
selected = "";
|
||||
|
||||
@property({ attribute: false })
|
||||
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>`;
|
||||
}
|
||||
}
|
@ -1,12 +1,17 @@
|
||||
import { PFSize } from "@goauthentik/common/enums.js";
|
||||
import { LayoutType } from "@goauthentik/common/ui/config";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { AKElement, rootInterface } from "@goauthentik/elements/Base";
|
||||
|
||||
import { css, html } from "lit";
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.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 PFTable from "@patternfly/patternfly/components/Table/table.css";
|
||||
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
@ -36,13 +41,12 @@ export class LibraryPageApplicationList extends AKElement {
|
||||
static get styles() {
|
||||
return [
|
||||
PFBase,
|
||||
PFTable,
|
||||
PFButton,
|
||||
PFEmptyState,
|
||||
PFContent,
|
||||
PFGrid,
|
||||
css`
|
||||
.app-group-header {
|
||||
margin-bottom: 1em;
|
||||
margin-top: 1.2em;
|
||||
.app-row a {
|
||||
font-weight: bold;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@ -60,6 +64,8 @@ export class LibraryPageApplicationList extends AKElement {
|
||||
@property({ attribute: false })
|
||||
apps: AppGroupList = [];
|
||||
|
||||
expanded = new Set<string>();
|
||||
|
||||
get currentLayout(): Pair {
|
||||
const layout = LAYOUTS.get(this.layout);
|
||||
if (!layout) {
|
||||
@ -70,26 +76,109 @@ export class LibraryPageApplicationList extends AKElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
const canEdit =
|
||||
rootInterface()?.uiConfig?.enabledFeatures.applicationEdit &&
|
||||
rootInterface()?.me?.user.isSuperuser;
|
||||
|
||||
const toggleExpansion = (pk: string) => {
|
||||
if (this.expanded.has(pk)) {
|
||||
this.expanded.delete(pk);
|
||||
} else {
|
||||
this.expanded.add(pk);
|
||||
}
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
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>
|
||||
const expandedClass = (pk: string) => ({
|
||||
"pf-m-expanded": this.expanded.has(pk),
|
||||
});
|
||||
|
||||
const renderExpansionCell = (app: Application) =>
|
||||
app.metaDescription
|
||||
? html`<td class="pf-c-table__toggle" role="cell">
|
||||
<button
|
||||
class="pf-c-button pf-m-plain ${classMap(expandedClass(app.pk))}"
|
||||
@click=${() => toggleExpansion(app.pk)}
|
||||
>
|
||||
<div class="pf-c-table__toggle-icon">
|
||||
<i class="fas fa-angle-down" aria-hidden="true"></i>
|
||||
</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>`;
|
||||
</button>
|
||||
</td>`
|
||||
: nothing;
|
||||
|
||||
const renderAppIcon = (app: Application) =>
|
||||
html`<a
|
||||
href="${ifDefined(app.launchUrl ?? "")}"
|
||||
target="${ifDefined(app.openInNewTab ? "_blank" : undefined)}"
|
||||
>
|
||||
<ak-app-icon size=${PFSize.Small} .app=${app}></ak-app-icon>
|
||||
</a>`;
|
||||
|
||||
const renderAppUrl = (app: Application) =>
|
||||
app.launchUrl === "goauthentik.io://providers/rac/launch"
|
||||
? html`<ak-library-rac-endpoint-launch .app=${app}>
|
||||
<a slot="trigger"> ${app.name} </a>
|
||||
</ak-library-rac-endpoint-launch>`
|
||||
: html`<a
|
||||
href="${ifDefined(app.launchUrl ?? "")}"
|
||||
target="${ifDefined(app.openInNewTab ? "_blank" : undefined)}"
|
||||
>${app.name}</a
|
||||
>`;
|
||||
|
||||
const renderAppDescription = (app: Application) =>
|
||||
app.metaDescription
|
||||
? html` <tr
|
||||
class="pf-c-table__expandable-row ${classMap(expandedClass(app.pk))}"
|
||||
role="row"
|
||||
>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td colspan="3">${app.metaDescription}</td>
|
||||
</tr>`
|
||||
: nothing;
|
||||
|
||||
const renderGroup = ([group, apps]: AppGroupEntry) => html`
|
||||
${group
|
||||
? html`<tr>
|
||||
<td colspan="5"><h2>${group}</h2></td>
|
||||
</tr>`
|
||||
: nothing}
|
||||
${apps.map(
|
||||
(app: Application) =>
|
||||
html`<tr>
|
||||
<td>${renderExpansionCell(app)}</td>
|
||||
<td>${renderAppIcon(app)}</td>
|
||||
<td class="app-row">${renderAppUrl(app)}</td>
|
||||
<td>${app.metaPublisher ?? ""}</td>
|
||||
<td>
|
||||
<a
|
||||
class="pf-c-button pf-m-control pf-m-small pf-m-block"
|
||||
href="/if/admin/#/core/applications/${app?.slug}"
|
||||
>
|
||||
<i class="fas fa-edit"></i> ${msg("Edit")}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
${this.expanded.has(app.pk) ? renderAppDescription(app) : nothing} `,
|
||||
)}
|
||||
`;
|
||||
|
||||
return html`<table class="pf-c-table pf-m-compact pf-m-grid-sm pf-m-expandable">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>${msg("Application")}</th>
|
||||
<th>${msg("Publisher")}</th>
|
||||
${canEdit ? html`<th>${msg("Edit")}</th>` : nothing}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.apps.map(renderGroup)}
|
||||
</tbody>
|
||||
</table> `;
|
||||
}
|
||||
}
|
||||
|
@ -34,6 +34,30 @@ export const styles = [PFBase, PFDisplay, PFEmptyState, PFPage, PFContent].conca
|
||||
.pf-c-page__main-section {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#library-page-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#library-page-title h1 {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
#library-page-title i {
|
||||
display: inline-block;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
#library-page-title i[checked] {
|
||||
border: 3px solid var(--pf-global--BorderColor--100);
|
||||
}
|
||||
|
||||
#library-page-title a,
|
||||
#library-page-title i {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
`);
|
||||
|
||||
export default styles;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { groupBy } from "@goauthentik/common/utils";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/EmptyState";
|
||||
import { tryCatch } from "@goauthentik/elements/utils/tryCatch.js";
|
||||
import "@goauthentik/user/LibraryApplication";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
@ -12,6 +13,7 @@ import styles from "./LibraryPageImpl.css";
|
||||
|
||||
import type { Application } from "@goauthentik/api";
|
||||
|
||||
import "./ApplicationCards";
|
||||
import "./ApplicationEmptyState";
|
||||
import "./ApplicationList";
|
||||
import "./ApplicationSearch";
|
||||
@ -20,6 +22,8 @@ import { SEARCH_ITEM_SELECTED, SEARCH_UPDATED } from "./constants";
|
||||
import { isCustomEvent, loading } from "./helpers";
|
||||
import type { AppGroupList, PageUIConfig } from "./types";
|
||||
|
||||
const VIEW_KEY = "ak-library-page-view-preference";
|
||||
|
||||
/**
|
||||
* List of Applications available
|
||||
*
|
||||
@ -53,6 +57,9 @@ export class LibraryPage extends AKElement {
|
||||
@state()
|
||||
filteredApps: Application[] = [];
|
||||
|
||||
@property()
|
||||
viewPreference?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.searchUpdated = this.searchUpdated.bind(this);
|
||||
@ -66,6 +73,15 @@ export class LibraryPage extends AKElement {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.filteredApps = this.apps;
|
||||
this.viewPreference =
|
||||
this.viewPreference ??
|
||||
tryCatch(
|
||||
() => window.localStorage.getItem(VIEW_KEY),
|
||||
(e) => {
|
||||
console.log(e);
|
||||
return "card";
|
||||
},
|
||||
);
|
||||
if (this.filteredApps === undefined) {
|
||||
throw new Error(
|
||||
"Application.results should never be undefined when passed to the Library Page.",
|
||||
@ -110,6 +126,11 @@ export class LibraryPage extends AKElement {
|
||||
return groupBy(this.filteredApps.filter(appHasLaunchUrl), (app) => app.group || "");
|
||||
}
|
||||
|
||||
setView(view: string) {
|
||||
this.viewPreference = view;
|
||||
tryCatch(() => window.localStorage.setItem(VIEW_KEY, view));
|
||||
}
|
||||
|
||||
renderEmptyState() {
|
||||
return html`<ak-library-application-empty-list
|
||||
?isadmin=${this.isAdmin}
|
||||
@ -122,12 +143,17 @@ export class LibraryPage extends AKElement {
|
||||
const layout = this.uiConfig.layout as string;
|
||||
const background = this.uiConfig.background;
|
||||
|
||||
return html`<ak-library-application-list
|
||||
return this.viewPreference === "list"
|
||||
? html`<ak-library-application-list
|
||||
selected="${ifDefined(selected)}"
|
||||
.apps=${apps}
|
||||
></ak-library-application-list>`
|
||||
: html`<ak-library-application-cards
|
||||
layout="${layout}"
|
||||
background="${ifDefined(background)}"
|
||||
selected="${ifDefined(selected)}"
|
||||
.apps=${apps}
|
||||
></ak-library-application-list>`;
|
||||
></ak-library-application-cards>`;
|
||||
}
|
||||
|
||||
renderSearch() {
|
||||
@ -137,9 +163,15 @@ export class LibraryPage extends AKElement {
|
||||
render() {
|
||||
return html`<main role="main" class="pf-c-page__main" tabindex="-1" id="main-content">
|
||||
<div class="pf-c-content header">
|
||||
<h1 role="heading" aria-level="1" id="library-page-title">
|
||||
${msg("My applications")}
|
||||
</h1>
|
||||
<div id="library-page-title">
|
||||
<h1 role="heading" aria-level="1">${msg("My applications")}</h1>
|
||||
<a id="card-view" @click=${() => this.setView("card")}
|
||||
><i ?checked=${this.viewPreference === "card"} class="fas fa-th-large"></i
|
||||
></a>
|
||||
<a id="list-view" @click=${() => this.setView("list")}
|
||||
><i ?checked=${this.viewPreference === "list"} class="fas fa-list"></i
|
||||
></a>
|
||||
</div>
|
||||
${this.uiConfig.searchEnabled ? this.renderSearch() : html``}
|
||||
</div>
|
||||
<section class="pf-c-page__main-section">
|
||||
|
Reference in New Issue
Block a user