feat: Add custom behaviors to workflow (#2520)

Resolves https://github.com/webrecorder/browsertrix/issues/2151
Follows https://github.com/webrecorder/browsertrix/pull/2505

## Changes

- Allows users to set custom behaviors in workflow editor.
- Allows one or more behaviors, as simple URL or Git URL to be added
- Calls validation endpoint to check if URL is valid.

---------

Co-authored-by: emma <hi@emma.cafe>
This commit is contained in:
sua yoo 2025-04-02 17:45:27 -07:00 committed by GitHub
parent cd7b695520
commit 23f9e08a22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 819 additions and 20 deletions

View File

@ -178,6 +178,16 @@ export class ConfigDetails extends BtrixElement {
.filter((v) => v)
.join(", ") || none,
)}
${this.renderSetting(
labelFor.customBehaviors,
seedsConfig?.customBehaviors.length
? html`
<btrix-custom-behaviors-table
.customBehaviors=${seedsConfig.customBehaviors}
></btrix-custom-behaviors-table>
`
: none,
)}
${this.renderSetting(
labelFor.pageLoadTimeoutSeconds,
renderTimeLimit(

View File

@ -35,6 +35,9 @@ export class CopyField extends TailwindElement {
@property({ type: Boolean })
hoist = false;
@property({ type: Boolean })
border = true;
@property({ type: Boolean })
monostyle = true;
@ -65,7 +68,7 @@ export class CopyField extends TailwindElement {
<div
role="group"
class=${clsx(
tw`rounded border`,
this.border && tw`rounded border`,
this.filled ? tw`bg-slate-50` : tw`border-neutral-150`,
this.monostyle && tw`font-monostyle`,
)}

View File

@ -1,4 +1,3 @@
// import type { SlInputEvent } from "@shoelace-style/shoelace";
import { msg } from "@lit/localize";
import SlInput from "@shoelace-style/shoelace/dist/components/input/input.js";
import { customElement, property } from "lit/decorators.js";
@ -20,45 +19,41 @@ export function validURL(url: string) {
* @attr {String} name
* @attr {String} label
* @attr {String} value
* @attr {Boolean} required
*/
@customElement("btrix-url-input")
export class Component extends SlInput {
export class UrlInput extends SlInput {
@property({ type: Number, reflect: true })
minlength = 4;
@property({ type: String, reflect: true })
placeholder = "https://example.com";
connectedCallback(): void {
constructor() {
super();
this.inputmode = "url";
super.connectedCallback();
this.addEventListener("sl-input", this.onInput);
this.addEventListener("sl-blur", this.onBlur);
this.addEventListener("sl-change", this.onChange);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("sl-input", this.onInput);
this.removeEventListener("sl-blur", this.onBlur);
this.removeEventListener("sl-change", this.onChange);
}
private readonly onInput = async () => {
console.log("input 1");
await this.updateComplete;
private readonly onInput = () => {
if (!this.checkValidity() && validURL(this.value)) {
this.setCustomValidity("");
this.helpText = "";
}
};
private readonly onBlur = async () => {
await this.updateComplete;
const value = this.value;
private readonly onChange = () => {
const value = this.value.trim();
if (value && !validURL(value)) {
const text = msg("Please enter a valid URL.");

View File

@ -170,6 +170,7 @@ export class APIController implements ReactiveController {
message: errorMessage,
status: resp.status,
details: errorDetails,
errorCode: errorDetail,
});
}

View File

@ -0,0 +1,558 @@
import { localized, msg } from "@lit/localize";
import { Task } from "@lit/task";
import type {
SlChangeEvent,
SlInput,
SlInputEvent,
SlSelect,
} from "@shoelace-style/shoelace";
import clsx from "clsx";
import { css, html, type PropertyValues } from "lit";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators.js";
import { when } from "lit/directives/when.js";
import { BtrixElement } from "@/classes/BtrixElement";
import type { UrlInput } from "@/components/ui/url-input";
import { notSpecified } from "@/layouts/empty";
import { APIErrorDetail } from "@/types/api";
import type { SeedConfig } from "@/types/crawler";
import { APIError } from "@/utils/api";
import { tw } from "@/utils/tailwind";
export type CustomBehaviors = SeedConfig["customBehaviors"];
export type CustomBehaviorSource = CustomBehaviors[number];
export enum CustomBehaviorType {
FileURL = "fileUrl",
GitRepo = "gitRepo",
}
const ValidationErrorCodes = [
APIErrorDetail.InvalidCustomBehavior,
APIErrorDetail.CustomBehaviorBranchNotFound,
APIErrorDetail.CustomBehaviorNotFound,
] as const;
type ValidationErrorCode = (typeof ValidationErrorCodes)[number];
type RowValidation = { success: true };
type BehaviorBase = {
type: CustomBehaviorType;
url: string;
path?: string;
branch?: string;
};
export type BehaviorFileURL = BehaviorBase & {
type: CustomBehaviorType.FileURL;
};
export type BehaviorGitRepo = Required<BehaviorBase> & {
type: CustomBehaviorType.GitRepo;
};
export type ChangeEventDetail = {
value: CustomBehaviorSource;
};
export type RemoveEventDetail = {
item: CustomBehaviorSource;
};
const labelFor: Record<CustomBehaviorType, string> = {
[CustomBehaviorType.FileURL]: msg("URL"),
[CustomBehaviorType.GitRepo]: msg("Git Repo"),
};
const errorFor: Record<ValidationErrorCode, string> = {
[APIErrorDetail.InvalidCustomBehavior]: msg("Please enter a valid URL"),
[APIErrorDetail.CustomBehaviorBranchNotFound]: msg(
"Please enter a valid branch",
),
[APIErrorDetail.CustomBehaviorNotFound]: msg("Please enter an existing URL"),
};
const inputStyle = [
tw`[--sl-input-background-color-hover:transparent] [--sl-input-background-color:transparent] [--sl-input-border-color-hover:transparent] [--sl-input-border-radius-medium:0] [--sl-input-spacing-medium:var(--sl-spacing-small)]`,
tw`data-[valid]:[--sl-input-border-color:transparent]`,
tw`part-[form-control-help-text]:mx-1 part-[form-control-help-text]:mb-1`,
];
const INPUT_CLASSNAME = "input" as const;
const INVALID_CLASSNAME = "invalid" as const;
export const GIT_PREFIX = "git+" as const;
export const isGitRepo = (url: CustomBehaviorSource) =>
url.startsWith(GIT_PREFIX);
export const stringifyGitRepo = (behavior: BehaviorGitRepo): string => {
return `${GIT_PREFIX}${behavior.url}?branch=${behavior.branch}&path=${behavior.path}`;
};
export const stringifyBehavior = (behavior: BehaviorBase): string => {
if (!behavior.url) return "";
if (behavior.type === CustomBehaviorType.GitRepo) {
return stringifyGitRepo(behavior as BehaviorGitRepo);
}
return behavior.url;
};
const parseGitRepo = (repoUrl: string): Omit<BehaviorGitRepo, "type"> => {
const url = new URL(repoUrl.slice(GIT_PREFIX.length));
return {
url: `${url.origin}${url.pathname}`,
path: url.searchParams.get("path") || "",
branch: url.searchParams.get("branch") || "",
};
};
export const parseBehavior = (url: string): BehaviorBase => {
if (!url) {
return {
type: CustomBehaviorType.GitRepo,
url: "",
path: "",
branch: "",
};
}
if (isGitRepo(url)) {
try {
return {
type: CustomBehaviorType.GitRepo,
...parseGitRepo(url),
};
} catch {
return {
type: CustomBehaviorType.GitRepo,
url: "",
path: "",
branch: "",
};
}
}
return {
type: CustomBehaviorType.FileURL,
url,
};
};
/**
* @fires btrix-change
* @fires btrix-invalid
* @fires btrix-remove
*/
@customElement("btrix-custom-behaviors-table-row")
@localized()
export class CustomBehaviorsTableRow extends BtrixElement {
static styles = css`
:host {
display: contents;
}
`;
@property({ type: String })
behaviorSource?: string;
@property({ type: Boolean })
editable = false;
@state()
private behavior?: BehaviorBase;
@queryAll(`.${INPUT_CLASSNAME}`)
private readonly inputs!: NodeListOf<SlInput | UrlInput>;
@query(`#url`)
private readonly urlInput?: UrlInput | null;
@query(`#branch`)
private readonly branchInput?: SlInput | null;
@query(`#path`)
private readonly pathInput?: SlInput | null;
public get taskComplete() {
return this.validateTask.taskComplete;
}
public checkValidity(): boolean {
return ![...this.inputs].some((input) => !input.checkValidity());
}
public reportValidity(): boolean {
return ![...this.inputs].some((input) => !input.reportValidity());
}
private readonly validateTask = new Task(this, {
task: async ([behaviorSource], { signal }) => {
if (!behaviorSource) {
return null;
}
try {
return await this.validateBehavior(behaviorSource, signal);
} catch (err) {
if (
typeof err === "string" &&
ValidationErrorCodes.includes(err as ValidationErrorCode)
) {
this.setInputCustomValidity(err);
throw err;
}
if (err instanceof Error && err.name === "AbortError") {
console.debug(err);
} else {
console.error(err);
}
}
},
args: () => [this.behaviorSource] as const,
});
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("behaviorSource")) {
this.behavior = parseBehavior(this.behaviorSource || "");
}
}
protected updated(changedProperties: PropertyValues): void {
if (changedProperties.has("behavior") && this.behavior) {
this.dispatchEvent(
new CustomEvent<ChangeEventDetail>("btrix-change", {
detail: {
value: stringifyBehavior(this.behavior),
},
}),
);
}
}
render() {
const behavior = this.behavior;
if (!behavior) return;
return html`
<btrix-table-row class="border-t">
<btrix-table-cell
class=${clsx(
this.editable
? tw`h-[var(--sl-input-height-medium)] p-1`
: tw`items-start`,
)}
>
${this.renderType(behavior)}
</btrix-table-cell>
<btrix-table-cell
class=${clsx(
tw`block border-l p-0`,
this.editable ? tw`overflow-visible` : tw`overflow-auto`,
)}
>
${behavior.type === CustomBehaviorType.GitRepo
? this.renderGitRepoCell(behavior as BehaviorGitRepo)
: this.renderFileUrlCell(behavior as BehaviorFileURL)}
</btrix-table-cell>
${when(
this.editable,
() => html`
<btrix-table-cell class="border-l p-1">
<sl-icon-button
class="text-base hover:text-danger"
name="trash3"
@click=${() =>
this.dispatchEvent(
new CustomEvent<RemoveEventDetail>("btrix-remove"),
)}
></sl-icon-button>
</btrix-table-cell>
`,
)}
</btrix-table-row>
`;
}
private renderType(behavior: BehaviorBase) {
if (!this.editable) {
return html`${labelFor[behavior.type]}`;
}
return html`
<sl-select
placeholder=${msg("Select Source")}
size="small"
class="w-[8em]"
value=${behavior.type}
@sl-change=${(e: SlChangeEvent) => {
const el = e.target as SlSelect;
this.behavior = {
...behavior,
type: el.value as CustomBehaviorType,
path: behavior.path || "",
branch: behavior.branch || "",
};
}}
>
${Object.values(CustomBehaviorType).map(
(behaviorType) => html`
<sl-option value=${behaviorType} class="whitespace-nowrap">
${labelFor[behaviorType]}
</sl-option>
`,
)}
</sl-select>
`;
}
private renderGitRepoCell(behavior: BehaviorGitRepo) {
const subgridStyle = tw`grid grid-cols-[max-content_1fr] border-t`;
const labelStyle = tw`flex inline-flex items-center justify-end border-r bg-neutral-50 p-2 text-xs leading-none text-neutral-700`;
const pathLabel = msg("Path");
const branchLabel = msg("Branch");
if (!this.editable) {
return html`
${this.renderReadonlyUrl(behavior)}
<dl class=${subgridStyle}>
<dt class=${clsx(labelStyle, tw`border-b`)}>${pathLabel}</dt>
<dd class="border-b p-2">${behavior.path || notSpecified}</dd>
<dt class=${labelStyle}>${branchLabel}</dt>
<dd class="p-2">${behavior.branch || notSpecified}</dd>
</dl>
`;
}
return html`${this.renderUrlInput(behavior, {
placeholder: msg("Enter URL to Git repository"),
})}
<div class=${subgridStyle}>
<label for="path" class=${clsx(labelStyle, tw`border-b`)}
>${pathLabel}</label
>
<div class="border-b">
${this.renderGitDetailInput(behavior, {
placeholder: msg("Optional path"),
key: "path",
})}
</div>
<label for="branch" class=${labelStyle}>${branchLabel}</label>
<div>
${this.renderGitDetailInput(behavior, {
placeholder: msg("Optional branch"),
key: "branch",
})}
</div>
</div> `;
}
private renderFileUrlCell(behavior: BehaviorFileURL) {
if (!this.editable) {
return this.renderReadonlyUrl(behavior);
}
return this.renderUrlInput(behavior, {
placeholder: msg("Enter URL to JavaScript file"),
});
}
private renderReadonlyUrl(behavior: BehaviorBase) {
return html`
<btrix-copy-field
class="mt-0.5"
.value=${behavior.url}
.monostyle=${false}
.border=${false}
.filled=${false}
>
<sl-tooltip slot="prefix" content=${msg("Open in New Tab")} hoist>
<sl-icon-button
href=${behavior.url}
name="box-arrow-up-right"
target="_blank"
rel="noopener noreferrer nofollow"
class="m-px"
>
</sl-icon-button>
</sl-tooltip>
</btrix-copy-field>
`;
}
private renderUrlInput(
behavior: BehaviorBase,
{ placeholder }: { placeholder: string },
) {
return html`
<btrix-url-input
id="url"
placeholder=${placeholder}
class=${clsx(inputStyle, INPUT_CLASSNAME)}
value=${behavior.url}
@sl-input=${this.onInput}
@sl-change=${this.onInputChangeForKey(behavior, "url")}
@sl-invalid=${() =>
this.dispatchEvent(new CustomEvent("btrix-invalid"))}
>
${this.validateTask.render({
pending: this.renderPendingValidation,
complete: this.renderValidTooltip,
error: this.renderInvalidTooltip,
})}
</btrix-url-input>
`;
}
private readonly renderPendingValidation = () => {
return html`
<div slot="suffix" class="inline-flex items-center">
<sl-spinner class="size-4 text-base"></sl-spinner>
</div>
`;
};
private readonly renderInvalidTooltip = (err: unknown) => {
const message =
typeof err === "string" && errorFor[err as ValidationErrorCode];
if (!message) {
console.debug("no message for error:", err);
return;
}
return html`
<div slot="suffix" class="inline-flex items-center">
<sl-tooltip hoist content=${message} placement="bottom-end">
<sl-icon
name="exclamation-lg"
class="size-4 text-base text-danger"
></sl-icon>
</sl-tooltip>
</div>
`;
};
private readonly renderValidTooltip = (
validation: typeof this.validateTask.value,
) => {
if (!validation) return;
return html`
<div slot="suffix" class="inline-flex items-center">
<sl-tooltip hoist content=${msg("URL is valid")} placement="bottom-end">
<sl-icon
name="check-lg"
class="size-4 text-base text-success"
></sl-icon>
</sl-tooltip>
</div>
`;
};
private renderGitDetailInput(
behavior: BehaviorGitRepo,
{ placeholder, key }: { placeholder: string; key: "path" | "branch" },
) {
return html`
<sl-input
id=${key}
class=${clsx(inputStyle, INPUT_CLASSNAME, key)}
size="small"
value=${behavior[key]}
placeholder=${placeholder}
spellcheck="false"
@sl-input=${this.onInput}
@sl-change=${this.onInputChangeForKey(behavior, key)}
@sl-invalid=${() =>
this.dispatchEvent(new CustomEvent("btrix-invalid"))}
></sl-input>
`;
}
private readonly onInput = (e: SlInputEvent) => {
const el = e.target as SlInput;
el.classList.remove(INVALID_CLASSNAME);
el.setCustomValidity("");
};
private readonly onInputChangeForKey =
(behavior: BehaviorBase, key: string) => async (e: SlChangeEvent) => {
const el = e.target as SlInput;
const value = el.value.trim();
this.behavior = {
...behavior,
[key]: value,
};
};
private setInputCustomValidity(error: unknown) {
const updateValidity = (
input: SlInput | null | undefined,
error?: ValidationErrorCode,
) => {
if (!input) return;
if (error) {
input.classList.add(INVALID_CLASSNAME);
} else {
input.classList.remove(INVALID_CLASSNAME);
}
input.setCustomValidity(error ? errorFor[error] : "");
};
switch (error) {
case APIErrorDetail.InvalidCustomBehavior: {
updateValidity(this.urlInput, APIErrorDetail.InvalidCustomBehavior);
updateValidity(this.branchInput);
updateValidity(this.pathInput);
break;
}
case APIErrorDetail.CustomBehaviorBranchNotFound: {
updateValidity(
this.branchInput,
APIErrorDetail.CustomBehaviorBranchNotFound,
);
break;
}
case APIErrorDetail.CustomBehaviorNotFound: {
updateValidity(this.urlInput, APIErrorDetail.CustomBehaviorNotFound);
updateValidity(this.branchInput);
updateValidity(this.pathInput);
break;
}
default:
break;
}
}
private async validateBehavior(
behaviorSource: CustomBehaviorSource,
signal: AbortSignal,
): Promise<RowValidation | undefined> {
try {
return await this.api.fetch<RowValidation>(
`/orgs/${this.orgId}/crawlconfigs/validate/custom-behavior`,
{
method: "POST",
body: JSON.stringify({
customBehavior: behaviorSource,
}),
signal,
},
);
} catch (err) {
if (err instanceof APIError) {
throw err.errorCode;
}
throw err;
}
}
}

View File

@ -0,0 +1,164 @@
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 { repeat } from "lit/directives/repeat.js";
import { when } from "lit/directives/when.js";
import { nanoid } from "nanoid";
import { z } from "zod";
import { BtrixElement } from "@/classes/BtrixElement";
import {
type CustomBehaviors,
type CustomBehaviorSource,
type CustomBehaviorsTableRow,
type ChangeEventDetail as RowChangeEventDetail,
} from "@/features/crawl-workflows/custom-behaviors-table-row";
import { tw } from "@/utils/tailwind";
import "@/features/crawl-workflows/custom-behaviors-table-row";
type ChangeEventDetail = {
value: CustomBehaviors;
};
const rowIdSchema = z.string().nanoid();
type RowId = z.infer<typeof rowIdSchema>;
/**
* @fires btrix-change
* @fires btrix-invalid
*/
@customElement("btrix-custom-behaviors-table")
@localized()
export class CustomBehaviorsTable extends BtrixElement {
@property({ type: Array })
customBehaviors: CustomBehaviors = [];
@property({ type: Boolean })
editable = false;
@state()
private rows = new Map<RowId, CustomBehaviorSource>();
@queryAll("btrix-custom-behaviors-table-row")
private readonly rowElems!: NodeListOf<CustomBehaviorsTableRow>;
public get value(): CustomBehaviors {
return [...this.rows.values()].filter((v) => v);
}
public get taskComplete() {
return Promise.all([...this.rowElems].map(async (row) => row.taskComplete));
}
public checkValidity(): boolean {
return ![...this.rowElems].some((row) => !row.checkValidity());
}
public reportValidity(): boolean {
return ![...this.rowElems].some((row) => !row.reportValidity());
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("customBehaviors")) {
if (!this.customBehaviors.length) {
const id = nanoid();
this.rows = new Map([[id, ""]]);
} else {
// TODO Reuse IDs?
this.rows = new Map(this.customBehaviors.map((url) => [nanoid(), url]));
}
}
}
protected updated(changedProperties: PropertyValues): void {
if (changedProperties.get("rows")) {
this.dispatchEvent(
new CustomEvent<ChangeEventDetail>("btrix-change", {
detail: {
value: this.value,
},
}),
);
}
}
render() {
return html`
<btrix-table
class=${clsx(
tw`relative h-full w-full grid-cols-[max-content_1fr_min-content] rounded border`,
// TODO Consolidate with data-table
// https://github.com/webrecorder/browsertrix/issues/2497
tw`[--btrix-cell-padding-bottom:var(--sl-spacing-x-small)] [--btrix-cell-padding-left:var(--sl-spacing-x-small)] [--btrix-cell-padding-right:var(--sl-spacing-x-small)] [--btrix-cell-padding-top:var(--sl-spacing-x-small)]`,
)}
>
<btrix-table-head class="rounded-t bg-neutral-50">
<btrix-table-header-cell> ${msg("Source")} </btrix-table-header-cell>
<btrix-table-header-cell class="border-l">
${msg("Script Location")}
</btrix-table-header-cell>
${when(
this.editable,
() => html`
<btrix-table-header-cell class="border-l">
<span class="sr-only">${msg("Row actions")}</span>
</btrix-table-header-cell>
`,
)}
</btrix-table-head>
<btrix-table-body>
${repeat(
this.rows,
([id]) => id,
(args) => this.renderRow(...args),
)}
</btrix-table-body>
</btrix-table>
${when(
this.editable,
() => html`
<sl-button class="mt-2 w-full" @click=${() => this.addRow()}>
<sl-icon slot="prefix" name="plus-lg"></sl-icon>
<span class="text-neutral-600">${msg("Add More")}</span>
</sl-button>
`,
)}
`;
}
private readonly renderRow = (id: RowId, url: CustomBehaviorSource) => {
return html`
<btrix-custom-behaviors-table-row
behaviorSource=${url}
?editable=${this.editable}
@btrix-remove=${() => this.removeRow(id)}
@btrix-change=${(e: CustomEvent<RowChangeEventDetail>) => {
const url = e.detail.value;
this.rows = new Map(this.rows.set(id, url));
}}
@btrix-invalid=${() =>
this.dispatchEvent(new CustomEvent("btrix-invalid"))}
>
</btrix-custom-behaviors-table-row>
`;
};
private addRow() {
const id = nanoid();
this.rows = new Map(this.rows.set(id, ""));
}
private removeRow(id: RowId) {
this.rows.delete(id);
if (this.rows.size === 0) {
this.addRow();
} else {
this.rows = new Map(this.rows);
}
}
}

View File

@ -1,3 +1,4 @@
import("./custom-behaviors-table");
import("./exclusion-editor");
import("./live-workflow-status");
import("./link-selector-table");

View File

@ -52,6 +52,7 @@ import {
} from "@/controllers/observable";
import { type SelectBrowserProfileChangeEvent } from "@/features/browser-profiles/select-browser-profile";
import type { CollectionsChangeEvent } from "@/features/collections/collections-add";
import type { CustomBehaviorsTable } from "@/features/crawl-workflows/custom-behaviors-table";
import type { CrawlStatusChangedEventDetail } from "@/features/crawl-workflows/live-workflow-status";
import type {
ExclusionChangeEvent,
@ -316,6 +317,9 @@ export class WorkflowEditor extends BtrixElement {
@query("btrix-link-selector-table")
private readonly linkSelectorTable?: LinkSelectorTable | null;
@query("btrix-custom-behaviors-table")
private readonly customBehaviorsTable?: CustomBehaviorsTable | null;
connectedCallback(): void {
this.initializeEditor();
super.connectedCallback();
@ -1259,6 +1263,7 @@ https://archiveweb.page/images/${"logo.svg"}`}
),
false,
)}
${this.renderCustomBehaviors()}
${this.renderSectionHeading(msg("Page Timing"))}
${inputCol(html`
<sl-input
@ -1321,6 +1326,42 @@ https://archiveweb.page/images/${"logo.svg"}`}
`;
}
private renderCustomBehaviors() {
return html`
${this.renderSectionHeading(labelFor.customBehaviors)}
${inputCol(
html`<btrix-custom-behaviors-table
.customBehaviors=${this.initialWorkflow?.config.customBehaviors || []}
editable
@btrix-change=${() => {
this.customBehaviorsTable?.removeAttribute("data-invalid");
this.customBehaviorsTable?.removeAttribute("data-user-invalid");
}}
@btrix-invalid=${() => {
/**
* HACK Set data attribute manually so that
* table works with `syncTabErrorState`
*
* FIXME Should be fixed with
* https://github.com/webrecorder/browsertrix/issues/2497
*/
this.customBehaviorsTable?.setAttribute("data-invalid", "true");
this.customBehaviorsTable?.setAttribute(
"data-user-invalid",
"true",
);
}}
></btrix-custom-behaviors-table>`,
)}
${this.renderHelpTextCol(
msg(
`Enable custom page actions with behavior scripts. You can specify any publicly accessible URL or public Git repository.`,
),
false,
)}
`;
}
private renderBrowserSettings() {
if (!this.formState.lang) throw new Error("missing formstate.lang");
return html`
@ -1893,6 +1934,9 @@ https://archiveweb.page/images/${"logo.svg"}`}
/**
* HACK Set data attribute manually so that
* exclusions table works with `syncTabErrorState`
*
* FIXME Should be fixed with
* https://github.com/webrecorder/browsertrix/issues/2497
*/
private updateExclusionsValidity() {
if (this.exclusionTable?.checkValidity() === false) {
@ -2065,6 +2109,14 @@ https://archiveweb.page/images/${"logo.svg"}`}
private async save() {
if (!this.formElem) return;
// Wait for custom behaviors validation to finish
try {
await this.customBehaviorsTable?.taskComplete;
} catch {
this.customBehaviorsTable?.reportValidity();
return;
}
const isValid = await this.checkFormValidity(this.formElem);
if (!isValid || this.formHasError) {
@ -2149,12 +2201,12 @@ https://archiveweb.page/images/${"logo.svg"}`}
if (isApiErrorDetail(errorDetail)) {
switch (errorDetail) {
case APIErrorDetail.WorkflowInvalidLinkSelector:
case APIErrorDetail.InvalidLinkSelector:
errorDetailMessage = msg(
"Page link selectors contain invalid selector or attribute",
);
break;
case APIErrorDetail.WorkflowInvalidRegex:
case APIErrorDetail.InvalidRegex:
errorDetailMessage = msg(
"Page exclusion contains invalid regex",
);
@ -2280,6 +2332,7 @@ https://archiveweb.page/images/${"logo.svg"}`}
selectLinks: this.linkSelectorTable?.value.length
? this.linkSelectorTable.value
: DEFAULT_SELECT_LINKS,
customBehaviors: this.customBehaviorsTable?.value || [],
},
crawlerChannel: this.formState.crawlerChannel || "default",
proxyId: this.formState.proxyId,

View File

@ -88,6 +88,7 @@ export class WorkflowsNew extends LiteElement {
failOnFailedSeed: false,
userAgent: null,
selectLinks: DEFAULT_SELECT_LINKS,
customBehaviors: [],
},
tags: [],
crawlTimeout: null,

View File

@ -2,6 +2,7 @@ import { msg } from "@lit/localize";
export const labelFor = {
behaviors: msg("Built-in Behaviors"),
customBehaviors: msg("Custom Behaviors"),
autoscrollBehavior: msg("Autoscroll"),
autoclickBehavior: msg("Autoclick"),
pageLoadTimeoutSeconds: msg("Page Load Limit"),

View File

@ -185,11 +185,19 @@
}
/* Validation styles */
/**
* FIXME Use [data-user-invalid] selector once following is fixed
* https://github.com/webrecorder/browsertrix/issues/2497
*/
.invalid[data-invalid]:not([disabled])::part(base),
btrix-url-input[data-user-invalid]:not([disabled])::part(base),
sl-input[data-user-invalid]:not([disabled])::part(base),
sl-textarea[data-user-invalid]:not([disabled])::part(base) {
border-color: var(--sl-color-danger-400);
}
.invalid[data-invalid]:focus-within::part(base),
btrix-url-input[data-user-invalid]:focus-within::part(base),
sl-input[data-user-invalid]:focus-within::part(base),
sl-textarea[data-user-invalid]:focus-within::part(base) {
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-100);

View File

@ -38,8 +38,11 @@ export type APISortQuery<T = Record<string, unknown>> = {
// TODO Add all error codes
// https://github.com/webrecorder/browsertrix/issues/2512
export enum APIErrorDetail {
WorkflowInvalidLinkSelector = "invalid_link_selector",
WorkflowInvalidRegex = "invalid_regex",
InvalidLinkSelector = "invalid_link_selector",
InvalidRegex = "invalid_regex",
InvalidCustomBehavior = "invalid_custom_behavior",
CustomBehaviorNotFound = "custom_behavior_not_found",
CustomBehaviorBranchNotFound = "custom_behavior_branch_not_found",
}
export const APIErrorDetailEnum = z.nativeEnum(APIErrorDetail);
export type APIErrorDetailEnum = z.infer<typeof APIErrorDetailEnum>;

View File

@ -45,6 +45,7 @@ export type SeedConfig = Expand<
depth?: number | null;
userAgent?: string | null;
selectLinks: string[];
customBehaviors: string[];
}
>;