Compare commits

...

2 Commits

Author SHA1 Message Date
c8be337414 Prettier and ESLint had opinions. 2024-05-06 14:47:50 -07:00
5c85c2c9e6 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.
2024-05-06 14:02:57 -07:00
8 changed files with 1621 additions and 1197 deletions

File diff suppressed because it is too large Load Diff

View File

@ -14,8 +14,8 @@
"build": "run-s build-locales esbuild:build",
"build-proxy": "run-s build-locales esbuild:build-proxy",
"watch": "run-s build-locales esbuild:watch",
"lint": "cross-env NODE_OPTIONS='--max_old_space_size=16384' eslint . --max-warnings 0 --fix",
"lint:precommit": "cross-env NODE_OPTIONS='--max_old_space_size=16384' node scripts/eslint-precommit.mjs",
"lint": "cross-env NODE_OPTIONS='--max_old_space_size=65536' eslint . --max-warnings 0 --fix",
"lint:precommit": "cross-env NODE_OPTIONS='--max_old_space_size=65536' node scripts/eslint-precommit.mjs",
"lint:spelling": "node scripts/check-spelling.mjs",
"lit-analyse": "lit-analyzer src",
"precommit": "npm-run-all --parallel tsc lit-analyse lint:spelling --sequential lint:precommit prettier",

View File

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

View File

@ -0,0 +1,31 @@
type TryFn<T> = () => T;
type CatchFn<T> = (error: unknown) => T;
type TryCatchArgs<T> = {
tryFn: TryFn<T>;
catchFn?: CatchFn<T>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isTryCatchArgs = <T>(t: any): t is TryCatchArgs<T> =>
typeof t === "object" && "tryFn" in t && "catchFn" in t;
export function tryCatch<T>({ tryFn, catchFn }: TryCatchArgs<T>): T;
export function tryCatch<T>(tryFn: TryFn<T>): T;
export function tryCatch<T>(tryFn: TryFn<T>, catchFn: CatchFn<T>): T;
export function tryCatch<T>(tryFn: TryFn<T> | TryCatchArgs<T>, catchFn?: CatchFn<T>): T {
if (isTryCatchArgs(tryFn)) {
catchFn = tryFn.catchFn;
tryFn = tryFn.tryFn;
}
if (catchFn === undefined) {
catchFn = () => null as T;
}
try {
return tryFn();
} catch (error) {
return catchFn(error);
}
}

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"],
],
]);
@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>`;
}
}

View File

@ -1,48 +1,34 @@
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 { UserInterface } from "@goauthentik/user/UserInterface";
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 PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFTable from "@patternfly/patternfly/components/Table/table.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-list")
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,36 +46,110 @@ export class LibraryPageApplicationList extends AKElement {
@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;
}
expanded = new Set<string>();
render() {
const [groupClass, groupGrid] = this.currentLayout;
const me = rootInterface<UserInterface>()?.me;
const canEdit =
rootInterface()?.uiConfig?.enabledFeatures.applicationEdit && me?.user.isSuperuser;
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>`;
const toggleExpansion = (pk: string) => {
if (this.expanded.has(pk)) {
this.expanded.delete(pk);
} else {
this.expanded.add(pk);
}
this.requestUpdate();
};
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">
&nbsp;<i class="fas fa-angle-down" aria-hidden="true"></i>&nbsp;
</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>&nbsp;${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> `;
}
}

View File

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

View File

@ -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,12 @@ export class LibraryPage extends AKElement {
connectedCallback() {
super.connectedCallback();
this.filteredApps = this.apps;
this.viewPreference =
this.viewPreference ??
tryCatch(
() => window.localStorage.getItem(VIEW_KEY) ?? undefined,
(e) => "card",
);
if (this.filteredApps === undefined) {
throw new Error(
"Application.results should never be undefined when passed to the Library Page.",
@ -110,6 +123,13 @@ 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 +142,17 @@ export class LibraryPage extends AKElement {
const layout = this.uiConfig.layout as string;
const background = this.uiConfig.background;
return html`<ak-library-application-list
layout="${layout}"
background="${ifDefined(background)}"
selected="${ifDefined(selected)}"
.apps=${apps}
></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-cards>`;
}
renderSearch() {
@ -137,9 +162,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">