web: update storybook, storybook a few things, fix a few things

After examining how people like Adobe and Salesforce do things, I have updated the storybook
configuration to provide run-time configuration of light/dark mode (although right now nothing
happens), inject the correct styling into the page, and update the preview handling so that we can
see the components better.  We'll see how this pans out.

I have provided stories for the AggregateCard, AggregatePromiseCard, and a new QuickActionsCard. I
also fixed a bug in AggregatePromiseCard where it would fail to report a fetch error. It will only
report that "the operation falied," but it will give the full error into the console.

**As an experiment**, I have changed the interpreter for `lint:precommit` and `build:watch` to use
[Bun](https://bun.sh/) instead of NodeJS. We have observed significant speed-ups and much better
memory management with Bun for these two operations. Those are both developer-facing operations, the
behavior of the system undur current CI/CD should not change.

And finally, I've switched the QuickActionsCard view in Admin-Overview to use the new component.
Looks the same.  Reads *way* easier.  :-)
This commit is contained in:
Ken Sternberg
2024-05-14 14:17:15 -07:00
parent 09803fee11
commit 99f8802122
15 changed files with 504 additions and 79 deletions

View File

@ -1,7 +1,9 @@
import { create } from "@storybook/theming/create";
const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
export default create({
base: "light",
base: isDarkMode ? "dark" : "light",
brandTitle: "authentik Storybook",
brandUrl: "https://goauthentik.io",
brandImage: "https://goauthentik.io/img/icon_left_brand_colour.svg",

View File

@ -19,6 +19,20 @@ const config: StorybookConfig = {
"@jeysal/storybook-addon-css-user-preferences",
"storybook-addon-mock",
],
staticDirs: [
{
from: "../node_modules/@patternfly/patternfly/patternfly-base.css",
to: "@patternfly/patternfly/patternfly-base.css",
},
{
from: "../src/common/styles/authentik.css",
to: "@goauthentik/common/styles/authentik.css",
},
{
from: "../src/common/styles/theme-dark.css",
to: "@goauthentik/common/styles/theme-dark.css",
},
],
framework: {
name: "@storybook/web-components-vite",
options: {},

View File

@ -0,0 +1,60 @@
<link rel="stylesheet" href="@patternfly/patternfly/patternfly-base.css" />
<link rel="stylesheet" href="@goauthentik/common/styles/authentik.css" />
<style>
body {
overflow-y: scroll;
margin: 0;
}
.sb-main-padded {
padding: 0 !important;
}
.story-shadow-container {
background-color: #fff;
box-shadow: 0 0 0.25em #ddd;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 1em;
margin: 0 auto;
max-width: 72em;
padding: 1em;
width: 100%;
}
.docs-story .story-shadow-container {
box-shadow: none;
}
.story-shadow-container[display-mode="flex-wrap"] {
flex-wrap: wrap;
flex-direction: row;
}
.title {
border-bottom: 1px solid #ccc;
color: #333;
font-size: 13px;
font-weight: bold;
margin: 2rem -1rem 1rem;
padding-bottom: 0.25rem;
padding-left: 1rem;
}
.title code {
background-color: #f5f5f5;
border-radius: 0.25rem;
font-weight: bold;
padding: 0.1rem 0.25rem;
}
.sbdocs-preview .hljs {
color: #fff !important;
white-space: pre-line;
}
.sbdocs-pre > div {
margin: 1em 0;
}
</style>

View File

@ -9,6 +9,11 @@ import "@patternfly/patternfly/patternfly-base.css";
const preview: Preview = {
parameters: {
options: {
storySort: {
method: "alphabetical",
},
},
actions: { argTypesRegex: "^on[A-Z].*" },
cssUserPrefs: {
"prefers-color-scheme": "light",

View File

@ -10,12 +10,12 @@
"build-locales:repair": "prettier --write ./src/locale-codes.ts",
"esbuild:build": "node build.mjs",
"esbuild:build-proxy": "node build.mjs --proxy",
"esbuild:watch": "node build.mjs --watch",
"esbuild:watch": "bun build.mjs --watch",
"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=65536' eslint . --max-warnings 0 --fix",
"lint:precommit": "cross-env NODE_OPTIONS='--max_old_space_size=65536' node scripts/eslint-precommit.mjs",
"lint:precommit": "bun 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

@ -29,6 +29,7 @@ const eslintConfig = {
sourceType: "module",
},
plugins: ["@typescript-eslint", "lit", "custom-elements", "sonarjs"],
ignorePatterns: ["!./.storybook/**/*.ts"],
rules: {
"indent": "off",
"linebreak-style": ["error", "unix"],
@ -60,9 +61,14 @@ const modified = (s) => isModified.test(s);
const isCheckable = /\.(ts|js|mjs)$/;
const checkable = (s) => isCheckable.test(s);
const ignored = /\/\.storybook\//;
const notIgnored = (s) => !ignored.test(s);
const updated = statuses.reduce(
(acc, [status, filename]) =>
modified(status) && checkable(filename) ? [...acc, path.join(projectRoot, filename)] : acc,
modified(status) && checkable(filename) && notIgnored(filename)
? [...acc, path.join(projectRoot, filename)]
: acc,
[],
);
@ -72,5 +78,6 @@ const formatter = await eslint.loadFormatter("stylish");
const resultText = formatter.format(results);
const errors = results.reduce((acc, result) => acc + result.errorCount, 0);
// eslint-disable-next-line no-console
console.log(resultText);
process.exit(errors > 1 ? 1 : 0);

63
web/scripts/eslint.mjs Normal file
View File

@ -0,0 +1,63 @@
#!/usr/bin/env node --max_old_space_size=65536
import { execFileSync } from "child_process";
import { ESLint } from "eslint";
import path from "path";
import process from "process";
// Code assumes this script is in the './web/scripts' folder.
const projectRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf8",
}).replace("\n", "");
process.chdir(path.join(projectRoot, "./web"));
const eslintConfig = {
fix: true,
overrideConfig: {
env: {
browser: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:lit/recommended",
"plugin:custom-elements/recommended",
"plugin:storybook/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 12,
sourceType: "module",
project: true,
},
plugins: ["@typescript-eslint", "lit", "custom-elements"],
ignorePatterns: ["authentik-live-tests/**"],
rules: {
"indent": "off",
"linebreak-style": ["error", "unix"],
"quotes": ["error", "double", { avoidEscape: true }],
"semi": ["error", "always"],
"@typescript-eslint/ban-ts-comment": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"no-console": ["error", { allow: ["debug", "warn", "error"] }],
},
},
};
const eslint = new ESLint(eslintConfig);
const results = await eslint.lintFiles(".");
const formatter = await eslint.loadFormatter("stylish");
const resultText = formatter.format(results);
const errors = results.reduce((acc, result) => acc + result.errorCount, 0);
// eslint-disable-next-line no-console
console.log(resultText);
process.exit(errors > 1 ? 1 : 0);

View File

@ -12,6 +12,8 @@ import { me } from "@goauthentik/common/users";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/cards/AggregatePromiseCard";
import "@goauthentik/elements/cards/QuickActionsCard.js";
import type { QuickAction } from "@goauthentik/elements/cards/QuickActionsCard.js";
import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
import { msg, str } from "@lit/localize";
@ -20,7 +22,6 @@ import { customElement, state } from "lit/decorators.js";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDivider from "@patternfly/patternfly/components/Divider/divider.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@ -33,6 +34,11 @@ export function versionFamily(): string {
return parts.join(".");
}
const RELEASE = `${VERSION.split(".").slice(0, -1).join(".")}#fixed-in-${VERSION.replaceAll(
".",
"",
)}`;
@customElement("ak-admin-overview")
export class AdminOverviewPage extends AKElement {
static get styles(): CSSResult[] {
@ -41,7 +47,6 @@ export class AdminOverviewPage extends AKElement {
PFGrid,
PFPage,
PFContent,
PFList,
PFDivider,
css`
.pf-l-grid__item {
@ -64,6 +69,14 @@ export class AdminOverviewPage extends AKElement {
];
}
quickActions: QuickAction[] = [
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
[msg("Check the logs"), paramURL("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/", true],
[msg("Manage users"), paramURL("/identity/users")],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${RELEASE}`, true],
];
@state()
user?: SessionUser;
@ -83,56 +96,8 @@ export class AdminOverviewPage extends AKElement {
class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-6-col-on-2xl pf-l-grid pf-m-gutter"
>
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-4-col-on-2xl">
<ak-aggregate-card
icon="fa fa-share"
header=${msg("Quick actions")}
.isCenter=${false}
>
<ul class="pf-c-list">
<li>
<a
class="pf-u-mb-xl"
href=${paramURL("/core/applications", {
createForm: true,
})}
>${msg("Create a new application")}</a
>
</li>
<li>
<a class="pf-u-mb-xl" href=${paramURL("/events/log")}
>${msg("Check the logs")}</a
>
</li>
<li>
<a
class="pf-u-mb-xl"
target="_blank"
href="https://goauthentik.io/integrations/"
>${msg("Explore integrations")}<i
class="fas fa-external-link-alt ak-external-link"
></i
></a>
</li>
<li>
<a class="pf-u-mb-xl" href=${paramURL("/identity/users")}
>${msg("Manage users")}</a
>
</li>
<li>
<a
class="pf-u-mb-xl"
target="_blank"
href="https://goauthentik.io/docs/releases/${versionFamily()}#fixed-in-${VERSION.replaceAll(
".",
"",
)}"
>${msg("Check the release notes")}<i
class="fas fa-external-link-alt ak-external-link"
></i
></a>
</li>
</ul>
</ak-aggregate-card>
<ak-quick-actions-card .actions=${this.quickActions}>
</ak-quick-actions-card>
</div>
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-4-col-on-2xl">
<ak-aggregate-card

View File

@ -8,8 +8,16 @@ import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export interface IAggregateCard {
icon?: string;
header?: string;
headerLink?: string;
subtext?: string;
leftJustified?: boolean;
}
@customElement("ak-aggregate-card")
export class AggregateCard extends AKElement {
export class AggregateCard extends AKElement implements IAggregateCard {
@property()
icon?: string;
@ -22,8 +30,8 @@ export class AggregateCard extends AKElement {
@property()
subtext?: string;
@property({ type: Boolean })
isCenter = true;
@property({ type: Boolean, attribute: "left-justified" })
leftJustified = false;
static get styles(): CSSResult[] {
return [PFBase, PFCard, PFFlex].concat([
@ -80,7 +88,7 @@ export class AggregateCard extends AKElement {
</div>
${this.renderHeaderLink()}
</div>
<div class="pf-c-card__body ${this.isCenter ? "center-value" : ""}">
<div class="pf-c-card__body ${this.leftJustified ? "" : "center-value"}">
${this.renderInner()}
${this.subtext ? html`<p class="subtext">${this.subtext}</p>` : html``}
</div>
@ -88,3 +96,9 @@ export class AggregateCard extends AKElement {
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-aggregate-card": AggregateCard;
}
}

View File

@ -1,22 +1,34 @@
import { PFSize } from "@goauthentik/common/enums.js";
import "@goauthentik/elements/Spinner";
import { AggregateCard } from "@goauthentik/elements/cards/AggregateCard";
import { AggregateCard, type IAggregateCard } from "@goauthentik/elements/cards/AggregateCard";
import { TemplateResult, html } from "lit";
import { msg } from "@lit/localize";
import { TemplateResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { until } from "lit/directives/until.js";
export interface IAggregatePromiseCard extends IAggregateCard {
promise?: Promise<Record<string, unknown>>;
}
@customElement("ak-aggregate-card-promise")
export class AggregatePromiseCard extends AggregateCard {
export class AggregatePromiseCard extends AggregateCard implements IAggregatePromiseCard {
@property({ attribute: false })
promise?: Promise<Record<string, unknown>>;
async promiseProxy(): Promise<TemplateResult> {
if (!this.promise) {
return html``;
async promiseProxy(): Promise<TemplateResult | typeof nothing> {
try {
if (!this.promise) {
return nothing;
}
const value = await this.promise;
return html`<i class="fa fa-check-circle"></i>&nbsp;${value.toString()}`;
} catch (error: unknown) {
console.warn(error);
return html`<i class="fa fa-exclamation-circle"></i>&nbsp;${msg(
"Operation failed to complete",
)}`;
}
const value = await this.promise;
return html`<i class="fa fa-check-circle"></i>&nbsp;${value.toString()}`;
}
renderInner(): TemplateResult {
@ -25,3 +37,9 @@ export class AggregatePromiseCard extends AggregateCard {
</p>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-aggregate-card-promise": AggregatePromiseCard;
}
}

View File

@ -0,0 +1,73 @@
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/cards/AggregateCard.js";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export type QuickAction = [label: string, url: string, isExternal?: boolean];
/**
* class QuickActionsCard
* element ak-quick-actions-card
*
* Specialized card for navigation.
*/
export interface IQuickActionsCard {
title: string;
actions: QuickAction[];
}
@customElement("ak-quick-actions-card")
export class QuickActionsCard extends AKElement implements IQuickActionsCard {
static get styles() {
return [PFBase, PFList];
}
/**
* Card title
*
* @attr
*/
@property()
title = msg("Quick actions");
/**
* Card contents. An array of [label, url, isExternal]. External links will
* be rendered with an external link icon and will always open in a new tab.
*
* @attr
*/
@property({ type: Array })
actions: QuickAction[] = [];
render() {
const renderItem = ([label, url, external]: QuickAction) =>
html` <li>
<a class="pf-u-mb-xl" href=${url} ${external ? 'target="_blank"' : ""}>
${external
? html`${label}&nbsp;<i
class="fas fa-external-link-alt ak-external-link"
></i>`
: label}
</a>
</li>`;
return html` <ak-aggregate-card icon="fa fa-share" header=${this.title} left-justified>
<ul class="pf-c-list">
${map(this.actions, renderItem)}
</ul>
</ak-aggregate-card>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-quick-actions-card": QuickActionsCard;
}
}

View File

@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { AggregateCard, type IAggregateCard } from "../AggregateCard.js";
import "../AggregateCard.js";
const metadata: Meta<AggregateCard> = {
title: "Elements/<ak-aggregate-card>",
component: "ak-aggregate-card",
parameters: {
docs: {
description: "A specialized card for displaying collections",
},
},
argTypes: {
icon: { control: "text" },
header: { control: "text" },
headerLink: { control: "text" },
subtext: { control: "text" },
leftJustified: { control: "boolean" },
},
};
export default metadata;
export const DefaultStory: StoryObj = {
args: {
icon: undefined,
header: "Default",
headerLink: undefined,
subtext: undefined,
isCenter: false,
},
render: ({ icon, header, headerLink, subtext, leftJustified }: IAggregateCard) => {
return html` <div style="background-color: #f0f0f0; padding: 1rem;">
<style>
ak-aggregate-card {
display: inline-block;
width: 32rem;
max-width: 32rem;
}
</style>
<ak-aggregate-card
header=${ifDefined(header)}
headerLink=${ifDefined(headerLink)}
subtext=${ifDefined(subtext)}
icon=${ifDefined(icon)}
?left-justified=${leftJustified}
>
<p>
Form without content style without meaning quick-win, for that is a good problem
to have, so this is our north star design. Can you champion this cross sabers
run it up the flagpole, ping the boss and circle back race without a finish line
in an ideal world. Price point innovation is hot right now, nor it's not hard
guys, but race without a finish line, nor thought shower.
</p>
</ak-aggregate-card>
</div>`;
},
};

View File

@ -0,0 +1,95 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { AggregatePromiseCard, type IAggregatePromiseCard } from "../AggregatePromiseCard.js";
import "../AggregatePromiseCard.js";
const metadata: Meta<AggregatePromiseCard> = {
title: "Elements/<ak-aggregate-card-promise>",
component: "ak-aggregate-card-promise",
parameters: {
docs: {
description: "A specialized card for displaying information after a fetch",
},
},
argTypes: {
icon: { control: "text" },
header: { control: "text" },
headerLink: { control: "text" },
subtext: { control: "text" },
leftJustified: { control: "boolean" },
},
};
export default metadata;
const text =
"Curl up and sleep on the freshly laundered towels mew, but make meme, make cute face growl at dogs in my sleep. Scratch me there, elevator butt humans,humans, humans oh how much they love us felines we are the center of attention they feed, they clean hopped up on catnip mice. Kitty time flop over, for see owner, run in terror";
export const DefaultStory: StoryObj = {
args: {
icon: undefined,
header: "Default",
headerLink: undefined,
subtext: "Demo has an eight second delay until resolution",
leftJustified: false,
},
render: ({ icon, header, headerLink, subtext, leftJustified }: IAggregatePromiseCard) => {
const runThis = (timeout: number, value: string) =>
new Promise((resolve) => setTimeout(resolve, timeout, value));
return html` <div style="background-color: #f0f0f0; padding: 1rem;">
<style>
ak-aggregate-card-promise {
display: inline-block;
width: 32rem;
max-width: 32rem;
}
</style>
<ak-aggregate-card-promise
header=${ifDefined(header)}
headerLink=${ifDefined(headerLink)}
subtext=${ifDefined(subtext)}
icon=${ifDefined(icon)}
?left-justified=${leftJustified}
.promise=${runThis(8000, text)}
>
</ak-aggregate-card-promise>
</div>`;
},
};
export const PromiseRejected: StoryObj = {
args: {
icon: undefined,
header: "Default",
headerLink: undefined,
subtext: "Demo has an eight second delay until rejection",
leftJustified: false,
},
render: ({ icon, header, headerLink, subtext, leftJustified }: IAggregatePromiseCard) => {
const runThis = (timeout: number, value: string) =>
new Promise((_resolve, reject) => setTimeout(reject, timeout, value));
return html` <div style="background-color: #f0f0f0; padding: 1rem;">
<style>
ak-aggregate-card-promise {
display: inline-block;
width: 32rem;
max-width: 32rem;
}
</style>
<ak-aggregate-card-promise
header=${ifDefined(header)}
headerLink=${ifDefined(headerLink)}
subtext=${ifDefined(subtext)}
icon=${ifDefined(icon)}
?left-justified=${leftJustified}
.promise=${runThis(8000, text)}
>
</ak-aggregate-card-promise>
</div>`;
},
};

View File

@ -0,0 +1,47 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { html } from "lit";
import "../QuickActionsCard.js";
import { QuickAction, QuickActionsCard } from "../QuickActionsCard.js";
const ACTIONS: QuickAction[] = [
["Create a new application", "/core/applications"],
["Check the logs", "/events/log"],
["Explore integrations", "https://goauthentik.io/integrations/", true],
["Manage users", "/identity/users"],
["Check the release notes", "https://goauthentik.io/docs/releases/", true],
];
const metadata: Meta<QuickActionsCard> = {
title: "Elements/<ak-quick-action-card>",
component: "ak-quick-action-card",
parameters: {
docs: {
description: "A specialized card for a list of navigation links",
},
},
argTypes: {
title: { control: "text" },
},
};
export default metadata;
export const DefaultStory: StoryObj = {
args: {
title: "Quick actions",
},
render: ({ title }) => {
return html` <div style="background-color: #f0f0f0; padding: 1rem;">
<style>
ak-quick-actions-card {
display: inline-block;
width: 16rem;
max-width: 16rem;
}
</style>
<ak-quick-actions-card title=${title} .actions=${ACTIONS}></ak-quick-actions-card>
</div>`;
},
};

View File

@ -2,17 +2,17 @@
"extends": "./tsconfig.base.json",
"compilerOptions": {
"paths": {
"@goauthentik/authentik/*": ["src/*"],
"@goauthentik/admin/*": ["src/admin/*"],
"@goauthentik/common/*": ["src/common/*"],
"@goauthentik/components/*": ["src/components/*"],
"@goauthentik/authentik/*": ["./src/*"],
"@goauthentik/admin/*": ["./src/admin/*"],
"@goauthentik/common/*": ["./src/common/*"],
"@goauthentik/components/*": ["./src/components/*"],
"@goauthentik/docs/*": ["../website/docs/*"],
"@goauthentik/elements/*": ["src/elements/*"],
"@goauthentik/flow/*": ["src/flow/*"],
"@goauthentik/locales/*": ["src/locales/*"],
"@goauthentik/polyfill/*": ["src/polyfill/*"],
"@goauthentik/standalone/*": ["src/standalone/*"],
"@goauthentik/user/*": ["src/user/*"]
},
"@goauthentik/elements/*": ["./src/elements/*"],
"@goauthentik/flow/*": ["./src/flow/*"],
"@goauthentik/locales/*": ["./src/locales/*"],
"@goauthentik/polyfill/*": ["./src/polyfill/*"],
"@goauthentik/standalone/*": ["./src/standalone/*"],
"@goauthentik/user/*": ["./src/user/*"]
}
}
}