${this.renderType({ exclusion, index })}
|
-
+ |
${this.renderValue({ exclusion, index })}
|
@@ -266,11 +274,23 @@ export class QueueExclusionTable extends TailwindElement {
return html`
{
+ const inputElem = (e.target as SlSelect)
+ .closest("tr")
+ ?.querySelector("sl-input");
+
+ if (inputElem) {
+ this.checkInputValidity(inputElem);
+ this.reportInputValidity(inputElem);
+ } else {
+ console.debug("no inputElem for ", e.target);
+ }
+
void this.updateExclusion({
type: (e.target as HTMLSelectElement).value as Exclusion["type"],
value: exclusion.value,
@@ -302,7 +322,13 @@ export class QueueExclusionTable extends TailwindElement {
{
- const inputElem = e.target as SLInputElement;
- const validityMessage = this.getInputValidity(inputElem) || "";
-
- inputElem.classList.remove("invalid");
- inputElem.setCustomValidity(validityMessage);
+ const inputElem = e.target as SlInput;
+ this.checkInputValidity(inputElem);
this.checkSiblingRowValidity(e);
}}
@sl-change=${(e: CustomEvent) => {
- const inputElem = e.target as SLInputElement;
+ const inputElem = e.target as SlInput;
+
+ this.reportInputValidity(inputElem);
+
const values = this.getCurrentValues(inputElem);
const params = {
type: values.type || exclusion.type,
@@ -332,11 +358,6 @@ export class QueueExclusionTable extends TailwindElement {
index,
};
- if (!inputElem.checkValidity()) {
- inputElem.classList.add("invalid");
- }
- inputElem.reportValidity();
-
void this.updateExclusion(params);
}}
>
@@ -344,7 +365,7 @@ export class QueueExclusionTable extends TailwindElement {
}
if (exclusion.type === "regex") {
- value = staticHtml`${unsafeStatic(
+ value = staticHtml`${unsafeStatic(
new RegexColorize().colorizeText(exclusion.value) as string,
)}`;
}
@@ -352,6 +373,27 @@ export class QueueExclusionTable extends TailwindElement {
return value;
}
+ private checkInputValidity(inputElem: SlInput) {
+ const validityMessage = this.getInputValidity(inputElem) || "";
+
+ inputElem.setCustomValidity(validityMessage);
+
+ if (inputElem.classList.contains("invalid")) {
+ // Update help text on change
+ this.reportInputValidity(inputElem);
+ }
+ }
+
+ private reportInputValidity(inputElem: SlInput) {
+ if (inputElem.validationMessage) {
+ inputElem.classList.add("invalid");
+ } else {
+ inputElem.classList.remove("invalid");
+ }
+
+ inputElem.helpText = inputElem.validationMessage;
+ }
+
private getColumnClassNames(
index: number,
count: number,
@@ -398,7 +440,7 @@ export class QueueExclusionTable extends TailwindElement {
return [typeColClass, valueColClass, actionColClass];
}
- private getCurrentValues(inputElem: SLInputElement) {
+ private getCurrentValues(inputElem: SlInput) {
// Get latest exclusion type value from select
const typeSelectElem = inputElem.closest("tr")?.querySelector("sl-select");
const exclusionType = typeSelectElem?.value;
@@ -408,7 +450,7 @@ export class QueueExclusionTable extends TailwindElement {
};
}
- private getInputDuplicateValidity(inputElem: SLInputElement) {
+ private getInputDuplicateValidity(inputElem: SlInput) {
const siblingElems = inputElem
.closest("table")
?.querySelectorAll(`sl-input:not([name="${inputElem.name}"])`);
@@ -417,7 +459,7 @@ export class QueueExclusionTable extends TailwindElement {
return;
}
const siblingValues = Array.from(siblingElems).map(
- (elem) => (elem as SLInputElement).value,
+ (elem) => (elem as SlInput).value,
);
const { type, value } = this.getCurrentValues(inputElem);
const formattedValue = formatValue(type!, value);
@@ -426,10 +468,24 @@ export class QueueExclusionTable extends TailwindElement {
}
}
- private getInputValidity(inputElem: SLInputElement): string | void {
+ private getInputValidity(inputElem: SlInput): string | void {
const { type, value } = this.getCurrentValues(inputElem);
if (!value) return;
+ const validityMessage = this.getValidityMessage({ type, value });
+
+ if (validityMessage) return validityMessage;
+
+ return this.getInputDuplicateValidity(inputElem);
+ }
+
+ private getValidityMessage({
+ type,
+ value,
+ }: {
+ type?: Exclusion["type"];
+ value: string;
+ }) {
if (value.length < MIN_LENGTH) {
return msg(str`Please enter ${MIN_LENGTH} or more characters`);
}
@@ -439,13 +495,9 @@ export class QueueExclusionTable extends TailwindElement {
// Check if valid regex
new RegExp(value);
} catch (err) {
- return msg(
- "Please enter a valid Regular Expression constructor pattern",
- );
+ return msg("Please enter a valid regular expression");
}
}
-
- return this.getInputDuplicateValidity(inputElem);
}
private checkSiblingRowValidity(e: CustomEvent) {
@@ -456,9 +508,8 @@ export class QueueExclusionTable extends TailwindElement {
Array.from(table.querySelectorAll("sl-input[data-invalid]")).map((elem) => {
if (elem !== inputElem) {
const validityMessage =
- this.getInputDuplicateValidity(elem as SLInputElement) || "";
- (elem as SLInputElement).setCustomValidity(validityMessage);
- (elem as SLInputElement).reportValidity();
+ this.getInputDuplicateValidity(elem as SlInput) || "";
+ (elem as SlInput).setCustomValidity(validityMessage);
}
});
}
@@ -477,11 +528,18 @@ export class QueueExclusionTable extends TailwindElement {
await this.updateComplete;
+ let valid: boolean | undefined;
+
+ if (value.length) {
+ valid = !this.getValidityMessage({ type, value });
+ }
+
this.dispatchEvent(
- new CustomEvent("btrix-remove", {
+ new CustomEvent("btrix-remove", {
detail: {
index,
regex,
+ valid,
},
}) as ExclusionRemoveEvent,
);
@@ -517,13 +575,20 @@ export class QueueExclusionTable extends TailwindElement {
await this.updateComplete;
+ let valid: boolean | undefined;
+
+ if (value.length) {
+ valid = !this.getValidityMessage({ type, value });
+ }
+
this.dispatchEvent(
- new CustomEvent("btrix-change", {
+ new CustomEvent("btrix-change", {
detail: {
index,
regex,
+ valid,
},
- }) as ExclusionChangeEvent,
+ }),
);
}
diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts
index d0023fb506..631e8e34ea 100644
--- a/frontend/src/features/crawl-workflows/workflow-editor.ts
+++ b/frontend/src/features/crawl-workflows/workflow-editor.ts
@@ -1,29 +1,25 @@
import { consume } from "@lit/context";
import { localized, msg, str } from "@lit/localize";
import type {
- SlChangeEvent,
SlCheckbox,
+ SlDetails,
+ SlHideEvent,
SlInput,
SlRadio,
SlRadioGroup,
SlSelect,
- SlSwitch,
SlTextarea,
} from "@shoelace-style/shoelace";
+import clsx from "clsx";
import Fuse from "fuse.js";
import { mergeDeep } from "immutable";
import type { LanguageCode } from "iso-639-1";
-import {
- html,
- nothing,
- type LitElement,
- type PropertyValues,
- type TemplateResult,
-} from "lit";
+import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
import {
customElement,
property,
query,
+ queryAll,
queryAsync,
state,
} from "lit/decorators.js";
@@ -33,6 +29,7 @@ import { range } from "lit/directives/range.js";
import { when } from "lit/directives/when.js";
import compact from "lodash/fp/compact";
import flow from "lodash/fp/flow";
+import throttle from "lodash/fp/throttle";
import uniq from "lodash/fp/uniq";
import { BtrixElement } from "@/classes/BtrixElement";
@@ -41,33 +38,38 @@ import type {
SelectCrawlerUpdateEvent,
} from "@/components/ui/select-crawler";
import type { SelectCrawlerProxyChangeEvent } from "@/components/ui/select-crawler-proxy";
-import type { Tab } from "@/components/ui/tab-list";
+import type { TabListTab } from "@/components/ui/tab-list";
import type { TagInputEvent, TagsChangeEvent } from "@/components/ui/tag-input";
import type { TimeInputChangeEvent } from "@/components/ui/time-input";
import { validURL } from "@/components/ui/url-input";
import { proxiesContext, type ProxiesContext } from "@/context/org";
+import {
+ ObservableController,
+ type IntersectEvent,
+} from "@/controllers/observable";
import { type SelectBrowserProfileChangeEvent } from "@/features/browser-profiles/select-browser-profile";
import type { CollectionsChangeEvent } from "@/features/collections/collections-add";
-import type { QueueExclusionTable } from "@/features/crawl-workflows/queue-exclusion-table";
+import type {
+ ExclusionChangeEvent,
+ QueueExclusionTable,
+} from "@/features/crawl-workflows/queue-exclusion-table";
import { infoCol, inputCol } from "@/layouts/columns";
+import { pageSectionsWithNav } from "@/layouts/pageSectionsWithNav";
+import { panel } from "@/layouts/panel";
import infoTextStrings from "@/strings/crawl-workflows/infoText";
import scopeTypeLabels from "@/strings/crawl-workflows/scopeType";
import sectionStrings from "@/strings/crawl-workflows/section";
-import {
- ScopeType,
- type CrawlConfig,
- type Seed,
- type WorkflowParams,
-} from "@/types/crawler";
+import { ScopeType, type Seed, type WorkflowParams } from "@/types/crawler";
+import type { UnderlyingFunction } from "@/types/utils";
import { NewWorkflowOnlyScopeType } from "@/types/workflow";
-import { isApiError, type Detail } from "@/utils/api";
+import { isApiError } from "@/utils/api";
import { DEPTH_SUPPORTED_SCOPES, isPageScopeType } from "@/utils/crawler";
import {
getUTCSchedule,
humanizeNextDate,
humanizeSchedule,
} from "@/utils/cron";
-import { maxLengthValidator } from "@/utils/form";
+import { formValidator, maxLengthValidator } from "@/utils/form";
import localize from "@/utils/localize";
import { isArchivingDisabled } from "@/utils/orgs";
import { AppStateService } from "@/utils/state";
@@ -97,7 +99,6 @@ const STEPS = [
"browserSettings",
"crawlScheduling",
"crawlMetadata",
- "confirmSettings",
] as const;
type StepName = (typeof STEPS)[number];
type TabState = {
@@ -115,6 +116,8 @@ const DEFAULT_BEHAVIORS = [
"autofetch",
"siteSpecific",
];
+const formName = "newJobConfig" as const;
+const panelSuffix = "--panel" as const;
const getDefaultProgressState = (hasConfigId = false): ProgressState => {
let activeTab: StepName = "crawlSetup";
@@ -128,6 +131,7 @@ const getDefaultProgressState = (hasConfigId = false): ProgressState => {
return {
activeTab,
+ // TODO Mark as completed only if form section has data
tabs: {
crawlSetup: { error: false, completed: hasConfigId },
crawlLimits: {
@@ -146,10 +150,6 @@ const getDefaultProgressState = (hasConfigId = false): ProgressState => {
error: false,
completed: hasConfigId,
},
- confirmSettings: {
- error: false,
- completed: hasConfigId,
- },
},
};
};
@@ -222,15 +222,30 @@ export class WorkflowEditor extends BtrixElement {
@state()
private serverError?: TemplateResult | string;
+ // For observing panel sections position in viewport
+ private readonly observable = new ObservableController(this, {
+ // Add some padding to account for stickied elements
+ rootMargin: "-100px 0px -100px 0px",
+ });
+
// For fuzzy search:
private readonly fuse = new Fuse([], {
shouldSort: false,
threshold: 0.2, // stricter; default is 0.6
});
+ private readonly checkFormValidity = formValidator(this);
private readonly validateNameMax = maxLengthValidator(50);
private readonly validateDescriptionMax = maxLengthValidator(350);
+ private readonly tabLabels: Record = {
+ crawlSetup: sectionStrings.scope,
+ crawlLimits: msg("Limits"),
+ browserSettings: sectionStrings.browserSettings,
+ crawlScheduling: sectionStrings.scheduling,
+ crawlMetadata: msg("Metadata"),
+ };
+
private get formHasError() {
return (
!this.hasRequiredFields() ||
@@ -271,25 +286,35 @@ export class WorkflowEditor extends BtrixElement {
"": "",
};
- @query('form[name="newJobConfig"]')
- formElem?: HTMLFormElement;
+ @query(`form[name="${formName}"]`)
+ private readonly formElem?: HTMLFormElement;
+
+ @queryAll(`.${formName}${panelSuffix}`)
+ private readonly panels?: NodeListOf;
+
+ @state()
+ private readonly visiblePanels = new Set();
+
+ @queryAsync(`.${formName}${panelSuffix}--active`)
+ private readonly activeTabPanel!: Promise;
- @queryAsync("btrix-tab-panel[aria-hidden=false]")
- activeTabPanel!: Promise;
+ @query("btrix-queue-exclusion-table")
+ private readonly exclusionTable?: QueueExclusionTable | null;
connectedCallback(): void {
this.initializeEditor();
super.connectedCallback();
void this.fetchServerDefaults();
- window.addEventListener("hashchange", () => {
- const hashValue = window.location.hash.slice(1);
- if (STEPS.includes(hashValue as (typeof STEPS)[number])) {
- this.updateProgressState({
- activeTab: hashValue as StepName,
- });
- }
- });
+ this.addEventListener(
+ "btrix-intersect",
+ this.onPanelIntersect as UnderlyingFunction,
+ );
+ }
+
+ disconnectedCallback(): void {
+ this.onPanelIntersect.cancel();
+ super.disconnectedCallback();
}
async willUpdate(
@@ -302,54 +327,29 @@ export class WorkflowEditor extends BtrixElement {
this.initializeEditor();
}
}
- if (changedProperties.get("progressState") && this.progressState) {
- if (
- (changedProperties.get("progressState") as ProgressState).activeTab ===
- "crawlSetup" &&
- this.progressState.activeTab !== "crawlSetup"
- ) {
- // Show that required tab has error even if input hasn't been touched
- if (
- !this.hasRequiredFields() &&
- !this.progressState.tabs.crawlSetup.error
- ) {
- this.updateProgressState({
- tabs: {
- crawlSetup: { error: true },
- },
- });
- }
- }
- }
}
- async updated(
- changedProperties: PropertyValues & Map,
- ) {
- if (changedProperties.get("progressState") && this.progressState) {
- if (
- (changedProperties.get("progressState") as ProgressState).activeTab !==
- this.progressState.activeTab
- ) {
- void this.scrollToPanelTop();
-
- // Focus on first field in section
- (await this.activeTabPanel)
- ?.querySelector(
- "sl-input, sl-textarea, sl-select, sl-radio-group",
- )
- ?.focus();
- }
+ updated(changedProperties: PropertyValues & Map) {
+ if (
+ changedProperties.has("progressState") &&
+ this.progressState &&
+ this.progressState.activeTab !==
+ (changedProperties.get("progressState") as ProgressState | undefined)
+ ?.activeTab
+ ) {
+ window.location.hash = this.progressState.activeTab;
}
}
async firstUpdated() {
- // Focus on first field in section
- (await this.activeTabPanel)
- ?.querySelector(
- "sl-input, sl-textarea, sl-select, sl-radio-group",
- )
- ?.focus();
+ // Observe form sections to get scroll position
+ this.panels?.forEach((panel) => {
+ this.observable.observe(panel);
+ });
+
+ if (this.progressState?.activeTab !== STEPS[0]) {
+ void this.scrollToActivePanel();
+ }
if (this.orgId) {
void this.fetchTags();
@@ -378,305 +378,212 @@ export class WorkflowEditor extends BtrixElement {
}
render() {
- const tabLabels: Record = {
- crawlSetup: sectionStrings.scope,
- crawlLimits: msg("Limits"),
- browserSettings: sectionStrings.browserSettings,
- crawlScheduling: sectionStrings.scheduling,
- crawlMetadata: msg("Metadata"),
- confirmSettings: msg("Review Settings"),
- };
- let orderedTabNames = STEPS as readonly StepName[];
-
- if (this.configId) {
- // Remove review tab
- orderedTabNames = orderedTabNames.slice(0, -1);
- }
-
return html`
`;
}
- private renderNavItem(tabName: StepName, content: TemplateResult | string) {
- const isActive = tabName === this.progressState!.activeTab;
- const isConfirmSettings = tabName === "confirmSettings";
- const { error: isInvalid, completed } = this.progressState!.tabs[tabName];
- let icon: TemplateResult = html``;
-
- if (!this.configId) {
- const iconProps = {
- name: "circle",
- library: "default",
- class: "text-neutral-400",
- };
- if (isConfirmSettings) {
- iconProps.name = "info-circle";
- iconProps.class = "text-base";
- } else {
- if (isInvalid) {
- iconProps.name = "exclamation-circle";
- iconProps.class = "text-danger";
- } else if (isActive) {
- iconProps.name = "pencil-circle-dashed";
- iconProps.library = "app";
- iconProps.class = "text-base";
- } else if (completed) {
- iconProps.name = "check-circle";
- }
- }
-
- icon = html`
- {
+ const isActive = tab === this.progressState?.activeTab;
+ return html`
+
-
-
+ ${this.tabLabels[tab]}
+
`;
- }
+ };
return html`
-
- ${icon}
-
- ${content}
-
-
+
+ ${STEPS.map(button)}
+
`;
}
- private renderPanelContent(
- content: TemplateResult,
- { isFirst = false, isLast = false } = {},
- ) {
- return html`
-
-
- ${content}
- ${when(this.serverError, () =>
- this.renderErrorAlert(this.serverError!),
- )}
-
+ private renderFormSections() {
+ const panelBody = ({
+ name,
+ desc,
+ render,
+ required,
+ }: (typeof this.formSections)[number]) => {
+ const tabProgress = this.progressState?.tabs[name];
+ const hasError = tabProgress?.error;
+
+ return html` {
+ this.updateProgressState({
+ activeTab: name,
+ });
+ }}
+ @sl-show=${() => {
+ this.pauseObserve();
+ this.updateProgressState({
+ activeTab: name,
+ });
+ }}
+ @sl-hide=${(e: SlHideEvent) => {
+ const el = e.currentTarget as SlDetails;
- ${this.renderFooter({ isFirst, isLast })}
-
- `;
- }
+ // Check if there's any invalid elements before hiding
+ let invalidEl: SlInput | null = null;
- private renderFooter({ isFirst = false, isLast = false }) {
- if (this.configId) {
- return html`
-
- `;
- }
+ if (required) {
+ invalidEl = el.querySelector("[required][data-invalid]");
+ }
- if (!this.configId) {
- return html`
-
- `;
- }
+ invalidEl =
+ invalidEl || el.querySelector("[data-user-invalid]");
- return html`
-
- ${when(
- this.configId,
- () => html`
- ${this.renderRunNowToggle()}
-
- ${msg("Save Changes")}
-
- `,
- () => this.renderSteppedFooterButtons({ isFirst, isLast }),
- )}
-
- `;
- }
+ if (invalidEl) {
+ e.preventDefault();
- private renderSteppedFooterButtons({
- isFirst,
- isLast,
- }: {
- isFirst: boolean;
- isLast: boolean;
- }) {
- if (isLast) {
- return html`
-
- ${msg("Previous Step")}
-
- ${this.renderRunNowToggle()}
-
- ${msg("Save Workflow")}
- `;
- }
- return html`
- ${isFirst
- ? nothing
- : html`
-
-
- ${msg("Previous Step")}
-
- `}
-
-
- ${msg("Next Step")}
-
- {
- if (this.hasRequiredFields()) {
- this.updateProgressState({
- activeTab: "confirmSettings",
- });
- } else {
- this.nextStep();
+ invalidEl.focus();
+ invalidEl.checkValidity();
}
}}
+ @sl-after-show=${this.resumeObserve}
+ @sl-after-hide=${this.resumeObserve}
>
-
- ${msg(html`Review & Save`)}
-
+
+
+
+
+
+
+ ${when(
+ hasError,
+ () => html`
+
+
+
+ `,
+ () =>
+ !required || this.configId
+ ? html`
+
+ `
+ : nothing,
+ )}
+
+
+ ${desc}
+ ${render.bind(this)()}
+ `;
+ };
+
+ const formSection = (section: (typeof this.formSections)[number]) => html`
+ ${panel({
+ id: `${section.name}${panelSuffix}`,
+ className: clsx(
+ `${formName}${panelSuffix}`,
+ section.name === this.progressState?.activeTab &&
+ `${formName}${panelSuffix}--active`,
+ tw`scroll-mt-7`,
+ ),
+ heading: this.tabLabels[section.name],
+ body: panelBody(section),
+ actions: section.required
+ ? html`
+ ${msg(
+ html`Fields marked with
+ *
+ are required`,
+ )}
+ `
+ : undefined,
+ })}
+ `;
+
+ return html`
+
+ ${this.formSections.map(formSection)}
+
`;
}
- private renderRunNowToggle() {
+ private renderFooter() {
return html`
- {
- this.updateFormState(
- {
- runNow: (e.target as SlSwitch).checked,
- },
- true,
- );
- }}
+
+ ${this.configId
+ ? html`
+
+ ${msg("Cancel")}
+
+ `
+ : nothing}
+ ${when(this.serverError, (error) => this.renderErrorAlert(error))}
+
+
+
+ ${msg("Save")}
+
+
+
+
+ ${msg(html`Run Crawl`)}
+
+
+
`;
}
@@ -706,6 +613,7 @@ export class WorkflowEditor extends BtrixElement {
name="scopeType"
label=${msg("Crawl Scope")}
value=${this.formState.scopeType}
+ hoist
@sl-change=${(e: Event) =>
this.changeScopeType(
(e.target as HTMLSelectElement).value as FormState["scopeType"],
@@ -767,11 +675,7 @@ export class WorkflowEditor extends BtrixElement {
@btrix-change=${this.handleChangeRegex}
>
`)}
- ${this.renderHelpTextCol(
- msg(
- `Specify exclusion rules for what pages should not be visited.`,
- ),
- )}
+ ${this.renderHelpTextCol(infoTextStrings["exclusions"])}
@@ -793,6 +697,7 @@ export class WorkflowEditor extends BtrixElement {
autocomplete="off"
inputmode="url"
value=${this.formState.urlList}
+ autofocus
required
@sl-input=${async (e: Event) => {
const inputEl = e.target as SlInput;
@@ -1659,64 +1564,44 @@ https://archiveweb.page/images/${"logo.svg"}`}
}
private renderErrorAlert(errorMessage: string | TemplateResult) {
- return html`
-
- ${errorMessage}
-
- `;
+ return html` ${errorMessage} `;
}
- private readonly renderConfirmSettings = () => {
- const errorAlert = when(this.formHasError, () => {
- const pageScope = isPageScopeType(this.formState.scopeType);
- const crawlSetupUrl = `${window.location.href.split("#")[0]}#crawlSetup`;
- const errorMessage = this.hasRequiredFields()
- ? msg(
- "There are issues with this Workflow. Please go through previous steps and fix all issues to continue.",
- )
- : html`
- ${msg("There is an issue with this Crawl Workflow:")}
- ${msg(
- html`${pageScope ? msg("Page URL(s)") : msg("Crawl Start URL")}
- required in
- Scope. `,
- )}
-
- ${msg("Please fix to continue.")}
- `;
-
- return this.renderErrorAlert(errorMessage);
- });
-
- return html`
- ${errorAlert}
-
-
- ${when(this.progressState!.activeTab === "confirmSettings", () => {
- // Prevent parsing and rendering tab when not visible
- const crawlConfig = this.parseConfig();
- const profileName = this.formState.browserProfile?.name;
-
- return html`
- `;
- })}
-
-
- ${errorAlert}
- `;
- };
+ private readonly formSections: {
+ name: StepName;
+ desc: string;
+ render: () => TemplateResult<1>;
+ required?: boolean;
+ }[] = [
+ {
+ name: "crawlSetup",
+ desc: msg("Specify the range and depth of your crawl."),
+ render: this.renderScope,
+ required: true,
+ },
+ {
+ name: "crawlLimits",
+ desc: msg("Enforce maximum limits on your crawl."),
+ render: this.renderCrawlLimits,
+ },
+ {
+ name: "browserSettings",
+ desc: msg(
+ "Configure the browser that's used to visit URLs during the crawl.",
+ ),
+ render: this.renderCrawlBehaviors,
+ },
+ {
+ name: "crawlScheduling",
+ desc: msg("Schedule recurring crawls."),
+ render: this.renderJobScheduling,
+ },
+ {
+ name: "crawlMetadata",
+ desc: msg("Describe and organize crawls from this workflow."),
+ render: this.renderJobMetadata,
+ },
+ ];
private changeScopeType(value: FormState["scopeType"]) {
const prevScopeType = this.formState.scopeType;
@@ -1752,6 +1637,48 @@ https://archiveweb.page/images/${"logo.svg"}`}
this.updateFormState(formState);
}
+ // Use to skip updates on intersect changes, like when scrolling
+ // an element into view on click
+ private skipIntersectUpdate = false;
+
+ private pauseObserve() {
+ this.onPanelIntersect.flush();
+ this.skipIntersectUpdate = true;
+ }
+
+ private resumeObserve() {
+ this.skipIntersectUpdate = false;
+ }
+
+ private readonly onPanelIntersect = throttle(10)((e: Event) => {
+ if (this.skipIntersectUpdate) {
+ this.resumeObserve();
+ return;
+ }
+
+ const { entries } = (e as IntersectEvent).detail;
+
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ this.visiblePanels.add(entry.target.id);
+ } else {
+ this.visiblePanels.delete(entry.target.id);
+ }
+ });
+
+ const panels = [...(this.panels ?? [])];
+ const activeTab = panels
+ .find((panel) => this.visiblePanels.has(panel.id))
+ ?.id.split(panelSuffix)[0] as StepName | undefined;
+
+ if (!STEPS.includes(activeTab!)) {
+ console.debug("tab not in steps:", activeTab, this.visiblePanels);
+ return;
+ }
+
+ this.updateProgressState({ activeTab });
+ });
+
private hasRequiredFields(): boolean {
if (isPageScopeType(this.formState.scopeType)) {
return Boolean(this.formState.urlList);
@@ -1760,17 +1687,36 @@ https://archiveweb.page/images/${"logo.svg"}`}
return Boolean(this.formState.primarySeedUrl);
}
- private async scrollToPanelTop() {
+ private async scrollToActivePanel() {
const activeTabPanel = await this.activeTabPanel;
- if (activeTabPanel && activeTabPanel.getBoundingClientRect().top < 0) {
- activeTabPanel.scrollIntoView({
- behavior: "smooth",
+ if (!activeTabPanel) {
+ console.debug("no activeTabPanel");
+ return;
+ }
+
+ this.pauseObserve();
+
+ // Focus on focusable element, if found, to highlight the section
+ const summary = activeTabPanel
+ .querySelector("sl-details")
+ ?.shadowRoot?.querySelector("summary[aria-controls]");
+
+ if (summary) {
+ summary.focus({
+ // Prevent firefox from applying own focus styles
+ focusVisible: false,
+ } as FocusOptions & {
+ focusVisible: boolean;
});
+ } else {
+ console.debug("summary not found in sl-details");
}
+ activeTabPanel.scrollIntoView({ block: "start" });
}
private async handleRemoveRegex(e: CustomEvent) {
- const { exclusions } = e.target as QueueExclusionTable;
+ const table = e.target as QueueExclusionTable;
+ const { exclusions } = table;
if (!this.formState.exclusions) {
this.updateFormState(
@@ -1783,17 +1729,50 @@ https://archiveweb.page/images/${"logo.svg"}`}
this.updateFormState({ exclusions }, true);
}
- // Check if we removed an erroring input
- const table = e.target as LitElement;
+ this.updateExclusionsValidity();
+
await this.updateComplete;
- await table.updateComplete;
- this.syncTabErrorState(table);
+ await this.exclusionTable?.updateComplete;
+
+ // Update tab error state if an invalid regex was removed
+ if (e.detail.valid === false && table.checkValidity()) {
+ this.syncTabErrorState(table);
+ }
}
- private handleChangeRegex(e: CustomEvent) {
- const { exclusions } = e.target as QueueExclusionTable;
+ private async handleChangeRegex(e: ExclusionChangeEvent) {
+ const table = e.target as QueueExclusionTable;
+ const { exclusions } = table;
this.updateFormState({ exclusions }, true);
+ this.updateExclusionsValidity();
+
+ await this.updateComplete;
+ await this.exclusionTable?.updateComplete;
+
+ if (e.detail.valid === false || !table.checkValidity()) {
+ this.updateProgressState({
+ tabs: {
+ crawlSetup: { error: true },
+ },
+ });
+ } else {
+ this.syncTabErrorState(table);
+ }
+ }
+
+ /**
+ * HACK Set data attribute manually so that
+ * exclusions table works with `syncTabErrorState`
+ */
+ private updateExclusionsValidity() {
+ if (this.exclusionTable?.checkValidity() === false) {
+ this.exclusionTable.setAttribute("data-invalid", "true");
+ this.exclusionTable.setAttribute("data-user-invalid", "true");
+ } else {
+ this.exclusionTable?.removeAttribute("data-invalid");
+ this.exclusionTable?.removeAttribute("data-user-invalid");
+ }
}
private readonly validateOnBlur = async (e: Event) => {
@@ -1807,7 +1786,13 @@ https://archiveweb.page/images/${"logo.svg"}`}
await el.updateComplete;
await this.updateComplete;
- const currentTab = this.progressState!.activeTab as StepName;
+ const panelEl = el.closest(`.${formName}${panelSuffix}`);
+
+ if (!panelEl) {
+ return;
+ }
+
+ 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;
@@ -1822,10 +1807,14 @@ https://archiveweb.page/images/${"logo.svg"}`}
};
private syncTabErrorState(el: HTMLElement) {
- const panelEl = el.closest("btrix-tab-panel")!;
- const tabName = panelEl
- .getAttribute("name")!
- .replace("newJobConfig-", "") as StepName;
+ const panelEl = el.closest(`.${formName}${panelSuffix}`);
+
+ if (!panelEl) {
+ console.debug("no panel for element:", el);
+ return;
+ }
+
+ const tabName = panelEl.id.split(panelSuffix)[0] as StepName;
const hasInvalid = panelEl.querySelector("[data-user-invalid]");
if (!hasInvalid && this.progressState!.tabs[tabName].error) {
@@ -1878,61 +1867,21 @@ https://archiveweb.page/images/${"logo.svg"}`}
});
}
- private readonly tabClickHandler = (step: StepName) => (e: MouseEvent) => {
- const tab = e.currentTarget as Tab;
- if (tab.disabled || tab.active) {
- e.preventDefault();
- e.stopPropagation();
- return;
- }
- window.location.hash = step;
- this.updateProgressState({ activeTab: step });
- };
-
- private backStep() {
- const targetTabIdx = STEPS.indexOf(this.progressState!.activeTab);
- if (targetTabIdx) {
- this.updateProgressState({
- activeTab: STEPS[targetTabIdx - 1] as StepName,
- });
- }
- }
-
- private nextStep() {
- const isValid = this.checkCurrentPanelValidity();
-
- if (isValid) {
- const { activeTab } = this.progressState!;
- const nextTab = STEPS[STEPS.indexOf(activeTab) + 1] as StepName;
- this.updateProgressState({
- activeTab: nextTab,
- tabs: {
- [activeTab]: {
- completed: true,
- },
- },
- });
- }
- }
+ private readonly tabClickHandler =
+ (step: StepName) => async (e: MouseEvent) => {
+ const tab = e.currentTarget as TabListTab;
+ if (tab.disabled || tab.active) {
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ }
- private readonly checkCurrentPanelValidity = (): boolean => {
- if (!this.formElem) return false;
+ this.updateProgressState({ activeTab: step });
- const currentTab = this.progressState!.activeTab as StepName;
- const activePanel = this.formElem.querySelector(
- `btrix-tab-panel[name="newJobConfig-${currentTab}"]`,
- );
- const invalidElems = [...activePanel!.querySelectorAll("[data-invalid]")];
+ await this.updateComplete;
- const hasInvalid = Boolean(invalidElems.length);
- if (hasInvalid) {
- invalidElems.forEach((el) => {
- (el as HTMLInputElement).reportValidity();
- });
- }
-
- return !hasInvalid;
- };
+ void this.scrollToActivePanel();
+ };
private onKeyDown(event: KeyboardEvent) {
const el = event.target as HTMLElement;
@@ -1962,10 +1911,21 @@ https://archiveweb.page/images/${"logo.svg"}`}
private async onSubmit(event: SubmitEvent) {
event.preventDefault();
- const isValid = this.checkCurrentPanelValidity();
- await this.updateComplete;
+
+ this.updateFormState({
+ runNow: true,
+ });
+
+ void this.save();
+ }
+
+ private async save() {
+ if (!this.formElem) return;
+
+ const isValid = await this.checkFormValidity(this.formElem);
if (!isValid || this.formHasError) {
+ this.formElem.reportValidity();
return;
}
@@ -2028,16 +1988,32 @@ https://archiveweb.page/images/${"logo.svg"}`}
id: "workflow-created-status",
});
} else {
- const isConfigError = ({ loc }: Detail) =>
- loc.some((v: string) => v === "config");
- if (Array.isArray(e.details) && e.details.some(isConfigError)) {
- this.serverError = this.formatConfigServerError(e.details);
- } else {
- this.serverError = e.message;
+ this.notify.toast({
+ message: msg("Please fix all errors and try again."),
+ variant: "danger",
+ icon: "exclamation-octagon",
+ id: "workflow-created-status",
+ });
+
+ const errorDetail = Array.isArray(e.details)
+ ? e.details[0]
+ : e.details;
+
+ if (typeof errorDetail === "string") {
+ this.serverError = `${msg("Please fix the following issue: ")} ${
+ errorDetail === "invalid_regex"
+ ? msg("Page exclusion contains invalid regex")
+ : errorDetail.replace(/_/, " ")
+ }`;
}
}
} else {
- this.serverError = msg("Something unexpected went wrong");
+ this.notify.toast({
+ message: msg("Sorry, couldn't save workflow at this time."),
+ variant: "danger",
+ icon: "exclamation-octagon",
+ id: "workflow-created-status",
+ });
}
}
@@ -2045,35 +2021,10 @@ https://archiveweb.page/images/${"logo.svg"}`}
}
private async onReset() {
- this.initializeEditor();
- }
-
- /**
- * Format `config` related API error returned from server
- */
- private formatConfigServerError(details: Detail[]): TemplateResult {
- const detailsWithoutDictError = details.filter(
- ({ type }) => type !== "type_error.dict",
+ this.navigate.to(
+ `${this.navigate.orgBasePath}/workflows${this.configId ? `/${this.configId}#settings` : ""}`,
);
-
- const renderDetail = ({ loc, msg: detailMsg }: Detail) => html`
-
- ${loc.some((v: string) => v === "seeds") &&
- typeof loc[loc.length - 1] === "number"
- ? msg(str`Seed URL ${loc[loc.length - 1] + 1}: `)
- : `${loc[loc.length - 1]}: `}
- ${detailMsg}
-
- `;
-
- return html`
- ${msg(
- "Couldn't save Workflow. Please fix the following Workflow issues:",
- )}
-
- ${detailsWithoutDictError.map(renderDetail)}
-
- `;
+ // this.initializeEditor();
}
private validateUrlList(
diff --git a/frontend/src/layouts/pageSectionsWithNav.ts b/frontend/src/layouts/pageSectionsWithNav.ts
new file mode 100644
index 0000000000..44c7db903f
--- /dev/null
+++ b/frontend/src/layouts/pageSectionsWithNav.ts
@@ -0,0 +1,36 @@
+import clsx from "clsx";
+import { html, type TemplateResult } from "lit";
+
+import { tw } from "@/utils/tailwind";
+
+export function pageSectionsWithNav({
+ nav,
+ main,
+ placement = "start",
+ sticky = false,
+}: {
+ nav: TemplateResult;
+ main: TemplateResult;
+ placement?: "start" | "top";
+ sticky?: boolean;
+}) {
+ return html`
+
+ `;
+}
diff --git a/frontend/src/layouts/panel.ts b/frontend/src/layouts/panel.ts
new file mode 100644
index 0000000000..6bfe56fb2a
--- /dev/null
+++ b/frontend/src/layouts/panel.ts
@@ -0,0 +1,44 @@
+import { html, type TemplateResult } from "lit";
+import { ifDefined } from "lit/directives/if-defined.js";
+
+import { pageHeading } from "./page";
+
+export function panelHeader({
+ heading,
+ actions,
+}: {
+ heading: string | Parameters[0];
+ actions?: TemplateResult;
+}) {
+ return html`
+
+ ${typeof heading === "string"
+ ? pageHeading({ content: heading })
+ : pageHeading(heading)}
+ ${actions}
+
+ `;
+}
+
+export function panelBody({ content }: { content: TemplateResult }) {
+ return html`${content} `;
+}
+
+/**
+ * @TODO Refactor components to use panel
+ */
+export function panel({
+ heading,
+ actions,
+ body,
+ id,
+ className,
+}: {
+ body: TemplateResult;
+ id?: string;
+ className?: string;
+} & Parameters[0]) {
+ return html`
+ ${panelHeader({ heading, actions })} ${body}
+ `;
+}
diff --git a/frontend/src/strings/crawl-workflows/infoText.ts b/frontend/src/strings/crawl-workflows/infoText.ts
index bcc0721389..2d525e94a6 100644
--- a/frontend/src/strings/crawl-workflows/infoText.ts
+++ b/frontend/src/strings/crawl-workflows/infoText.ts
@@ -6,8 +6,9 @@ import { type FormState } from "@/utils/workflow";
type Field = keyof FormState;
const infoText: Partial> = {
- exclusions: msg(`Specify exclusion rules for what pages should not be visited.
- Exclusions apply to all URLs.`),
+ exclusions: msg(
+ "Specify exclusion rules for what pages should not be visited.",
+ ),
pageLimit: msg(
"Adds a hard limit on the number of pages that will be crawled.",
),
diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css
index c39b4b3d64..f316393ec9 100644
--- a/frontend/src/theme.stylesheet.css
+++ b/frontend/src/theme.stylesheet.css
@@ -183,11 +183,13 @@
}
/* Validation styles */
- [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);
}
- [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);
}
diff --git a/frontend/src/utils/workflow.ts b/frontend/src/utils/workflow.ts
index ad1010c24e..85d1016461 100644
--- a/frontend/src/utils/workflow.ts
+++ b/frontend/src/utils/workflow.ts
@@ -155,9 +155,6 @@ export function getInitialFormState(params: {
org?: OrgData | null;
}): FormState {
const defaultFormState = getDefaultFormState();
- if (!params.configId) {
- defaultFormState.runNow = true;
- }
if (!params.initialWorkflow) return defaultFormState;
const formState: Partial = {};
const seedsConfig = params.initialWorkflow.config;
|