diff --git a/frontend/custom-elements-manifest.config.mjs b/frontend/custom-elements-manifest.config.mjs index 519cd4f61b..bef8676f2d 100644 --- a/frontend/custom-elements-manifest.config.mjs +++ b/frontend/custom-elements-manifest.config.mjs @@ -1,8 +1,8 @@ export default { /** Globs to analyze */ - globs: ["src/**/*.ts"], + globs: ["src/components/**/*.ts", "src/features/**/*.ts"], /** Globs to exclude */ - exclude: ["__generated__", "__mocks__"], + exclude: ["src/**/*.stories.ts"], /** Directory to output CEM to */ outdir: "src/__generated__", /** Run in dev mode, provides extra logging */ @@ -15,4 +15,33 @@ export default { packagejson: false, /** Enable special handling for litelement */ litelement: true, + /** Provide custom plugins */ + plugins: [filterPrivateFields()], }; + +// Filter private fields +// Based on https://github.com/storybookjs/storybook/issues/15436#issuecomment-1856333227 +function filterPrivateFields() { + return { + name: "web-components-private-fields-filter", + analyzePhase({ ts, node, moduleDoc }) { + switch (node.kind) { + case ts.SyntaxKind.ClassDeclaration: { + const className = node.name.getText(); + const classDoc = moduleDoc?.declarations?.find( + (declaration) => declaration.name === className, + ); + + if (classDoc?.members) { + // Filter both private and static members + // TODO May be able to avoid some of this with `#` private member prefix + // https://github.com/webrecorder/browsertrix/issues/2563 + classDoc.members = classDoc.members.filter( + (member) => !member.privacy && !member.static, + ); + } + } + } + }, + }; +} diff --git a/frontend/package.json b/frontend/package.json index 05072b9e19..248ca68eaa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -79,6 +79,7 @@ "replaywebpage": "^2.2.4", "slugify": "^1.6.6", "style-loader": "^3.3.0", + "tabbable": "^6.2.0", "tailwindcss": "^3.4.1", "terser-webpack-plugin": "^5.3.10", "thread-loader": "^4.0.4", diff --git a/frontend/src/__mocks__/api/orgs/[id].js b/frontend/src/__mocks__/api/orgs/[id].js new file mode 100644 index 0000000000..93b86b380d --- /dev/null +++ b/frontend/src/__mocks__/api/orgs/[id].js @@ -0,0 +1,156 @@ +// API v1.15.0 +export default { + id: "x_example_org_id_x", + name: "Example Org", + slug: "example-org", + users: { + "alice@example.com": { + role: 40, + name: "Alice", + email: "alice@example.com", + }, + "bob@example.com": { + role: 20, + name: "Bob", + email: "bob@example.com", + }, + "carol@example.com": { + role: 20, + name: "Carol", + email: "carol@example.com", + }, + "dave@example.com": { + role: 10, + name: "Dave", + email: "dave@example.com", + }, + "eve@example.com": { + role: 10, + name: "Eve", + email: "eve@example.com", + }, + }, + created: "2023-11-08T17:19:23Z", + default: false, + bytesStored: 22143878904, + bytesStoredCrawls: 22023942070, + bytesStoredUploads: 38872471, + bytesStoredProfiles: 81064363, + origin: null, + storageQuotaReached: false, + execMinutesQuotaReached: false, + usage: { + "2023-11": 473, + "2023-12": 1273, + "2024-01": 4752, + "2024-03": 26, + "2024-04": 398, + "2024-05": 3030, + "2024-06": 2628, + "2024-07": 1655, + "2024-08": 1289, + "2024-10": 308, + "2025-04": 5723, + }, + crawlExecSeconds: { + "2023-11": 760, + "2023-12": 1771, + "2024-01": 4939, + "2024-03": 14, + "2024-04": 253, + "2024-05": 2958, + "2024-06": 4276, + "2024-07": 2066, + "2024-08": 2250, + "2024-10": 233, + "2025-01": 1, + "2025-04": 5688, + }, + qaUsage: { + "2024-04": 214, + "2024-05": 1526, + "2024-06": 894, + "2024-08": 1188, + "2024-10": 314, + "2025-01": 166, + }, + qaCrawlExecSeconds: { + "2024-04": 174, + "2024-05": 1484, + "2024-06": 1914, + "2024-08": 3131, + "2024-10": 943, + "2025-01": 366, + }, + monthlyExecSeconds: { "2023-11": 760, "2023-12": 1771, "2024-01": 3000 }, + extraExecSeconds: {}, + giftedExecSeconds: {}, + extraExecSecondsAvailable: 0, + giftedExecSecondsAvailable: 0, + quotas: { + storageQuota: 100000000000, + maxExecMinutesPerMonth: 0, + maxConcurrentCrawls: 10, + maxPagesPerCrawl: 0, + extraExecMinutes: 0, + giftedExecMinutes: 0, + }, + quotaUpdates: [ + { + modified: "2024-01-18T07:16:22.534000Z", + update: { + storageQuota: 1000000000000, + maxExecMinutesPerMonth: 0, + maxConcurrentCrawls: 10, + maxPagesPerCrawl: 0, + extraExecMinutes: 0, + giftedExecMinutes: 0, + }, + }, + { + modified: "2024-07-17T15:36:45Z", + update: { + storageQuota: 10000000000, + maxExecMinutesPerMonth: 0, + maxConcurrentCrawls: 10, + maxPagesPerCrawl: 0, + extraExecMinutes: 0, + giftedExecMinutes: 0, + }, + }, + { + modified: "2024-07-17T15:39:26Z", + update: { + storageQuota: 100000000000, + maxExecMinutesPerMonth: 0, + maxConcurrentCrawls: 10, + maxPagesPerCrawl: 0, + extraExecMinutes: 0, + giftedExecMinutes: 0, + }, + }, + ], + webhookUrls: { + crawlStarted: null, + crawlFinished: null, + crawlDeleted: null, + qaAnalysisStarted: null, + qaAnalysisFinished: null, + crawlReviewed: null, + uploadFinished: null, + uploadDeleted: null, + addedToCollection: null, + removedFromCollection: null, + collectionDeleted: null, + }, + readOnly: false, + readOnlyReason: "", + subscription: null, + allowSharedProxies: false, + allowedProxies: ["nz-proxy-1"], + crawlingDefaults: null, + lastCrawlFinished: "2025-04-02T23:00:50Z", + enablePublicProfile: false, + publicDescription: "This is an example org.", + publicUrl: "https://example.com", +}; diff --git a/frontend/src/components/ui/data-grid/cellDirective.ts b/frontend/src/components/ui/data-grid/cellDirective.ts new file mode 100644 index 0000000000..615b1e8ee5 --- /dev/null +++ b/frontend/src/components/ui/data-grid/cellDirective.ts @@ -0,0 +1,29 @@ +import { Directive, type PartInfo } from "lit/directive.js"; + +import type { DataGridCell } from "./data-grid-cell"; +import type { GridColumn } from "./types"; + +/** + * Directive for replacing `renderCell` and `renderEditCell` + * methods with custom render functions. + */ +export class CellDirective extends Directive { + private readonly element?: DataGridCell; + + constructor(partInfo: PartInfo & { element?: DataGridCell }) { + super(partInfo); + this.element = partInfo.element; + } + + render(col: GridColumn) { + if (!this.element) return; + + if (col.renderCell) { + this.element.renderCell = col.renderCell; + } + + if (col.renderEditCell) { + this.element.renderEditCell = col.renderEditCell; + } + } +} diff --git a/frontend/src/components/ui/data-grid/controllers/focus.ts b/frontend/src/components/ui/data-grid/controllers/focus.ts new file mode 100644 index 0000000000..78957c7a7d --- /dev/null +++ b/frontend/src/components/ui/data-grid/controllers/focus.ts @@ -0,0 +1,135 @@ +import type { ReactiveController } from "lit"; +import { + focusable, + isFocusable, + isTabbable, + tabbable, + type FocusableElement, +} from "tabbable"; + +import type { DataGridCell } from "../data-grid-cell"; +import type { DataGridRow } from "../data-grid-row"; + +type Options = { + /** + * Set focus on first non-input item according to + * tabindex, rather than DOM order. + */ + setFocusOnTabbable?: boolean; +}; + +/** + * Utilities for managing focus in a data grid. + */ +export class DataGridFocusController implements ReactiveController { + readonly #host: DataGridRow | DataGridCell; + + constructor( + host: DataGridRow | DataGridCell, + opts: Options = { + setFocusOnTabbable: false, + }, + ) { + this.#host = host; + host.addController(this); + + this.#host.addEventListener( + "focus", + () => { + if (!this.#host.matches(":focus-visible")) { + // Only handle focus on keyboard tabbing + return; + } + + const el = opts.setFocusOnTabbable + ? this.firstTabbable + : this.firstFocusable; + + if (el) { + if (this.isFocusableInput(el)) { + this.#host.addEventListener("keydown", this.#onFocusForEl(el), { + once: true, + capture: true, + }); + } else { + el.focus(); + } + } + }, + { passive: true, capture: true }, + ); + } + + hostConnected() {} + hostDisconnected() {} + + /** + * Focusable elements in DOM order. This will include + * all focusable elements, including elements with `tabindex="1"`. + */ + public get focusable() { + return focusable(this.#host, { + getShadowRoot: true, + }); + } + + /** + * Focusable elements in `tabindex` order. + */ + public get tabbable() { + return tabbable(this.#host, { + getShadowRoot: true, + }); + } + + public get firstFocusable(): FocusableElement | undefined { + return this.focusable[0]; + } + + public get firstTabbable(): FocusableElement | undefined { + return this.tabbable[0]; + } + + public isFocusable(el: Element) { + return isFocusable(el); + } + + public isTabbable(el: Element) { + return isTabbable(el); + } + + public isFocusableInput(el: Element) { + // TODO Handle `<sl-select>`/`<sl-option>` + return el.tagName === "INPUT" && this.isFocusable(el); + } + + /** + * Based on recommendations from + * https://www.w3.org/WAI/ARIA/apg/patterns/grid/#keyboardinteraction-settingfocusandnavigatinginsidecells + */ + readonly #onFocusForEl = (el: FocusableElement) => (e: KeyboardEvent) => { + const { key } = e; + + switch (key) { + case "Tab": { + // Prevent entering cell + e.preventDefault(); + break; + } + case "Enter": { + e.preventDefault(); + + // Enter cell and focus on input + el.focus(); + break; + } + default: { + if (key.length === 1) { + // Enter cell and focus on input + el.focus(); + } + break; + } + } + }; +} diff --git a/frontend/src/components/ui/data-grid/controllers/rows.ts b/frontend/src/components/ui/data-grid/controllers/rows.ts new file mode 100644 index 0000000000..fdb5ceed0b --- /dev/null +++ b/frontend/src/components/ui/data-grid/controllers/rows.ts @@ -0,0 +1,96 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import { nanoid } from "nanoid"; +import type { EmptyObject } from "type-fest"; + +import type { DataGrid } from "../data-grid"; +import type { GridItem, GridRowId, GridRows } from "../types"; + +import { cached } from "@/utils/weakCache"; + +/** + * Enables removing and adding rows from a grid. + * + * Implementing this controller isn't necessary if the `.items` property + * is specified in `<btrix-data-grid>`. For grids with editable rows + * that are slotted into `<btrix-data-grid>`, it may be necessary to + * implement this controller on the container component. + */ +export class DataGridRowsController implements ReactiveController { + readonly #host: ReactiveControllerHost & + EventTarget & { + items?: GridItem[]; + rowKey?: DataGrid["rowKey"]; + defaultItem?: DataGrid["defaultItem"]; + removeRows?: DataGrid["removeRows"]; + addRows?: DataGrid["addRows"]; + }; + + #prevItems?: GridItem[]; + + public rows: GridRows<GridItem> = new Map<GridRowId, GridItem>(); + + constructor(host: ReactiveControllerHost & EventTarget) { + this.#host = host; + host.addController(this); + } + + hostConnected() { + if (this.#host.items) { + this.setItems(this.#host.items); + } + } + hostDisconnected() {} + hostUpdate() { + if (this.#host.items) { + this.setItems(this.#host.items); + } + } + + private setRowsFromItems<T extends GridItem = GridItem>(items: T[]) { + const rowKey = this.#host.rowKey; + + this.rows = new Map( + this.#host.rowKey + ? items.map((item) => [ + item[rowKey as unknown as string] as GridRowId, + item, + ]) + : items.map( + cached((item) => [nanoid(), item], { cacheConstructor: Map }), + ), + ); + } + + public setItems<T extends GridItem = GridItem>(items: T[]) { + if (!this.#prevItems || items !== this.#prevItems) { + this.setRowsFromItems(items); + + // this.#host.requestUpdate(); + + this.#prevItems = items; + } + } + + public addRows<T extends GridItem = GridItem>( + defaultItem: T | EmptyObject = {}, + count = 1, + ) { + for (let i = 0; i < count; i++) { + const id = nanoid(); + + this.rows.set(id, defaultItem); + } + + this.#host.requestUpdate(); + } + + public removeRow(id: GridRowId) { + this.rows.delete(id); + + if (this.rows.size === 0 && this.#host.defaultItem) { + this.addRows(this.#host.defaultItem); + } + + this.#host.requestUpdate(); + } +} diff --git a/frontend/src/components/ui/data-grid/data-grid-cell.ts b/frontend/src/components/ui/data-grid/data-grid-cell.ts new file mode 100644 index 0000000000..acbe1abeae --- /dev/null +++ b/frontend/src/components/ui/data-grid/data-grid-cell.ts @@ -0,0 +1,215 @@ +import type { SlInput, SlSelect } from "@shoelace-style/shoelace"; +import clsx from "clsx"; +import { html, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { TableCell } from "../table/table-cell"; + +import type { GridColumn, GridColumnSelectType, GridItem } from "./types"; +import { GridColumnType } from "./types"; + +import { DataGridFocusController } from "@/components/ui/data-grid/controllers/focus"; +import type { UrlInput } from "@/components/ui/url-input"; +import { tw } from "@/utils/tailwind"; + +const cellInputStyle = [ + tw`size-full [--sl-input-background-color-hover:transparent] [--sl-input-background-color:transparent] [--sl-input-border-radius-medium:0] [--sl-input-spacing-medium:var(--sl-spacing-small)] focus:z-10`, + // TODO We need to upgrade to Tailwind v4 for inset rings to actually work + // tw`focus-within:part-[base]:inset-ring-2`, + tw`data-[invalid]:[--sl-input-border-color:transparent] data-[valid]:[--sl-input-border-color:transparent]`, + tw`part-[form-control-help-text]:mx-1 part-[form-control-help-text]:mb-1`, + tw`part-[base]:h-full part-[form-control-input]:h-full part-[form-control]:h-full part-[input]:h-full`, + tw`part-[input]:px-[var(--sl-spacing-x-small)]`, +]; + +export type InputElement = SlInput | SlSelect | UrlInput; + +export type CellEditEventDetail = { + field: GridColumn["field"]; + value: InputElement["value"]; + validity: InputElement["validity"]; + validationMessage: InputElement["validationMessage"]; +}; + +/** + * @fires btrix-input CustomEvent + * @fires btrix-change CustomEvent + */ +@customElement("btrix-data-grid-cell") +export class DataGridCell extends TableCell { + @property({ type: Object }) + column?: GridColumn; + + @property({ type: Object }) + item?: GridItem; + + @property({ type: Boolean }) + editable = false; + + @property({ type: String, reflect: true, noAccessor: true }) + role = "gridcell"; + + @property({ attribute: false }) + customRenderCell?: () => TemplateResult; + + @property({ attribute: false }) + customRenderEditCell?: () => TemplateResult; + + @property({ type: Number, reflect: true }) + tabindex = 0; + + readonly #focus = new DataGridFocusController(this, { + setFocusOnTabbable: true, + }); + + public checkValidity() { + return this.input?.checkValidity(); + } + + public get validity() { + return this.input?.validity; + } + + public get validationMessage() { + return this.input?.validationMessage; + } + + public get input() { + if (!this.column) return null; + + return this.shadowRoot!.querySelector<InputElement>( + `[name=${this.column.field}]`, + ); + } + + protected createRenderRoot() { + const root = super.createRenderRoot(); + const inputEvents = ["btrix-input", "sl-input"]; + const changeEvents = ["btrix-change", "sl-change"]; + + // Attach to render root so that `e.target` is input + inputEvents.forEach((name) => { + root.addEventListener(name, this.onInput); + }); + + changeEvents.forEach((name) => { + root.addEventListener(name, this.onChange); + }); + + return root; + } + + render() { + if (!this.column || !this.item) return html`<slot></slot>`; + + if (this.editable) { + return this.renderEditCell({ item: this.item }); + } + + return this.renderCell({ item: this.item }); + } + + renderCell = ({ item }: { item: GridItem }) => { + return html`${(this.column && item[this.column.field]) ?? ""}`; + }; + + renderEditCell = ({ item }: { item: GridItem }) => { + const col = this.column; + + if (!col) return html``; + + const value = item[col.field] ?? ""; + + switch (col.inputType) { + case GridColumnType.Select: { + return html` + <div class="box-border w-full p-1"> + <sl-select + name=${col.field} + value=${value} + placeholder=${ifDefined(col.inputPlaceholder)} + class="w-full min-w-[5em]" + size="small" + ?required=${col.required} + hoist + > + ${(col as GridColumnSelectType).selectOptions.map( + (opt) => html` + <sl-option value=${opt.value}> + ${opt.label ?? opt.value} + </sl-option> + `, + )} + </sl-select> + </div> + `; + } + case GridColumnType.URL: + return html`<btrix-url-input + name=${col.field} + class=${clsx(cellInputStyle)} + value=${value} + placeholder=${ifDefined(col.inputPlaceholder)} + ?required=${col.required} + hideHelpText + > + </btrix-url-input>`; + default: + break; + } + + return html` + <sl-input + name=${col.field} + class=${clsx(cellInputStyle)} + type=${col.inputType === GridColumnType.Number ? "number" : "text"} + value=${value} + placeholder=${ifDefined(col.inputPlaceholder)} + ?required=${col.required} + ></sl-input> + `; + }; + + private readonly onInput = (e: Event) => { + if (!this.column) return; + + e.stopPropagation(); + + const input = e.target as InputElement; + + this.dispatchEvent( + new CustomEvent<CellEditEventDetail>("btrix-input", { + detail: { + field: this.column.field, + value: input.value, + validity: input.validity, + validationMessage: input.validationMessage, + }, + bubbles: true, + composed: true, + }), + ); + }; + + private readonly onChange = (e: Event) => { + if (!this.column) return; + + e.stopPropagation(); + + const input = e.target as InputElement; + + this.dispatchEvent( + new CustomEvent<CellEditEventDetail>("btrix-change", { + detail: { + field: this.column.field, + value: input.value, + validity: input.validity, + validationMessage: input.validationMessage, + }, + bubbles: true, + composed: true, + }), + ); + }; +} diff --git a/frontend/src/components/ui/data-grid/data-grid-row.ts b/frontend/src/components/ui/data-grid/data-grid-row.ts new file mode 100644 index 0000000000..73b01e39a4 --- /dev/null +++ b/frontend/src/components/ui/data-grid/data-grid-row.ts @@ -0,0 +1,384 @@ +import { localized, msg } from "@lit/localize"; +import clsx from "clsx"; +import { html, type PropertyValues } from "lit"; +import { customElement, property, queryAll, state } from "lit/decorators.js"; +import { directive } from "lit/directive.js"; +import isEqual from "lodash/fp/isEqual"; + +import { CellDirective } from "./cellDirective"; +import type { + CellEditEventDetail, + DataGridCell, + InputElement, +} from "./data-grid-cell"; +import type { GridColumn, GridItem, GridRowId } from "./types"; + +import { DataGridFocusController } from "@/components/ui/data-grid/controllers/focus"; +import { TableRow } from "@/components/ui/table/table-row"; +import { tw } from "@/utils/tailwind"; + +export type RowRemoveEventDetail = { + key?: string; +}; + +const cell = directive(CellDirective); + +const cellStyle = tw`focus-visible:-outline-offset-2`; +const editableCellStyle = tw`p-0 focus-visible:bg-slate-50 `; + +/** + * @fires btrix-remove CustomEvent + */ +@customElement("btrix-data-grid-row") +@localized() +export class DataGridRow extends TableRow { + // TODO Abstract to mixin or decorator + // https://github.com/webrecorder/browsertrix/issues/2577 + static formAssociated = true; + readonly #internals: ElementInternals; + + /** + * Set of columns. + */ + @property({ type: Array }) + columns?: GridColumn[] = []; + + /** + * Row key/ID. + */ + @property({ type: String }) + key?: GridRowId; + + /** + * Data to be presented as a row. + */ + @property({ type: Object, hasChanged: (a, b) => !isEqual(a, b) }) + item?: GridItem; + + /** + * Whether the row can be removed. + */ + @property({ type: Boolean }) + removable = false; + + /** + * Whether cells can be edited. + */ + @property({ type: Boolean }) + editCells = false; + + /** + * Form control name, if used in a form. + */ + @property({ type: String, reflect: true }) + name?: string; + + /** + * Make row focusable on validation. + */ + @property({ type: Number, reflect: true }) + tabindex = 0; + + @state() + private cellValues: Partial<GridItem> = {}; + + readonly #focus = new DataGridFocusController(this); + + readonly #invalidInputsMap = new Map< + GridColumn["field"], + InputElement["validationMessage"] + >(); + + public formAssociatedCallback() { + console.debug("form associated"); + } + + public formResetCallback() { + this.setValue(this.item || {}); + this.commitValue(); + } + + public formDisabledCallback(disabled: boolean) { + console.debug("form disabled:", disabled); + } + + public formStateRestoreCallback(state: string | FormData, reason: string) { + console.debug("formStateRestoreCallback:", state, reason); + } + + public checkValidity(): boolean { + return this.#internals.checkValidity(); + } + + public reportValidity(): void { + this.#internals.reportValidity(); + } + + public get validity(): ValidityState { + return this.#internals.validity; + } + + public get validationMessage(): string { + return this.#internals.validationMessage; + } + + constructor() { + super(); + this.#internals = this.attachInternals(); + } + + protected createRenderRoot() { + const root = super.createRenderRoot(); + + // Attach to render root so that `e.target` is table cell + root.addEventListener( + "btrix-input", + (e) => void this.onCellInput(e as CustomEvent<CellEditEventDetail>), + ); + root.addEventListener( + "btrix-change", + (e) => void this.onCellChange(e as CustomEvent<CellEditEventDetail>), + ); + + return root; + } + + protected willUpdate(changedProperties: PropertyValues): void { + if ( + (changedProperties.has("item") || changedProperties.has("editCells")) && + this.item && + this.editCells + ) { + this.setValue(this.item); + this.commitValue(); + } + } + + @queryAll("btrix-data-grid-cell") + private readonly gridCells?: NodeListOf<DataGridCell>; + + private setValue(cellValues: Partial<GridItem>) { + Object.keys(cellValues).forEach((field) => { + this.cellValues[field] = cellValues[field]; + }); + + this.#internals.setFormValue(JSON.stringify(this.cellValues)); + } + + private commitValue() { + this.cellValues = { + ...this.cellValues, + }; + } + + render() { + if (!this.columns?.length) return html``; + + let removeCell = html``; + + if (this.removable) { + removeCell = html` + <btrix-data-grid-cell + class=${clsx(tw`border-l p-0`, cellStyle)} + @keydown=${this.onKeydown} + > + <sl-tooltip content=${msg("Remove")} hoist> + <sl-icon-button + class="p-1 text-base hover:text-danger" + name="trash3" + @click=${() => + this.dispatchEvent( + new CustomEvent<RowRemoveEventDetail>("btrix-remove", { + detail: { + key: this.key, + }, + bubbles: true, + composed: true, + }), + )} + ></sl-icon-button> + </sl-tooltip> + </btrix-data-grid-cell> + `; + } + + return html`${this.columns.map(this.renderCell)}${removeCell}`; + } + + private readonly renderCell = (col: GridColumn, i: number) => { + const validationMessage = this.#invalidInputsMap.get(col.field); + const editable = this.editCells && col.editable; + + return html` + <sl-tooltip + ?disabled=${!validationMessage} + content=${validationMessage || ""} + hoist + placement="bottom" + trigger=${ + // Manually show/hide tooltip on blur/focus + "manual" + } + > + <btrix-data-grid-cell + class=${clsx( + i > 0 && tw`border-l`, + cellStyle, + editable && editableCellStyle, + )} + .column=${col} + .item=${this.item} + ?editable=${editable} + ${cell(col)} + @keydown=${this.onKeydown} + @focus=${(e: CustomEvent) => { + e.stopPropagation(); + + const tableCell = e.target as DataGridCell; + const tooltip = tableCell.closest("sl-tooltip"); + + if (tooltip?.open) { + void tooltip.hide(); + } + }} + @blur=${(e: CustomEvent) => { + e.stopPropagation(); + + const tableCell = e.target as DataGridCell; + const tooltip = tableCell.closest("sl-tooltip"); + + if (tooltip && !tooltip.disabled) { + void tooltip.show(); + } + }} + ></btrix-data-grid-cell> + </sl-tooltip> + `; + }; + + /** + * Keyboard navigation based on recommendations from + * https://www.w3.org/WAI/ARIA/apg/patterns/grid/#keyboardinteraction-settingfocusandnavigatinginsidecells + */ + private onKeydown(e: KeyboardEvent) { + const tableCell = e.currentTarget as DataGridCell; + const composedTarget = e.composedPath()[0] as HTMLElement; + + if (composedTarget === tableCell) { + if (!this.gridCells) { + console.debug("no grid cells"); + return; + } + + const gridCells = Array.from(this.gridCells); + const i = gridCells.indexOf(e.target as DataGridCell); + + if (i === -1) return; + + const findNextTabbable = (idx: number, direction: -1 | 1) => { + const el = gridCells[idx + direction]; + + if (!(el as unknown)) return; + + if (this.#focus.isTabbable(el)) { + e.preventDefault(); + + el.focus(); + } else { + findNextTabbable(idx + direction, direction); + } + }; + + switch (e.key) { + case "ArrowRight": + case "ArrowDown": { + findNextTabbable(i, 1); + break; + } + case "ArrowLeft": + case "ArrowUp": { + findNextTabbable(i, -1); + break; + } + case "Tab": { + // Check if tabbing was prevented, likely by the focus controller + if (e.defaultPrevented) { + findNextTabbable(i, 1); + } + break; + } + default: + break; + } + } else { + if (e.key === "Escape") { + const tabIndex = composedTarget.tabIndex; + + // Temporarily disable focusable child so that focus + // doesn't move when exiting + composedTarget.setAttribute("tabindex", "-1"); + // Exit back into grid navigation + tableCell.focus(); + // Reinstate focusable child + composedTarget.setAttribute("tabindex", `${tabIndex}`); + } + } + } + + private readonly onCellInput = async ( + e: CustomEvent<CellEditEventDetail>, + ) => { + e.stopPropagation(); + + const { field, value, validity, validationMessage } = e.detail; + const tableCell = e.target as DataGridCell; + + if (validity.valid) { + this.#invalidInputsMap.delete(field); + } else { + this.#invalidInputsMap.set(field, validationMessage); + this.#internals.setValidity(validity, validationMessage, tableCell); + } + + this.setValue({ + [field]: value.toString(), + }); + }; + + private readonly onCellChange = async ( + e: CustomEvent<CellEditEventDetail>, + ) => { + e.stopPropagation(); + + const { field, validity, validationMessage } = e.detail; + const tableCell = e.target as DataGridCell; + + if (validity.valid) { + this.#invalidInputsMap.delete(field); + } else { + this.#invalidInputsMap.set(field, validationMessage); + this.#internals.setValidity(validity, validationMessage, tableCell); + } + + this.commitValue(); + + await this.updateComplete; + await tableCell.input?.updateComplete; + + if (validity.valid) { + const firstInvalid = Array.from(this.gridCells || []).find((cell) => + cell.validity?.valid ? false : cell, + ); + + if (firstInvalid?.validity && firstInvalid.validationMessage) { + this.#internals.setValidity( + firstInvalid.validity, + firstInvalid.validationMessage, + firstInvalid, + ); + } else { + this.#internals.setValidity({}); + } + } + }; +} diff --git a/frontend/src/components/ui/data-grid/data-grid.ts b/frontend/src/components/ui/data-grid/data-grid.ts new file mode 100644 index 0000000000..49c5055be7 --- /dev/null +++ b/frontend/src/components/ui/data-grid/data-grid.ts @@ -0,0 +1,319 @@ +import { localized, msg } from "@lit/localize"; +import type { SlChangeEvent, SlInput } from "@shoelace-style/shoelace"; +import clsx from "clsx"; +import { css, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { nanoid } from "nanoid"; +import type { EmptyObject } from "type-fest"; + +import { DataGridRowsController } from "./controllers/rows"; +import type { DataGridRow, RowRemoveEventDetail } from "./data-grid-row"; +import { renderRows } from "./renderRows"; +import type { GridColumn, GridItem } from "./types"; + +import { TailwindElement } from "@/classes/TailwindElement"; +import { pluralOf } from "@/utils/pluralize"; +import { tw } from "@/utils/tailwind"; + +/** + * Data grids structure data into rows and and columns. + * + * @slot label + * @slot rows + * @fires btrix-change + * @fires btrix-remove + */ +@customElement("btrix-data-grid") +@localized() +export class DataGrid extends TailwindElement { + static styles = css` + :host { + --border: 1px solid var(--sl-panel-border-color); + } + + btrix-data-grid-row:not(:first-of-type), + btrix-table-body ::slotted(*:nth-of-type(n + 2)) { + border-top: var(--border) !important; + } + + btrix-data-grid-row, + btrix-table-body ::slotted(btrix-data-grid-row) { + /* TODO Support different input sizes */ + min-height: calc(var(--sl-input-height-medium) + 1px); + } + `; + + /** + * Set of columns. + */ + @property({ type: Array }) + columns?: GridColumn[]; + + /** + * Set of data to be presented as rows. Omit if using the `rows` slot. + */ + @property({ type: Array }) + items?: GridItem[]; + + /** + * Stick header row to the top of the viewport. + */ + @property({ type: Boolean }) + stickyHeader = false; + + /** + * Item key to use as the row key, like an ID. + * Defaults to one generated by nanoid. + */ + @property({ type: String }) + rowKey?: string; + + /** + * Whether rows can be removed. + */ + @property({ type: Boolean }) + removeRows = false; + + /** + * Whether rows can be added. + */ + @property({ type: Boolean }) + addRows = false; + + /** + * Make the number of rows being added configurable, + * with a default starting value. + */ + @property({ type: Number }) + addRowsInputValue?: number; + + /** + * Whether cells can be edited. + */ + @property({ type: Boolean }) + editCells = false; + + /** + * Disable an editable grid. + */ + @property({ type: Boolean }) + disabled?: boolean; + + /** + * Default item for new rows. + */ + @property({ type: Object }) + defaultItem?: EmptyObject | GridItem = {}; + + /** + * Text for form control label. Use slot to include markup. + */ + @property({ type: String }) + formControlLabel?: string; + + /** + * ID for form control label. + */ + @property({ type: String }) + formControlLabelId = `label-${nanoid()}`; + + /** + * Optional external controller for removing and adding rows, + * if rendering rows into the `rows` slot. + */ + @property({ attribute: false }) + rowsController = new DataGridRowsController(this); + + /** + * Make grid focusable on validation. + */ + @property({ type: Number, reflect: true }) + tabindex = 0; + + render() { + if (!this.columns?.length) return; + + const cssWidths = this.columns.map((col) => col.width ?? "1fr"); + + return html` + <slot name="label"> + <label id=${this.formControlLabelId} class="form-label text-xs"> + ${this.formControlLabel} + </label> + </slot> + + <btrix-table + role="grid" + class=${clsx( + tw`relative size-full overflow-auto`, + this.stickyHeader && tw`rounded border`, + )} + style="--btrix-table-grid-template-columns: ${cssWidths.join(" ")}${this + .removeRows + ? " max-content" + : ""}" + aria-labelledby=${ifDefined( + (this.formControlLabel && this.formControlLabelId) ?? undefined, + )} + aria-readonly=${ifDefined(this.disabled)} + > + <btrix-table-head + class=${clsx( + tw`[--btrix-table-cell-padding:var(--sl-spacing-x-small)]`, + this.stickyHeader + ? tw`sticky top-0 z-10 rounded-t-[0.1875rem] border-b bg-neutral-50 [&>*:not(:first-of-type)]:border-l` + : tw`px-px`, + )} + > + ${this.columns.map( + (col) => html` + <btrix-table-header-cell> + ${col.label} + ${col.description + ? html` + <sl-tooltip content=${col.description}> + <sl-icon + name="info-circle" + class="ml-1.5 align-[-.175em] text-sm text-slate-500" + ></sl-icon> + </sl-tooltip> + ` + : nothing} + </btrix-table-header-cell> + `, + )} + ${this.removeRows + ? html`<btrix-table-header-cell> + <span class="sr-only">${msg("Remove row")}</span> + </btrix-table-header-cell>` + : nothing} + </btrix-table-head> + <btrix-table-body + class=${clsx( + tw`[--btrix-table-cell-padding:var(--sl-spacing-x-small)]`, + tw`leading-none`, + !this.stickyHeader && tw`rounded border`, + )} + @btrix-remove=${(e: CustomEvent<RowRemoveEventDetail>) => { + const { key } = e.detail; + + if (key) { + this.rowsController.removeRow(key); + } else { + console.warn("Could not remove row without key or item"); + } + }} + > + ${this.renderRows()} + ${this.addRows && this.addRowsInputValue + ? html` + <btrix-table-row class="border-t"> + <btrix-table-cell class="col-span-full px-1"> + <!-- TODO Replace navigation button --> + <btrix-navigation-button + size="small" + @click=${() => + this.rowsController.addRows( + this.defaultItem, + this.addRowsInputValue, + )} + > + <sl-icon name="plus-lg"></sl-icon> + ${msg("Add")} + </btrix-navigation-button> + <btrix-inline-input + value=${this.addRowsInputValue} + min="1" + max="99" + minlength="1" + maxlength="2" + class="ml-1 w-10" + @sl-change=${(e: SlChangeEvent) => { + const input = e.target as SlInput; + const value = +input.value; + + this.addRowsInputValue = Math.max(1, value); + input.value = `${this.addRowsInputValue}`; + }} + ></btrix-inline-input> + <span class="ml-2.5 text-neutral-500"> + ${msg("more")} ${pluralOf("rows", this.addRowsInputValue)} + </span> + </btrix-table-cell> + </btrix-table-row> + ` + : nothing} + </btrix-table-body> + </btrix-table> + + ${this.addRows && !this.addRowsInputValue + ? this.renderAddButton() + : nothing} + `; + } + + private renderRows() { + return html` + <slot name="rows" class="contents" @slotchange=${this.onRowSlotChange}> + ${this.items + ? renderRows( + this.rowsController.rows, + ({ id, item }) => html` + <btrix-data-grid-row + key=${id} + .item=${item} + .columns=${this.columns} + ?removable=${this.removeRows} + ?editCells=${this.editCells} + ></btrix-data-grid-row> + `, + ) + : nothing} + </slot> + `; + } + + private readonly renderAddButton = () => { + return html`<footer class="mt-2"> + <sl-button + size="small" + class="w-full" + @click=${() => this.rowsController.addRows(this.defaultItem)} + > + <sl-icon slot="prefix" name="plus-lg"></sl-icon> + <span class="text-neutral-600">${msg("Add More")}</span> + </sl-button> + </footer>`; + }; + + private readonly onRowSlotChange = (e: Event) => { + const rows = (e.target as HTMLSlotElement).assignedElements(); + const assignProp = ( + el: Element, + { name, value }: { name: keyof DataGridRow; value: string | boolean }, + ) => { + if (el.attributes.getNamedItem(name)) return; + + if (typeof value === "boolean") { + if (value) { + el.setAttribute(name, "true"); + } else { + el.removeAttribute(name); + } + } else { + el.setAttribute(name, value); + } + }; + + const removable = this.removeRows; + const editCells = this.editCells; + + rows.forEach((el) => { + assignProp(el, { name: "removable", value: removable }); + assignProp(el, { name: "editCells", value: editCells }); + + (el as DataGridRow)["columns"] = this.columns; + }); + }; +} diff --git a/frontend/src/components/ui/data-grid/index.ts b/frontend/src/components/ui/data-grid/index.ts new file mode 100644 index 0000000000..8d7ac436c4 --- /dev/null +++ b/frontend/src/components/ui/data-grid/index.ts @@ -0,0 +1,3 @@ +import "./data-grid"; +import "./data-grid-cell"; +import "./data-grid-row"; diff --git a/frontend/src/components/ui/data-grid/renderRows.ts b/frontend/src/components/ui/data-grid/renderRows.ts new file mode 100644 index 0000000000..e3a436ac40 --- /dev/null +++ b/frontend/src/components/ui/data-grid/renderRows.ts @@ -0,0 +1,15 @@ +import { type TemplateResult } from "lit"; +import { repeat } from "lit/directives/repeat.js"; + +import type { GridItem, GridRowId, GridRows } from "./types"; + +export function renderRows<T = GridItem>( + rows: GridRows<GridItem>, + renderRow: ({ id, item }: { id: GridRowId; item: T }) => TemplateResult, +) { + return repeat( + rows, + ([id]) => id, + ([id, item]) => renderRow({ id, item: item as T }), + ); +} diff --git a/frontend/src/components/ui/data-grid/types.ts b/frontend/src/components/ui/data-grid/types.ts new file mode 100644 index 0000000000..c122529eec --- /dev/null +++ b/frontend/src/components/ui/data-grid/types.ts @@ -0,0 +1,45 @@ +import type { TemplateResult } from "lit"; +import { z } from "zod"; + +export type GridItem<T extends PropertyKey = string> = Record< + T, + string | number | null | undefined +>; + +export enum GridColumnType { + Text = "text", + Number = "number", + URL = "url", + // Syntax = "syntax", + Select = "select", +} + +export type GridColumnSelectType = { + inputType: GridColumnType.Select; + selectOptions: { + value: string; + label?: string | TemplateResult; + }[]; +}; + +export type GridColumn<T = string> = { + field: T; + label: string | TemplateResult; + description?: string; + editable?: boolean; + required?: boolean; + inputPlaceholder?: string; + width?: string; + renderEditCell?: ({ item }: { item: GridItem }) => TemplateResult<1>; + renderCell?: ({ item }: { item: GridItem }) => TemplateResult<1>; +} & ( + | { + inputType?: GridColumnType; + } + | GridColumnSelectType +); + +const rowIdSchema = z.string().nanoid(); +export type GridRowId = z.infer<typeof rowIdSchema>; + +export interface GridRows<T> extends Map<GridRowId, T> {} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index c7c9f96f61..91deadc6f4 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -16,6 +16,7 @@ import("./combobox"); import("./config-details"); import("./copy-button"); import("./copy-field"); +import("./data-grid"); import("./details"); import("./file-list"); import("./format-date"); diff --git a/frontend/src/components/ui/inline-input.ts b/frontend/src/components/ui/inline-input.ts index 315b5e7937..b4f566e88b 100644 --- a/frontend/src/components/ui/inline-input.ts +++ b/frontend/src/components/ui/inline-input.ts @@ -1,12 +1,27 @@ import SlInput from "@shoelace-style/shoelace/dist/components/input/input.js"; import { css } from "lit"; -import { customElement } from "lit/decorators.js"; +import { customElement, property } from "lit/decorators.js"; /** * Input to use inline with text. + * + * @attr value + * @attr max + * @attr min + * @attr maxlength + * @attr minlength */ @customElement("btrix-inline-input") export class InlineInput extends SlInput { + @property({ type: String, reflect: true }) + size: SlInput["size"] = "small"; + + @property({ type: String, reflect: true }) + inputmode: SlInput["inputmode"] = "numeric"; + + @property({ type: String, reflect: true }) + autocomplete: SlInput["autocomplete"] = "off"; + static styles = [ SlInput.styles, css` diff --git a/frontend/src/components/ui/syntax-input.ts b/frontend/src/components/ui/syntax-input.ts index 7be71679a6..a0ae4b6217 100644 --- a/frontend/src/components/ui/syntax-input.ts +++ b/frontend/src/components/ui/syntax-input.ts @@ -8,6 +8,7 @@ import clsx from "clsx"; import { html } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import type { EmptyObject } from "type-fest"; import { TailwindElement } from "@/classes/TailwindElement"; import type { Code } from "@/components/ui/code"; @@ -16,6 +17,10 @@ import { tw } from "@/utils/tailwind"; /** * Basic text input with code syntax highlighting * + * @TODO Refactor to use `ElementInternals` + * https://github.com/webrecorder/browsertrix/issues/2577 + * + * @fires btrix-input * @fires btrix-change */ @customElement("btrix-syntax-input") @@ -65,6 +70,10 @@ export class SyntaxInput extends TailwindElement { @query("btrix-code") private readonly code?: Code | null; + public get validity(): ValidityState | EmptyObject { + return this.input?.validity || {}; + } + public setCustomValidity(message: string) { this.input?.setCustomValidity(message); if (this.disableTooltip) { @@ -150,6 +159,13 @@ export class SyntaxInput extends TailwindElement { await this.code.updateComplete; + this.dispatchEvent( + new CustomEvent("btrix-input", { + detail: { value }, + bubbles: true, + }), + ); + void this.scrollSync({ pad: true }); } }} @@ -184,6 +200,7 @@ export class SyntaxInput extends TailwindElement { this.dispatchEvent( new CustomEvent("btrix-change", { detail: { value: this.code.value }, + bubbles: true, }), ); } diff --git a/frontend/src/components/ui/table/table-row.ts b/frontend/src/components/ui/table/table-row.ts index 043fcfe3fb..fea948593d 100644 --- a/frontend/src/components/ui/table/table-row.ts +++ b/frontend/src/components/ui/table/table-row.ts @@ -1,8 +1,10 @@ -import { css, html, LitElement } from "lit"; +import { css, html } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { TailwindElement } from "@/classes/TailwindElement"; + @customElement("btrix-table-row") -export class TableRow extends LitElement { +export class TableRow extends TailwindElement { static styles = css` :host { grid-column: 1 / -1; diff --git a/frontend/src/components/ui/table/table.ts b/frontend/src/components/ui/table/table.ts index 46b0be8376..f405ca98d1 100644 --- a/frontend/src/components/ui/table/table.ts +++ b/frontend/src/components/ui/table/table.ts @@ -19,6 +19,8 @@ tableCSS.split("}").forEach((rule: string) => { }); /** + * @deprecated Use `<btrix-data-grid>` instead. + * * Low-level component for displaying content into columns and rows. * To style tables, use TailwindCSS utility classes. * To render styled, tabular data, use `<btrix-data-table>`. diff --git a/frontend/src/components/ui/url-input.ts b/frontend/src/components/ui/url-input.ts index bc41bc0402..9b5c16e11d 100644 --- a/frontend/src/components/ui/url-input.ts +++ b/frontend/src/components/ui/url-input.ts @@ -29,6 +29,9 @@ export class UrlInput extends SlInput { @property({ type: String, reflect: true }) placeholder = "https://example.com"; + @property({ type: Boolean }) + hideHelpText = false; + constructor() { super(); @@ -48,7 +51,7 @@ export class UrlInput extends SlInput { private readonly onInput = () => { if (!this.checkValidity() && validURL(this.value)) { this.setCustomValidity(""); - this.helpText = ""; + if (!this.hideHelpText) this.helpText = ""; } }; @@ -57,7 +60,7 @@ export class UrlInput extends SlInput { if (value && !validURL(value)) { const text = msg("Please enter a valid URL."); - this.helpText = text; + if (!this.hideHelpText) this.helpText = text; this.setCustomValidity(text); } else if ( value && diff --git a/frontend/src/features/org/org-status-banner.ts b/frontend/src/features/org/org-status-banner.ts index c780b17b50..0999c6b976 100644 --- a/frontend/src/features/org/org-status-banner.ts +++ b/frontend/src/features/org/org-status-banner.ts @@ -157,7 +157,8 @@ export class OrgStatusBanner extends BtrixElement { }, }, { - test: () => !readOnly && readOnlyOnCancel && !!futureCancelDate, + test: () => + !readOnly && (readOnlyOnCancel ?? false) && !!futureCancelDate, content: () => { return { diff --git a/frontend/src/features/org/usage-history-table.ts b/frontend/src/features/org/usage-history-table.ts index 6780e04de3..135780114a 100644 --- a/frontend/src/features/org/usage-history-table.ts +++ b/frontend/src/features/org/usage-history-table.ts @@ -3,8 +3,19 @@ import { html } from "lit"; import { customElement } from "lit/decorators.js"; import { BtrixElement } from "@/classes/BtrixElement"; +import type { GridColumn, GridItem } from "@/components/ui/data-grid/types"; +import { noData } from "@/strings/ui"; import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; +enum Field { + Month = "month", + ElapsedTime = "elapsedTime", + ExecutionTime = "executionTime", + BillableExecutionTime = "billableExecutionTime", + RolloverExecutionTime = "rolloverExecutionTime", + GiftedExecutionTime = "giftedExecutionTime", +} + @customElement("btrix-usage-history-table") @localized() export class UsageHistoryTable extends BtrixElement { @@ -22,7 +33,10 @@ export class UsageHistoryTable extends BtrixElement { render() { if (!this.org) return; - if (this.org.usage && !Object.keys(this.org.usage).length) { + const org = this.org; + const usageEntries = Object.entries(org.usage || {}); + + if (!usageEntries.length) { return html` <p class="rounded border bg-neutral-50 p-3 text-center text-neutral-500" @@ -32,160 +46,129 @@ export class UsageHistoryTable extends BtrixElement { `; } - const usageTableCols = [ - msg("Month"), - html` - ${msg("Elapsed Time")} - <sl-tooltip> - <div slot="content" style="text-transform: initial"> - ${msg( - "Total duration of crawls and QA analysis runs, from start to finish", - )} - </div> - <sl-icon name="info-circle" style="vertical-align: -.175em"></sl-icon> - </sl-tooltip> - `, - html` - ${msg("Execution Time")} - <sl-tooltip> - <div slot="content" style="text-transform: initial"> - ${msg( - "Aggregated time across all browser windows that the crawler was actively executing a crawl or QA analysis run, i.e. not in a waiting state", - )} - </div> - <sl-icon name="info-circle" style="vertical-align: -.175em"></sl-icon> - </sl-tooltip> - `, + const cols: GridColumn<Field>[] = [ + { + field: Field.Month, + label: msg("Month"), + renderCell({ item }) { + return html`<btrix-format-date + date="${item.month}-15T00:00:00.000Z" + time-zone="utc" + month="long" + year="numeric" + > + </btrix-format-date>`; + }, + }, + { + field: Field.ElapsedTime, + label: msg("Elapsed Time"), + description: msg( + "Total duration of crawls and QA analysis runs, from start to finish", + ), + }, + { + field: Field.ExecutionTime, + label: msg("Execution Time"), + description: msg( + "Aggregated time across all browser windows that the crawler was actively executing a crawl or QA analysis run, i.e. not in a waiting state", + ), + }, ]; if (this.hasMonthlyTime()) { - usageTableCols.push( - html`${msg("Billable Execution Time")} - <sl-tooltip> - <div slot="content" style="text-transform: initial"> - ${msg( - "Execution time used that is billable to the current month of the plan", - )} - </div> - <sl-icon - name="info-circle" - style="vertical-align: -.175em" - ></sl-icon> - </sl-tooltip>`, - ); + cols.push({ + field: Field.BillableExecutionTime, + label: msg("Billable Execution Time"), + description: msg( + "Execution time used that is billable to the current month of the plan", + ), + }); } if (this.hasExtraTime()) { - usageTableCols.push( - html`${msg("Rollover Execution Time")} - <sl-tooltip> - <div slot="content" style="text-transform: initial"> - ${msg( - "Additional execution time used, of which any extra minutes will roll over to next month as billable time", - )} - </div> - <sl-icon - name="info-circle" - style="vertical-align: -.175em" - ></sl-icon> - </sl-tooltip>`, - ); + cols.push({ + field: Field.RolloverExecutionTime, + label: msg("Rollover Execution Time"), + description: msg( + "Additional execution time used, of which any extra minutes will roll over to next month as billable time", + ), + }); } if (this.hasGiftedTime()) { - usageTableCols.push( - html`${msg("Gifted Execution Time")} - <sl-tooltip> - <div slot="content" style="text-transform: initial"> - ${msg("Execution time used that is free of charge")} - </div> - <sl-icon - name="info-circle" - style="vertical-align: -.175em" - ></sl-icon> - </sl-tooltip>`, - ); + cols.push({ + field: Field.GiftedExecutionTime, + label: msg("Gifted Execution Time"), + description: msg("Execution time used that is free of charge"), + }); } - const rows = Object.entries(this.org.usage || {}) - // Sort latest - .reverse() - .map(([mY, crawlTime]) => { - if (!this.org) return []; - - let monthlySecondsUsed = this.org.monthlyExecSeconds?.[mY] || 0; - let maxMonthlySeconds = 0; - if (this.org.quotas.maxExecMinutesPerMonth) { - maxMonthlySeconds = this.org.quotas.maxExecMinutesPerMonth * 60; - } - if (monthlySecondsUsed > maxMonthlySeconds) { - monthlySecondsUsed = maxMonthlySeconds; - } - - let extraSecondsUsed = this.org.extraExecSeconds?.[mY] || 0; - let maxExtraSeconds = 0; - if (this.org.quotas.extraExecMinutes) { - maxExtraSeconds = this.org.quotas.extraExecMinutes * 60; - } - if (extraSecondsUsed > maxExtraSeconds) { - extraSecondsUsed = maxExtraSeconds; - } - - let giftedSecondsUsed = this.org.giftedExecSeconds?.[mY] || 0; - let maxGiftedSeconds = 0; - if (this.org.quotas.giftedExecMinutes) { - maxGiftedSeconds = this.org.quotas.giftedExecMinutes * 60; - } - if (giftedSecondsUsed > maxGiftedSeconds) { - giftedSecondsUsed = maxGiftedSeconds; - } - - let totalSecondsUsed = this.org.crawlExecSeconds?.[mY] || 0; - const totalMaxQuota = - maxMonthlySeconds + maxExtraSeconds + maxGiftedSeconds; - if (totalSecondsUsed > totalMaxQuota) { - totalSecondsUsed = totalMaxQuota; - } - - const tableRows = [ - html` - <btrix-format-date - date="${mY}-15T00:00:00.000Z" - time-zone="utc" - month="long" - year="numeric" - > - </btrix-format-date> - `, - humanizeExecutionSeconds(crawlTime || 0), - totalSecondsUsed ? humanizeExecutionSeconds(totalSecondsUsed) : "--", - ]; - if (this.hasMonthlyTime()) { - tableRows.push( - monthlySecondsUsed - ? humanizeExecutionSeconds(monthlySecondsUsed) - : "--", - ); - } - if (this.hasExtraTime()) { - tableRows.push( - extraSecondsUsed - ? humanizeExecutionSeconds(extraSecondsUsed) - : "--", - ); - } - if (this.hasGiftedTime()) { - tableRows.push( - giftedSecondsUsed - ? humanizeExecutionSeconds(giftedSecondsUsed) - : "--", - ); - } - return tableRows; - }); + cols.forEach((col) => { + if (!col.renderCell) { + col.renderCell = this.renderSecondsForField(col.field); + } + }); + + const items: GridItem[] = []; + + usageEntries.forEach(([mY, crawlTime]) => { + let monthlySecondsUsed = org.monthlyExecSeconds?.[mY] || 0; + let maxMonthlySeconds = 0; + if (org.quotas.maxExecMinutesPerMonth) { + maxMonthlySeconds = org.quotas.maxExecMinutesPerMonth * 60; + } + if (monthlySecondsUsed > maxMonthlySeconds) { + monthlySecondsUsed = maxMonthlySeconds; + } + + let extraSecondsUsed = org.extraExecSeconds?.[mY] || 0; + let maxExtraSeconds = 0; + if (org.quotas.extraExecMinutes) { + maxExtraSeconds = org.quotas.extraExecMinutes * 60; + } + if (extraSecondsUsed > maxExtraSeconds) { + extraSecondsUsed = maxExtraSeconds; + } + + let giftedSecondsUsed = org.giftedExecSeconds?.[mY] || 0; + let maxGiftedSeconds = 0; + if (org.quotas.giftedExecMinutes) { + maxGiftedSeconds = org.quotas.giftedExecMinutes * 60; + } + if (giftedSecondsUsed > maxGiftedSeconds) { + giftedSecondsUsed = maxGiftedSeconds; + } + + let totalSecondsUsed = org.crawlExecSeconds?.[mY] || 0; + const totalMaxQuota = + maxMonthlySeconds + maxExtraSeconds + maxGiftedSeconds; + if (totalSecondsUsed > totalMaxQuota) { + totalSecondsUsed = totalMaxQuota; + } + + const item: Partial<GridItem<Field>> = { + [Field.Month]: mY, + [Field.ElapsedTime]: crawlTime || 0, + [Field.ExecutionTime]: totalSecondsUsed, + [Field.BillableExecutionTime]: monthlySecondsUsed, + [Field.RolloverExecutionTime]: extraSecondsUsed, + [Field.GiftedExecutionTime]: giftedSecondsUsed, + }; + + items.unshift(item); + }); + return html` - <btrix-data-table - .columns=${usageTableCols} - .rows=${rows} - ></btrix-data-table> + <btrix-data-grid + .columns=${cols} + .items=${items} + stickyHeader + ></btrix-data-grid> `; } + + private readonly renderSecondsForField = + (field: Field) => + ({ item }: { item: GridItem<Field> }) => html` + ${item[field] ? humanizeExecutionSeconds(+item[field]) : noData} + `; } diff --git a/frontend/src/stories/components/DataGrid.stories.ts b/frontend/src/stories/components/DataGrid.stories.ts new file mode 100644 index 0000000000..e1884b2ed6 --- /dev/null +++ b/frontend/src/stories/components/DataGrid.stories.ts @@ -0,0 +1,264 @@ +import type { Meta, StoryObj } from "@storybook/web-components"; +import { html } from "lit"; + +import { defaultArgs, renderComponent, type RenderProps } from "./DataGrid"; +import { + dataGridDecorator, + formControlName, +} from "./decorators/dataGridDecorator"; + +import { DataGridRowsController } from "@/components/ui/data-grid/controllers/rows"; +import { renderRows } from "@/components/ui/data-grid/renderRows"; +import { GridColumnType } from "@/components/ui/data-grid/types"; + +const meta = { + title: "Components/Data Grid", + component: "btrix-data-grid", + subcomponents: { + DataGridRow: "btrix-data-grid-row", + DataGridCell: "btrix-data-grid-cell", + }, + tags: ["autodocs"], + render: renderComponent, + argTypes: {}, + args: {}, +} satisfies Meta<RenderProps>; + +export default meta; +type Story = StoryObj<RenderProps>; + +/** + * In its most basic configuration, the only required fields + * are a list of items, and a list of columns that define which + * key-value pairs of an item should be displayed. + */ +export const Basic: Story = { + args: {}, +}; + +/** + * The table header can stick to the top of the containing element. + */ +export const StickyHeader: Story = { + args: { + stickyHeader: true, + }, +}; + +/** + * Table header cells can convey additional information in a tooltip. + */ +export const HeaderTooltip: Story = { + args: { + columns: [ + { + ...defaultArgs.columns[0], + description: "This is a description of 'A'", + }, + { + ...defaultArgs.columns[1], + description: "This is a description of 'B'", + }, + ...defaultArgs.columns.slice(2), + ], + }, +}; + +const colWidths = ["200px", "10em", "min-content", "auto", "1fr"]; + +/** + * Columns can have specified widths set to any `grid-template-columns` + * [track list value](https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-columns#syntax). + */ +export const ColumnWidths: Story = { + args: { + columns: defaultArgs.columns.map((col, i) => ({ + ...col, + width: colWidths[i], + })), + }, +}; + +/** + * Rows can be removed. + */ +export const RemoveRows: Story = { + args: { + removeRows: true, + }, +}; + +/** + * Rows can also be added, with an optional default item for new rows. + */ +export const AddRows: Story = { + args: { + addRows: true, + defaultItem: { + a: "A", + b: "--", + c: "--", + d: "--", + e: "--", + }, + }, +}; + +/** + * The number of rows being added can be configurable. + */ +export const AddRowsInput: Story = { + name: "Add more than one row", + args: { + addRows: true, + addRowsInputValue: 5, + defaultItem: { + a: "A", + b: "--", + c: "--", + d: "--", + e: "--", + }, + }, +}; + +/** + * Cells can be editable. + */ +export const EditCells: Story = { + args: { + editCells: true, + columns: defaultArgs.columns.map((col) => ({ + ...col, + width: "1fr", + })), + items: defaultArgs.items.map((item) => ({ + ...item, + a: `${(item as Record<string, string>).a} (not editable)`, + })), + }, +}; + +/** + * The data grid can become a group of form controls, complete with validation. + * + * The caveat is that in order for the outer form to recognize the rows as form + * controls, row components must be slotted into the `rows` slot of the grid + * component. Each row must have the same `name` attribute in order to be + * serialized as the same form control. + * + * A few helpers are included to make managing rows easier: + * - `DataGridController` to add and remove slotted rows + * - `renderRows` to render `<btrix-data-grid-row>` + * - `serializeDeep` to parse form values + * + * Open console logs to view the form value submitted in this example. + */ +export const FormControl: Story = { + args: { + columns: [ + { + field: "url", + label: "URL", + editable: true, + inputType: GridColumnType.URL, + inputPlaceholder: "Enter URL", + required: true, + }, + { + field: "title", + label: "Title", + editable: true, + inputPlaceholder: "Enter page title", + required: true, + }, + { + field: "selector", + label: "Heading Selector", + editable: true, + inputPlaceholder: "h1", + renderEditCell({ item }) { + return html` + <btrix-syntax-input + name="selector" + class="flex-1 [--sl-input-border-radius-medium:0] [--sl-input-border-color:transparent]" + value=${item.selector || ""} + language="css" + ></btrix-syntax-input> + `; + }, + }, + { + field: "count", + label: "Crawl Count", + editable: true, + inputType: GridColumnType.Number, + inputPlaceholder: "Enter count", + }, + { + field: "status", + label: "Status", + editable: true, + inputType: GridColumnType.Select, + selectOptions: [ + { + value: "Pending", + }, + { + value: "Approved", + }, + ], + }, + ], + items: [ + { + title: "Title 1", + selector: "h1", + count: 2, + url: "https://example.com/page-1", + status: "Approved", + }, + { + title: "Title 2", + selector: "div.heading", + count: 1, + url: "https://example.com/page-2", + status: "Pending", + }, + ], + }, + decorators: [dataGridDecorator], + render: (args, context) => { + const rows = + context.rowsController instanceof DataGridRowsController + ? context.rowsController.rows + : new Map(); + + return html` + <btrix-data-grid + .columns=${args.columns} + .rowsController=${ + // `rowsController` context is added by `dataGridDecorator` + context.rowsController + } + formControlLabel="Page QA Table" + stickyHeader + addRows + removeRows + editCells + > + ${renderRows( + rows, + ({ id, item }) => html` + <btrix-data-grid-row + slot="rows" + name="${formControlName}" + key=${id} + .item=${item} + ></btrix-data-grid-row> + `, + )} + </btrix-data-grid> + `; + }, +}; diff --git a/frontend/src/stories/components/DataGrid.ts b/frontend/src/stories/components/DataGrid.ts new file mode 100644 index 0000000000..3f07d21166 --- /dev/null +++ b/frontend/src/stories/components/DataGrid.ts @@ -0,0 +1,57 @@ +import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { nanoid } from "nanoid"; + +import type { DataGrid } from "@/components/ui/data-grid/data-grid"; + +import "@/components/ui/data-grid"; + +export type RenderProps = Pick<DataGrid, keyof DataGrid>; + +const columns = "abcde".split("").map((field, i) => ({ + field, + label: field.toUpperCase(), + editable: i > 0, +})) satisfies RenderProps["columns"]; +const items = Array.from({ length: 5 }).map((_, i) => ({ + ...columns.reduce( + (obj, { field, label }) => ({ + ...obj, + [field]: `${label}${i + 1}`, + }), + {}, + ), + id: nanoid(), +})) satisfies RenderProps["items"]; + +export const defaultArgs = { columns, items } satisfies Pick< + RenderProps, + "columns" | "items" +>; + +export const renderComponent = ({ + columns, + items, + formControlLabel, + stickyHeader, + addRows, + addRowsInputValue, + removeRows, + editCells, + defaultItem, +}: Partial<RenderProps>) => { + return html` + <btrix-data-grid + .columns=${columns || defaultArgs.columns} + .items=${items || defaultArgs.items} + .defaultItem=${defaultItem} + formControlLabel=${ifDefined(formControlLabel)} + ?stickyHeader=${stickyHeader} + ?addRows=${addRows} + addRowsInputValue=${ifDefined(addRowsInputValue)} + ?removeRows=${removeRows} + ?editCells=${editCells} + > + </btrix-data-grid> + `; +}; diff --git a/frontend/src/stories/components/decorators/DataGridDecorator.ts b/frontend/src/stories/components/decorators/DataGridDecorator.ts new file mode 100644 index 0000000000..8eade25023 --- /dev/null +++ b/frontend/src/stories/components/decorators/DataGridDecorator.ts @@ -0,0 +1,61 @@ +import type { StoryContext, StoryFn } from "@storybook/web-components"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { TailwindElement } from "@/classes/TailwindElement"; +import { DataGridRowsController } from "@/components/ui/data-grid/controllers/rows"; +import type { GridItem } from "@/components/ui/data-grid/types"; +import { serializeDeep } from "@/utils/form"; + +type DataGridStoryContext = { rowsController: DataGridRowsController }; + +export const formControlName = "storybook--data-grid-form-example"; + +@customElement("btrix-storybook-data-grid-form") +export class StorybookDataGridForm extends TailwindElement { + readonly #rowsController = new DataGridRowsController(this); + + public renderStory!: (context: DataGridStoryContext) => ReturnType<StoryFn>; + + @property({ type: Array }) + items?: GridItem[] = []; + + render() { + const onSubmit = (e: SubmitEvent) => { + e.preventDefault(); + + const form = e.target as HTMLFormElement; + const value = serializeDeep(form, { parseKeys: [formControlName] }); + + console.log("form value:", value); + }; + + return html` + <form @submit=${onSubmit}> + ${this.renderStory({ + rowsController: this.#rowsController, + })} + <footer class="mt-4"> + <sl-button type="reset">Reset</sl-button> + <sl-button type="submit" variant="primary">Submit</sl-button> + </footer> + </form> + `; + } +} + +export function dataGridDecorator(story: StoryFn, context: StoryContext) { + return html`<btrix-storybook-data-grid-form + .items=${context.args.items as GridItem[]} + .renderStory=${(ctx: DataGridStoryContext) => { + return story( + { + ...context.args, + ...ctx, + }, + context, + ); + }} + > + </btrix-storybook-data-grid-form>`; +} diff --git a/frontend/src/stories/components/decorators/dataGridDecorator.ts b/frontend/src/stories/components/decorators/dataGridDecorator.ts new file mode 100644 index 0000000000..8eade25023 --- /dev/null +++ b/frontend/src/stories/components/decorators/dataGridDecorator.ts @@ -0,0 +1,61 @@ +import type { StoryContext, StoryFn } from "@storybook/web-components"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { TailwindElement } from "@/classes/TailwindElement"; +import { DataGridRowsController } from "@/components/ui/data-grid/controllers/rows"; +import type { GridItem } from "@/components/ui/data-grid/types"; +import { serializeDeep } from "@/utils/form"; + +type DataGridStoryContext = { rowsController: DataGridRowsController }; + +export const formControlName = "storybook--data-grid-form-example"; + +@customElement("btrix-storybook-data-grid-form") +export class StorybookDataGridForm extends TailwindElement { + readonly #rowsController = new DataGridRowsController(this); + + public renderStory!: (context: DataGridStoryContext) => ReturnType<StoryFn>; + + @property({ type: Array }) + items?: GridItem[] = []; + + render() { + const onSubmit = (e: SubmitEvent) => { + e.preventDefault(); + + const form = e.target as HTMLFormElement; + const value = serializeDeep(form, { parseKeys: [formControlName] }); + + console.log("form value:", value); + }; + + return html` + <form @submit=${onSubmit}> + ${this.renderStory({ + rowsController: this.#rowsController, + })} + <footer class="mt-4"> + <sl-button type="reset">Reset</sl-button> + <sl-button type="submit" variant="primary">Submit</sl-button> + </footer> + </form> + `; + } +} + +export function dataGridDecorator(story: StoryFn, context: StoryContext) { + return html`<btrix-storybook-data-grid-form + .items=${context.args.items as GridItem[]} + .renderStory=${(ctx: DataGridStoryContext) => { + return story( + { + ...context.args, + ...ctx, + }, + context, + ); + }} + > + </btrix-storybook-data-grid-form>`; +} diff --git a/frontend/src/stories/decorators/orgDecorator.ts b/frontend/src/stories/decorators/orgDecorator.ts new file mode 100644 index 0000000000..4ec4a7294e --- /dev/null +++ b/frontend/src/stories/decorators/orgDecorator.ts @@ -0,0 +1,57 @@ +import type { StoryContext, StoryFn } from "@storybook/web-components"; +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import mapValues from "lodash/fp/mapValues"; + +import orgMock from "@/__mocks__/api/orgs/[id]"; +import { AppStateService } from "@/utils/state"; + +const { users, usage, quotas, ...org } = orgMock; + +export type StorybookOrgProps = { + orgUsers?: boolean; + orgUsage?: boolean; + orgQuotas?: boolean; +}; + +@customElement("btrix-storybook-org") +export class StorybookOrg extends LitElement { + @property({ type: Boolean }) + users?: boolean; + + @property({ type: Boolean }) + usage?: boolean; + + @property({ type: Boolean }) + quotas?: boolean; + + connectedCallback(): void { + super.connectedCallback(); + + AppStateService.updateOrg({ + ...org, + users: this.users ? users : {}, + usage: this.usage ? usage : {}, + quotas: this.quotas + ? quotas + : (mapValues(() => 0, quotas) as typeof quotas), + }); + } + + render() { + return html`<slot></slot>`; + } +} + +export function orgDecorator(story: StoryFn, context: StoryContext) { + const { args } = context; + const { orgUsers, orgUsage, orgQuotas } = args as StorybookOrgProps; + + return html`<btrix-storybook-org + ?users=${orgUsers} + ?usage=${orgUsage} + ?quotas=${orgQuotas} + > + ${story(args, context)} + </btrix-storybook-org>`; +} diff --git a/frontend/src/stories/features/archived-items/CrawlLogs.stories.ts b/frontend/src/stories/features/archived-items/CrawlLogs.stories.ts index 05f0826c57..ad1221f203 100644 --- a/frontend/src/stories/features/archived-items/CrawlLogs.stories.ts +++ b/frontend/src/stories/features/archived-items/CrawlLogs.stories.ts @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/web-components"; import { html } from "lit"; -import { argTypes } from "../excludeBtrixElementProperties"; +import { argTypes } from "../excludeContainerProperties"; import type { CrawlLogTable } from "@/features/archived-items/crawl-log-table"; import { CrawlLogContext, CrawlLogLevel } from "@/types/crawler"; diff --git a/frontend/src/stories/features/crawl-workflows/CustomBehaviorsTable.stories.ts b/frontend/src/stories/features/crawl-workflows/CustomBehaviorsTable.stories.ts index 4ab22a6840..84d64ec3db 100644 --- a/frontend/src/stories/features/crawl-workflows/CustomBehaviorsTable.stories.ts +++ b/frontend/src/stories/features/crawl-workflows/CustomBehaviorsTable.stories.ts @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/web-components"; import { html } from "lit"; -import { argTypes } from "../excludeBtrixElementProperties"; +import { argTypes } from "../excludeContainerProperties"; import type { CustomBehaviorsTable } from "@/features/crawl-workflows/custom-behaviors-table"; diff --git a/frontend/src/stories/features/crawl-workflows/LinkSelectorTable.stories.ts b/frontend/src/stories/features/crawl-workflows/LinkSelectorTable.stories.ts index 4e43638ae3..3a8114da33 100644 --- a/frontend/src/stories/features/crawl-workflows/LinkSelectorTable.stories.ts +++ b/frontend/src/stories/features/crawl-workflows/LinkSelectorTable.stories.ts @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/web-components"; import { html } from "lit"; -import { argTypes } from "../excludeBtrixElementProperties"; +import { argTypes } from "../excludeContainerProperties"; import type { LinkSelectorTable } from "@/features/crawl-workflows/link-selector-table"; diff --git a/frontend/src/stories/features/crawl-workflows/QueueExclusionForm.stories.ts b/frontend/src/stories/features/crawl-workflows/QueueExclusionForm.stories.ts index 4d6f1bcf4d..58b8ecb5a2 100644 --- a/frontend/src/stories/features/crawl-workflows/QueueExclusionForm.stories.ts +++ b/frontend/src/stories/features/crawl-workflows/QueueExclusionForm.stories.ts @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/web-components"; import { html } from "lit"; -import { argTypes } from "../excludeBtrixElementProperties"; +import { argTypes } from "../excludeContainerProperties"; import type { QueueExclusionForm } from "@/features/crawl-workflows/queue-exclusion-form"; diff --git a/frontend/src/stories/features/crawl-workflows/QueueExclusionTable.stories.ts b/frontend/src/stories/features/crawl-workflows/QueueExclusionTable.stories.ts index cae65f77ac..a903050632 100644 --- a/frontend/src/stories/features/crawl-workflows/QueueExclusionTable.stories.ts +++ b/frontend/src/stories/features/crawl-workflows/QueueExclusionTable.stories.ts @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/web-components"; import { html } from "lit"; -import { argTypes } from "../excludeBtrixElementProperties"; +import { argTypes } from "../excludeContainerProperties"; import type { QueueExclusionTable } from "@/features/crawl-workflows/queue-exclusion-table"; import { tw } from "@/utils/tailwind"; diff --git a/frontend/src/stories/features/excludeBtrixElementProperties.ts b/frontend/src/stories/features/excludeContainerProperties.ts similarity index 60% rename from frontend/src/stories/features/excludeBtrixElementProperties.ts rename to frontend/src/stories/features/excludeContainerProperties.ts index 9fe37dd5a3..5e3a430bf0 100644 --- a/frontend/src/stories/features/excludeBtrixElementProperties.ts +++ b/frontend/src/stories/features/excludeContainerProperties.ts @@ -1,6 +1,8 @@ /** - * Exclude `BtrixElement` properties from story controls + * Exclude `BtrixElement` and `StorybookOrg` properties from story controls */ +import { StorybookOrg } from "../decorators/orgDecorator"; + import { BtrixElement } from "@/classes/BtrixElement"; const controlOpts = { table: { disable: true } }; @@ -17,4 +19,10 @@ Object.getOwnPropertyNames(BtrixElement.prototype).forEach((prop) => { argTypes[prop] = controlOpts; }); +Object.getOwnPropertyNames(StorybookOrg.prototype).forEach((prop) => { + if (prop === "constructor") return; + + argTypes[prop] = controlOpts; +}); + export { argTypes }; diff --git a/frontend/src/stories/features/org/UsageHistoryTable.stories.ts b/frontend/src/stories/features/org/UsageHistoryTable.stories.ts new file mode 100644 index 0000000000..a61e8e45a1 --- /dev/null +++ b/frontend/src/stories/features/org/UsageHistoryTable.stories.ts @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/web-components"; +import { html } from "lit"; +import type { DecoratorFunction } from "storybook/internal/types"; + +import { argTypes } from "../excludeContainerProperties"; + +import type { UsageHistoryTable } from "@/features/org/usage-history-table"; +import { + orgDecorator, + type StorybookOrgProps, +} from "@/stories/decorators/orgDecorator"; + +import "@/features/org/usage-history-table"; + +type RenderProps = UsageHistoryTable & StorybookOrgProps; + +const meta = { + title: "Features/Usage History Table", + component: "btrix-usage-history-table", + tags: ["autodocs"], + decorators: [orgDecorator as DecoratorFunction], + render: () => html` <btrix-usage-history-table></btrix-usage-history-table> `, + argTypes: { + ...argTypes, + }, + args: {}, +} satisfies Meta<RenderProps>; + +export default meta; +type Story = StoryObj<RenderProps>; + +/** + * @FIXME The "Docs" view will currently always show an empty usage history table + * since usage is configured through global app state. + * + * Navigate to "With Usage" to see a working example. + */ +export const WithUsage: Story = { + args: { + orgUsage: true, + }, +}; + +export const WithoutUsage: Story = { + args: {}, +}; diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index 3bf6766863..d7520b2a7e 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -61,6 +61,10 @@ --sl-font-weight-medium: 500; --sl-font-weight-semibold: 600; + /* Focus rings */ + --sl-focus-ring-color: var(--sl-color-primary-200); + --sl-focus-ring-width: 2px; + /* * Forms */ @@ -101,6 +105,11 @@ body { font-size: var(--sl-font-size-medium); } + + :focus-visible { + outline: var(--sl-focus-ring); + outline-offset: var(--sl-focus-ring-offset); + } } @layer components { diff --git a/frontend/src/types/org.ts b/frontend/src/types/org.ts index e5b5e4d08a..3a50aaec71 100644 --- a/frontend/src/types/org.ts +++ b/frontend/src/types/org.ts @@ -90,7 +90,7 @@ export const orgDataSchema = z.object({ .optional(), readOnly: z.boolean().nullable(), readOnlyReason: z.union([orgReadOnlyReasonSchema, z.string()]).nullable(), - readOnlyOnCancel: z.boolean(), + readOnlyOnCancel: z.boolean().optional(), subscription: subscriptionSchema.nullable(), crawlingDefaults: crawlingDefaultsSchema.nullable(), allowSharedProxies: z.boolean(), diff --git a/frontend/src/utils/form.ts b/frontend/src/utils/form.ts index ad7edfec8e..a525dad30f 100644 --- a/frontend/src/utils/form.ts +++ b/frontend/src/utils/form.ts @@ -1,6 +1,9 @@ import { msg, str } from "@lit/localize"; import type { SlInput, SlTextarea } from "@shoelace-style/shoelace"; -import { getFormControls } from "@shoelace-style/shoelace/dist/utilities/form.js"; +import { + getFormControls, + serialize, +} from "@shoelace-style/shoelace/dist/utilities/form.js"; import type { LitElement } from "lit"; import localize from "./localize"; @@ -85,3 +88,30 @@ export function formValidator(el: LitElement) { ); }; } + +/** + * Serialize forms with stringified JSON data, likely + * when used with `<btrix-data-grid>`. + */ +export function serializeDeep( + form: HTMLFormElement, + opts?: { parseKeys: string[] }, +) { + const values = serialize(form); + + if (opts) { + opts.parseKeys.forEach((key) => { + const val = values[key]; + + if (typeof val === "string") { + values[key] = JSON.parse(val); + } else if (Array.isArray(val)) { + values[key] = val.map<unknown>((v) => + typeof v === "string" ? JSON.parse(v) : v, + ); + } + }); + } + + return values; +} diff --git a/frontend/src/utils/pluralize.ts b/frontend/src/utils/pluralize.ts index ebbb592e87..5fac6a9a6b 100644 --- a/frontend/src/utils/pluralize.ts +++ b/frontend/src/utils/pluralize.ts @@ -143,6 +143,32 @@ const plurals = { id: "URLs.plural.other", }), }, + rows: { + zero: msg("rows", { + desc: 'plural form of "rows" for zero rows', + id: "rows.plural.zero", + }), + one: msg("row", { + desc: 'singular form for "row"', + id: "rows.plural.one", + }), + two: msg("rows", { + desc: 'plural form of "rows" for two rows', + id: "rows.plural.two", + }), + few: msg("rows", { + desc: 'plural form of "rows" for few rows', + id: "rows.plural.few", + }), + many: msg("rows", { + desc: 'plural form of "rows" for many rows', + id: "rows.plural.many", + }), + other: msg("rows", { + desc: 'plural form of "rows" for multiple/other rows', + id: "rows.plural.other", + }), + }, }; export const pluralOf = (word: keyof typeof plurals, count: number) => { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c4390f225f..5b56925be8 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -10169,6 +10169,11 @@ swc-loader@^0.2.6: dependencies: "@swc/counter" "^0.1.3" +tabbable@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" + integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew== + table-layout@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.2.tgz#c4038a1853b0136d63365a734b6931cf4fad4a04"