From 21e5441f92a6fa7a14739c5953e43e7c86fddce2 Mon Sep 17 00:00:00 2001 From: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com> Date: Thu, 12 Oct 2023 10:44:15 -0700 Subject: [PATCH] web: patternfly hints as ak-web-component (#7120) * web: patternfly hints as ak-web-component Patternfly 5's "Hints" React Component, but ported to web components. The discovery that CSS Custom Properties are still available in child components, even if they're within independent ShadowDOMs, made this fairly easy to port from Handlebars to Lit-HTML. Moving the definitions into `:host` and the applications into the root DIV of the component made duplicating the Patternfly 5 structure straightforward. Despite the [Patternfly Elements]documentation](https://patternflyelements.org/docs/develop/create/), there's a lot to Patternfly Elements that isn't well documented, such as their slot controller, which near as I can tell just makes it easy to determine if a slot with the given name is actually being used by the client code, but it's hard to tell why, other than that it provides an easy way to determine if some CSS should be included. * Pre-commit fixes. * web: fix some issues with styling found while testing. * web: separated the "with Title" and "without Title" stories. * Added footer story, fixed some CSS. * web: hint controller Add the `ShowHintController`. This ReactiveController takes a token in its constructor, and looks in LocalStorage for that token and an associated value. If that value is not `undefined`, it sets the field `this.host.showHint` to the value found. It also provides a `render()` method that provides an `ak-hint-footer` with a checkbox and the "Don't show this message again," and responds to clicks on the checkbox by setting the `this.hint.showHint` and LocalStorage values to "false". An example web component using it has been supplied. * web: support dark mode for hints. This was nifty. Still not entirely sure about the `theme="dark"` rippling through the product, but in this case it works quite well. All it took was defining the alternative dark mode values in a CSS entry, `:host([theme="dark"]) { ... }` and exploiting Patternfly's already intensely atomized CSS Custom Properties properly. * web: revise colors to use more of the Authentik dark-mode style. * Update web/src/components/ak-hint/ak-hint.ts Signed-off-by: Jens L. * remove any Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens L. Signed-off-by: Jens Langhammer Co-authored-by: Jens L --- .../applications/ApplicationWizardHint.ts | 69 +++++++++ web/src/common/constants.ts | 2 + .../components/ak-hint/ShowHintController.ts | 63 ++++++++ web/src/components/ak-hint/ak-hint-actions.ts | 32 ++++ web/src/components/ak-hint/ak-hint-body.ts | 24 +++ web/src/components/ak-hint/ak-hint-footer.ts | 26 ++++ web/src/components/ak-hint/ak-hint-title.ts | 23 +++ web/src/components/ak-hint/ak-hint.stories.ts | 137 ++++++++++++++++++ web/src/components/ak-hint/ak-hint.ts | 72 +++++++++ 9 files changed, 448 insertions(+) create mode 100644 web/src/admin/applications/ApplicationWizardHint.ts create mode 100644 web/src/components/ak-hint/ShowHintController.ts create mode 100644 web/src/components/ak-hint/ak-hint-actions.ts create mode 100644 web/src/components/ak-hint/ak-hint-body.ts create mode 100644 web/src/components/ak-hint/ak-hint-footer.ts create mode 100644 web/src/components/ak-hint/ak-hint-title.ts create mode 100644 web/src/components/ak-hint/ak-hint.stories.ts create mode 100644 web/src/components/ak-hint/ak-hint.ts diff --git a/web/src/admin/applications/ApplicationWizardHint.ts b/web/src/admin/applications/ApplicationWizardHint.ts new file mode 100644 index 000000000..9f0a22e88 --- /dev/null +++ b/web/src/admin/applications/ApplicationWizardHint.ts @@ -0,0 +1,69 @@ +import { MessageLevel } from "@goauthentik/common/messages"; +import { + ShowHintController, + ShowHintControllerHost, +} from "@goauthentik/components/ak-hint/ShowHintController"; +import "@goauthentik/components/ak-hint/ak-hint"; +import "@goauthentik/components/ak-hint/ak-hint-body"; +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/buttons/ActionButton/ak-action-button"; +import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; + +import { html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import PFPage from "@patternfly/patternfly/components/Page/page.css"; + +@customElement("ak-application-wizard-hint") +export class AkApplicationWizardHint extends AKElement implements ShowHintControllerHost { + static get styles() { + return [PFPage]; + } + + @property({ type: Boolean, attribute: "show-hint" }) + forceHint: boolean = false; + + @state() + showHint: boolean = true; + + showHintController: ShowHintController; + + constructor() { + super(); + this.showHintController = new ShowHintController( + this, + "202310-application-wizard-announcement", + ); + } + + renderHint() { + return html`
+ + +

+ Authentik has a new Application Wizard that can configure both an + application and its authentication provider at the same time. + Learn more about the wizard here. +

+ { + showMessage({ + message: "This would have shown the wizard", + level: MessageLevel.success, + }); + }} + >Create with Wizard
+ ${this.showHintController.render()} +
+
`; + } + + render() { + return this.showHint || this.forceHint ? this.renderHint() : nothing; + } +} + +export default AkApplicationWizardHint; diff --git a/web/src/common/constants.ts b/web/src/common/constants.ts index ca6ce33a1..1e58a838a 100644 --- a/web/src/common/constants.ts +++ b/web/src/common/constants.ts @@ -22,3 +22,5 @@ export const EVENT_THEME_CHANGE = "ak-theme-change"; export const WS_MSG_TYPE_MESSAGE = "message"; export const WS_MSG_TYPE_REFRESH = "refresh"; + +export const LOCALSTORAGE_AUTHENTIK_KEY = "authentik-local-settings"; diff --git a/web/src/components/ak-hint/ShowHintController.ts b/web/src/components/ak-hint/ShowHintController.ts new file mode 100644 index 000000000..b3eb1393c --- /dev/null +++ b/web/src/components/ak-hint/ShowHintController.ts @@ -0,0 +1,63 @@ +import { LOCALSTORAGE_AUTHENTIK_KEY } from "@goauthentik/common/constants"; + +import { msg } from "@lit/localize"; +import { LitElement, ReactiveController, ReactiveControllerHost, html } from "lit"; + +type ReactiveLitElement = LitElement & ReactiveControllerHost; + +export interface ShowHintControllerHost extends ReactiveLitElement { + showHint: boolean; + + showHintController: ShowHintController; +} + +const getCurrentStorageValue = (): Record => { + try { + return JSON.parse(window?.localStorage.getItem(LOCALSTORAGE_AUTHENTIK_KEY) ?? "{}"); + } catch (_err: unknown) { + return {}; + } +}; + +export class ShowHintController implements ReactiveController { + host: ShowHintControllerHost; + + hintToken: string; + + constructor(host: ShowHintControllerHost, hintToken: string) { + (this.host = host).addController(this); + this.hintToken = hintToken; + this.hideTheHint = this.hideTheHint.bind(this); + } + + hideTheHint() { + window?.localStorage.setItem( + LOCALSTORAGE_AUTHENTIK_KEY, + JSON.stringify({ + ...getCurrentStorageValue(), + [this.hintToken]: false, + }), + ); + this.host.showHint = false; + } + + hostConnected() { + const localStores = getCurrentStorageValue(); + if (!(this.hintToken in localStores)) { + return; + } + // Note that we only do this IF the field exists and is defined. `undefined` means "do the + // default thing of showing the hint." + this.host.showHint = localStores[this.hintToken] as boolean; + } + + render() { + return html`
+ ${msg( + "Don't show this message again.", + )} +
`; + } +} diff --git a/web/src/components/ak-hint/ak-hint-actions.ts b/web/src/components/ak-hint/ak-hint-actions.ts new file mode 100644 index 000000000..8af341965 --- /dev/null +++ b/web/src/components/ak-hint/ak-hint-actions.ts @@ -0,0 +1,32 @@ +import { AKElement } from "@goauthentik/app/elements/Base"; + +import { css, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +const style = css` + div { + display: inline-grid; + grid-row: 1; + grid-column: 2; + grid-auto-flow: column; + margin-left: var(--ak-hint__actions--MarginLeft); + text-align: right; + } + + ::slotted(ak-hint-body) { + grid-column: 1; + } +`; + +@customElement("ak-hint-actions") +export class AkHintActions extends AKElement { + static get styles() { + return [style]; + } + + render() { + return html`
`; + } +} + +export default AkHintActions; diff --git a/web/src/components/ak-hint/ak-hint-body.ts b/web/src/components/ak-hint/ak-hint-body.ts new file mode 100644 index 000000000..f5c122eb5 --- /dev/null +++ b/web/src/components/ak-hint/ak-hint-body.ts @@ -0,0 +1,24 @@ +import { AKElement } from "@goauthentik/app/elements/Base"; + +import { css, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +const style = css` + div { + grid-column: 1 / -1; + font-size: var(--ak-hint__body--FontSize); + } +`; + +@customElement("ak-hint-body") +export class AkHintBody extends AKElement { + static get styles() { + return [style]; + } + + render() { + return html`
`; + } +} + +export default AkHintBody; diff --git a/web/src/components/ak-hint/ak-hint-footer.ts b/web/src/components/ak-hint/ak-hint-footer.ts new file mode 100644 index 000000000..a08197d11 --- /dev/null +++ b/web/src/components/ak-hint/ak-hint-footer.ts @@ -0,0 +1,26 @@ +import { AKElement } from "@goauthentik/app/elements/Base"; + +import { css, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +const style = css` + #host { + grid-column: 1 / -1; + } + ::slotted(div#host > *:not(:last-child)) { + margin-right: var(--ak-hint__footer--child--MarginRight); + } +`; + +@customElement("ak-hint-footer") +export class AkHintFooter extends AKElement { + static get styles() { + return [style]; + } + + render() { + return html`
`; + } +} + +export default AkHintFooter; diff --git a/web/src/components/ak-hint/ak-hint-title.ts b/web/src/components/ak-hint/ak-hint-title.ts new file mode 100644 index 000000000..accc881d0 --- /dev/null +++ b/web/src/components/ak-hint/ak-hint-title.ts @@ -0,0 +1,23 @@ +import { AKElement } from "@goauthentik/app/elements/Base"; + +import { css, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +const style = css` + #host { + font-size: var(--ak-hint__title--FontSize); + } +`; + +@customElement("ak-hint-title") +export class AkHintTitle extends AKElement { + static get styles() { + return [style]; + } + + render() { + return html`
`; + } +} + +export default AkHintTitle; diff --git a/web/src/components/ak-hint/ak-hint.stories.ts b/web/src/components/ak-hint/ak-hint.stories.ts new file mode 100644 index 000000000..281913761 --- /dev/null +++ b/web/src/components/ak-hint/ak-hint.stories.ts @@ -0,0 +1,137 @@ +import { MessageLevel } from "@goauthentik/common/messages"; +import "@goauthentik/elements/buttons/ActionButton/ak-action-button"; +import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "../ak-radio-input"; +import "./ak-hint"; +import AkHint from "./ak-hint"; +import "./ak-hint-body"; +import "./ak-hint-title"; + +const metadata: Meta = { + title: "Components / Patternfly Hint", + component: "ak-hint", + parameters: { + docs: { + description: { + component: "A stylized hint box", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
+ + + ${testItem} + +
    +
    `; + +export const Default = () => { + return container( + html`
    + + +

    + Authentik has a new Application Wizard that can configure both an + application and its authentication provider at the same time. + Learn more about the wizard here. +

    + { + showMessage({ + message: "This would have shown the wizard", + level: MessageLevel.success, + }); + }} + >Create with Wizard
    +
    +
    `, + ); +}; + +export const WithTitle = () => { + return container( + html`
    + + New Application Wizard + +

    + Authentik has a new Application Wizard that can configure both an + application and its authentication provider at the same time. + Learn more about the wizard here. +

    + { + showMessage({ + message: "This would have shown the wizard", + level: MessageLevel.success, + }); + }} + >Create with Wizard
    +
    +
    `, + ); +}; + +export const WithTitleAndFooter = () => { + return container( + html`
    + + New Application Wizard + +

    + Authentik has a new Application Wizard that can configure both an + application and its authentication provider at the same time. + Learn more about the wizard here. +

    + { + showMessage({ + message: "This would have shown the wizard", + level: MessageLevel.success, + }); + }} + >Create with Wizard
    +
    + Don't show this message again. +
    +
    +
    `, + ); +}; diff --git a/web/src/components/ak-hint/ak-hint.ts b/web/src/components/ak-hint/ak-hint.ts new file mode 100644 index 000000000..731dbe0ca --- /dev/null +++ b/web/src/components/ak-hint/ak-hint.ts @@ -0,0 +1,72 @@ +import { AKElement } from "@goauthentik/app/elements/Base"; + +import { css, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +const styles = css` + :host { + --ak-hint--GridRowGap: var(--pf-global--spacer--md); + --ak-hint--PaddingTop: var(--pf-global--spacer--md); + --ak-hint--PaddingRight: var(--pf-global--spacer--lg); + --ak-hint--PaddingBottom: var(--pf-global--spacer--md); + --ak-hint--PaddingLeft: var(--pf-global--spacer--lg); + --ak-hint--BackgroundColor: var(--pf-global--palette--blue-50); + --ak-hint--BorderColor: var(--pf-global--palette--blue-100); + --ak-hint--BorderWidth: var(--pf-global--BorderWidth--sm); + --ak-hint--BoxShadow: var(--pf-global--BoxShadow--sm); + --ak-hint--Color: var(--pf-global--Color--100); + + /* Hint Title */ + --ak-hint__title--FontSize: var(--pf-global--FontSize--lg); + + /* Hint Body */ + --ak-hint__body--FontSize: var(--pf-global--FontSize--md); + + /* Hint Footer */ + --ak-hint__footer--child--MarginRight: var(--pf-global--spacer--md); + + /* Hint Actions */ + --ak-hint__actions--MarginLeft: var(--pf-global--spacer--2xl); + --ak-hint__actions--c-dropdown--MarginTop: calc( + var(--pf-global--spacer--form-element) * -1 + ); + } + + :host([theme="dark"]) { + --ak-hint--BackgroundColor: var(--ak-dark-background-darker); + --ak-hint--BorderColor: var(--ak-dark-background-lighter); + --ak-hint--Color: var(--ak-dark-foreground); + } + + div#host { + display: flex; + flex-direction: column; + gap: var(--ak-hint--GridRowGap); + background-color: var(--ak-hint--BackgroundColor); + color: var(--ak-hint--Color); + border: var(--ak-hint--BorderWidth) solid var(--ak-hint--BorderColor); + box-shadow: var(--ak-hint--BoxShadow); + padding: var(--ak-hint--PaddingTop) var(--ak-hint--PaddingRight) + var(--ak-hint--PaddingBottom) var(--ak-hint--PaddingLeft); + } + + ::slotted(ak-hint-title), + ::slotted(ak-hint-body) { + display: grid; + grid-template-columns: 1fr auto; + grid-row-gap: var(--ak-hint--GridRowGap); + } +`; + +@customElement("ak-hint") +export class AkHint extends AKElement { + static get styles() { + return [styles]; + } + + render() { + return html`
    `; + } +} + +export default AkHint;