From 01e6a5c9f6d94d0e8d9edb7e8866c09995b6e4a4 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 30 Apr 2025 14:33:06 -0700 Subject: [PATCH 1/7] move to mixin --- .../components/ui/data-grid/data-grid-cell.ts | 15 ++++- .../components/ui/data-grid/data-grid-row.ts | 59 +++---------------- .../src/components/ui/data-grid/data-grid.ts | 6 -- frontend/src/components/ui/data-grid/types.ts | 7 ++- frontend/src/mixins/FormControl.ts | 53 +++++++++++++++++ .../components/decorators/dataGridForm.ts | 5 +- frontend/src/utils/form.ts | 23 ++++++-- 7 files changed, 102 insertions(+), 66 deletions(-) create mode 100644 frontend/src/mixins/FormControl.ts diff --git a/frontend/src/components/ui/data-grid/data-grid-cell.ts b/frontend/src/components/ui/data-grid/data-grid-cell.ts index acbe1abeae..8c8749067f 100644 --- a/frontend/src/components/ui/data-grid/data-grid-cell.ts +++ b/frontend/src/components/ui/data-grid/data-grid-cell.ts @@ -44,6 +44,9 @@ export class DataGridCell extends TableCell { @property({ type: Object }) item?: GridItem; + @property({ type: String }) + value?: GridItem[keyof GridItem]; + @property({ type: Boolean }) editable = false; @@ -104,7 +107,7 @@ export class DataGridCell extends TableCell { if (!this.column || !this.item) return html``; if (this.editable) { - return this.renderEditCell({ item: this.item }); + return this.renderEditCell({ item: this.item, value: this.value }); } return this.renderCell({ item: this.item }); @@ -114,12 +117,18 @@ export class DataGridCell extends TableCell { return html`${(this.column && item[this.column.field]) ?? ""}`; }; - renderEditCell = ({ item }: { item: GridItem }) => { + renderEditCell = ({ + item, + value: cellValue, + }: { + item: GridItem; + value?: GridItem[keyof GridItem]; + }) => { const col = this.column; if (!col) return html``; - const value = item[col.field] ?? ""; + const value = cellValue ?? item[col.field] ?? ""; switch (col.inputType) { case GridColumnType.Select: { diff --git a/frontend/src/components/ui/data-grid/data-grid-row.ts b/frontend/src/components/ui/data-grid/data-grid-row.ts index 73b01e39a4..75f5d50fad 100644 --- a/frontend/src/components/ui/data-grid/data-grid-row.ts +++ b/frontend/src/components/ui/data-grid/data-grid-row.ts @@ -3,6 +3,7 @@ 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 { ifDefined } from "lit/directives/if-defined.js"; import isEqual from "lodash/fp/isEqual"; import { CellDirective } from "./cellDirective"; @@ -15,6 +16,7 @@ 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 { FormControl } from "@/mixins/FormControl"; import { tw } from "@/utils/tailwind"; export type RowRemoveEventDetail = { @@ -31,12 +33,7 @@ const editableCellStyle = tw`p-0 focus-visible:bg-slate-50 `; */ @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; - +export class DataGridRow extends FormControl(TableRow) { /** * Set of columns. */ @@ -73,12 +70,6 @@ export class DataGridRow extends TableRow { @property({ type: String, reflect: true }) name?: string; - /** - * Make row focusable on validation. - */ - @property({ type: Number, reflect: true }) - tabindex = 0; - @state() private cellValues: Partial = {}; @@ -89,44 +80,11 @@ export class DataGridRow extends TableRow { 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(); @@ -162,7 +120,7 @@ export class DataGridRow extends TableRow { this.cellValues[field] = cellValues[field]; }); - this.#internals.setFormValue(JSON.stringify(this.cellValues)); + this.setFormValue(JSON.stringify(this.cellValues)); } private commitValue() { @@ -228,6 +186,7 @@ export class DataGridRow extends TableRow { )} .column=${col} .item=${this.item} + value=${ifDefined(this.cellValues[col.field] ?? undefined)} ?editable=${editable} ${cell(col)} @keydown=${this.onKeydown} @@ -337,7 +296,7 @@ export class DataGridRow extends TableRow { this.#invalidInputsMap.delete(field); } else { this.#invalidInputsMap.set(field, validationMessage); - this.#internals.setValidity(validity, validationMessage, tableCell); + this.setValidity(validity, validationMessage, tableCell); } this.setValue({ @@ -357,7 +316,7 @@ export class DataGridRow extends TableRow { this.#invalidInputsMap.delete(field); } else { this.#invalidInputsMap.set(field, validationMessage); - this.#internals.setValidity(validity, validationMessage, tableCell); + this.setValidity(validity, validationMessage, tableCell); } this.commitValue(); @@ -371,13 +330,13 @@ export class DataGridRow extends TableRow { ); if (firstInvalid?.validity && firstInvalid.validationMessage) { - this.#internals.setValidity( + this.setValidity( firstInvalid.validity, firstInvalid.validationMessage, firstInvalid, ); } else { - this.#internals.setValidity({}); + this.setValidity({}); } } }; diff --git a/frontend/src/components/ui/data-grid/data-grid.ts b/frontend/src/components/ui/data-grid/data-grid.ts index 29cbf523af..6bc5fcd389 100644 --- a/frontend/src/components/ui/data-grid/data-grid.ts +++ b/frontend/src/components/ui/data-grid/data-grid.ts @@ -127,12 +127,6 @@ export class DataGrid extends TailwindElement { @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; diff --git a/frontend/src/components/ui/data-grid/types.ts b/frontend/src/components/ui/data-grid/types.ts index c122529eec..cc62ca7346 100644 --- a/frontend/src/components/ui/data-grid/types.ts +++ b/frontend/src/components/ui/data-grid/types.ts @@ -30,8 +30,11 @@ export type GridColumn = { required?: boolean; inputPlaceholder?: string; width?: string; - renderEditCell?: ({ item }: { item: GridItem }) => TemplateResult<1>; - renderCell?: ({ item }: { item: GridItem }) => TemplateResult<1>; + renderEditCell?: (props: { + item: GridItem; + value?: GridItem[keyof GridItem]; + }) => TemplateResult<1>; + renderCell?: (props: { item: GridItem }) => TemplateResult<1>; } & ( | { inputType?: GridColumnType; diff --git a/frontend/src/mixins/FormControl.ts b/frontend/src/mixins/FormControl.ts new file mode 100644 index 0000000000..89a3eae18e --- /dev/null +++ b/frontend/src/mixins/FormControl.ts @@ -0,0 +1,53 @@ +import type { LitElement } from "lit"; +import type { Constructor } from "type-fest"; + +export const FormControl = >(superClass: T) => + class extends superClass { + static formAssociated = true; + readonly #internals: ElementInternals; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(...args: any[]) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + super(...args); + + this.tabIndex = Math.max(this.tabIndex, 0); + this.#internals = this.attachInternals(); + } + + public formAssociatedCallback() {} + public formResetCallback() {} + public formDisabledCallback(_disabled: boolean) {} + public formStateRestoreCallback( + _state: string | FormData, + _reason: string, + ) {} + + public checkValidity(): boolean { + return this.#internals.checkValidity(); + } + + public reportValidity(): boolean { + return this.#internals.reportValidity(); + } + + public get validity(): ValidityState { + return this.#internals.validity; + } + + public get validationMessage(): string { + return this.#internals.validationMessage; + } + + protected setFormValue( + ...args: Parameters + ): void { + this.#internals.setFormValue(...args); + } + + protected setValidity( + ...args: Parameters + ): void { + this.#internals.setValidity(...args); + } + }; diff --git a/frontend/src/stories/components/decorators/dataGridForm.ts b/frontend/src/stories/components/decorators/dataGridForm.ts index 8eade25023..1d7507fe2f 100644 --- a/frontend/src/stories/components/decorators/dataGridForm.ts +++ b/frontend/src/stories/components/decorators/dataGridForm.ts @@ -25,7 +25,10 @@ export class StorybookDataGridForm extends TailwindElement { e.preventDefault(); const form = e.target as HTMLFormElement; - const value = serializeDeep(form, { parseKeys: [formControlName] }); + const value = serializeDeep(form, { + parseKeys: [formControlName], + filterEmpty: {}, + }); console.log("form value:", value); }; diff --git a/frontend/src/utils/form.ts b/frontend/src/utils/form.ts index a525dad30f..4c67c25874 100644 --- a/frontend/src/utils/form.ts +++ b/frontend/src/utils/form.ts @@ -5,6 +5,8 @@ import { serialize, } from "@shoelace-style/shoelace/dist/utilities/form.js"; import type { LitElement } from "lit"; +import { isEqual } from "lodash/fp"; +import type { EmptyObject } from "type-fest"; import localize from "./localize"; @@ -95,7 +97,10 @@ export function formValidator(el: LitElement) { */ export function serializeDeep( form: HTMLFormElement, - opts?: { parseKeys: string[] }, + opts?: { + parseKeys: string[]; + filterEmpty?: boolean | Record | EmptyObject; + }, ) { const values = serialize(form); @@ -106,9 +111,19 @@ export function serializeDeep( if (typeof val === "string") { values[key] = JSON.parse(val); } else if (Array.isArray(val)) { - values[key] = val.map((v) => - typeof v === "string" ? JSON.parse(v) : v, - ); + const arr: unknown[] = []; + + val.forEach((v) => { + if (opts.filterEmpty === true && !v) return; + + const value = typeof v === "string" ? JSON.parse(v) : v; + + if (opts.filterEmpty && isEqual(opts.filterEmpty, value)) return; + + arr.push(value); + }); + + values[key] = arr; } }); } From 444ab3cd013dd8879256b83608ea20b7cc213ae3 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 30 Apr 2025 15:32:20 -0700 Subject: [PATCH 2/7] switch to submitter buttons --- .../ui/data-grid/controllers/rows.ts | 2 -- .../crawl-workflows/workflow-editor.ts | 27 ++++++++++++------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/ui/data-grid/controllers/rows.ts b/frontend/src/components/ui/data-grid/controllers/rows.ts index fdb5ceed0b..140a289cc9 100644 --- a/frontend/src/components/ui/data-grid/controllers/rows.ts +++ b/frontend/src/components/ui/data-grid/controllers/rows.ts @@ -65,8 +65,6 @@ export class DataGridRowsController implements ReactiveController { if (!this.#prevItems || items !== this.#prevItems) { this.setRowsFromItems(items); - // this.#host.requestUpdate(); - this.#prevItems = items; } } diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index 264258528a..4552b633b6 100644 --- a/frontend/src/features/crawl-workflows/workflow-editor.ts +++ b/frontend/src/features/crawl-workflows/workflow-editor.ts @@ -137,6 +137,11 @@ const formName = "newJobConfig"; const panelSuffix = "--panel"; const defaultFormState = getDefaultFormState(); +enum SubmitType { + Save = "save", + SaveAndRun = "run", +} + const getDefaultProgressState = (hasConfigId = false): ProgressState => { let activeTab: StepName = "scope"; if (window.location.hash) { @@ -623,10 +628,10 @@ export class WorkflowEditor extends BtrixElement { void this.save()} > ${msg("Save")} @@ -641,6 +646,7 @@ export class WorkflowEditor extends BtrixElement { size="small" variant="primary" type="submit" + value=${SubmitType.SaveAndRun} ?disabled=${(!this.isCrawlRunning && isArchivingDisabled(this.org, true)) || this.isSubmitting || @@ -2191,13 +2197,14 @@ https://archiveweb.page/images/${"logo.svg"}`} private async onSubmit(event: SubmitEvent) { event.preventDefault(); - void this.save({ - runNow: !this.isCrawlRunning, - updateRunning: Boolean(this.isCrawlRunning), - }); - } + const submitType = ( + event.submitter as HTMLButtonElement & { + value?: SubmitType; + } + ).value; + + const saveAndRun = submitType === SubmitType.SaveAndRun; - private async save(opts?: WorkflowRunParams) { if (!this.formElem) return; // TODO Move away from manual validation check @@ -2236,11 +2243,11 @@ https://archiveweb.page/images/${"logo.svg"}`} const config: CrawlConfigParams & WorkflowRunParams = { ...this.parseConfig(), - runNow: Boolean(opts?.runNow), + runNow: saveAndRun && !this.isCrawlRunning, }; if (this.configId) { - config.updateRunning = Boolean(opts?.updateRunning); + config.updateRunning = saveAndRun && Boolean(this.isCrawlRunning); } this.isSubmitting = true; From 6040f59336154f5c3c9502c535cae3cca0f022ef Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 30 Apr 2025 16:25:16 -0700 Subject: [PATCH 3/7] convert syntax input --- frontend/src/components/ui/code.ts | 2 +- frontend/src/components/ui/syntax-input.ts | 77 ++++++------- .../stories/components/SyntaxInput.stories.ts | 102 ++++++++++++++++++ .../src/stories/components/SyntaxInput.ts | 31 ++++++ 4 files changed, 167 insertions(+), 45 deletions(-) create mode 100644 frontend/src/stories/components/SyntaxInput.stories.ts create mode 100644 frontend/src/stories/components/SyntaxInput.ts diff --git a/frontend/src/components/ui/code.ts b/frontend/src/components/ui/code.ts index 4601e372f8..7bcbf90bc0 100644 --- a/frontend/src/components/ui/code.ts +++ b/frontend/src/components/ui/code.ts @@ -8,7 +8,7 @@ import { html as staticHtml, unsafeStatic } from "lit/static-html.js"; import { TailwindElement } from "@/classes/TailwindElement"; import { tw } from "@/utils/tailwind"; -enum Language { +export enum Language { Javascript = "javascript", XML = "xml", CSS = "css", diff --git a/frontend/src/components/ui/syntax-input.ts b/frontend/src/components/ui/syntax-input.ts index a0ae4b6217..284adb4aad 100644 --- a/frontend/src/components/ui/syntax-input.ts +++ b/frontend/src/components/ui/syntax-input.ts @@ -2,30 +2,28 @@ import { localized } from "@lit/localize"; import type { SlInput, SlInputEvent, + SlInvalidEvent, SlTooltip, } from "@shoelace-style/shoelace"; 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"; +import { FormControl } from "@/mixins/FormControl"; 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") @localized() -export class SyntaxInput extends TailwindElement { +export class SyntaxInput extends FormControl(TailwindElement) { @property({ type: String }) value = ""; @@ -70,54 +68,31 @@ 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 (message) { + this.setValidity({ customError: true }, message); + } else { + this.setValidity({}); + } + if (this.disableTooltip) { this.input?.setAttribute("help-text", message); } + this.error = message; } - public reportValidity() { - if (this.input) { - if (this.tooltip) { - this.tooltip.disabled = true; - } - - // Suppress tooltip validation from showing on focus - this.input.addEventListener( - "focus", - async () => { - await this.updateComplete; - await this.input!.updateComplete; - - if (this.tooltip && !this.disableTooltip) { - this.tooltip.disabled = !this.error; - } - }, - { once: true }, - ); - - return this.input.reportValidity(); - } - - return this.checkValidity(); - } + public async formResetCallback() { + super.formResetCallback(); - public checkValidity() { - if (!this.input?.input) { - if (this.required) { - return false; - } + this.input?.setAttribute("value", this.value); + this.code?.setAttribute("value", this.value); - return true; - } + await this.code?.updateComplete; - return this.input.checkValidity(); + void this.scrollSync({ pad: true }); } disconnectedCallback(): void { @@ -150,9 +125,18 @@ export class SyntaxInput extends TailwindElement { ?required=${this.required} ?disabled=${this.disabled} @sl-input=${async (e: SlInputEvent) => { - const value = (e.target as SlInput).value; + const input = e.target as SlInput; + const value = input.value; + + if (input.validity.customError) { + input.setCustomValidity(""); + } - this.setCustomValidity(""); + if (input.validity.valid) { + this.setValidity({}); + } else { + this.setValidity(input.validity, input.validationMessage); + } if (this.code) { this.code.value = value; @@ -205,6 +189,11 @@ export class SyntaxInput extends TailwindElement { ); } }} + @sl-invalid=${(e: SlInvalidEvent) => { + const input = e.currentTarget as SlInput; + + this.setValidity(input.validity, input.validationMessage); + }} > ; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + args: { + value: "
Edit me
", + language: Language.XML, + placeholder: "Enter HTML", + }, +}; + +/** + * Syntax input supports CSS and XML. + */ +export const CSS: Story = { + args: { + label: "CSS Selector", + value: "div > a", + language: Language.CSS, + placeholder: "Enter a CSS selector", + }, +}; + +/** + * Syntax can be used a form control. + * + * To see how the validation message is displayed, interact with + * the input, click "Set custom validity", and then submit. + */ +export const FormControl: Story = { + decorators: [ + (story) => + html`
{ + e.preventDefault(); + + console.log("form values:", serialize(e.target as HTMLFormElement)); + }} + > + ${story()} +
+ { + const input = + document.querySelector("btrix-syntax-input"); + + input?.setCustomValidity("This is a custom validity message"); + }} + > + Set custom validity + + { + const input = + document.querySelector("btrix-syntax-input"); + + input?.setCustomValidity(""); + }} + > + Clear custom validity + +
+ Reset + Submit +
`, + ], + args: { + name: "selector", + label: "CSS Selector", + value: "div > a", + language: Language.CSS, + placeholder: "Enter a CSS selector", + disableTooltip: true, + required: true, + }, +}; diff --git a/frontend/src/stories/components/SyntaxInput.ts b/frontend/src/stories/components/SyntaxInput.ts new file mode 100644 index 0000000000..57187a5b7c --- /dev/null +++ b/frontend/src/stories/components/SyntaxInput.ts @@ -0,0 +1,31 @@ +import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { Language } from "@/components/ui/code"; +import type { SyntaxInput } from "@/components/ui/syntax-input"; + +import "@/components/ui/syntax-input"; + +export { Language }; + +export type RenderProps = Pick & { + name?: string; +}; + +export const defaultArgs = { + value: "
Edit me
", + language: Language.XML, + placeholder: "Enter HTML", +} satisfies Partial; + +export const renderComponent = (opts: Partial) => html` + +`; From bfb079c0dbb2180442f2f905fca991aaeb6f3c0d Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 30 Apr 2025 17:32:43 -0700 Subject: [PATCH 4/7] convert link selector --- frontend/src/components/ui/syntax-input.ts | 18 ++- .../crawl-workflows/link-selector-table.ts | 129 +++++++++++------- .../crawl-workflows/workflow-editor.ts | 9 -- frontend/src/strings/validation.ts | 7 + 4 files changed, 98 insertions(+), 65 deletions(-) create mode 100644 frontend/src/strings/validation.ts diff --git a/frontend/src/components/ui/syntax-input.ts b/frontend/src/components/ui/syntax-input.ts index 284adb4aad..94f701a64b 100644 --- a/frontend/src/components/ui/syntax-input.ts +++ b/frontend/src/components/ui/syntax-input.ts @@ -1,4 +1,3 @@ -import { localized } from "@lit/localize"; import type { SlInput, SlInputEvent, @@ -6,13 +5,14 @@ import type { SlTooltip, } from "@shoelace-style/shoelace"; import clsx from "clsx"; -import { html } from "lit"; +import { html, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { TailwindElement } from "@/classes/TailwindElement"; import type { Code } from "@/components/ui/code"; import { FormControl } from "@/mixins/FormControl"; +import { validationMessageFor } from "@/strings/validation"; import { tw } from "@/utils/tailwind"; /** @@ -22,7 +22,6 @@ import { tw } from "@/utils/tailwind"; * @fires btrix-change */ @customElement("btrix-syntax-input") -@localized() export class SyntaxInput extends FormControl(TailwindElement) { @property({ type: String }) value = ""; @@ -100,6 +99,17 @@ export class SyntaxInput extends FormControl(TailwindElement) { document.removeEventListener("selectionchange", this.onSelectionChange); } + protected updated(changedProperties: PropertyValues): void { + if (changedProperties.has("value") || changedProperties.has("required")) { + if (this.required && !this.value) { + this.setValidity( + { valueMissing: true }, + validationMessageFor.valueMissing, + ); + } + } + } + render() { return html` cells.join(SELECTOR_DELIMITER)); } - public reportValidity() { - let tableValid = true; - - this.syntaxInputs.forEach((input) => { - const valid = input.reportValidity(); - - if (!valid) { - tableValid = valid; - } - }); - - return tableValid; - } - - public checkValidity() { - let tableValid = true; - - this.syntaxInputs.forEach((input) => { - const valid = input.checkValidity(); - - if (!valid) { - tableValid = valid; - } - }); - - return tableValid; - } - protected willUpdate(changedProperties: PropertyValues): void { if (changedProperties.has("selectors")) { this.rows = this.selectors.map((str) => ({ @@ -157,11 +130,25 @@ export class LinkSelectorTable extends BtrixElement { value=${sel} language="css" placeholder=${msg("Enter selector")} - ?required=${Boolean(attr)} + required @btrix-change=${(e: BtrixChangeEvent) => { const el = e.target as SyntaxInput; const value = e.detail.value.trim(); + this.validateValue( + { + input: el, + value, + + validationMessage: msg( + "Please enter a valid CSS selector", + ), + }, + () => { + this.cssParser(value); + }, + ); + void this.updateRows( { id, @@ -169,17 +156,6 @@ export class LinkSelectorTable extends BtrixElement { }, i, ); - - if (value) { - try { - // Validate selector - this.cssParser(value); - } catch { - el.setCustomValidity( - msg("Please enter a valid CSS selector"), - ); - } - } }} > @@ -203,11 +179,25 @@ export class LinkSelectorTable extends BtrixElement { value=${attr} language="xml" placeholder=${msg("Enter attribute")} - ?required=${Boolean(sel)} + required @btrix-change=${(e: BtrixChangeEvent) => { const el = e.target as SyntaxInput; const value = e.detail.value.trim(); + this.validateValue( + { + input: el, + value, + + validationMessage: msg( + "Please enter a valid HTML attribute", + ), + }, + () => { + document.createElement("a").setAttribute(value, "x-test"); + }, + ); + void this.updateRows( { id, @@ -215,17 +205,6 @@ export class LinkSelectorTable extends BtrixElement { }, i, ); - - if (value) { - try { - // Validate attribute - document.createElement("a").setAttribute(value, "x-test"); - } catch { - el.setCustomValidity( - msg("Please enter a valid HTML attribute"), - ); - } - } }} > @@ -257,6 +236,50 @@ export class LinkSelectorTable extends BtrixElement { `; }; + private validateValue( + { + input, + value, + validationMessage, + }: { + input: SyntaxInput; + value: string; + validationMessage: string; + }, + validate: () => void, + ) { + if (!value) { + if (input.validity.valueMissing) { + this.setValidity(input.validity, input.validationMessage, input); + } + return; + } + + try { + validate(); + + input.setCustomValidity(""); + + // Check if any others are invalid + const invalidInput = Array.from(this.syntaxInputs).find((input) => { + return !input.validity.valid; + }); + + if (invalidInput) { + this.setValidity( + invalidInput.validity, + invalidInput.validationMessage, + invalidInput, + ); + } else { + this.setValidity({}); + } + } catch { + input.setCustomValidity(validationMessage); + this.setValidity(input.validity, input.validationMessage, input); + } + } + private async updateRows( row: LinkSelectorTable["rows"][0] | undefined, idx: number, diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index 4552b633b6..6305b268d8 100644 --- a/frontend/src/features/crawl-workflows/workflow-editor.ts +++ b/frontend/src/features/crawl-workflows/workflow-editor.ts @@ -2207,15 +2207,6 @@ https://archiveweb.page/images/${"logo.svg"}`} if (!this.formElem) return; - // TODO Move away from manual validation check - // See https://github.com/webrecorder/browsertrix/issues/2536 - if (this.formState.autoclickBehavior && this.clickSelector) { - if (!this.clickSelector.checkValidity()) { - this.clickSelector.reportValidity(); - return; - } - } - // Wait for custom behaviors validation to finish // TODO Move away from manual validation check // See https://github.com/webrecorder/browsertrix/issues/2536 diff --git a/frontend/src/strings/validation.ts b/frontend/src/strings/validation.ts new file mode 100644 index 0000000000..9f456ca476 --- /dev/null +++ b/frontend/src/strings/validation.ts @@ -0,0 +1,7 @@ +import { msg } from "@lit/localize"; + +export const validationMessageFor: Partial< + Record +> = { + valueMissing: msg("Please fill out this field."), +}; From 737dd5d9d7d1d5f2aa3b488ec2f8095565b801da Mon Sep 17 00:00:00 2001 From: sua yoo Date: Thu, 1 May 2025 22:18:14 -0700 Subject: [PATCH 5/7] validate link selector --- frontend/src/components/ui/config-details.ts | 5 +- .../ui/data-grid/controllers/rows.ts | 5 + .../src/components/ui/data-grid/renderRows.ts | 7 +- frontend/src/controllers/formControl.ts | 59 ++++++ frontend/src/events/btrix-input.ts | 7 + frontend/src/events/index.ts | 1 + .../crawl-workflows/link-selector-table.ts | 191 +++++++++++------- .../crawl-workflows/workflow-editor.ts | 40 ++-- frontend/src/mixins/FormControl.ts | 3 + frontend/src/theme.stylesheet.css | 4 +- 10 files changed, 216 insertions(+), 106 deletions(-) create mode 100644 frontend/src/controllers/formControl.ts create mode 100644 frontend/src/events/btrix-input.ts diff --git a/frontend/src/components/ui/config-details.ts b/frontend/src/components/ui/config-details.ts index 52aa00d611..45009e21a1 100644 --- a/frontend/src/components/ui/config-details.ts +++ b/frontend/src/components/ui/config-details.ts @@ -503,7 +503,10 @@ export class ConfigDetails extends BtrixElement { selectors.length ? html`
- +
` diff --git a/frontend/src/components/ui/data-grid/controllers/rows.ts b/frontend/src/components/ui/data-grid/controllers/rows.ts index 140a289cc9..f31c56b99f 100644 --- a/frontend/src/components/ui/data-grid/controllers/rows.ts +++ b/frontend/src/components/ui/data-grid/controllers/rows.ts @@ -69,6 +69,11 @@ export class DataGridRowsController implements ReactiveController { } } + public updateItem(id: GridRowId, item: T) { + this.rows.set(id, item); + this.#host.requestUpdate(); + } + public addRows( defaultItem: T | EmptyObject = {}, count = 1, diff --git a/frontend/src/components/ui/data-grid/renderRows.ts b/frontend/src/components/ui/data-grid/renderRows.ts index e3a436ac40..8c436c0bbc 100644 --- a/frontend/src/components/ui/data-grid/renderRows.ts +++ b/frontend/src/components/ui/data-grid/renderRows.ts @@ -5,11 +5,14 @@ import type { GridItem, GridRowId, GridRows } from "./types"; export function renderRows( rows: GridRows, - renderRow: ({ id, item }: { id: GridRowId; item: T }) => TemplateResult, + renderRow: ( + { id, item }: { id: GridRowId; item: T }, + index: number, + ) => TemplateResult, ) { return repeat( rows, ([id]) => id, - ([id, item]) => renderRow({ id, item: item as T }), + ([id, item], i) => renderRow({ id, item: item as T }, i), ); } diff --git a/frontend/src/controllers/formControl.ts b/frontend/src/controllers/formControl.ts new file mode 100644 index 0000000000..c9c9379c58 --- /dev/null +++ b/frontend/src/controllers/formControl.ts @@ -0,0 +1,59 @@ +import type { + LitElement, + ReactiveController, + ReactiveControllerHost, +} from "lit"; + +/** + * Adds `user-valid`, and `user-invalid` data attributes to custom + * form-associated elements (e.g. ones created with `FormControl`) + * to match Shoelace forms. + */ +export class FormControlController implements ReactiveController { + readonly #host: ReactiveControllerHost & LitElement; + + #oneUserInput = false; + + constructor(host: ReactiveControllerHost & LitElement) { + this.#host = host; + host.addController(this); + } + + hostConnected() { + const inputEvents = ["sl-input", "btrix-input"]; + const changeEvents = ["sl-change", "btrix-change"]; + + inputEvents.forEach((name) => { + this.#host.addEventListener( + name, + () => { + this.#oneUserInput = true; + }, + { once: true }, + ); + }); + + // IDEA Mutation observer with attributeFilter to `value` could work + // if custom form controls consistently set a value, in the future + changeEvents.forEach((name) => { + this.#host.addEventListener(name, async (e: Event) => { + const el = e.target as LitElement; + + if (this.#oneUserInput && "validity" in el && el.validity) { + await el.updateComplete; + + // Add user-valid or user-invalid to match `ShoelaceFormControl` + if ((el.validity as ValidityState).valid) { + el.setAttribute("user-valid", ""); + el.removeAttribute("user-invalid"); + } else { + el.setAttribute("user-invalid", ""); + el.removeAttribute("user-valid"); + } + } + }); + }); + } + + hostDisconnected() {} +} diff --git a/frontend/src/events/btrix-input.ts b/frontend/src/events/btrix-input.ts new file mode 100644 index 0000000000..787f388e27 --- /dev/null +++ b/frontend/src/events/btrix-input.ts @@ -0,0 +1,7 @@ +export type BtrixInputEvent = CustomEvent<{ value: T }>; + +declare global { + interface GlobalEventHandlersEventMap { + "btrix-input": BtrixInputEvent; + } +} diff --git a/frontend/src/events/index.ts b/frontend/src/events/index.ts index 8ba0b32b62..ca58e8d0ae 100644 --- a/frontend/src/events/index.ts +++ b/frontend/src/events/index.ts @@ -1 +1,2 @@ import "./btrix-change"; +import "./btrix-input"; diff --git a/frontend/src/features/crawl-workflows/link-selector-table.ts b/frontend/src/features/crawl-workflows/link-selector-table.ts index e0ce5f4c5f..b45e505bd8 100644 --- a/frontend/src/features/crawl-workflows/link-selector-table.ts +++ b/frontend/src/features/crawl-workflows/link-selector-table.ts @@ -2,25 +2,41 @@ import { localized, msg } from "@lit/localize"; import clsx from "clsx"; import { createParser } from "css-selector-parser"; import { html, type PropertyValues } from "lit"; -import { customElement, property, queryAll, state } from "lit/decorators.js"; -import { repeat } from "lit/directives/repeat.js"; +import { customElement, property, queryAll } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; -import { nanoid } from "nanoid"; +import isEqual from "lodash/fp/isEqual"; import { BtrixElement } from "@/classes/BtrixElement"; +import { DataGridRowsController } from "@/components/ui/data-grid/controllers/rows"; +import { renderRows } from "@/components/ui/data-grid/renderRows"; import type { SyntaxInput } from "@/components/ui/syntax-input"; +import { FormControlController } from "@/controllers/formControl"; import type { BtrixChangeEvent } from "@/events/btrix-change"; import { FormControl } from "@/mixins/FormControl"; import type { SeedConfig } from "@/types/crawler"; import { tw } from "@/utils/tailwind"; export const SELECTOR_DELIMITER = "->"; -const emptyCells = ["", ""]; const syntaxInputClasses = tw`flex-1 [--sl-input-border-color:transparent] [--sl-input-border-radius-medium:0]`; +type SelectorItem = { + selector: string; + attribute: string; +}; + +const emptyItem = { + selector: "", + attribute: "", +}; + /** * Displays link selector crawl configuration in an editable table. * + * @TODO Migrate to `` + * https://github.com/webrecorder/browsertrix/issues/2543 + * + * @attr name + * * @fires btrix-change */ @customElement("btrix-link-selector-table") @@ -32,31 +48,50 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { @property({ type: Boolean }) editable = false; - @state() - private rows: { - id: string; - cells: string[]; - }[] = []; + readonly #rowsController = new DataGridRowsController(this); @queryAll("btrix-syntax-input") private readonly syntaxInputs!: NodeListOf; + readonly #formControl = new FormControlController(this); + // CSS parser should ideally match the parser used in browsertrix-crawler. // https://github.com/webrecorder/browsertrix-crawler/blob/v1.5.8/package.json#L23 private readonly cssParser = createParser(); + // Selectors without empty items + #value() { + const selectLinks: string[] = []; + + this.#rowsController.rows.forEach((val) => { + if (val === emptyItem) return; + selectLinks.push(`${val.selector}${SELECTOR_DELIMITER}${val.attribute}`); + }); + + return selectLinks; + } + + // Selectors without missing fields public get value(): SeedConfig["selectLinks"] { - return this.rows - .filter(({ cells }) => cells[0] || cells[1]) - .map(({ cells }) => cells.join(SELECTOR_DELIMITER)); + const selectLinks: string[] = []; + + this.#rowsController.rows.forEach((val) => { + if (!val.selector || !val.attribute) return; + selectLinks.push(`${val.selector}${SELECTOR_DELIMITER}${val.attribute}`); + }); + + return selectLinks; } protected willUpdate(changedProperties: PropertyValues): void { if (changedProperties.has("selectors")) { - this.rows = this.selectors.map((str) => ({ - id: nanoid(), - cells: str.split(SELECTOR_DELIMITER), - })); + const items = this.selectors.map((str) => { + const [selector, attribute] = str.split(SELECTOR_DELIMITER); + + return { selector, attribute }; + }); + + this.#rowsController.setItems(items); } } @@ -87,7 +122,7 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { )} - ${repeat(this.rows, (row) => row.id, this.row)} + ${renderRows(this.#rowsController.rows, this.row)} @@ -96,14 +131,9 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { () => html` - void this.updateRows( - { - id: nanoid(), - cells: emptyCells, - }, - this.rows.length, - )} + @click=${() => { + this.#rowsController.addRows(emptyItem); + }} > ${msg("Add More")} @@ -114,10 +144,11 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { } private readonly row = ( - { id, cells }: LinkSelectorTable["rows"][0], + { id, item }: { id: string; item: SelectorItem }, i: number, ) => { - const [sel, attr] = cells; + const sel = item.selector; + const attr = item.attribute; return html` 0 ? "border-t" : ""}> @@ -130,8 +161,10 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { value=${sel} language="css" placeholder=${msg("Enter selector")} - required + ?required=${Boolean(attr)} @btrix-change=${(e: BtrixChangeEvent) => { + e.stopPropagation(); + const el = e.target as SyntaxInput; const value = e.detail.value.trim(); @@ -149,13 +182,11 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { }, ); - void this.updateRows( - { - id, - cells: [value, attr], - }, - i, - ); + this.#rowsController.updateItem(id, { + selector: value, + attribute: attr, + }); + void this.dispatchChange(); }} > @@ -179,8 +210,10 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { value=${attr} language="xml" placeholder=${msg("Enter attribute")} - required + ?required=${Boolean(sel)} @btrix-change=${(e: BtrixChangeEvent) => { + e.stopPropagation(); + const el = e.target as SyntaxInput; const value = e.detail.value.trim(); @@ -198,13 +231,11 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { }, ); - void this.updateRows( - { - id, - cells: [sel, value], - }, - i, - ); + this.#rowsController.updateItem(id, { + selector: sel, + attribute: value, + }); + void this.dispatchChange(); }} > @@ -226,7 +257,11 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { label=${msg("Remove exclusion")} class="text-base hover:text-danger" name="trash3" - @click=${() => void this.updateRows(undefined, i)} + @click=${async () => { + this.#rowsController.removeRow(id); + await this.updateValidity(); + void this.dispatchChange(); + }} >
@@ -259,53 +294,57 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { validate(); input.setCustomValidity(""); - - // Check if any others are invalid - const invalidInput = Array.from(this.syntaxInputs).find((input) => { - return !input.validity.valid; - }); - - if (invalidInput) { - this.setValidity( - invalidInput.validity, - invalidInput.validationMessage, - invalidInput, - ); - } else { - this.setValidity({}); - } + void this.updateValidity(); } catch { input.setCustomValidity(validationMessage); this.setValidity(input.validity, input.validationMessage, input); } } - private async updateRows( - row: LinkSelectorTable["rows"][0] | undefined, - idx: number, - ) { - const pre = this.rows.slice(0, idx); - const ap = this.rows.slice(idx + 1); + private async anyInvalidInput(): Promise { + await this.updateComplete; + + // Check if any others are invalid + let invalidInput: SyntaxInput | null = null; + let i = 0; + + while (!invalidInput && i < this.syntaxInputs.length) { + const input = this.syntaxInputs[i]; - const rows = row ? [...pre, row, ...ap] : [...pre, ...ap]; + await input; - if (rows.length) { - this.rows = rows; + if (!input.validity.valid) { + invalidInput = input; + } + i++; + } + + return invalidInput; + } + + private async updateValidity() { + const invalidInput = await this.anyInvalidInput(); + + if (invalidInput) { + this.setValidity( + invalidInput.validity, + invalidInput.validationMessage, + invalidInput, + ); } else { - this.rows = [ - { - id: nanoid(), - cells: emptyCells, - }, - ]; + this.setValidity({}); } + } - await this.updateComplete; + private async dispatchChange() { + await this.anyInvalidInput(); + + if (isEqual(this.selectors, this.#value)) return; this.dispatchEvent( new CustomEvent("btrix-change", { detail: { - value: this.value, + value: this.#value, }, }), ); diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index 6305b268d8..22848fbc03 100644 --- a/frontend/src/features/crawl-workflows/workflow-editor.ts +++ b/frontend/src/features/crawl-workflows/workflow-editor.ts @@ -7,7 +7,6 @@ import type { SlInput, SlRadio, SlRadioGroup, - SlSelect, SlTextarea, } from "@shoelace-style/shoelace"; import clsx from "clsx"; @@ -15,7 +14,13 @@ import { createParser } from "css-selector-parser"; import Fuse from "fuse.js"; import { mergeDeep } from "immutable"; import type { LanguageCode } from "iso-639-1"; -import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; +import { + html, + nothing, + type LitElement, + type PropertyValues, + type TemplateResult, +} from "lit"; import { customElement, property, @@ -332,9 +337,6 @@ export class WorkflowEditor extends BtrixElement { @query("btrix-custom-behaviors-table") private readonly customBehaviorsTable?: CustomBehaviorsTable | null; - @query("btrix-syntax-input[name='clickSelector']") - private readonly clickSelector?: SyntaxInput | null; - // CSS parser should ideally match the parser used in browsertrix-crawler. // https://github.com/webrecorder/browsertrix-crawler/blob/v1.5.8/package.json#L23 private readonly cssParser = createParser(); @@ -1170,11 +1172,9 @@ https://archiveweb.page/images/${"logo.svg"}`}
${inputCol( html` { - this.updateSelectorsValidity(); - }} >`, )} ${this.renderHelpTextCol( @@ -2046,25 +2046,14 @@ https://archiveweb.page/images/${"logo.svg"}`} } } - /** - * HACK Set data attribute manually so that - * selectors table works with `syncTabErrorState` - */ - private updateSelectorsValidity() { - if (this.linkSelectorTable?.checkValidity() === false) { - this.linkSelectorTable.setAttribute("data-invalid", "true"); - this.linkSelectorTable.setAttribute("data-user-invalid", "true"); - } else { - this.linkSelectorTable?.removeAttribute("data-invalid"); - this.linkSelectorTable?.removeAttribute("data-user-invalid"); - } - } - private readonly validateOnBlur = async (e: Event) => { - const el = e.target as SlInput | SlTextarea | SlSelect | SlCheckbox; + const el = e.target as LitElement; const tagName = el.tagName.toLowerCase(); if ( - !["sl-input", "sl-textarea", "sl-select", "sl-checkbox"].includes(tagName) + !["sl-input", "sl-textarea", "sl-select", "sl-checkbox"].includes( + tagName, + ) && + !("value" in el) ) { return; } @@ -2078,6 +2067,7 @@ https://archiveweb.page/images/${"logo.svg"}`} } const currentTab = panelEl.id.split(panelSuffix)[0] as StepName; + // Check [data-user-invalid] to validate only touched inputs if ("userInvalid" in el.dataset) { if (this.progressState!.tabs[currentTab].error) return; @@ -2091,7 +2081,7 @@ https://archiveweb.page/images/${"logo.svg"}`} } }; - private syncTabErrorState(el: HTMLElement) { + private syncTabErrorState(el: LitElement) { const panelEl = el.closest(`.${formName}${panelSuffix}`); if (!panelEl) { diff --git a/frontend/src/mixins/FormControl.ts b/frontend/src/mixins/FormControl.ts index 89a3eae18e..475cded9e0 100644 --- a/frontend/src/mixins/FormControl.ts +++ b/frontend/src/mixins/FormControl.ts @@ -1,6 +1,9 @@ import type { LitElement } from "lit"; import type { Constructor } from "type-fest"; +/** + * Associate a custom element with a form. + */ export const FormControl = >(superClass: T) => class extends superClass { static formAssociated = true; diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index 605db16efd..d00803352f 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -195,8 +195,8 @@ /* Validation styles */ /** - * FIXME Use [data-user-invalid] selector once following is fixed - * https://github.com/webrecorder/browsertrix/issues/2497 + * FIXME Use [data-user-invalid] selector exclusion table is migrated + * https://github.com/webrecorder/browsertrix/issues/2542 */ .invalid[data-invalid]:not([disabled])::part(base), btrix-url-input[data-user-invalid]:not([disabled])::part(base), From e90cff0ed9ff6056ab68ee7efcecda1f5b5dde37 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 5 May 2025 15:02:05 -0700 Subject: [PATCH 6/7] update lodash import --- frontend/src/utils/form.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/utils/form.ts b/frontend/src/utils/form.ts index 4c67c25874..03d1f0a004 100644 --- a/frontend/src/utils/form.ts +++ b/frontend/src/utils/form.ts @@ -5,7 +5,7 @@ import { serialize, } from "@shoelace-style/shoelace/dist/utilities/form.js"; import type { LitElement } from "lit"; -import { isEqual } from "lodash/fp"; +import isEqual from "lodash/fp/isEqual"; import type { EmptyObject } from "type-fest"; import localize from "./localize"; From 8a5dd6aeae352529de96ee3e753a30039f9e9f3c Mon Sep 17 00:00:00 2001 From: sua yoo Date: Thu, 8 May 2025 14:57:33 -0700 Subject: [PATCH 7/7] clean up grid types --- .../src/components/ui/data-grid/data-grid-cell.ts | 11 ++++++++--- frontend/src/components/ui/data-grid/types.ts | 3 +++ frontend/src/components/ui/data-table.ts | 2 ++ frontend/src/components/ui/table/table.ts | 4 +--- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/ui/data-grid/data-grid-cell.ts b/frontend/src/components/ui/data-grid/data-grid-cell.ts index 8c8749067f..c02ef6b931 100644 --- a/frontend/src/components/ui/data-grid/data-grid-cell.ts +++ b/frontend/src/components/ui/data-grid/data-grid-cell.ts @@ -6,7 +6,12 @@ import { ifDefined } from "lit/directives/if-defined.js"; import { TableCell } from "../table/table-cell"; -import type { GridColumn, GridColumnSelectType, GridItem } from "./types"; +import type { + GridColumn, + GridColumnSelectType, + GridItem, + GridItemValue, +} from "./types"; import { GridColumnType } from "./types"; import { DataGridFocusController } from "@/components/ui/data-grid/controllers/focus"; @@ -45,7 +50,7 @@ export class DataGridCell extends TableCell { item?: GridItem; @property({ type: String }) - value?: GridItem[keyof GridItem]; + value?: GridItemValue; @property({ type: Boolean }) editable = false; @@ -122,7 +127,7 @@ export class DataGridCell extends TableCell { value: cellValue, }: { item: GridItem; - value?: GridItem[keyof GridItem]; + value?: GridItemValue; }) => { const col = this.column; diff --git a/frontend/src/components/ui/data-grid/types.ts b/frontend/src/components/ui/data-grid/types.ts index cc62ca7346..cc353eae4f 100644 --- a/frontend/src/components/ui/data-grid/types.ts +++ b/frontend/src/components/ui/data-grid/types.ts @@ -6,6 +6,9 @@ export type GridItem = Record< string | number | null | undefined >; +export type GridItemValue = + GridItem[keyof GridItem]; + export enum GridColumnType { Text = "text", Number = "number", diff --git a/frontend/src/components/ui/data-table.ts b/frontend/src/components/ui/data-table.ts index bd9c736b95..b25c312d16 100644 --- a/frontend/src/components/ui/data-table.ts +++ b/frontend/src/components/ui/data-table.ts @@ -6,6 +6,8 @@ import { TailwindElement } from "@/classes/TailwindElement"; type CellContent = string | TemplateResult<1>; /** + * @deprecated Use `` instead. + * * Styled tables for handling lists of tabular data. * Data tables are less flexible than `` but require less configuration. */ diff --git a/frontend/src/components/ui/table/table.ts b/frontend/src/components/ui/table/table.ts index 8fd5cd2df8..497a47f9ae 100644 --- a/frontend/src/components/ui/table/table.ts +++ b/frontend/src/components/ui/table/table.ts @@ -19,11 +19,9 @@ tableCSS.split("}").forEach((rule: string) => { }); /** - * @deprecated Use `` instead. - * * Low-level component for displaying content into columns and rows. * To style tables, use TailwindCSS utility classes. - * To render styled, tabular data, use ``. + * To render styled, tabular data, use ``. * * Table columns are automatically sized according to their content. * To specify column sizes, use `--btrix-table-grid-template-columns`.