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:
parent
cd7b695520
commit
23f9e08a22
@ -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(
|
||||
|
@ -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`,
|
||||
)}
|
||||
|
@ -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.");
|
||||
|
@ -170,6 +170,7 @@ export class APIController implements ReactiveController {
|
||||
message: errorMessage,
|
||||
status: resp.status,
|
||||
details: errorDetails,
|
||||
errorCode: errorDetail,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
164
frontend/src/features/crawl-workflows/custom-behaviors-table.ts
Normal file
164
frontend/src/features/crawl-workflows/custom-behaviors-table.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import("./custom-behaviors-table");
|
||||
import("./exclusion-editor");
|
||||
import("./live-workflow-status");
|
||||
import("./link-selector-table");
|
||||
|
@ -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,
|
||||
|
@ -88,6 +88,7 @@ export class WorkflowsNew extends LiteElement {
|
||||
failOnFailedSeed: false,
|
||||
userAgent: null,
|
||||
selectLinks: DEFAULT_SELECT_LINKS,
|
||||
customBehaviors: [],
|
||||
},
|
||||
tags: [],
|
||||
crawlTimeout: null,
|
||||
|
@ -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"),
|
||||
|
@ -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);
|
||||
|
@ -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>;
|
||||
|
@ -45,6 +45,7 @@ export type SeedConfig = Expand<
|
||||
depth?: number | null;
|
||||
userAgent?: string | null;
|
||||
selectLinks: string[];
|
||||
customBehaviors: string[];
|
||||
}
|
||||
>;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user