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 & { type: CustomBehaviorType.GitRepo; }; export type ChangeEventDetail = { value: CustomBehaviorSource; }; export type RemoveEventDetail = { item: CustomBehaviorSource; }; const labelFor: Record = { [CustomBehaviorType.FileURL]: msg("URL"), [CustomBehaviorType.GitRepo]: msg("Git Repo"), }; const errorFor: Record = { [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"; const INVALID_CLASSNAME = "invalid"; export const GIT_PREFIX = "git+"; 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 => { 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; @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("btrix-change", { detail: { value: stringifyBehavior(this.behavior), }, }), ); } } render() { const behavior = this.behavior; if (!behavior) return; return html` ${this.renderType(behavior)} ${behavior.type === CustomBehaviorType.GitRepo ? this.renderGitRepoCell(behavior as BehaviorGitRepo) : this.renderFileUrlCell(behavior as BehaviorFileURL)} ${when( this.editable, () => html` this.dispatchEvent( new CustomEvent("btrix-remove"), )} > `, )} `; } private renderType(behavior: BehaviorBase) { if (!this.editable) { return html`${labelFor[behavior.type]}`; } return html` { 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` ${labelFor[behaviorType]} `, )} `; } 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)}
${pathLabel}
${behavior.path || notSpecified}
${branchLabel}
${behavior.branch || notSpecified}
`; } return html`${this.renderUrlInput(behavior, { placeholder: msg("Enter URL to Git repository"), })}
${this.renderGitDetailInput(behavior, { placeholder: msg("Optional path"), key: "path", })}
${this.renderGitDetailInput(behavior, { placeholder: msg("Optional branch"), key: "branch", })}
`; } private renderFileUrlCell(behavior: BehaviorFileURL) { if (!this.editable) { return this.renderReadonlyUrl(behavior); } return this.renderUrlInput(behavior, { placeholder: msg("Enter location of behavior file"), }); } private renderReadonlyUrl(behavior: BehaviorBase) { return html` `; } private renderUrlInput( behavior: BehaviorBase, { placeholder }: { placeholder: string }, ) { return html` this.dispatchEvent(new CustomEvent("btrix-invalid"))} > ${this.validateTask.render({ pending: this.renderPendingValidation, complete: this.renderValidTooltip, error: this.renderInvalidTooltip, })} `; } private readonly renderPendingValidation = () => { return html`
`; }; 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`
`; }; private readonly renderValidTooltip = ( validation: typeof this.validateTask.value, ) => { if (!validation) return; return html`
`; }; private renderGitDetailInput( behavior: BehaviorGitRepo, { placeholder, key }: { placeholder: string; key: "path" | "branch" }, ) { return html` this.dispatchEvent(new CustomEvent("btrix-invalid"))} > `; } 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 { try { return await this.api.fetch( `/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; } } }