From 9996eafe75b600c6359d737ed11761dae671ddd6 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 27 Dec 2023 17:00:42 -0800 Subject: [PATCH] web: provide a "select / select all" tool for the dual list multiselect **This commit** This commit provides the following new features for dual list multiselect: - The "available" pane, which has all of the entries that are available to be selected. Items that are already selected will remain, but they're marked with a checkmark and can neither be selected or moved. - The "selected" pane, which has *all* of the entries that have been selected. - The Pagination control, which in this case only sends an event upstream. **Plan**: The plan is to have a master control that marries the available-pane, selected-pane, select-controls, and pagination-controls into a single component that receives the list of "currently visible" available entries and keeps the list of "currently selected" entries, as well as a pass-through for the pagination value that allows it to hide the pagination control if there is only one page. A master component *above that* will provide the list of currently visible entries and, at need, read the value of the master control object for the "selected" list. That component will mostly be data-only; it's render will probably just be ``; its duty will be only to map entries to string keys Lit can use, and to provide the lists we want to provide and the pagination ranges we want to show. Some judicious use of grid will allow me size the controls properly with/without the pagination control. Status and Title are going to be in the master control. A will be provided for Search, but I have no plans to integrate that into this control as of yet. There is already a planned fallback control; the multi-select experience on mobile is actually excellent, and we should exploit that appropriately. --- web/.storybook/preview.ts | 2 +- web/package-lock.json | 13 +- web/package.json | 1 + .../ak-dual-select-available-pane.stories.ts | 114 +++++++++++++++++ .../ak-dual-select-available-pane.ts | 121 ++++++++++++++++++ .../ak-dual-select-selected-pane.stories.ts | 94 ++++++++++++++ .../ak-dual-select-selected-pane.ts | 112 ++++++++++++++++ .../ak-dual-select/ak-pagination.stories.ts | 83 ++++++++++++ .../elements/ak-dual-select/ak-pagination.ts | 94 ++++++++++++++ web/src/elements/ak-dual-select/types.ts | 10 ++ web/src/elements/utils/eventEmitter.ts | 1 - 11 files changed, 640 insertions(+), 5 deletions(-) create mode 100644 web/src/elements/ak-dual-select/ak-dual-select-available-pane.stories.ts create mode 100644 web/src/elements/ak-dual-select/ak-dual-select-available-pane.ts create mode 100644 web/src/elements/ak-dual-select/ak-dual-select-selected-pane.stories.ts create mode 100644 web/src/elements/ak-dual-select/ak-dual-select-selected-pane.ts create mode 100644 web/src/elements/ak-dual-select/ak-pagination.stories.ts create mode 100644 web/src/elements/ak-dual-select/ak-pagination.ts create mode 100644 web/src/elements/ak-dual-select/types.ts diff --git a/web/.storybook/preview.ts b/web/.storybook/preview.ts index 08bd2119e..258afd215 100644 --- a/web/.storybook/preview.ts +++ b/web/.storybook/preview.ts @@ -1,7 +1,7 @@ import type { Preview } from "@storybook/web-components"; import "@goauthentik/common/styles/authentik.css"; -import "@goauthentik/common/styles/theme-dark.css"; +// import "@goauthentik/common/styles/theme-dark.css"; import "@patternfly/patternfly/components/Brand/brand.css"; import "@patternfly/patternfly/components/Page/page.css"; // .storybook/preview.js diff --git a/web/package-lock.json b/web/package-lock.json index 8caa84294..9b3d02f5c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -83,6 +83,7 @@ "eslint-plugin-lit": "^1.11.0", "eslint-plugin-sonarjs": "^0.23.0", "eslint-plugin-storybook": "^0.6.15", + "github-slugger": "^2.0.0", "lit-analyzer": "^2.0.2", "npm-run-all": "^4.1.5", "prettier": "^3.1.1", @@ -11814,9 +11815,9 @@ "optional": true }, "node_modules/github-slugger": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz", - "integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", "dev": true }, "node_modules/glob": { @@ -16171,6 +16172,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-slug/node_modules/github-slugger": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz", + "integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==", + "dev": true + }, "node_modules/remark-slug/node_modules/mdast-util-to-string": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index 4d7002840..776963070 100644 --- a/web/package.json +++ b/web/package.json @@ -108,6 +108,7 @@ "eslint-plugin-lit": "^1.11.0", "eslint-plugin-sonarjs": "^0.23.0", "eslint-plugin-storybook": "^0.6.15", + "github-slugger": "^2.0.0", "lit-analyzer": "^2.0.2", "npm-run-all": "^4.1.5", "prettier": "^3.1.1", diff --git a/web/src/elements/ak-dual-select/ak-dual-select-available-pane.stories.ts b/web/src/elements/ak-dual-select/ak-dual-select-available-pane.stories.ts new file mode 100644 index 000000000..76b428647 --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-dual-select-available-pane.stories.ts @@ -0,0 +1,114 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta, StoryObj } from "@storybook/web-components"; +import { slug } from "github-slugger"; + +import { TemplateResult, html } from "lit"; + +import "./ak-dual-select-available-pane"; +import { AkDualSelectAvailablePane } from "./ak-dual-select-available-pane"; + +const metadata: Meta = { + title: "Elements / Dual Select / Available Items Pane", + component: "ak-dual-select-available-pane", + parameters: { + docs: { + description: { + component: "The vertical panel separating two dual-select elements.", + }, + }, + }, + argTypes: { + options: { + type: "string", + description: "An array of [key, label] pairs of what to show", + }, + selected: { + type: "string", + description: "An array of [key] of what has already been selected", + }, + toMove: { + type: "string", + description: "An array of items which are to be moved to the receiving pane.", + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
+ + + ${testItem} +

Messages received from the button:

+
    +
    `; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const handleMoveChanged = (result: any) => { + const target = document.querySelector("#action-button-message-pad"); + target!.innerHTML = ""; + result.detail.forEach((key: string) => { + target!.append(new DOMParser().parseFromString(`
  • ${key}
  • `, "text/xml").firstChild!); + }); +}; + +window.addEventListener("ak-dual-select-move-changed", handleMoveChanged); + +type Story = StoryObj; + +const goodForYou = [ + "Apple", + "Arrowroot", + "Artichoke", + "Arugula", + "Asparagus", + "Avocado", + "Bamboo", + "Banana", + "Basil", + "Beet Root", + "Blackberry", + "Blueberry", + "Bok Choy", + "Broccoli", + "Brussels sprouts", + "Cabbage", + "Cantaloupes", + "Carrot", + "Cauliflower", +]; + +const goodForYouPairs = goodForYou.map((key) => [slug(key), key]); + +export const Default: Story = { + render: () => + container( + html` `, + ), +}; + +const someSelected = new Set([ + goodForYouPairs[2][0], + goodForYouPairs[8][0], + goodForYouPairs[14][0], +]); + +export const SomeSelected: Story = { + render: () => + container( + html` `, + ), +}; diff --git a/web/src/elements/ak-dual-select/ak-dual-select-available-pane.ts b/web/src/elements/ak-dual-select/ak-dual-select-available-pane.ts new file mode 100644 index 000000000..d7ce75ffc --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-dual-select-available-pane.ts @@ -0,0 +1,121 @@ +import { AKElement } from "@goauthentik/elements/Base"; +import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; + +import { css, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { map } from "lit/directives/map.js"; + +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import type { DualSelectPair } from "./types"; + +const styles = [ + PFBase, + PFButton, + PFDualListSelector, + css` + .pf-c-dual-list-selector__item { + padding: 0.25rem; + } + .pf-c-dual-list-selector__item-text i { + display: inline-block; + margin-left: 0.5rem; + font-weight: 200; + color: var(--pf-global--palette--black-500); + font-size: var(--pf-global--FontSize--xs); + } + `, +]; + +const hostAttributes = [ + ["aria-labelledby", "dual-list-selector-available-pane-status"], + ["aria-multiselectable", "true"], + ["role", "listbox"], +]; + +@customElement("ak-dual-select-available-pane") +export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) { + static get styles() { + return styles; + } + + @property({ type: Array }) + options: DualSelectPair[] = []; + + @property({ attribute: "to-move", type: Object }) + toMove: Set = new Set(); + + @property({ attribute: "selected", type: Object }) + selected: Set = new Set(); + + @property({ attribute: "disabled", type: Boolean }) + disabled = false; + + constructor() { + super(); + this.onClick = this.onClick.bind(this); + } + + onClick(key: string) { + if (this.selected.has(key)) { + // An already selected item cannot be moved into the "selected" category + return; + } + if (this.toMove.has(key)) { + this.toMove.delete(key); + } else { + this.toMove.add(key); + } + this.requestUpdate(); // Necessary because updating a map won't trigger a state change + this.dispatchCustomEvent("ak-dual-select-move-changed", Array.from(this.toMove.keys())); + } + + connectedCallback() { + super.connectedCallback(); + hostAttributes.forEach(([attr, value]) => { + if (!this.hasAttribute(attr)) { + this.setAttribute(attr, value); + } + }); + } + + render() { + return html` +
    +
    +
      + ${map(this.options, ([key, label]) => { + const selected = classMap({ + "pf-m-selected": this.toMove.has(key), + }); + return html`
    • this.onClick(key)} + role="option" + tabindex="-1" + > +
      + + + ${label}${this.selected.has(key) + ? html`` + : nothing} +
      +
    • `; + })} +
    +
    +
    + `; + } +} + +export default AkDualSelectAvailablePane; diff --git a/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.stories.ts b/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.stories.ts new file mode 100644 index 000000000..4fdd48009 --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.stories.ts @@ -0,0 +1,94 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta, StoryObj } from "@storybook/web-components"; +import { slug } from "github-slugger"; + +import { TemplateResult, html } from "lit"; + +import "./ak-dual-select-selected-pane"; +import { AkDualSelectSelectedPane } from "./ak-dual-select-selected-pane"; + +const metadata: Meta = { + title: "Elements / Dual Select / Selected Items Pane", + component: "ak-dual-select-selected-pane", + parameters: { + docs: { + description: { + component: "The vertical panel separating two dual-select elements.", + }, + }, + }, + argTypes: { + options: { + type: "string", + description: "An array of [key, label] pairs of what to show", + }, + toMove: { + type: "string", + description: "An array of items which are to be moved to the receiving pane.", + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
    + + + ${testItem} +

    Messages received from the button:

    +
      +
      `; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const handleMoveChanged = (result: any) => { + const target = document.querySelector("#action-button-message-pad"); + target!.innerHTML = ""; + result.detail.forEach((key: string) => { + target!.append(new DOMParser().parseFromString(`
    • ${key}
    • `, "text/xml").firstChild!); + }); +}; + +window.addEventListener("ak-dual-select-selected-move-changed", handleMoveChanged); + +type Story = StoryObj; + +const goodForYou = [ + "Apple", + "Arrowroot", + "Artichoke", + "Arugula", + "Asparagus", + "Avocado", + "Bamboo", + "Banana", + "Basil", + "Beet Root", + "Blackberry", + "Blueberry", + "Bok Choy", + "Broccoli", + "Brussels sprouts", + "Cabbage", + "Cantaloupes", + "Carrot", + "Cauliflower", +]; + +const goodForYouPairs = goodForYou.map((key) => [slug(key), key]); + +export const Default: Story = { + render: () => + container( + html` `, + ), +}; diff --git a/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.ts b/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.ts new file mode 100644 index 000000000..b0c807b9e --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.ts @@ -0,0 +1,112 @@ +import { AKElement } from "@goauthentik/elements/Base"; +import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; + +import { css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { map } from "lit/directives/map.js"; + +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import type { DualSelectPair } from "./types"; + +const styles = [ + PFBase, + PFButton, + PFDualListSelector, + css` + .pf-c-dual-list-selector__item { + padding: 0.25rem; + } + input[type="checkbox"][readonly] { + pointer-events: none; + } + `, +]; + +const hostAttributes = [ + ["aria-labelledby", "dual-list-selector-selected-pane-status"], + ["aria-multiselectable", "true"], + ["role", "listbox"], +]; + +@customElement("ak-dual-select-selected-pane") +export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) { + static get styles() { + return styles; + } + + @property({ type: Array }) + options: DualSelectPair[] = []; + + @property({ attribute: "to-move", type: Object }) + toMove: Set = new Set(); + + @property({ attribute: "disabled", type: Boolean }) + disabled = false; + + constructor() { + super(); + this.onClick = this.onClick.bind(this); + } + + onClick(key: string) { + if (this.toMove.has(key)) { + this.toMove.delete(key); + } else { + this.toMove.add(key); + } + this.requestUpdate(); // Necessary because updating a map won't trigger a state change + this.dispatchCustomEvent( + "ak-dual-select-selected-move-changed", + Array.from(this.toMove.keys()), + ); + } + + connectedCallback() { + super.connectedCallback(); + hostAttributes.forEach(([attr, value]) => { + if (!this.hasAttribute(attr)) { + this.setAttribute(attr, value); + } + }); + } + + render() { + return html` +
      +
      +
        + ${map(this.options, ([key, label]) => { + const selected = classMap({ + "pf-m-selected": this.toMove.has(key), + }); + return html`
      • this.onClick(key)} + role="option" + tabindex="-1" + > +
        + + + ${label} +
        +
      • `; + })} +
      +
      +
      + `; + } +} + +export default AkDualSelectSelectedPane; diff --git a/web/src/elements/ak-dual-select/ak-pagination.stories.ts b/web/src/elements/ak-dual-select/ak-pagination.stories.ts new file mode 100644 index 000000000..01183bbe5 --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-pagination.stories.ts @@ -0,0 +1,83 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta, StoryObj } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "./ak-pagination"; +import { AkPagination } from "./ak-pagination"; + +const metadata: Meta = { + title: "Elements / Dual Select / Pagination Control", + component: "ak-pagination", + parameters: { + docs: { + description: { + component: "The Pagination Control", + }, + }, + }, + argTypes: { + pages: { + type: "string", + description: "An authentik Pagination struct", + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
      + + + ${testItem} +

      Messages received from the button:

      +
        +
        `; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const handleMoveChanged = (result: any) => { + console.log(result); + const target = document.querySelector("#action-button-message-pad"); + target!.append( + new DOMParser().parseFromString( + `
      • Request to move to page ${result.detail}
      • `, + "text/xml", + ).firstChild!, + ); +}; + +window.addEventListener("ak-pagination-nav-to", handleMoveChanged); + +type Story = StoryObj; + +const pages = { + count: 44, + startIndex: 1, + endIndex: 20, + next: 2, + previous: 0, +}; + +export const Default: Story = { + render: () => container(html` `), +}; + +const morePages = { + count: 86, + startIndex: 21, + endIndex: 40, + next: 3, + previous: 1, +}; + +export const More: Story = { + render: () => container(html` `), +}; diff --git a/web/src/elements/ak-dual-select/ak-pagination.ts b/web/src/elements/ak-dual-select/ak-pagination.ts new file mode 100644 index 000000000..b919d7bde --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-pagination.ts @@ -0,0 +1,94 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { msg, str } from "@lit/localize"; +import { css, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFPagination from "@patternfly/patternfly/components/Pagination/pagination.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { CustomEmitterElement } from "../utils/eventEmitter"; +import type { BasePagination } from "./types"; + +const styles = [ + PFBase, + PFButton, + PFPagination, + css` + :host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button { + color: var(--pf-c-button--m-plain--disabled--Color); + --pf-c-button--disabled--Color: var(--pf-c-button--m-plain--Color); + } + :host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button:disabled { + color: var(--pf-c-button--disabled--Color); + } + `, +]; + +@customElement("ak-pagination") +export class AkPagination extends CustomEmitterElement(AKElement) { + static get styles() { + return styles; + } + + @property({ attribute: false }) + pages?: BasePagination; + + constructor() { + super(); + this.onClick = this.onClick.bind(this); + } + + onClick(nav: number | undefined) { + this.dispatchCustomEvent("ak-pagination-nav-to", nav ?? 0); + } + + render() { + return this.pages + ? html`
        +
        +
        +
        + + ${msg( + str`${this.pages?.startIndex} - ${this.pages?.endIndex} of ${this.pages?.count}`, + )} + +
        +
        + +
        +
        ` + : nothing; + } +} + +export default AkPagination; diff --git a/web/src/elements/ak-dual-select/types.ts b/web/src/elements/ak-dual-select/types.ts new file mode 100644 index 000000000..65a8d090d --- /dev/null +++ b/web/src/elements/ak-dual-select/types.ts @@ -0,0 +1,10 @@ +import { TemplateResult } from "lit"; + +import { Pagination } from "@goauthentik/api"; + +export type DualSelectPair = [string, string | TemplateResult]; + +export type BasePagination = Pick< + Pagination, + "startIndex" | "endIndex" | "count" | "previous" | "next" +>; diff --git a/web/src/elements/utils/eventEmitter.ts b/web/src/elements/utils/eventEmitter.ts index 54b472825..6184fe0cd 100644 --- a/web/src/elements/utils/eventEmitter.ts +++ b/web/src/elements/utils/eventEmitter.ts @@ -14,7 +14,6 @@ export function CustomEmitterElement>(supercla const fullDetail = typeof detail === "object" && !Array.isArray(detail) ? { - target: this, ...detail, } : detail;