web: provide simple tables for API-less displays (#11028)

* web: fix Flash of Unstructured Content while SearchSelect is loading from the backend

Provide an alternative, readonly, disabled, unindexed input object with the text "Loading...", to be
replaced with the _real_ input element after the content is loaded.

This provides the correct appearance and spacing so the content doesn't jiggle about between the
start of loading and the SearchSelect element being finalized.  It was visually distracting and
unappealing.

* web: comment on state management in API layer, move file to point to correct component under test.

* web: test for flash of unstructured content

- Add a unit test to ensure the "Loading..." element is displayed correctly before data arrives
- Demo how to mock a `fetchObjects()` call in testing. Very cool.
- Make distinguishing rule sets for code, tests, and scripts in nightmare mode
- In SearchSelect, Move the `styles()` declaration to the top of the class for consistency.

- To test for the FLOUC issue in SearchSelect.

This is both an exercise in mocking @beryju's `fetchObjects()` protocol, and shows how we can unit
test generic components that render API objects.

* web: interim commit of the basic sortable & selectable table.

* web: added basic unit testing to API-free tables

Mostly these tests assert that the table renders and that the content we give it
is where we expect it to be after sorting. For select tables, it also asserts that
the overall value of the table is what we expect it to be when we click on a
single row, or on the "select all" button.

* web: finalize testing for tables

Includes documentation updates and better tests for select-table.

* Provide unit test accessibility to Firefox and Safari; wrap calls to manipulate test DOMs directly in a browser.exec call so they run in the proper context and be await()ed properly

* web: repeat is needed to make sure sub-elements move around correctly. Map does not do full tracking.

* web: update api-less tables

- Replace `th` with `td` in `thead` components. Because Patternfly.
- Add @beryju's styling to the tables, which make it much better looking

* web: rollback dependabot "upgrade" that broke testing

Dependabot rolled us into WebdriverIO 9.  While that's probably the
right thing to do, right now it breaks out end-to-end tests badly.
Dependabot's mucking with infrastructure should not be taken lightly,
especially in cases when the infrastructure is for DX, not UX, and
doesn't create a bigger attack surface on the running product.

* web: small fixes for wdio and lint

- Roll back another dependabot breaking change, this time to WebdriverIO
- Remove the redundant scripts wrapping ESLint for Precommit mode. Access to those modes is
  available through the flags to the `./web/scripts/eslint.mjs` script.
- Remove SonarJS checks until SonarJS is ESLint 9 compatible.
- Minor nitpicking.

* web: not sure where all these getElement() additions come from; did I add them?  Anyway, they were breaking the tests, they're a Wdio9-ism.

* package-lock.json update

* web: small fixes for wdio and lint

**PLEASE** Stop trying to upgrade WebdriverIO following Dependabot's instructions. The changes
between wdio8 and wdio9 are extensive enough to require a lot more manual intervention. The unit
tests fail in wdio 9, with the testbed driver Wdio uses to compile content to push to the browser
([vite](https://vitejs.dev) complaining:

```
2024-09-27T15:30:03.672Z WARN @wdio/browser-runner:vite: warning: Unrecognized default export in file /Users/ken/projects/dev/web/node_modules/@patternfly/patternfly/components/Dropdown/dropdown.css
  Plugin: postcss-lit
  File: /Users/ken/projects/dev/web/node_modules/@patternfly/patternfly/components/Dropdown/dropdown.css
[0-6] 2024-09-27T15:30:04.083Z INFO webdriver: BIDI COMMAND script.callFunction {"functionDeclaration":"<Function[976 bytes]>","awaitPromise":true,"arguments":[],"target":{"context":"8E608E6D13E355DFFC28112C236B73AF"}}
[0-6]  Error:  Test failed due to following error(s):
  - ak-search-select.test.ts: The requested module '/src/common/styles/authentik.css' does not provide an export named 'default': SyntaxError: The requested module '/src/common/styles/authentik.css' does not provide an export named 'default'

```

So until we can figure out why the Vite installation isn't liking our CSS import scheme, we'll
have to soldier on with what we have.  At least with Wdio 8, we get:

```
Spec Files:      7 passed, 7 total (100% completed) in 00:00:19
```

* Forgot to run prettier.

* web: small fixes for elements and forms

- provides a new utility, `_isSlug_`, used to verify a user input
- extends the ak-horizontal-component wrapper to have a stronger identity and available value
- updates the types that use the wrapper to be typed more strongly
  - (Why) The above are used in the wizard to get and store values
- fixes a bug in SearchSelectEZ that broke the display if the user didn't supply a `groupBy` field.
- Adds `@wdio/types` to the package file so eslint is satisfied wdio builds correctly
- updates the end-to-end test to understand the revised button identities on the login page
  - Running the end-to-end tests verifies that changes to the components listed above did not break
    the semantics of those components.

* Prettier had opinions

* Some lint over-eagerness.

* Updated after build.
This commit is contained in:
Ken Sternberg
2024-10-07 08:21:13 -07:00
committed by GitHub
parent 63196be36a
commit 9e2620a5b9
24 changed files with 1698 additions and 220 deletions

View File

@ -1,14 +1,14 @@
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet.js";
import { $, expect } from "@wdio/globals";
import { msg } from "@lit/localize";
import { TemplateResult, html, render as litRender } from "lit";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import AKGlobal from "../../common/styles/authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { LicenseForecast, LicenseSummary, LicenseSummaryStatusEnum } from "@goauthentik/api";
import { ensureCSSStyleSheet } from "../../elements/utils/ensureCSSStyleSheet.js";
import "./EnterpriseStatusCard.js";
const render = (body: TemplateResult) => {

View File

@ -104,6 +104,8 @@ export class AkWizard<D, Step extends WizardStep = WizardStep>
}
render() {
const step = this.step.render.bind(this.step);
return html`
<ak-wizard-frame
${ref(this.frame)}
@ -112,7 +114,7 @@ export class AkWizard<D, Step extends WizardStep = WizardStep>
prompt=${this.prompt}
.buttons=${this.step.buttons}
.stepLabels=${this.stepLabels}
.form=${this.step.render.bind(this.step)}
.form=${step}
>
<button slot="trigger" class="pf-c-button pf-m-primary">${this.prompt}</button>
</ak-wizard-frame>

View File

@ -1,13 +1,13 @@
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet.js";
import { $, expect } from "@wdio/globals";
import { msg } from "@lit/localize";
import { TemplateResult, html, render as litRender } from "lit";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import AKGlobal from "../common/styles/authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import "./EmptyState.js";
import { ensureCSSStyleSheet } from "./utils/ensureCSSStyleSheet.js";
const render = (body: TemplateResult) => {
document.adoptedStyleSheets = [

View File

@ -0,0 +1,103 @@
import { bound } from "@goauthentik/elements/decorators/bound";
import { html } from "lit";
import { classMap } from "lit/directives/class-map.js";
// Because TableColumn isn't a component, it won't be the dispatch target and it won't have an
// identity beyond the host passed in, so we must include with the event a payload that identifies
// the source TableColumn in some way.
//
export class TableSortEvent extends Event {
static readonly eventName = "tablesort";
public value: string;
constructor(value: string) {
super(TableSortEvent.eventName, { composed: true, bubbles: true });
this.value = value;
}
}
/**
* class TableColumn
*
* This is a helper class for rendering the contents of a table column header.
*
* ## Events
*
* - @fires tablesort: when the header is clicked, if the host is not undefined
*
*/
export class TableColumn {
/**
* The text to show in the column header
*/
value: string;
/**
* If not undefined, the element that will first receive the `tablesort` event
*/
host?: HTMLElement;
/**
* If not undefined, show the sort indicator, and indicate the sort state
*/
orderBy?: string;
constructor(value: string, orderBy?: string, host?: HTMLElement) {
this.value = value;
this.orderBy = orderBy;
if (host) {
this.host = host;
}
}
@bound
private onSort() {
if (this.host && this.orderBy) {
this.host.dispatchEvent(new TableSortEvent(this.orderBy));
}
}
private sortIndicator(orderBy: string) {
// prettier-ignore
switch(orderBy) {
case this.orderBy: return "fa-long-arrow-alt-down";
case `-${this.orderBy}`: return "fa-long-arrow-alt-up";
default: return "fa-arrows-alt-v";
}
}
private sortButton(orderBy: string) {
return html` <button class="pf-c-table__button" @click=${this.onSort}>
<div class="pf-c-table__button-content">
<span part="column-text" class="pf-c-table__text">${this.value}</span>
<span part="column-sort" class="pf-c-table__sort-indicator">
<i class="fas ${this.sortIndicator(orderBy)}"></i>
</span>
</div>
</button>`;
}
public render(orderBy?: string) {
const isSelected = orderBy === this.orderBy || orderBy === `-${this.orderBy}`;
const classes = {
"pf-c-table__sort": Boolean(this.host && this.orderBy),
"pf-m-selected": Boolean(this.host && isSelected),
};
return html`<td
part="column-item"
role="columnheader"
scope="col"
class="${classMap(classes)}"
>
${orderBy && this.orderBy ? this.sortButton(orderBy) : html`${this.value}`}
</td>`;
}
}
declare global {
interface GlobalEventHandlersEventMap {
[TableSortEvent.eventName]: TableSortEvent;
}
}

View File

@ -0,0 +1,261 @@
import { bound } from "@goauthentik/elements/decorators/bound";
import { msg } from "@lit/localize";
import { PropertyValues, TemplateResult, html } from "lit";
import { customElement, property, queryAll } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
import { type ISimpleTable, SimpleTable } from "./ak-simple-table";
import type { TableRow } from "./types";
export interface ISelectTable extends ISimpleTable {
value: string;
radio: boolean;
valueSep: string;
selected: string[];
}
/**
* @element ak-select-table
* @class SelectTable
*
* Extends the SimpleTable with a select column, emitting a `change` event whenever the selected
* table updates. The `multiple` keyword creates a multi-select table. Sorting behavior resembles
* that of `SimpleTable`.
*
* Aside from overriding the `renderRow()` and `renderColumnHeaders()` methods to add the room
* for the checkbox, this is entirely an additive feature; the logic of `ak-simple-table` is
* otherwise completely preserved.
*
* Note that this implementation caches any values that it may have seen prior, but are not
* currently visible on the page. This preserves the selection collection in case the client wishes
* to implement pagination.
*
* ## Properties
*
* - @prop content (see types): The content to show. The simplest content is just `string[][]`, but
* see the types.
*
* - @prop columns (see types): The column headers for the table. Can be just a `string[]`, but see
* the types.
*
* - @attr (string, optional): The current column to order the content by. By convention, prefix
* with a `-` to indicate a reverse sort order. (See "Does not handle sorting" above).
*
* - @attr multiple (boolean): If true, this table is "multi-select" and a 'select all' checkbox will
* be available.
*
* - @attr value (string): If set, will set the value of the component. For multi-select, will split
* on the `valueSep` (see next entry). Get is the reverse: either the value of the component,
* or for multi-select, the value of the component `.join()`ed with the `valueSep`
*
* - @attr valueSep (string): For multi-select only, the (ideally one) characters which will separate
* values.
*
* - @prop selected (string[]): The values selected. Always an array, even for mult-select. When not
* multi-select, will have zero or one items only.
*
* ## Messages
*
* - `clear()`: Sets the `selected` collection to empty, erasing all values.
*
* ## Events
*
* - @fires tablesort (Custom): A table header has been clicked, requesting a sort event. See "Does
* not handle sorting" above.
*
* ## CSS Customizations
*
* - @part table: the `<table>` element
* - @part column-header: the `<thead>` element for the column headers themselves
* - @part column-row: The `<tr>` element for the column headers
* - @part column-item: The `<th>` element for each column header
* - @part column-text: The text `<span>` of the column header
* - @part column-sort: The sort indicator `<span>` of a column header, if activated
* - @part group-header: The `<thead>` element for a group header
* - @part group-row: The `<tr>` element for a group header
* - @part group-head: The `<th>` element for a group header
* - @part row: The `<tr>` element for a standard row
* - @part cell cell-{index}: The `<td>` element for a single datum. Can be accessed via the index,
* which is zero-indexed
* - @part select-all-header: The `<th>` element for the select-all checkbox, when _multiple_
* - @part select-all-input: The `<input>` element for the select-all checkbox, when _multiple_
* - @part select-cell: The `<td>` element for a select checkbox
* - @part select-input: The `<input> element for a select checkbox
*
* NOTE: The select-cell is *not* indexed. The `::part(cell-{idx})` remains indexed by zero; you
* cannot access the select-cell via `cell-0`; that would be the first data column. This is due to a
* limitation on the `part::` semantics.
*
*/
@customElement("ak-select-table")
export class SelectTable extends SimpleTable {
// WARNING: This property and `set selected` must mirror each other perfectly.
@property({ type: String, attribute: true, reflect: true })
public set value(value: string) {
this._value = value;
this._selected = value.split(this.valueSep).filter((v) => v.trim() !== "");
}
public get value() {
return this._value;
}
private _value: string = "";
@property({ type: Boolean, attribute: true })
multiple = false;
@property({ type: String, attribute: true })
valueSep = ";";
// WARNING: This property and `set value` must mirror each other perfectly.
@property({ attribute: false })
public set selected(selected: string[]) {
this._selected = selected;
this._value = this._selected.toSorted().join(this.valueSep);
}
@queryAll('input[data-ouia-component-role="select"]')
selectCheckboxesOnPage!: HTMLInputElement[];
public get selected() {
return this._selected;
}
public json() {
return this._selected;
}
private get valuesOnPage() {
return Array.from(this.selectCheckboxesOnPage).map((checkbox) => checkbox.value);
}
private get checkedValuesOnPage() {
return Array.from(this.selectCheckboxesOnPage)
.filter((checkbox) => checkbox.checked)
.map((checkbox) => checkbox.value);
}
private get selectedOnPage() {
return this.checkedValuesOnPage.filter((value) => this._selected.includes(value));
}
public clear() {
this.selected = [];
}
private _selected: string[] = [];
@bound
private onSelect(ev: InputEvent) {
ev.stopPropagation();
const value = (ev.target as HTMLInputElement).value;
if (this.multiple) {
this.selected = this.selected.includes(value)
? this.selected.filter((v) => v !== value)
: [...this.selected, value];
} else {
this.selected = this.selected.includes(value) ? [] : [value];
}
this.dispatchEvent(new Event("change"));
}
protected override ouiaTypeDeclaration() {
this.setAttribute("data-ouia-component-type", "ak-select-table");
}
public override connectedCallback(): void {
super.connectedCallback();
this.dataset.akControl = "true";
}
public override willUpdate(changed: PropertyValues<this>) {
super.willUpdate(changed);
// Ensure the value attribute in the component reflects the current value after an update
// via onSelect() or other change to `this.selected`. Done here instead of in `updated` as
// changes here cannot trigger an update. See:
// https://lit.dev/docs/components/lifecycle/#willupdate
this.setAttribute("value", this._value);
}
public renderCheckbox(key: string | undefined) {
if (key === undefined) {
return html`<td class="pf-c-table__check" role="cell"></td>`;
}
// The double `checked` there is not a typo. The first one ensures the input's DOM object
// receives the state; the second ensures the input tag on the page reflects the state
// accurately. See https://github.com/lit/lit-element/issues/601
const checked = this.selected.includes(key);
return html`<td part="select-cell" class="pf-c-table__check" role="cell">
<input
type="checkbox"
name="${key}"
part="select-input"
data-ouia-component-type="checkbox"
data-ouia-component-role="select"
value=${key}
?checked=${checked}
.checked=${checked}
@click=${this.onSelect}
/>
</td>`;
}
// Without the `bound`, Lit's `map()` will pick up the parent class's `renderRow()`. This
// override makes room for the select checkbox.
@bound
public override renderRow(row: TableRow, _rowidx: number) {
return html` <tr part="row">
${this.renderCheckbox(row.key)}
${map(
row.content,
(col, idx) => html`<td part="cell cell-${idx}" role="cell">${col}</td>`,
)}
</tr>`;
}
renderAllOnThisPageCheckbox(): TemplateResult {
const checked =
this.selectedOnPage.length && this.selectedOnPage.length === this.valuesOnPage.length;
const onInput = (ev: InputEvent) => {
const selected = [...this.selected];
const values = this.valuesOnPage;
// The behavior preserves the `selected` elements that are not currently visible; its
// purpose is to preserve the complete value list locally in case clients want to
// implement pagination. To clear the entire list, call `clear()` on the component.
this.selected = (ev.target as HTMLInputElement).checked
? // add to `selected` all values not already present
[...selected, ...values.filter((i) => !selected.includes(i))]
: // remove from `selected` all values present
this.selected.filter((i) => !values.includes(i));
};
return html`<td part="select-all-header" class="pf-c-table__check" role="cell">
<input
part="select-all-input"
name="select-all-input"
type="checkbox"
aria-label=${msg("Select all rows")}
.checked=${checked}
@input=${onInput}
/>
</td>`;
}
// This override makes room for the select checkbox.
public override renderColumnHeaders() {
return html`<tr part="column-row" role="row">
${this.multiple ? this.renderAllOnThisPageCheckbox() : html`<td></td>`}
${map(this.icolumns, (col) => col.render(this.order))}
</tr>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-select-table": SelectTable;
}
}

View File

@ -0,0 +1,217 @@
import { AKElement } from "@goauthentik/elements/Base.js";
import { bound } from "@goauthentik/elements/decorators/bound";
import { randomId } from "@goauthentik/elements/utils/randomId.js";
import { TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
import { repeat } from "lit/directives/repeat.js";
import PFTable from "@patternfly/patternfly/components/Table/table.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { TableColumn } from "./TableColumn.js";
import type { Column, TableFlat, TableGroup, TableGrouped, TableRow } from "./types";
import { convertContent } from "./utils";
export type RawContent = string | number | TemplateResult;
export type ContentType = RawContent[][] | TableRow[] | TableGrouped;
export interface ISimpleTable {
columns: Column[];
content: TableGrouped | TableFlat;
order?: string;
}
/**
* @element ak-simple-table
* class Table
*
* Our simplest table. It takes a column definition and an array (rows) of array (one row) of
* TemplateResults, and it renders a table. If the column definition includes keys, the column will
* be rendered with a sort indicator.
*
* ## Does not handle sorting.
*
* ... that's _all_ this does. It is the responsibility of clients using this table to:
*
* - marshall their content into TemplateResults
* - catch the 'tablesort' event and send the table a new collection of rows sorted according to
* the client scheme.
*
* ## Properties
*
* - @prop content (see types): The content to show. The simplest content is just `string[][]`, but
* see the types.
*
* - @prop columns (see types): The column headers for the table. Can be just a `string[]`, but see
* the types.
*
* - @attr order (string, optional): The current column to order the content by. By convention, prefix
* with a `-` to indicate a reverse sort order. (See "Does not handle sorting" above).
*
* ## Events
*
* - @fires tablesort (Custom): A table header has been clicked, requesting a sort event. See "Does
* not handle sorting" above.
*
* ## CSS Customizations
*
* - @part table: the `<table>` element
* - @part column-header: the `<thead>` element for the column headers themselves
* - @part column-row: The `<tr>` element for the column headers
* - @part column-item: The `<th>` element for each column header
* - @part column-text: The text `<span>` of the column header
* - @part column-sort: The sort indicator `<span>` of a column header, if activated
* - @part group-header: The `<thead>` element for a group header
* - @part group-row: The `<tr>` element for a group header
* - @part group-head: The `<th>` element for a group header
* - @part row: The `<tr>` element for a standard row
* - @part cell cell-{index}: The `<td>` element for a single datum. Can be accessed via the index,
* which is zero-indexed
*
*/
@customElement("ak-simple-table")
export class SimpleTable extends AKElement implements ISimpleTable {
static get styles() {
return [
PFBase,
PFTable,
css`
.pf-c-table thead .pf-c-table__check {
min-width: 3rem;
}
.pf-c-table tbody .pf-c-table__check input {
margin-top: calc(var(--pf-c-table__check--input--MarginTop) + 1px);
}
.pf-c-toolbar__content {
row-gap: var(--pf-global--spacer--sm);
}
.pf-c-toolbar__item .pf-c-input-group {
padding: 0 var(--pf-global--spacer--sm);
}
`,
];
}
@property({ type: String, attribute: true, reflect: true })
order?: string;
@property({ type: Array, attribute: false })
columns: Column[] = [];
@property({ type: Object, attribute: false })
set content(content: ContentType) {
this._content = convertContent(content);
}
get content(): TableGrouped | TableFlat {
return this._content;
}
private _content: TableGrouped | TableFlat = {
kind: "flat",
content: [],
};
protected get icolumns(): TableColumn[] {
const hosted = (column: TableColumn) => {
column.host = this;
return column;
};
return this.columns.map((column) =>
typeof column === "string"
? hosted(new TableColumn(column))
: Array.isArray(column)
? hosted(new TableColumn(...column))
: hosted(column),
);
}
protected ouiaTypeDeclaration() {
this.setAttribute("data-ouia-component-type", "ak-simple-table");
}
public override connectedCallback(): void {
super.connectedCallback();
this.ouiaTypeDeclaration();
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
}
public override performUpdate() {
this.removeAttribute("data-ouia-component-safe");
super.performUpdate();
}
public renderRow(row: TableRow, _rownum: number) {
return html` <tr part="row">
${map(
row.content,
(col, idx) => html`<td part="cell cell-${idx}" role="cell">${col}</td>`,
)}
</tr>`;
}
public renderRows(rows: TableRow[]) {
return html`<tbody part="body">
${repeat(rows, (row) => row.key, this.renderRow)}
</tbody>`;
}
@bound
public renderRowGroup({ group, content }: TableGroup) {
return html`<thead part="group-header">
<tr part="group-row">
<td role="columnheader" scope="row" colspan="200" part="group-head">
${group}
</td>
</tr>
</thead>
${this.renderRows(content)}`;
}
@bound
public renderRowGroups(rowGroups: TableGroup[]) {
return html`${map(rowGroups, this.renderRowGroup)}`;
}
public renderBody() {
// prettier-ignore
return this.content.kind === 'flat'
? this.renderRows(this.content.content)
: this.renderRowGroups(this.content.content);
}
public renderColumnHeaders() {
return html`<tr part="column-row" role="row">
${map(this.icolumns, (col) => col.render(this.order))}
</tr>`;
}
public renderTable() {
return html`
<table part="table" class="pf-c-table pf-m-compact pf-m-grid-md pf-m-expandable">
<thead part="column-header">
${this.renderColumnHeaders()}
</thead>
${this.renderBody()}
</table>
`;
}
public render() {
return this.renderTable();
}
public override updated() {
this.setAttribute("data-ouia-component-safe", "true");
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-simple-table": SimpleTable;
}
}

View File

@ -0,0 +1,138 @@
import { Meta, StoryObj } from "@storybook/web-components";
import { slug } from "github-slugger";
import { LitElement, TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { TableSortEvent } from "../TableColumn.js";
import "../ak-select-table.js";
import { SelectTable } from "../ak-select-table.js";
import { nutritionDbUSDA } from "./sample_nutrition_db.js";
const metadata: Meta<SelectTable> = {
title: "Elements / Table / SelectTable",
component: "ak-select-table",
parameters: {
docs: {
description: {
component: "Our table with a select field",
},
},
},
argTypes: {
content: {
type: "function",
description: "An array of arrays of items to show",
},
columns: {
type: "function",
description: "An array of column headers",
},
order: {
type: "string",
description:
"A key indicating which column to highlight as the current sort target, if any",
},
},
};
export default metadata;
type Story = StoryObj;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
</div>`;
const columns = ["Name", "Calories", "Protein", "Fiber", "Sugar"];
const content = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) => ({
key: slug(name),
content: [name, calories, protein, fiber, sugar].map((a) => html`${a}`),
}));
export const Default: Story = {
render: () =>
container(
html`<ak-select-table .columns=${columns} .content=${content}></ak-select-table>`,
),
};
export const MultiSelect: Story = {
render: () =>
container(
html`<ak-select-table
.columns=${columns}
.content=${content}
multiple
></ak-select-table>`,
),
};
type Ord = Record<string | number, string | number>;
@customElement("ak-select-table-test-sort")
export class SimpleTableSortTest extends LitElement {
@state()
order = "name";
@state()
sortDown = true;
@property({ type: Boolean, attribute: true })
multiple = false;
columns = columns.map((a) => [a, a.toLowerCase()]);
get content() {
const content = [...nutritionDbUSDA];
// Sort according to the key
const comparison = this.sortDown
? (a: Ord, b: Ord) => (a[this.order] > b[this.order] ? -1 : 1)
: (a: Ord, b: Ord) => (a[this.order] > b[this.order] ? 1 : -1);
content.sort(comparison);
// Return the content, processed to comply with the format expected by a selectable table.
return content.map(({ name, calories, sugar, fiber, protein }) => ({
key: slug(name),
content: [name, calories, protein, fiber, sugar].map((a) => html`${a}`),
}));
}
render() {
const onTableSort = (event: TableSortEvent) => {
if (event.value === this.order) {
this.sortDown = !this.sortDown;
return;
}
this.order = event.value;
};
const direction = this.sortDown ? "" : "-";
return html`<ak-select-table
.columns=${this.columns}
.content=${this.content}
.order="${direction}${this.order}"
?multiple=${this.multiple}
@tablesort=${onTableSort}
></ak-select-table>`;
}
}
export const TableWithSorting: Story = {
render: () => container(html`<ak-select-table-test-sort></ak-select-table-test-sort>`),
};
export const MultiselectTableWithSorting: Story = {
render: () => container(html`<ak-select-table-test-sort multiple></ak-select-table-test-sort>`),
};

View File

@ -0,0 +1,147 @@
import { Meta, StoryObj } from "@storybook/web-components";
import { slug } from "github-slugger";
import { LitElement, TemplateResult, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { TableSortEvent } from "../TableColumn.js";
import "../ak-simple-table.js";
import { SimpleTable } from "../ak-simple-table.js";
import { KeyBy } from "../types";
import type { TableRow } from "../types";
import { convertContent } from "../utils.js";
import { nutritionDbUSDA } from "./sample_nutrition_db.js";
const metadata: Meta<SimpleTable> = {
title: "Elements / Table / SimpleTable",
component: "ak-simple-table",
parameters: {
docs: {
description: {
component: "Our basic table",
},
},
},
argTypes: {
content: {
type: "function",
description: "An array of arrays of items to show",
},
columns: {
type: "function",
description: "An array of column headers",
},
order: {
type: "string",
description:
"A key indicating which column to highlight as the current sort target, if any",
},
},
};
export default metadata;
type Story = StoryObj;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
</div>`;
const columns = ["Name", "Calories", "Protein", "Fiber", "Sugar"];
const content = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) => [
name,
calories,
protein,
fiber,
sugar,
]);
export const Default: Story = {
render: () =>
container(
html`<ak-simple-table .columns=${columns} .content=${content}></ak-simple-table>`,
),
};
type Ord = Record<string | number, string | number>;
@customElement("ak-simple-table-test-sort")
export class SimpleTableSortTest extends LitElement {
@state()
order = "name";
@state()
sortDown = true;
columns = columns.map((a) => [a, a.toLowerCase()]);
get content() {
const content = [...nutritionDbUSDA];
const comparison = this.sortDown
? (a: Ord, b: Ord) => (a[this.order] < b[this.order] ? -1 : 1)
: (a: Ord, b: Ord) => (a[this.order] < b[this.order] ? 1 : -1);
content.sort(comparison);
return content.map(({ name, calories, sugar, fiber, protein }) => [
name,
calories,
protein,
fiber,
sugar,
]);
}
render() {
const onTableSort = (event: TableSortEvent) => {
if (event.value === this.order) {
this.sortDown = !this.sortDown;
return;
}
this.order = event.value;
};
const direction = this.sortDown ? "" : "-";
return html`<ak-simple-table
.columns=${this.columns}
.content=${this.content}
.order="${direction}${this.order}"
@tablesort=${onTableSort}
></ak-simple-table>`;
}
}
export const TableWithSorting: Story = {
render: () => container(html`<ak-simple-table-test-sort></ak-simple-table-test-sort>`),
};
const rowContent: TableRow[] = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) => ({
key: slug(name),
content: [name, calories, protein, fiber, sugar].map((a) => html`${a}`),
}));
export const PreprocessedContent: Story = {
render: () =>
container(
html`<ak-simple-table .columns=${columns} .content=${rowContent}></ak-simple-table>`,
),
};
const capitalize = (s = "") => `${s.substring(0, 1).toUpperCase()}${s.substring(1)}`;
const groups = new Map(nutritionDbUSDA.map(({ name, group }) => [name, group]));
const groupFoods: KeyBy = (content) => capitalize(groups.get(content[0] as string));
const groupedContent = convertContent(content, { groupBy: groupFoods });
export const GroupedTable: Story = {
render: () =>
html`<ak-simple-table .columns=${columns} .content=${groupedContent}></ak-simple-table>`,
};

View File

@ -0,0 +1,213 @@
// Taken from the https://fdc.nal.usda.gov/data-documentation.html database of "Foundational Foods."
export const nutritionDbUSDA = [
{
name: "Hummus",
calories: 229,
sugar: "0.34g",
fiber: "5.4g",
protein: "7.35g",
group: "processed",
},
{
name: "Onion Rings, breaded",
calories: 288,
sugar: "4.5g",
fiber: "2.4g",
protein: "4.52g",
group: "processed",
},
{
name: "Bread, white",
calories: 270,
sugar: "5.34g",
fiber: "2.3g",
protein: "9.43g",
group: "processed",
},
{
name: "Sweet and Sour Pork, frozen",
calories: 260,
sugar: "10.3g",
fiber: "1g",
protein: "8.88g",
group: "processed",
},
{
name: "Almonds",
calories: 620,
sugar: "4.17g",
fiber: "11g",
protein: "20.4g",
group: "organic",
},
{
name: "Kale",
calories: 35,
sugar: "0.8g",
fiber: "4.1g",
protein: "2.92g",
group: "organic",
},
{
name: "Pickles",
calories: 12,
sugar: "1.28g",
fiber: "1g",
protein: "0.48g",
group: "organic",
},
{
name: "Kiwifruit",
calories: 58,
sugar: "8.99g",
fiber: "3g",
protein: "1.06g",
group: "organic",
},
{
name: "Sunflower Seeds",
calories: 612,
sugar: "3.14g",
fiber: "10.3g",
protein: "21g",
group: "organic",
},
{
name: "Nectarines",
calories: 39,
sugar: "7.89g",
fiber: "1.5g",
protein: "1.06g",
group: "organic",
},
{
name: "Oatmeal Cookies",
calories: 430,
sugar: "34.8g",
fiber: "3.3g",
protein: "5.79g",
group: "processed",
},
{
name: "Carrots",
calories: 37,
sugar: "4.2g",
fiber: "3.2g",
protein: "0.81g",
group: "organic",
},
{
name: "Figs",
calories: 249,
sugar: "47.9g",
fiber: "9.8g",
protein: "3.3g",
group: "organic",
},
{
name: "Lettuce",
calories: 17,
sugar: "1.19g",
fiber: "1.8g",
protein: "1.24g",
group: "organic",
},
{
name: "Cantaloupe",
calories: 34,
sugar: "7.88g",
fiber: "0.8g",
protein: "0.82g",
group: "organic",
},
{
name: "Oranges",
calories: 47,
sugar: "8.57g",
fiber: "2g",
protein: "0.91g",
group: "organic",
},
{
name: "Pears",
calories: 57,
sugar: "9.69g",
fiber: "3.1g",
protein: "0.38g",
group: "organic",
},
{
name: "Broccoli",
calories: 31,
sugar: "1.4g",
fiber: "2.4g",
protein: "2.57g",
group: "organic",
},
{
name: "Eggs",
calories: 148,
sugar: "0.2g",
fiber: "0g",
protein: "12.4g",
group: "organic",
},
{
name: "Onions",
calories: 44,
sugar: "5.76g",
fiber: "2.2g",
protein: "0.94g",
group: "organic",
},
{
name: "Bananas",
calories: 97,
sugar: "15.8g",
fiber: "1.7g",
protein: "0.74g",
group: "organic",
},
{
name: "Apples",
calories: 64.7,
sugar: "13.3g",
fiber: "2.08g",
protein: "0.148g",
group: "organic",
},
{
name: "Pineapple",
calories: 60.1,
sugar: "11.4g",
fiber: "0.935g",
protein: "0.461g",
group: "organic",
},
{
name: "Snap Green Beans",
calories: 40,
sugar: "2.33g",
fiber: "3.01g",
protein: "1.97g",
group: "organic",
},
{
name: "Beets",
calories: 44.6,
sugar: "5.1g",
fiber: "3.12g",
protein: "1.69g",
group: "organic",
},
{
name: "Eggplant",
calories: 26.1,
sugar: "2.35g",
fiber: "2.45g",
protein: "0.852g",
group: "organic",
},
];

View File

@ -0,0 +1,143 @@
import { $, browser } from "@wdio/globals";
import { slug } from "github-slugger";
import { html, render } from "lit";
import "../ak-select-table.js";
import { nutritionDbUSDA as unsortedNutritionDbUSDA } from "../stories/sample_nutrition_db.js";
type SortableRecord = Record<string, string | number>;
const dbSort = (a: SortableRecord, b: SortableRecord) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
const alphaSort = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const nutritionDbUSDA = unsortedNutritionDbUSDA.toSorted(dbSort);
const columns = ["Name", "Calories", "Protein", "Fiber", "Sugar"];
const content = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) => ({
key: slug(name),
content: [name, calories, protein, fiber, sugar].map((a) => html`${a}`),
}));
const item3 = nutritionDbUSDA[2];
describe("Select Table", () => {
let selecttable: WebdriverIO.Element;
let table: WebdriverIO.Element;
beforeEach(async () => {
await render(
html`<ak-select-table .content=${content} .columns=${columns}> </ak-select-table>`,
document.body,
);
// @ts-ignore
selecttable = await $("ak-select-table");
table = await selecttable.$(">>>table");
});
it("it should render a select table", async () => {
expect(table).toBeDisplayed();
});
it("the table should have as many entries as the data source", async () => {
const rows = await table.$("tbody").$$("tr");
expect(rows.length).toBe(content.length);
});
it(`the third item ought to have the name ${item3.name}`, async () => {
const rows = await table.$("tbody").$$("tr");
const cells = await rows[2].$$("td");
const cell1Text = await cells[1].getText();
expect(cell1Text).toEqual(item3.name);
});
it("Selecting one item ought to result in the value of the table being set", async () => {
const rows = await table.$("tbody").$$("tr");
const control = await rows[2].$$("td")[0].$("input");
await control.click();
expect(await selecttable.getValue()).toEqual(slug(item3.name));
});
afterEach(async () => {
await browser.execute(() => {
document.body.querySelector("ak-select-table")?.remove();
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
if (document.body["_$litPart$"]) {
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
delete document.body["_$litPart$"];
}
});
});
});
describe("Multiselect Table", () => {
let selecttable: WebdriverIO.Element;
let table: WebdriverIO.Element;
beforeEach(async () => {
await render(
html`<ak-select-table multiple .content=${content} .columns=${columns}>
</ak-select-table>`,
document.body,
);
// @ts-ignore
selecttable = await $("ak-select-table");
// @ts-ignore
table = await selecttable.$(">>>table");
});
it("it should render the select-all control", async () => {
const selall = await table.$("thead").$$("tr")[0].$$("td")[0];
if (selall === undefined) {
throw new Error("Could not find table header");
}
const input = await selall.$("input");
expect(await input.getProperty("name")).toEqual("select-all-input");
});
it("it should set the value when one input is clicked", async () => {
const input = await table.$("tbody").$$("tr")[2].$$("td")[0].$("input");
await input.click();
expect(await selecttable.getValue()).toEqual(slug(nutritionDbUSDA[2].name));
});
it("it should select all when that control is clicked", async () => {
const selall = await table.$("thead").$$("tr")[0].$$("td")[0];
if (selall === undefined) {
throw new Error("Could not find table header");
}
const input = await selall.$("input");
await input.click();
const value = await selecttable.getValue();
const values = value.split(";").toSorted(alphaSort).join(";");
const expected = nutritionDbUSDA.map((a) => slug(a.name)).join(";");
expect(values).toEqual(expected);
});
it("it should clear all when that control is clicked twice", async () => {
const selall = await table.$("thead").$$("tr")[0].$$("td")[0];
if (selall === undefined) {
throw new Error("Could not find table header");
}
const input = await selall.$("input");
await input.click();
const value = await selecttable.getValue();
const values = value.split(";").toSorted(alphaSort).join(";");
const expected = nutritionDbUSDA.map((a) => slug(a.name)).join(";");
expect(values).toEqual(expected);
await input.click();
const newvalue = await selecttable.getValue();
expect(newvalue).toEqual("");
});
afterEach(async () => {
await browser.execute(() => {
document.body.querySelector("ak-select-table")?.remove();
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
if (document.body["_$litPart$"]) {
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
delete document.body["_$litPart$"];
}
});
});
});

View File

@ -0,0 +1,46 @@
import { $ } from "@wdio/globals";
import { slug } from "github-slugger";
import { html, render } from "lit";
import "../ak-simple-table.js";
import { nutritionDbUSDA } from "../stories/sample_nutrition_db.js";
const columns = ["Name", "Calories", "Protein", "Fiber", "Sugar"];
const content = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) => ({
key: slug(name),
content: [name, calories, protein, fiber, sugar].map((a) => html`${a}`),
}));
describe("Simple Table", () => {
let table: WebdriverIO.Element;
beforeEach(async () => {
await render(
html`<ak-simple-table .content=${content} .columns=${columns}> </ak-simple-table>`,
document.body,
);
// @ts-ignore
table = await $("ak-simple-table").$(">>>table");
});
it("it should render a simple table", async () => {
expect(table).toBeDisplayed();
});
it("the table should have as many entries as the data source", async () => {
const rows = await table.$("tbody").$$("tr");
expect(rows.length).toBe(content.length);
});
afterEach(async () => {
await browser.execute(() => {
document.body.querySelector("ak-simple-table")?.remove();
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
if (document.body["_$litPart$"]) {
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
delete document.body["_$litPart$"];
}
});
});
});

View File

@ -0,0 +1,61 @@
import { TemplateResult } from "lit";
import { TableColumn } from "./TableColumn";
// authentik's standard tables (ak-simple-table, ak-select-table) all take a variety of types, the
// simplest of which is just an array of tuples, one for each column, along with an tuple for
// the definition of the column itself.
//
// More complex types are defined below, including those for grouped content. In he "utils"
// collection with this element you can find the [`convertContent`](./utils.ts) function, which can
// be used to create grouped content by providing a `groupBy` function, as well as selectable
// content by providing a `keyBy` function. See the documentation for `convertContent`.
/**
* - key (string, option): the value to return on "click", if the table is clickable / selectable
* - content (TemplateResult[]): The contents of the rows to be shown
*/
export type TableRow = {
key?: string;
content: TemplateResult[];
// expansion?: () => TemplateResult;
};
/**
* For a collection of rows without groups
*
*/
export type TableFlat = {
kind: "flat";
content: TableRow[];
};
/**
* For a single grouped collection; the name of the group and the contents.
*/
export type TableGroup = {
kind: "group";
group: string;
content: TableRow[];
};
/**
* For a grouped collection, all of the groups.
*/
export type TableGrouped = {
kind: "groups";
content: TableGroup[];
};
/**
* For convenience, a table column can be defined either by the string defining its
* content, or by a pair of strings defining the content and the sort-by header
* used to indicate and control sortability.
*/
export type Column = TableColumn | string | [string, string?];
export type RawType = string | number | TemplateResult;
export type TableInputType = RawType[][] | TableRow[] | TableGrouped | TableFlat;
export type TableType = TableGrouped | TableFlat;
export type KeyBy = (_: RawType[]) => string;

View File

@ -0,0 +1,107 @@
import { groupBy as groupByProcessor } from "@goauthentik/common/utils.js";
import { html } from "lit";
import {
KeyBy,
RawType,
TableFlat,
TableGrouped,
TableInputType,
TableRow,
TableType,
} from "./types";
// TypeScript was extremely specific about due diligence here.
export const isTableRows = (v: unknown): v is TableRow[] =>
Array.isArray(v) &&
v.length > 0 &&
typeof v[0] === "object" &&
v[0] !== null &&
!("kind" in v[0]) &&
"content" in v[0];
export const isTableGrouped = (v: unknown): v is TableGrouped =>
typeof v === "object" && v !== null && "kind" in v && v.kind === "groups";
export const isTableFlat = (v: unknown): v is TableFlat =>
typeof v === "object" && v !== null && "kind" in v && v.kind === "flat";
/**
* @func convertForTable
*
* Takes a variety of input types and streamlines them. Can't handle every contingency; be prepared
* to do conversions yourself as resources demand. Great for about 80% of use cases, though.
*
* - @param groupBy: If provided, for each item it must provide the group's name, by which the
* content will be grouped. The name is not a slug; it is what will be displayed.
* - @param keyBy: If provided, for each item it must provide a key for the item, which will be the
* value returned.
*
* For content that has already been grouped or converted into a single "flat" group, providing
* these functions will not do anything except generate a warning on the console.
*/
export function convertContent(
content: TableInputType,
{ groupBy, keyBy }: { groupBy?: KeyBy; keyBy?: KeyBy } = {},
): TableType {
// TableGrouped
if (isTableGrouped(content)) {
if (groupBy || keyBy) {
console.warn("Passed processor function when content is already marked as grouped");
}
return content;
}
if (isTableFlat(content)) {
if (groupBy || keyBy) {
console.warn("Passed processor function when content is already marked as flat");
}
return content;
}
// TableRow[]
if (isTableRows(content)) {
if (groupBy) {
console.warn(
"Passed processor function when content is processed and can't be analyzed for grouping",
);
}
return {
kind: "flat",
content: content,
};
}
// TableRow or Rawtype, but empty
if (Array.isArray(content) && content.length === 0) {
return {
kind: "flat",
content: [],
};
}
const templatizeAsNeeded = (rows: RawType[][]): TableRow[] =>
rows.map((row) => ({
...(keyBy ? { key: keyBy(row) } : {}),
content: row.map((item) => (typeof item === "object" ? item : html`${item}`)),
}));
if (groupBy) {
const groupedContent = groupByProcessor(content, groupBy);
return {
kind: "groups",
content: groupedContent.map(([group, rowsForGroup]) => ({
kind: "group",
group,
content: templatizeAsNeeded(rowsForGroup),
})),
};
}
return {
kind: "flat",
content: templatizeAsNeeded(content),
};
}

View File

@ -1,11 +1,11 @@
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet.js";
import { $, expect } from "@wdio/globals";
import { TemplateResult, html, render as litRender } from "lit";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import AKGlobal from "../../../common/styles/authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { ensureCSSStyleSheet } from "../../utils/ensureCSSStyleSheet.js";
import "../AggregateCard.js";
const render = (body: TemplateResult) => {

View File

@ -1,11 +1,11 @@
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet.js";
import { $, expect } from "@wdio/globals";
import { TemplateResult, html, render as litRender } from "lit";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import AKGlobal from "../../../common/styles/authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { ensureCSSStyleSheet } from "../../utils/ensureCSSStyleSheet.js";
import "../AggregatePromiseCard.js";
const render = (body: TemplateResult) => {

View File

@ -1,11 +1,11 @@
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet.js";
import { $, expect } from "@wdio/globals";
import { TemplateResult, html, render as litRender } from "lit";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import AKGlobal from "../../../common/styles/authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { ensureCSSStyleSheet } from "../../utils/ensureCSSStyleSheet.js";
import { QuickAction } from "../QuickActionsCard.js";
import "../QuickActionsCard.js";

View File

@ -97,12 +97,14 @@ describe("Search select: Test Input Field", () => {
});
afterEach(async () => {
document.body.querySelector("#a-separate-component")?.remove();
document.body.querySelector("ak-search-select-view")?.remove();
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
if (document.body["_$litPart$"]) {
await browser.execute(() => {
document.body.querySelector("#a-separate-component")?.remove();
document.body.querySelector("ak-search-select-view")?.remove();
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
delete document.body["_$litPart$"];
}
if (document.body["_$litPart$"]) {
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
delete document.body["_$litPart$"];
}
});
});
});

View File

@ -1,7 +1,4 @@
/* eslint-env jest */
import { AKElement } from "@goauthentik/elements/Base";
import { bound } from "@goauthentik/elements/decorators/bound.js";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { $, browser, expect } from "@wdio/globals";
import { slug } from "github-slugger";
@ -9,6 +6,9 @@ import { html, render } from "lit";
import { customElement } from "lit/decorators.js";
import { property, query } from "lit/decorators.js";
import { AKElement } from "../../../../elements/Base.js";
import { bound } from "../../../../elements/decorators/bound.js";
import { CustomListenerElement } from "../../../../elements/utils/eventEmitter";
import "../ak-search-select.js";
import { SearchSelect } from "../ak-search-select.js";
import { type ViewSample, sampleData } from "../stories/sampleData.js";
@ -103,11 +103,13 @@ describe("Search select: event driven startup", () => {
});
afterEach(async () => {
await document.body.querySelector("ak-mock-search-group")?.remove();
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
if (document.body["_$litPart$"]) {
await browser.execute(() => {
document.body.querySelector("ak-mock-search-group")?.remove();
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
delete document.body["_$litPart$"];
}
if (document.body["_$litPart$"]) {
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
delete document.body["_$litPart$"];
}
});
});
});