-
- (this.crawlerChannel = e.detail.value!)}
- >
+
+
+
+ ${showProxies
+ ? html`
+
+
+ (this.proxyId = e.detail.value)}
+ >
+
+ ${msg(
+ "When a proxy is selected, websites will see traffic as coming from the IP address of the proxy rather than where Browsertrix is deployed.",
+ )}
+
+
+
+ `
+ : nothing}
+
+
+
+
+ ${showChannels
+ ? html`
+ ${msg("Browser Session Settings")}
+
+
+ (this.crawlerChannel = e.detail.value!)}
+ >
`
+ : nothing}
+
+
+
+
+ {
+ // Using reset method instead of type="reset" fixes
+ // incorrect getRootNode in Chrome
+ (await this.form).reset();
+ }}
+ >${msg("Cancel")}
+ this.dialog?.submit()}
+ >
+ ${msg("Start Browser")}
+
- ${this.proxyServers?.length
- ? html`
-
-
- (this.proxyId = e.detail.value)}
- >
-
- `
- : nothing}
-
-
-
-
- {
- // Using reset method instead of type="reset" fixes
- // incorrect getRootNode in Chrome
- (await this.form).reset();
- }}
- >${msg("Cancel")}
- this.dialog?.submit()}
- >${msg("Start Browsing")}
-
- `;
+
+
+ ${when(
+ this.url,
+ (url) =>
+ html`
{}}
+ @sl-after-hide=${() => {}}
+ >
+ `,
+ )}
+ `;
}
private async hideDialog() {
@@ -146,71 +215,19 @@ export class NewBrowserProfileDialog extends BtrixElement {
private async onSubmit(event: SubmitEvent) {
event.preventDefault();
- this.isSubmitting = true;
-
- const formData = new FormData(event.target as HTMLFormElement);
- let url = formData.get("url") as string;
-
- try {
- url = url.trim();
- if (!url.startsWith("http://") && !url.startsWith("https://")) {
- url = `https://${url}`;
- }
- const data = await this.createBrowser({
- url: url,
- crawlerChannel: this.crawlerChannel,
- proxyId: this.proxyId,
- });
-
- this.notify.toast({
- message: msg("Starting up browser for new profile..."),
- variant: "success",
- icon: "check2-circle",
- id: "browser-profile-update-status",
- });
- await this.hideDialog();
- this.navigate.to(
- `${this.navigate.orgBasePath}/browser-profiles/profile/browser/${
- data.browserid
- }?${queryString.stringify({
- url,
- name: msg("My Profile"),
- crawlerChannel: this.crawlerChannel,
- proxyId: this.proxyId,
- })}`,
- );
- } catch (e) {
- this.notify.toast({
- message: msg("Sorry, couldn't create browser profile at this time."),
- variant: "danger",
- icon: "exclamation-octagon",
- id: "browser-profile-update-status",
- });
+
+ const form = event.target as HTMLFormElement;
+
+ if (!form.checkValidity()) {
+ return;
}
- this.isSubmitting = false;
- }
- private async createBrowser({
- url,
- crawlerChannel,
- proxyId,
- }: {
- url: string;
- crawlerChannel: string;
- proxyId: string | null;
- }) {
- const params = {
- url,
- crawlerChannel,
- proxyId,
- };
-
- return this.api.fetch<{ browserid: string }>(
- `/orgs/${this.orgId}/profiles/browser`,
- {
- method: "POST",
- body: JSON.stringify(params),
- },
- );
+ const formData = new FormData(form);
+ this.name = formData.get("profile-name") as string;
+ this.url = formData.get("profile-url") as string;
+
+ await this.updateComplete;
+
+ this.browserOpen = true;
}
}
diff --git a/frontend/src/features/browser-profiles/profile-browser-dialog.ts b/frontend/src/features/browser-profiles/profile-browser-dialog.ts
new file mode 100644
index 0000000000..9166155afc
--- /dev/null
+++ b/frontend/src/features/browser-profiles/profile-browser-dialog.ts
@@ -0,0 +1,496 @@
+import { localized, msg } from "@lit/localize";
+import { Task, TaskStatus } from "@lit/task";
+import clsx from "clsx";
+import { html, nothing, type PropertyValues } from "lit";
+import { customElement, property, query, state } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+import { when } from "lit/directives/when.js";
+
+import { badges } from "./templates/badges";
+
+import { BtrixElement } from "@/classes/BtrixElement";
+import type { Dialog } from "@/components/ui/dialog";
+import {
+ bgClass,
+ type BrowserConnectionChange,
+ type BrowserOriginsChange,
+ type ProfileBrowser,
+} from "@/features/browser-profiles/profile-browser";
+import type {
+ CreateBrowserOptions,
+ ProfileUpdatedEvent,
+} from "@/features/browser-profiles/types";
+import { OrgTab } from "@/routes";
+import type { Profile } from "@/types/crawler";
+import { isApiError } from "@/utils/api";
+import { tw } from "@/utils/tailwind";
+
+enum BrowserStatus {
+ Initial,
+ Pending,
+ Ready,
+ Error,
+}
+
+/**
+ * @fires btrix-updated
+ */
+@customElement("btrix-profile-browser-dialog")
+@localized()
+export class ProfileBrowserDialog extends BtrixElement {
+ @property({ type: Object })
+ profile?: Profile;
+
+ @property({ type: Object })
+ config?: CreateBrowserOptions & { name?: string };
+
+ @property({ type: Boolean })
+ duplicating = false;
+
+ @property({ type: Boolean })
+ open = false;
+
+ @state()
+ private browserStatus = BrowserStatus.Initial;
+
+ @state()
+ private originsLoaded = false;
+
+ @state()
+ private showConfirmation = false;
+
+ @query("btrix-dialog")
+ private readonly dialog?: Dialog | null;
+
+ @query("btrix-profile-browser")
+ private readonly profileBrowser?: ProfileBrowser | null;
+
+ #savedBrowserId?: string;
+
+ private readonly browserIdTask = new Task(this, {
+ task: async ([open, config], { signal }) => {
+ if (!open || !config) return null;
+
+ const browserId = this.browserIdTask.value;
+ if (browserId && browserId !== this.#savedBrowserId) {
+ // Delete previously created and unused browser
+ void this.deleteBrowser(browserId, signal);
+ }
+
+ const { browserid } = await this.createBrowser(config, signal);
+
+ return browserid;
+ },
+ args: () => [this.open, this.config] as const,
+ });
+
+ protected willUpdate(changedProperties: PropertyValues): void {
+ if (changedProperties.has("open")) {
+ if (!this.open) {
+ this.showConfirmation = false;
+ this.browserStatus = BrowserStatus.Initial;
+ this.#savedBrowserId = undefined;
+ this.browserIdTask.abort();
+ }
+ }
+ }
+
+ disconnectedCallback(): void {
+ const browserId = this.browserIdTask.value;
+
+ if (browserId && browserId !== this.#savedBrowserId) {
+ void this.deleteBrowser(browserId);
+ }
+
+ super.disconnectedCallback();
+ }
+
+ private readonly saveProfileTask = new Task(this, {
+ autoRun: false,
+ task: async ([browserId, config], { signal }) => {
+ if (!browserId || !config) return;
+
+ try {
+ const data = await this.saveProfile(
+ {
+ browserId,
+ name:
+ config.name ||
+ this.profile?.name ||
+ new URL(config.url).origin.slice(0, 50),
+ },
+ signal,
+ );
+
+ this.#savedBrowserId = browserId;
+
+ this.dispatchEvent(
+ new CustomEvent
("btrix-updated"),
+ );
+
+ return data;
+ } catch (err) {
+ let message = msg("Sorry, couldn't save browser profile at this time.");
+
+ if (isApiError(err) && err.statusCode === 403) {
+ if (err.details === "storage_quota_reached") {
+ message = msg(
+ "Your org does not have enough storage to save this browser profile.",
+ );
+ } else {
+ message = msg(
+ "You do not have permission to update browser profiles.",
+ );
+ }
+ }
+
+ throw message;
+ }
+ },
+ args: () => [this.browserIdTask.value, this.config] as const,
+ });
+
+ render() {
+ const isCrawler = this.appState.isCrawler;
+ const creatingNew = this.duplicating || !this.profile;
+ const incomplete =
+ !this.originsLoaded || this.browserStatus !== BrowserStatus.Ready;
+ const saving = this.saveProfileTask.status === TaskStatus.PENDING;
+
+ return html` {
+ // Hide viewport scrollbar when full screen dialog is open
+ document.documentElement.classList.add(tw`overflow-hidden`);
+ }}
+ @sl-hide=${() => {
+ document.documentElement.classList.remove(tw`overflow-hidden`);
+ }}
+ @sl-after-hide=${() => this.closeBrowser()}
+ >
+
+
+
+ {
+ this.saveProfileTask.abort();
+
+ if (this.browserIdTask.value) {
+ void this.deleteBrowser(this.browserIdTask.value);
+ }
+
+ void this.dialog?.hide();
+ }}
+ >
+
+
+
+
+
+ ${this.config?.name || this.profile?.name}
+
+ ${when(
+ this.config?.url,
+ (url) => html`
+
+
+ `,
+ )}
+
+ ${when(
+ (this.profile || this.config) && {
+ ...this.profile,
+ ...this.config,
+ },
+ badges,
+ )}
+
+
+
+
+
+ void this.profileBrowser?.enterFullscreen()}
+ >
+
+
+ this.profileBrowser?.toggleOrigins()}
+ >
+
+
+
+
+ ${when(
+ isCrawler,
+ () => html`
+
+ void this.dialog?.hide()
+ : () => (this.showConfirmation = true)}
+ >
+ ${this.showConfirmation ? msg("Yes, Exit") : msg("Exit")}
+
+
+
+
+ void this.submit()}
+ >
+ ${creatingNew ? msg("Create Profile") : msg("Save Profile")}
+
+
+ `,
+ () => html`
+ void this.dialog?.hide()}
+ >
+ ${msg("Exit")}
+
+ `,
+ )}
+
+
+
+
+
+ ${this.browserIdTask.render({
+ complete: (browserId) =>
+ browserId
+ ? html``
+ : nothing,
+ })}
+
+ `;
+ }
+
+ private readonly closeBrowser = () => {
+ this.browserStatus = BrowserStatus.Initial;
+ };
+
+ private readonly onBrowserLoad = () => {
+ this.browserStatus = BrowserStatus.Ready;
+ };
+
+ private readonly onBrowserReload = () => {
+ this.browserStatus = BrowserStatus.Pending;
+ };
+
+ private readonly onBrowserError = () => {
+ this.browserStatus = BrowserStatus.Error;
+ };
+
+ private readonly onBrowserOriginsChange = (
+ e: CustomEvent,
+ ) => {
+ if (e.detail.origins.length) {
+ this.originsLoaded = true;
+ }
+ };
+
+ private readonly onBrowserConnectionChange = (
+ e: CustomEvent,
+ ) => {
+ this.browserStatus = e.detail.connected
+ ? BrowserStatus.Pending
+ : BrowserStatus.Initial;
+ };
+
+ private async submit() {
+ this.notify.toast({
+ message: msg("Saving profile..."),
+ variant: "primary",
+ icon: "info-circle",
+ id: "browser-profile-save-status",
+ duration: Infinity,
+ });
+
+ try {
+ const dialog = this.dialog;
+
+ await this.saveProfileTask.run();
+
+ if (this.saveProfileTask.value?.id) {
+ void dialog?.hide();
+ this.navigate.to(
+ `${this.navigate.orgBasePath}/${OrgTab.BrowserProfiles}/profile/${this.saveProfileTask.value.id}`,
+ );
+ } else {
+ await dialog?.hide();
+ }
+
+ this.notify.toast({
+ message: msg("Successfully saved browser profile."),
+ variant: "success",
+ icon: "check2-circle",
+ id: "browser-profile-save-status",
+ });
+ } catch (err) {
+ if (typeof err === "string") {
+ this.notify.toast({
+ message: err,
+ variant: "danger",
+ icon: "exclamation-octagon",
+ id: "browser-profile-save-status",
+ });
+ } else {
+ console.debug(err);
+ }
+ }
+ }
+
+ private async saveProfile(
+ params: { browserId: string; name: string },
+ signal: AbortSignal,
+ ) {
+ const profileId = !this.duplicating && this.profile?.id;
+ const payload = {
+ browserid: params.browserId,
+ name: params.name,
+ };
+
+ let data: { id?: string; updated?: boolean; detail?: string } | undefined;
+ let retriesLeft = 300;
+
+ while (retriesLeft > 0) {
+ if (profileId) {
+ data = await this.api.fetch<{
+ updated?: boolean;
+ detail?: string;
+ }>(`/orgs/${this.orgId}/profiles/${profileId}`, {
+ method: "PATCH",
+ body: JSON.stringify(payload),
+ signal,
+ });
+
+ if (data.updated !== undefined) {
+ break;
+ }
+ } else {
+ data = await this.api.fetch<{ id?: string; detail?: string }>(
+ `/orgs/${this.orgId}/profiles`,
+ {
+ method: "POST",
+ body: JSON.stringify(payload),
+ signal,
+ },
+ );
+ }
+
+ if (data.id) {
+ break;
+ }
+ if (data.detail === "waiting_for_browser") {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ } else {
+ throw new Error("unknown response");
+ }
+
+ retriesLeft -= 1;
+ }
+
+ if (!retriesLeft) {
+ throw new Error("too many retries waiting for browser");
+ }
+
+ if (!data) {
+ throw new Error("unknown response");
+ }
+
+ return data;
+ }
+
+ private async createBrowser(
+ params: CreateBrowserOptions,
+ signal?: AbortSignal,
+ ) {
+ return this.api.fetch<{ browserid: string }>(
+ `/orgs/${this.orgId}/profiles/browser`,
+ {
+ method: "POST",
+ body: JSON.stringify(params),
+ signal,
+ },
+ );
+ }
+
+ private async deleteBrowser(browserId: string, signal?: AbortSignal) {
+ try {
+ const data = await this.api.fetch(
+ `/orgs/${this.orgId}/profiles/browser/${browserId}`,
+ {
+ method: "DELETE",
+ signal,
+ },
+ );
+
+ return data;
+ } catch (err) {
+ if (isApiError(err) && err.statusCode === 404) {
+ // Safe to ignore, since unloaded browser will have already been deleted
+ } else {
+ console.debug(err);
+ }
+ }
+ }
+}
diff --git a/frontend/src/features/browser-profiles/profile-browser.ts b/frontend/src/features/browser-profiles/profile-browser.ts
index 8248820a0e..61e4263fa4 100644
--- a/frontend/src/features/browser-profiles/profile-browser.ts
+++ b/frontend/src/features/browser-profiles/profile-browser.ts
@@ -1,10 +1,22 @@
-import { localized, msg, str } from "@lit/localize";
+import { localized, msg } from "@lit/localize";
+import { Task } from "@lit/task";
+import clsx from "clsx";
import { html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
+import { cache } from "lit/directives/cache.js";
+import { ifDefined } from "lit/directives/if-defined.js";
import { when } from "lit/directives/when.js";
import { BtrixElement } from "@/classes/BtrixElement";
+import { emptyMessage } from "@/layouts/emptyMessage";
+import type { Profile } from "@/types/crawler";
import { isApiError, type APIError } from "@/utils/api";
+import { isNotEqual } from "@/utils/is-not-equal";
+import { tw } from "@/utils/tailwind";
+
+// Matches background of embedded browser
+// TODO See if this can be configurable via API
+export const bgClass = tw`bg-[#282828]`;
const POLL_INTERVAL_SECONDS = 2;
const hiddenClassList = ["translate-x-2/3", "opacity-0", "pointer-events-none"];
@@ -20,6 +32,13 @@ export type BrowserNotAvailableError = {
export type BrowserConnectionChange = {
connected: boolean;
};
+export type BrowserOriginsChange = {
+ origins: string[];
+};
+
+const isPolling = (value: unknown): value is number => {
+ return typeof value === "number";
+};
/**
* View embedded profile browser
@@ -37,6 +56,10 @@ export type BrowserConnectionChange = {
* @fires btrix-browser-error
* @fires btrix-browser-reload
* @fires btrix-browser-connection-change
+ * @fires btrix-browser-origins-change Origins list has changed
+ * @cssPart base
+ * @cssPart browser
+ * @cssPart iframe
*/
@customElement("btrix-profile-browser")
@localized()
@@ -48,19 +71,10 @@ export class ProfileBrowser extends BtrixElement {
initialNavigateUrl?: string;
@property({ type: Array })
- origins?: string[];
-
- @state()
- private iframeSrc?: string;
+ initialOrigins?: Profile["origins"];
- @state()
- private isIframeLoaded = false;
-
- @state()
- private browserNotAvailable = false;
-
- @state()
- private browserDisconnected = false;
+ @property({ type: Boolean })
+ hideControls = false;
@state()
private isFullscreen = false;
@@ -68,54 +82,188 @@ export class ProfileBrowser extends BtrixElement {
@state()
private showOriginSidebar = false;
- @state()
- private newOrigins?: string[] = [];
-
@query("#interactiveBrowser")
private readonly interactiveBrowser?: HTMLElement;
@query("#profileBrowserSidebar")
private readonly sidebar?: HTMLElement;
- @query("#iframeWrapper")
- private readonly iframeWrapper?: HTMLElement;
-
@query("iframe")
private readonly iframe?: HTMLIFrameElement;
- private pollTimerId?: number;
+ /**
+ * Get temporary browser.
+ * If the browser is not available, the task also handles polling.
+ */
+ private readonly browserTask = new Task(this, {
+ task: async ([browserId], { signal }) => {
+ if (!browserId) {
+ console.debug("missing browserId");
+ return;
+ }
+
+ if (isPolling(this.browserTask.value)) {
+ window.clearTimeout(this.browserTask.value);
+ }
+
+ const poll = () =>
+ window.setTimeout(() => {
+ if (!signal.aborted) {
+ void this.browserTask.run();
+ }
+ }, POLL_INTERVAL_SECONDS * 1000);
+
+ let data: BrowserResponseData;
+
+ try {
+ data = await this.getBrowser(browserId, signal);
+ } catch (err) {
+ void this.onBrowserError();
+ throw err;
+ }
+
+ // Check whether temporary browser is up
+ if (data.detail === "waiting_for_browser") {
+ return poll();
+ }
+
+ if (data.url) {
+ // check that the browser is actually available
+ // if not, continue waiting
+ // (will not work with local frontend due to CORS)
+ try {
+ const resp = await fetch(data.url, { method: "HEAD" });
+ if (!resp.ok) {
+ return poll();
+ }
+ } catch (err) {
+ console.debug(err);
+ }
+ }
+
+ if (this.initialNavigateUrl) {
+ await this.navigateBrowser({ url: this.initialNavigateUrl }, signal);
+ }
+
+ window.addEventListener("beforeunload", this.onBeforeUnload);
+
+ this.dispatchEvent(
+ new CustomEvent("btrix-browser-load", {
+ detail: data.url,
+ }),
+ );
+
+ return {
+ id: browserId,
+ ...data,
+ };
+ },
+ args: () => [this.browserId] as const,
+ });
+
+ /**
+ * Get updated origins list in temporary browser
+ */
+ private readonly originsTask = new Task(this, {
+ task: async ([browser], { signal }) => {
+ window.clearTimeout(this.pingTask.value);
+
+ if (!browser || isPolling(browser)) return;
+
+ try {
+ const { origins } = await this.pingBrowser(browser.id, signal);
+
+ if (origins && isNotEqual(this.originsTask.value, origins)) {
+ this.dispatchEvent(
+ new CustomEvent(
+ "btrix-browser-origins-change",
+ {
+ detail: { origins },
+ },
+ ),
+ );
+ }
+
+ return origins || [];
+ } catch (err) {
+ if (isApiError(err) && err.details === "no_such_browser") {
+ void this.onBrowserError();
+ } else {
+ void this.onBrowserDisconnected();
+ }
+
+ throw err;
+ }
+ },
+ args: () => [this.browserTask.value] as const,
+ });
+
+ /**
+ * Keep temporary browser alive by polling for origins list
+ */
+ private readonly pingTask = new Task(this, {
+ task: async ([origins], { signal }) => {
+ window.clearTimeout(this.pingTask.value);
+
+ if (!origins) {
+ return;
+ }
+
+ return window.setTimeout(() => {
+ if (!signal.aborted) {
+ void this.originsTask.run();
+ }
+ }, POLL_INTERVAL_SECONDS * 1000);
+ },
+ args: () => [this.originsTask.value] as const,
+ });
connectedCallback() {
super.connectedCallback();
document.addEventListener("fullscreenchange", this.onFullscreenChange);
- window.addEventListener("beforeunload", this.onBeforeUnload);
}
disconnectedCallback() {
- window.clearTimeout(this.pollTimerId);
+ if (isPolling(this.browserTask.value))
+ window.clearTimeout(this.browserTask.value);
+
+ window.clearTimeout(this.pingTask.value);
+
document.removeEventListener("fullscreenchange", this.onFullscreenChange);
window.removeEventListener("beforeunload", this.onBeforeUnload);
super.disconnectedCallback();
}
- private onBeforeUnload(e: BeforeUnloadEvent) {
+ private readonly onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
- }
+ };
+
+ private readonly onBrowserError = async () => {
+ window.removeEventListener("beforeunload", this.onBeforeUnload);
+
+ await this.updateComplete;
+ this.dispatchEvent(
+ new CustomEvent("btrix-browser-error"),
+ );
+ };
+
+ private readonly onBrowserDisconnected = async () => {
+ window.removeEventListener("beforeunload", this.onBeforeUnload);
+
+ await this.updateComplete;
+ this.dispatchEvent(
+ new CustomEvent(
+ "btrix-browser-connection-change",
+ {
+ detail: { connected: false },
+ },
+ ),
+ );
+ };
willUpdate(changedProperties: PropertyValues & Map) {
- if (changedProperties.has("browserId")) {
- if (this.browserId) {
- window.clearTimeout(this.pollTimerId);
- void this.fetchBrowser();
- } else if (changedProperties.get("browserId")) {
- this.iframeSrc = undefined;
- this.isIframeLoaded = false;
-
- window.clearTimeout(this.pollTimerId);
- }
- }
if (
changedProperties.has("showOriginSidebar") &&
changedProperties.get("showOriginSidebar") !== undefined
@@ -124,29 +272,8 @@ export class ProfileBrowser extends BtrixElement {
}
}
- updated(changedProperties: PropertyValues & Map) {
- if (changedProperties.has("browserDisconnected")) {
- this.dispatchEvent(
- new CustomEvent(
- "btrix-browser-connection-change",
- {
- detail: {
- connected: !this.browserDisconnected,
- },
- },
- ),
- );
- }
- if (changedProperties.has("browserNotAvailable")) {
- if (this.browserNotAvailable) {
- window.removeEventListener("beforeunload", this.onBeforeUnload);
- } else {
- window.addEventListener("beforeunload", this.onBeforeUnload);
- }
- this.dispatchEvent(
- new CustomEvent("btrix-browser-error"),
- );
- }
+ public toggleOrigins() {
+ this.showOriginSidebar = !this.showOriginSidebar;
}
private animateSidebar() {
@@ -159,17 +286,88 @@ export class ProfileBrowser extends BtrixElement {
}
render() {
+ const loadingMsgChangeDelay = 10000;
+ const browserLoading = () =>
+ cache(
+ html`
+
+
+
+ ${msg("Loading interactive browser...")}
+
+
+
+
+ ${msg("Browser is still warming up...")}
+
+
+
+
+
+
`,
+ );
+
return html`
-
+
${this.renderControlBar()}
- ${this.renderBrowser()}
+ ${this.browserTask.render({
+ initial: browserLoading,
+ pending: browserLoading,
+ error: () => html`
+
+
+
+ ${msg(
+ `Interactive browser session timed out due to inactivity.`,
+ )}
+
+
+
+
+ ${msg("Load New Browser")}
+
+
+
+
+ `,
+ complete: (result) =>
+ !result || isPolling(result)
+ ? browserLoading()
+ : this.renderBrowser(result),
+ })}
@@ -187,14 +439,14 @@ export class ProfileBrowser extends BtrixElement {
`;
}
- private renderControlBar() {
+ private readonly renderControlBar = () => {
if (this.isFullscreen) {
return html`
${this.renderSidebarButton()}
-
+
void document.exitFullscreen()}
@@ -204,22 +456,28 @@ export class ProfileBrowser extends BtrixElement {
`;
}
+ if (this.hideControls) return;
+
return html`
-
+
${msg("Interactive Browser")}
-
-
-
+
+
${this.renderSidebarButton()}
-
+
void this.enterFullscreen()}
@@ -228,210 +486,129 @@ export class ProfileBrowser extends BtrixElement {
`;
- }
-
- private renderBrowser() {
- if (this.browserNotAvailable) {
- return html`
-
-
-
- ${msg(`Interactive browser session timed out due to inactivity.`)}
-
-
-
-
- ${msg("Load New Browser")}
-
-
-
-
- `;
- }
-
- if (this.iframeSrc) {
- return html`
-
- ${when(
- this.browserDisconnected,
- () => html`
-
-
-
- ${msg(
- "Connection to interactive browser lost. Waiting to reconnect...",
- )}
-
-
-
- `,
- )}
-
`;
- }
-
- if (this.browserId && !this.isIframeLoaded) {
- return html`
-
-
-
- `;
- }
+ };
- return "";
- }
+ private readonly renderBrowser = (browser: BrowserResponseData) => {
+ return html`
+ ${when(
+ browser.url,
+ (url) => html`
+
+ `,
+ )}
+ ${this.originsTask.render({
+ error: () => html`
+
+
+
+ ${msg(
+ "Connection to interactive browser lost. Waiting to reconnect...",
+ )}
+
+
+
+ `,
+ })}
+
`;
+ };
private renderSidebarButton() {
return html`
-
+
`;
}
- private renderOrigins() {
- return html`
-
- ${msg("Visited Sites")}
-
-
-
- ${this.origins?.map((url) => this.renderOriginItem(url))}
-
- `;
- }
-
- private renderNewOrigins() {
- if (!this.newOrigins?.length) return;
+ private renderOriginsList(
+ { heading, popoverContent }: { heading: string; popoverContent: string },
+ origins?: string[],
+ ) {
+ if (!origins?.length) return;
return html`
-
- ${msg("New Sites")}
-
-
+
+
+
${heading}
+
+
+ (this.showOriginSidebar = false)}
+ >
+
+
- ${this.newOrigins.map((url) => this.renderOriginItem(url))}
+ ${origins.map((url) => this.renderOriginItem(url))}
`;
}
private renderOriginItem(url: string) {
- return html` (this.iframeSrc ? this.navigateBrowser({ url }) : {})}
- >
- ${url}
- ${this.iframeSrc
- ? html``
- : ""}
+ const iframeSrc =
+ !isPolling(this.browserTask.value) && this.browserTask.value?.url;
+
+ return html`
+ void this.navigateBrowser({ url })
+ : undefined}
+ >
+ ${iframeSrc
+ ? html`
+
+ `
+ : ""}
+
+
+
`;
}
private onClickReload() {
- this.dispatchEvent(new CustomEvent("btrix-browser-reload"));
- }
-
- /**
- * Fetch browser profile and update internal state
- */
- private async fetchBrowser(): Promise {
- await this.updateComplete;
-
- this.iframeSrc = undefined;
- this.isIframeLoaded = false;
+ this.showOriginSidebar = false;
- await this.checkBrowserStatus();
- }
-
- /**
- * Check whether temporary browser is up
- **/
- private async checkBrowserStatus() {
- let result: BrowserResponseData;
- try {
- result = await this.getBrowser();
- this.browserNotAvailable = false;
- } catch (e) {
- this.browserNotAvailable = true;
- return;
- }
-
- if (result.detail === "waiting_for_browser") {
- this.pollTimerId = window.setTimeout(
- () => void this.checkBrowserStatus(),
- POLL_INTERVAL_SECONDS * 1000,
- );
-
- return;
- } else if (result.url) {
- // check that the browser is actually available
- // if not, continue waiting
- // (will not work with local frontend due to CORS)
- try {
- const resp = await fetch(result.url, { method: "HEAD" });
- if (!resp.ok) {
- this.pollTimerId = window.setTimeout(
- () => void this.checkBrowserStatus(),
- POLL_INTERVAL_SECONDS * 1000,
- );
- return;
- }
- } catch (e) {
- // ignore
- }
-
- if (this.initialNavigateUrl) {
- await this.navigateBrowser({ url: this.initialNavigateUrl });
- }
-
- this.iframeSrc = result.url;
-
- await this.updateComplete;
-
- this.dispatchEvent(
- new CustomEvent("btrix-browser-load", {
- detail: result.url,
- }),
- );
-
- void this.pingBrowser();
- } else {
- console.debug("Unknown checkBrowserStatus state");
- }
+ this.dispatchEvent(new CustomEvent("btrix-browser-reload"));
}
- private async getBrowser() {
+ private async getBrowser(browserId: string, signal?: AbortSignal) {
const data = await this.api.fetch(
- `/orgs/${this.orgId}/profiles/browser/${this.browserId}`,
+ `/orgs/${this.orgId}/profiles/browser/${browserId}`,
+ { signal },
);
return data;
@@ -440,14 +617,16 @@ export class ProfileBrowser extends BtrixElement {
/**
* Navigate to URL in temporary browser
**/
- private async navigateBrowser({ url }: { url: string }) {
- if (!this.iframeSrc) return;
-
+ private async navigateBrowser(
+ { url }: { url: string },
+ signal?: AbortSignal,
+ ) {
const data = this.api.fetch(
`/orgs/${this.orgId}/profiles/browser/${this.browserId}/navigate`,
{
method: "POST",
body: JSON.stringify({ url }),
+ signal,
},
);
@@ -457,46 +636,20 @@ export class ProfileBrowser extends BtrixElement {
/**
* Ping temporary browser to keep it alive
**/
- private async pingBrowser() {
- if (!this.iframeSrc) return;
-
- try {
- const data = await this.api.fetch<{ origins?: string[] }>(
- `/orgs/${this.orgId}/profiles/browser/${this.browserId}/ping`,
- {
- method: "POST",
- },
- );
-
- if (!this.origins) {
- this.origins = data.origins;
- } else {
- this.newOrigins = data.origins?.filter(
- (url: string) => !this.origins?.includes(url),
- );
- }
-
- this.browserDisconnected = false;
- } catch (e) {
- if (isApiError(e) && e.details === "no_such_browser") {
- this.browserNotAvailable = true;
- } else {
- this.browserDisconnected = true;
- }
-
- await this.updateComplete;
- }
-
- this.pollTimerId = window.setTimeout(
- () => void this.pingBrowser(),
- POLL_INTERVAL_SECONDS * 1000,
+ private async pingBrowser(browserId: string, signal?: AbortSignal) {
+ return this.api.fetch<{ origins?: string[] }>(
+ `/orgs/${this.orgId}/profiles/browser/${browserId}/ping`,
+ {
+ method: "POST",
+ signal,
+ },
);
}
/**
* Enter fullscreen mode
*/
- private async enterFullscreen() {
+ public async enterFullscreen() {
try {
await this.interactiveBrowser?.requestFullscreen({
// Hide browser navigation controls
@@ -507,16 +660,17 @@ export class ProfileBrowser extends BtrixElement {
}
}
- private onIframeLoad() {
- this.isIframeLoaded = true;
+ private async onIframeLoad(url: string) {
try {
this.iframe?.contentWindow?.localStorage.setItem("uiTheme", '"default"');
- } catch (e) {
- /* empty */
+ } catch (err) {
+ console.debug(err);
}
+
+ await this.updateComplete;
this.dispatchEvent(
new CustomEvent("btrix-browser-load", {
- detail: this.iframeSrc,
+ detail: url,
}),
);
}
diff --git a/frontend/src/features/browser-profiles/profile-metadata-dialog.ts b/frontend/src/features/browser-profiles/profile-metadata-dialog.ts
new file mode 100644
index 0000000000..d8eefe6380
--- /dev/null
+++ b/frontend/src/features/browser-profiles/profile-metadata-dialog.ts
@@ -0,0 +1,202 @@
+import { localized, msg } from "@lit/localize";
+import { Task, TaskStatus } from "@lit/task";
+import type { SlInput, SlTextarea } from "@shoelace-style/shoelace";
+import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
+import { html } from "lit";
+import { customElement, property, query, state } from "lit/decorators.js";
+
+import { BtrixElement } from "@/classes/BtrixElement";
+import type { Dialog } from "@/components/ui/dialog";
+import type { TagInput } from "@/components/ui/tag-input";
+import type { ProfileUpdatedEvent } from "@/features/browser-profiles/types";
+import type { Profile } from "@/types/crawler";
+import { isApiError } from "@/utils/api";
+import { maxLengthValidator } from "@/utils/form";
+
+/**
+ * @fires btrix-updated
+ */
+@customElement("btrix-profile-metadata-dialog")
+@localized()
+export class ProfileMetadataDialog extends BtrixElement {
+ @property({ type: Object })
+ profile?: Profile;
+
+ @property({ type: Boolean })
+ open = false;
+
+ @property({ type: String })
+ autofocusOn?: "name" | "description";
+
+ @state()
+ private isDialogVisible = false;
+
+ @query("btrix-dialog")
+ private readonly dialog?: Dialog | null;
+
+ @query("form")
+ private readonly form?: HTMLFormElement | null;
+
+ @query(`sl-input[name="name"]`)
+ private readonly nameInput?: SlInput | null;
+
+ @query(`sl-textarea[name="description"]`)
+ private readonly descriptionInput?: SlTextarea | null;
+
+ @query(`btrix-tag-input`)
+ private readonly tagInput?: TagInput | null;
+
+ private readonly validateNameMax = maxLengthValidator(50);
+ private readonly validateDescriptionMax = maxLengthValidator(500);
+
+ private readonly submitTask = new Task(this, {
+ autoRun: false,
+ task: async ([profile], { signal }) => {
+ if (!this.form || !profile) {
+ console.debug("no form or profile", this.form, profile);
+ return;
+ }
+
+ const formValues = serialize(this.form) as {
+ name: string;
+ description: string;
+ };
+
+ const tags = this.tagInput?.getTags();
+
+ const params = { ...formValues, tags };
+
+ try {
+ await this.api.fetch<{ updated: boolean }>(
+ `/orgs/${this.orgId}/profiles/${profile.id}`,
+ {
+ method: "PATCH",
+ body: JSON.stringify(params),
+ signal,
+ },
+ );
+
+ this.dispatchEvent(
+ new CustomEvent("btrix-updated", {
+ detail: params,
+ }),
+ );
+
+ this.notify.toast({
+ message: msg("Updated browser profile metadata."),
+ variant: "success",
+ icon: "check2-circle",
+ id: "browser-profile-save-status",
+ });
+ } catch (e) {
+ let message = msg("Sorry, couldn't save browser profile at this time.");
+
+ if (isApiError(e) && e.statusCode === 403) {
+ if (e.details === "storage_quota_reached") {
+ message = msg(
+ "Your org does not have enough storage to save this browser profile.",
+ );
+ } else {
+ message = msg(
+ "You do not have permission to edit browser profiles.",
+ );
+ }
+ }
+
+ this.notify.toast({
+ message: message,
+ variant: "danger",
+ icon: "exclamation-octagon",
+ id: "browser-profile-save-status",
+ });
+ }
+ },
+ args: () => [this.profile] as const,
+ });
+
+ render() {
+ return html` (this.isDialogVisible = true)}
+ @sl-initial-focus=${async () => {
+ await this.updateComplete;
+
+ switch (this.autofocusOn) {
+ case "name":
+ this.nameInput?.focus();
+ break;
+ case "description":
+ this.descriptionInput?.focus();
+ break;
+ default:
+ break;
+ }
+ }}
+ @sl-after-hide=${() => (this.isDialogVisible = false)}
+ >${this.isDialogVisible ? this.renderForm() : ""}
+ `;
+ }
+
+ private renderForm() {
+ if (!this.profile) return;
+
+ const submitting = this.submitTask.status === TaskStatus.PENDING;
+
+ return html`
+
+
+ ${msg("Cancel")}
+ ${msg("Save")}
+
+ `;
+ }
+}
diff --git a/frontend/src/features/browser-profiles/select-browser-profile.ts b/frontend/src/features/browser-profiles/select-browser-profile.ts
index 54d602972d..9f37619ce4 100644
--- a/frontend/src/features/browser-profiles/select-browser-profile.ts
+++ b/frontend/src/features/browser-profiles/select-browser-profile.ts
@@ -1,18 +1,41 @@
import { localized, msg } from "@lit/localize";
-import { type SlSelect } from "@shoelace-style/shoelace";
-import { html, nothing, type PropertyValues } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
+import { Task } from "@lit/task";
+import type {
+ SlChangeEvent,
+ SlDrawer,
+ SlSelect,
+} from "@shoelace-style/shoelace";
+import clsx from "clsx";
+import { html, nothing } from "lit";
+import { customElement, property, query, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
-import orderBy from "lodash/fp/orderBy";
+import { when } from "lit/directives/when.js";
+import queryString from "query-string";
+
+import { originsWithRemainder } from "./templates/origins-with-remainder";
import { BtrixElement } from "@/classes/BtrixElement";
-import type { Profile } from "@/pages/org/types";
-import type { APIPaginatedList } from "@/types/api";
+import { none } from "@/layouts/empty";
+import { pageHeading } from "@/layouts/page";
+import { CrawlerChannelImage, type Profile } from "@/pages/org/types";
+import { OrgTab } from "@/routes";
+import type {
+ APIPaginatedList,
+ APIPaginationQuery,
+ APISortQuery,
+} from "@/types/api";
+import { SortDirection } from "@/types/utils";
+import { isNotEqual } from "@/utils/is-not-equal";
+import { AppStateService } from "@/utils/state";
+import { tw } from "@/utils/tailwind";
type SelectBrowserProfileChangeDetail = {
value: Profile | undefined;
};
+// TODO Paginate results
+const INITIAL_PAGE_SIZE = 1000;
+
export type SelectBrowserProfileChangeEvent =
CustomEvent;
@@ -37,130 +60,311 @@ export class SelectBrowserProfile extends BtrixElement {
@property({ type: String })
profileId?: string;
- @state()
- private selectedProfile?: Profile;
+ @property({ type: String })
+ profileName?: string;
+
+ /**
+ * List of origins to match to prioritize profile options
+ */
+ @property({ type: Array, hasChanged: isNotEqual })
+ suggestOrigins?: string[];
@state()
- private browserProfiles?: Profile[];
+ selectedProfile?: Profile;
- willUpdate(changedProperties: PropertyValues) {
- if (changedProperties.has("profileId")) {
- void this.updateSelectedProfile();
- }
+ @query("sl-select")
+ private readonly select?: SlSelect | null;
+
+ @query("sl-drawer")
+ private readonly drawer?: SlDrawer | null;
+
+ public get value() {
+ return this.select?.value as string;
}
- firstUpdated() {
- void this.updateSelectedProfile();
+ private readonly profilesTask = new Task(this, {
+ task: async (_args, { signal }) => {
+ return this.getProfiles(
+ {
+ sortBy: "name",
+ sortDirection: SortDirection.Ascending,
+ pageSize: INITIAL_PAGE_SIZE,
+ },
+ signal,
+ );
+ },
+ args: () => [] as const,
+ });
+
+ private readonly selectedProfileTask = new Task(this, {
+ task: async ([profileId, profiles], { signal }) => {
+ if (!profileId || !profiles || signal.aborted) return;
+
+ this.selectedProfile = this.findProfileById(profileId);
+ },
+ args: () => [this.profileId, this.profilesTask.value] as const,
+ });
+
+ private findProfileById(profileId?: string) {
+ if (!profileId) return;
+ return this.profilesTask.value?.items.find(({ id }) => id === profileId);
}
render() {
+ const selectedProfile = this.selectedProfile;
+ const browserProfiles = this.profilesTask.value;
+ const loading = !browserProfiles && !this.profileName;
+
return html`
{
- // Refetch to keep list up to date
- void this.fetchBrowserProfiles();
- }}
@sl-hide=${this.stopProp}
@sl-after-hide=${this.stopProp}
>
- ${this.browserProfiles
- ? html`
- ${msg("No custom profile")}
-
- `
- : html` `}
- ${this.browserProfiles?.map(
- (profile) => html`
-
- ${profile.name}
-
- `,
- )}
- ${this.browserProfiles && !this.browserProfiles.length
+ ${loading ? html`` : nothing}
+ ${this.renderProfileOptions()}
+ ${browserProfiles && !browserProfiles.total
? this.renderNoProfiles()
: ""}
- ${this.selectedProfile
+ ${selectedProfile
? html`
+
- ${msg("Last updated")}
-
+ ${msg("Last saved")}
+ ${this.localize.relativeDate(
+ selectedProfile.modified || selectedProfile.created,
+ { capitalize: true },
+ )}
- ${this.selectedProfile.proxyId
- ? html`
- ${msg("Using proxy: ")}
- ${this.selectedProfile.proxyId}
- `
- : ``}
-
- ${msg("Check Profile")}
-
-
`
- : this.browserProfiles
+ : browserProfiles
? html`
-
- ${msg("View Profiles")}
-
-
+ ${msg("View Browser Profiles")}
+
`
: nothing}
- ${this.browserProfiles?.length ? this.renderSelectedProfileInfo() : ""}
+ ${browserProfiles || selectedProfile
+ ? this.renderSelectedProfileInfo()
+ : ""}
`;
}
- private renderSelectedProfileInfo() {
- if (!this.selectedProfile?.description) return;
+ private renderProfileOptions() {
+ const browserProfiles = this.profilesTask.value;
- return html`
-
-
- ${msg("Description")}
-
-
- ${this.selectedProfile.description}
+ ${this.profileName}
+ `;
+ }
+
+ return;
+ }
+
+ const option = (profile: Profile, i: number) => html`
+
+ ${this.renderOverview(profile)}
+
+
-
-
`;
+ ${profile.name}
+
+ ${originsWithRemainder(profile.origins, {
+ disablePopover: true,
+ })}
+
+
+
+ `;
+
+ const profiles = browserProfiles.items;
+ const priorityOrigins = this.suggestOrigins;
+ const suggestions: Profile[] = [];
+ let rest: Profile[] = [];
+
+ if (priorityOrigins?.length) {
+ profiles.forEach((profile) => {
+ const { origins } = profile;
+ if (
+ origins.some((origin) =>
+ priorityOrigins.includes(
+ new URL(origin).hostname.replace(/^www\./, ""),
+ ),
+ )
+ ) {
+ suggestions.push(profile);
+ } else {
+ rest.push(profile);
+ }
+ });
+ } else {
+ rest = profiles;
+ }
+
+ return html`
+ ${msg("No custom profile")}
+ ${suggestions.length
+ ? html`
+
+ ${msg("Suggested Profiles")}
+ ${suggestions.map(option)}
+ `
+ : nothing}
+ ${rest.length
+ ? html`
+
+ ${suggestions.length
+ ? msg("Other Saved Profiles")
+ : msg("Saved Profiles")}
+ ${rest.map(option)}
+ `
+ : nothing}
+ `;
+ }
+
+ private renderSelectedProfileInfo() {
+ const profileContent = (profile: Profile) => {
+ return html`${pageHeading({ content: msg("Overview"), level: 3 })}
+ ${this.renderOverview(profile)}
+
+
+
+ ${pageHeading({ content: msg("Saved Sites"), level: 3 })}
+
+ ${profile.origins.length
+ ? html`
+ ${profile.origins.map(
+ (origin) => html`
+ -
+
+
+ `,
+ )}
+
`
+ : none}
+
+
+
+
+ ${msg("View More")}
+
+
`;
+ };
+
+ return html` {
+ // Hide any other open panels
+ AppStateService.updateUserGuideOpen(false);
+ }}
+ >
+
+
+ ${this.selectedProfile?.name}
+
+
+ ${when(this.selectedProfile, profileContent)}
+ `;
}
+ private readonly renderOverview = (profile: Profile) => {
+ const modifiedByAnyDate = [
+ profile.modifiedCrawlDate,
+ profile.modified,
+ profile.created,
+ ].reduce((a, b) => (b && a && b > a ? b : a), profile.created);
+
+ return html`
+
+ ${profile.description
+ ? html`
+
+ ${profile.description}
+ `
+ : none}
+
+
+ ${profile.tags.length
+ ? html`
+ ${profile.tags.map((tag) => html`${tag}`)}
+
`
+ : none}
+
+
+
+
+ ${when(
+ profile.proxyId,
+ (proxyId) => html`
+
+
+
+ `,
+ )}
+
+ ${this.localize.relativeDate(modifiedByAnyDate || profile.created, {
+ capitalize: true,
+ })}
+
+ `;
+ };
+
private renderNoProfiles() {
return html`
@@ -194,10 +398,9 @@ export class SelectBrowserProfile extends BtrixElement {
`;
}
- private async onChange(e: Event) {
- this.selectedProfile = this.browserProfiles?.find(
- ({ id }) => id === (e.target as SlSelect | null)?.value,
- );
+ private async onChange(e: SlChangeEvent) {
+ const profileId = (e.target as SlSelect | null)?.value as string;
+ this.selectedProfile = this.findProfileById(profileId);
await this.updateComplete;
@@ -210,43 +413,30 @@ export class SelectBrowserProfile extends BtrixElement {
);
}
- private async updateSelectedProfile() {
- await this.fetchBrowserProfiles();
- await this.updateComplete;
-
- if (this.profileId && !this.selectedProfile) {
- this.selectedProfile = this.browserProfiles?.find(
- ({ id }) => id === this.profileId,
- );
- }
- }
-
- /**
- * Fetch browser profiles and update internal state
- */
- private async fetchBrowserProfiles(): Promise
{
- try {
- const data = await this.getProfiles();
-
- this.browserProfiles = orderBy(["name", "modified"])(["asc", "desc"])(
- data,
- ) as Profile[];
- } catch (e) {
- this.notify.toast({
- message: msg("Sorry, couldn't retrieve browser profiles at this time."),
- variant: "danger",
- icon: "exclamation-octagon",
- id: "browser-profile-status",
- });
- }
- }
+ private async getProfiles(
+ params: {
+ userid?: string;
+ tags?: string[];
+ tagMatch?: string;
+ } & APIPaginationQuery &
+ APISortQuery,
+ signal: AbortSignal,
+ ) {
+ const query = queryString.stringify(
+ {
+ ...params,
+ },
+ {
+ arrayFormat: "none", // For tags
+ },
+ );
- private async getProfiles() {
const data = await this.api.fetch>(
- `/orgs/${this.orgId}/profiles`,
+ `/orgs/${this.orgId}/profiles?${query}`,
+ { signal },
);
- return data.items;
+ return data;
}
/**
diff --git a/frontend/src/features/browser-profiles/start-browser-dialog.ts b/frontend/src/features/browser-profiles/start-browser-dialog.ts
new file mode 100644
index 0000000000..22ce2c18cd
--- /dev/null
+++ b/frontend/src/features/browser-profiles/start-browser-dialog.ts
@@ -0,0 +1,356 @@
+import { consume } from "@lit/context";
+import { localized, msg } from "@lit/localize";
+import { Task } from "@lit/task";
+import type {
+ SlButton,
+ SlChangeEvent,
+ SlCheckbox,
+ SlSelect,
+} from "@shoelace-style/shoelace";
+import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
+import { html, nothing, type PropertyValues } from "lit";
+import { customElement, property, query, state } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+import { when } from "lit/directives/when.js";
+import queryString from "query-string";
+
+import { BtrixElement } from "@/classes/BtrixElement";
+import type { Details } from "@/components/ui/details";
+import type { Dialog } from "@/components/ui/dialog";
+import type { SelectCrawlerProxy } from "@/components/ui/select-crawler-proxy";
+import type { UrlInput } from "@/components/ui/url-input";
+import {
+ orgCrawlerChannelsContext,
+ type OrgCrawlerChannelsContext,
+} from "@/context/org-crawler-channels";
+import {
+ orgProxiesContext,
+ type OrgProxiesContext,
+} from "@/context/org-proxies";
+import type { Profile } from "@/types/crawler";
+import type { WorkflowSearchValues } from "@/types/workflow";
+
+type StartBrowserEventDetail = {
+ url?: string;
+ crawlerChannel?: Profile["crawlerChannel"];
+ proxyId?: Profile["proxyId"];
+ replaceBrowser: boolean;
+};
+
+export type BtrixStartBrowserEvent = CustomEvent;
+
+const PRIMARY_SITE_FIELD_NAME = "primarySite";
+const URL_FORM_FIELD_NAME = "startingUrl";
+
+/**
+ * Start browser with specified profile and additional configuration.
+ *
+ * @fires btrix-start-browser
+ */
+@customElement("btrix-start-browser-dialog")
+@localized()
+export class StartBrowserDialog extends BtrixElement {
+ @consume({ context: orgProxiesContext, subscribe: true })
+ private readonly orgProxies?: OrgProxiesContext;
+
+ @consume({ context: orgCrawlerChannelsContext, subscribe: true })
+ private readonly orgCrawlerChannels?: OrgCrawlerChannelsContext;
+
+ @property({ type: Object })
+ profile?: Profile;
+
+ @property({ type: String })
+ initialUrl?: string;
+
+ @property({ type: Boolean })
+ open = false;
+
+ @state()
+ loadUrl?: string;
+
+ @state()
+ replaceBrowser = false;
+
+ @query("btrix-dialog")
+ private readonly dialog?: Dialog | null;
+
+ @query("form")
+ private readonly form?: HTMLFormElement | null;
+
+ @query("btrix-details")
+ private readonly details?: Details | null;
+
+ @query(`[name=${PRIMARY_SITE_FIELD_NAME}]`)
+ private readonly primarySiteInput?: SlSelect | null;
+
+ @query(`[name=${URL_FORM_FIELD_NAME}]`)
+ private readonly urlInput?: UrlInput | null;
+
+ @query("btrix-select-crawler-proxy")
+ private readonly selectCrawlerProxy?: SelectCrawlerProxy | null;
+
+ @query("#submit-button")
+ private readonly submitButton?: SlButton | null;
+
+ // Get unique origins from workflow first seeds/crawl start URLs
+ private readonly workflowOrigins = new Task(this, {
+ task: async ([profile], { signal }) => {
+ if (!profile) return null;
+
+ const query = queryString.stringify({ profileIds: profile.id });
+
+ try {
+ const { firstSeeds } = await this.api.fetch(
+ `/orgs/${this.orgId}/crawlconfigs/search-values?${query}`,
+ { signal },
+ );
+
+ const profileUrls = profile.origins.map((origin) => new URL(origin));
+ const originMap: { [url: string]: boolean } = {};
+
+ firstSeeds.forEach((seed) => {
+ const seedUrl = new URL(seed);
+
+ // Only check domain names without www
+ if (
+ profileUrls.some((url) => {
+ return (
+ seedUrl.hostname.replace(/^www\./, "") ===
+ url.hostname.replace(/^www\./, "")
+ );
+ })
+ ) {
+ return;
+ }
+
+ originMap[seedUrl.origin] = true;
+ });
+
+ return Object.keys(originMap);
+ } catch (e) {
+ console.debug(e);
+ }
+ },
+ args: () => [this.profile] as const,
+ });
+
+ protected willUpdate(changedProperties: PropertyValues): void {
+ if (changedProperties.has("initialUrl")) {
+ this.loadUrl = this.initialUrl;
+ }
+ }
+
+ protected firstUpdated(): void {
+ if (this.loadUrl === undefined) {
+ this.loadUrl = this.initialUrl;
+ }
+ }
+
+ render() {
+ const profile = this.profile;
+ const channels = this.orgCrawlerChannels;
+ const proxies = this.orgProxies;
+ const proxyServers = proxies?.servers;
+ const showChannels = channels && channels.length > 1 && profile;
+ const showProxies =
+ this.replaceBrowser && proxies && proxyServers?.length && profile;
+
+ return html` {
+ await this.updateComplete;
+
+ if (this.initialUrl) {
+ this.submitButton?.focus();
+ } else {
+ this.dialog?.querySelector("btrix-url-input")?.focus();
+ }
+ }}
+ @sl-after-hide=${async () => {
+ if (this.form) {
+ this.form.reset();
+ }
+
+ this.replaceBrowser = false;
+ }}
+ >
+
+
+ void this.dialog?.hide()}
+ >${msg("Cancel")}
+ this.dialog?.submit()}
+ >
+ ${msg("Start Browser")}
+
+
+ `;
+ }
+
+ private readonly renderUrl = (profile: Profile) => {
+ const option = (url: string) =>
+ html`
+ ${url}
+ `;
+
+ return html` {
+ this.loadUrl = (e.target as SlSelect).value as string;
+
+ await this.updateComplete;
+
+ if (this.open) {
+ this.urlInput?.focus();
+ }
+ }}
+ >
+ ${msg("New Site")}
+
+ ${when(
+ profile.origins.length,
+ () => html`
+
+ ${msg("Saved Sites")}
+ ${profile.origins.map(option)}
+ `,
+ )}
+ ${when(this.workflowOrigins.value, (seeds) =>
+ seeds.length
+ ? html`
+
+ ${msg("Suggestions from Related Workflows")}
+ ${seeds.slice(0, 10).map(option)}
+ `
+ : nothing,
+ )}
+
+
+
+ {
+ const value = (e.target as UrlInput).value;
+ let origin = "";
+
+ try {
+ origin = new URL(value).origin;
+ } catch {
+ // Not a valid URL
+ }
+
+ if (origin && this.primarySiteInput) {
+ const savedSite = profile.origins.find(
+ (url) => new URL(url).origin === origin,
+ );
+
+ this.primarySiteInput.value = savedSite || "";
+ }
+ }}
+ help-text=${msg(
+ "The first page of the site to load, like a login page.",
+ )}
+ >
+
+
`;
+ };
+}
diff --git a/frontend/src/features/browser-profiles/templates/badges.ts b/frontend/src/features/browser-profiles/templates/badges.ts
new file mode 100644
index 0000000000..5fdea74f6f
--- /dev/null
+++ b/frontend/src/features/browser-profiles/templates/badges.ts
@@ -0,0 +1,45 @@
+import { msg } from "@lit/localize";
+import { html, nothing } from "lit";
+import { when } from "lit/directives/when.js";
+
+import { type Profile } from "@/types/crawler";
+
+export const usageBadge = (inUse: boolean) =>
+ html`
+
+
+ ${inUse ? msg("In Use") : msg("Not In Use")}
+
+ `;
+
+export const badges = (
+ profile: Partial>,
+) => {
+ return html`
+ ${profile.inUse === undefined ? nothing : usageBadge(profile.inUse)}
+ ${when(
+ profile.crawlerChannel,
+ (channelImage) => html`
+
+ `,
+ )}
+ ${when(
+ profile.proxyId,
+ (proxyId) => html`
+
+ `,
+ )}
+
`;
+};
+
+export const badgesSkeleton = () =>
+ html``;
diff --git a/frontend/src/features/browser-profiles/templates/origins-with-remainder.ts b/frontend/src/features/browser-profiles/templates/origins-with-remainder.ts
new file mode 100644
index 0000000000..17080a26d9
--- /dev/null
+++ b/frontend/src/features/browser-profiles/templates/origins-with-remainder.ts
@@ -0,0 +1,39 @@
+import { html, nothing } from "lit";
+
+import type { Profile } from "@/types/crawler";
+import localize from "@/utils/localize";
+
+/**
+ * Displays primary origin with remainder in a popover badge
+ */
+export function originsWithRemainder(
+ origins: Profile["origins"],
+ { disablePopover } = { disablePopover: false },
+) {
+ const startingUrl = origins[0];
+ const otherOrigins = origins.slice(1);
+
+ return html`
+
+ ${otherOrigins.length
+ ? html`
+
+ +${localize.number(otherOrigins.length)}
+
+ ${otherOrigins.map((url) => html`- ${url}
`)}
+
+
+ `
+ : nothing}
+
`;
+}
diff --git a/frontend/src/features/browser-profiles/types.ts b/frontend/src/features/browser-profiles/types.ts
new file mode 100644
index 0000000000..57c15dd399
--- /dev/null
+++ b/frontend/src/features/browser-profiles/types.ts
@@ -0,0 +1,12 @@
+import type { Profile } from "@/types/crawler";
+
+export type ProfileUpdatedEvent = CustomEvent<
+ Partial>
+>;
+
+export type CreateBrowserOptions = {
+ url: string;
+ profileId?: string;
+ crawlerChannel?: string;
+ proxyId?: string;
+};
diff --git a/frontend/src/features/collections/collection-edit-dialog.ts b/frontend/src/features/collections/collection-edit-dialog.ts
index fed4f33913..8884f31b08 100644
--- a/frontend/src/features/collections/collection-edit-dialog.ts
+++ b/frontend/src/features/collections/collection-edit-dialog.ts
@@ -249,7 +249,7 @@ export class CollectionEdit extends BtrixElement {
})}
${this.renderTab({
panel: "sharing",
- icon: "globe2",
+ icon: "globe",
string: msg("Sharing"),
})}
diff --git a/frontend/src/features/collections/select-collection-access.ts b/frontend/src/features/collections/select-collection-access.ts
index 2e8c9d5df9..2065ac0c4a 100644
--- a/frontend/src/features/collections/select-collection-access.ts
+++ b/frontend/src/features/collections/select-collection-access.ts
@@ -27,7 +27,7 @@ export class SelectCollectionAccess extends BtrixElement {
},
[CollectionAccess.Public]: {
label: msg("Public"),
- icon: "globe2",
+ icon: "globe",
detail: msg("Anyone can view from the org public collections gallery"),
},
};
diff --git a/frontend/src/features/crawl-workflows/index.ts b/frontend/src/features/crawl-workflows/index.ts
index 8a662816b2..b946856927 100644
--- a/frontend/src/features/crawl-workflows/index.ts
+++ b/frontend/src/features/crawl-workflows/index.ts
@@ -10,6 +10,5 @@ import("./workflow-editor");
import("./workflow-list");
import("./workflow-schedule-filter");
import("./workflow-search");
-import("./workflow-tag-filter");
import("./workflow-profile-filter");
import("./workflow-last-crawl-state-filter");
diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts
index 4236b0ff13..28a0df4d48 100644
--- a/frontend/src/features/crawl-workflows/workflow-editor.ts
+++ b/frontend/src/features/crawl-workflows/workflow-editor.ts
@@ -1,5 +1,6 @@
import { consume } from "@lit/context";
import { localized, msg, str } from "@lit/localize";
+import { Task } from "@lit/task";
import type {
SlBlurEvent,
SlChangeEvent,
@@ -33,6 +34,7 @@ import {
state,
} from "lit/decorators.js";
import { choose } from "lit/directives/choose.js";
+import { guard } from "lit/directives/guard.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { map } from "lit/directives/map.js";
import { when } from "lit/directives/when.js";
@@ -50,23 +52,29 @@ import {
import { BtrixElement } from "@/classes/BtrixElement";
import type { BtrixFileChangeEvent } from "@/components/ui/file-list/events";
-import type {
- SelectCrawlerChangeEvent,
- SelectCrawlerUpdateEvent,
-} from "@/components/ui/select-crawler";
+import type { SelectCrawlerChangeEvent } from "@/components/ui/select-crawler";
import type { SelectCrawlerProxyChangeEvent } from "@/components/ui/select-crawler-proxy";
import type { SyntaxInput } from "@/components/ui/syntax-input";
import type { TabListTab } from "@/components/ui/tab-list";
+import type { TagCount, TagCounts } from "@/components/ui/tag-filter/types";
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 { docsUrlContext, type DocsUrlContext } from "@/context/docs-url";
-import { proxiesContext, type ProxiesContext } from "@/context/org";
+import {
+ orgCrawlerChannelsContext,
+ type OrgCrawlerChannelsContext,
+} from "@/context/org-crawler-channels";
+import {
+ orgProxiesContext,
+ type OrgProxiesContext,
+} from "@/context/org-proxies";
import {
ObservableController,
type IntersectEvent,
} from "@/controllers/observable";
import type { BtrixChangeEvent } from "@/events/btrix-change";
+import type { BtrixUserGuideShowEvent } from "@/events/btrix-user-guide-show";
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";
@@ -75,11 +83,10 @@ import type {
ExclusionChangeEvent,
QueueExclusionTable,
} from "@/features/crawl-workflows/queue-exclusion-table";
-import type { UserGuideEventMap } from "@/index";
import { infoCol, inputCol } from "@/layouts/columns";
import { pageSectionsWithNav } from "@/layouts/pageSectionsWithNav";
import { panel } from "@/layouts/panel";
-import { WorkflowTab } from "@/routes";
+import { OrgTab, WorkflowTab } from "@/routes";
import { infoTextFor } from "@/strings/crawl-workflows/infoText";
import { labelFor } from "@/strings/crawl-workflows/labels";
import scopeTypeLabels from "@/strings/crawl-workflows/scopeType";
@@ -90,6 +97,7 @@ import {
Behavior,
CrawlerChannelImage,
ScopeType,
+ type Profile,
type Seed,
type WorkflowParams,
} from "@/types/crawler";
@@ -97,8 +105,6 @@ import type { UnderlyingFunction } from "@/types/utils";
import {
NewWorkflowOnlyScopeType,
type StorageSeedFile,
- type WorkflowTag,
- type WorkflowTags,
} from "@/types/workflow";
import { track } from "@/utils/analytics";
import { isApiError, isApiErrorDetail } from "@/utils/api";
@@ -263,8 +269,11 @@ export class WorkflowEditor extends BtrixElement {
}
`;
- @consume({ context: proxiesContext, subscribe: true })
- private readonly proxies?: ProxiesContext;
+ @consume({ context: orgProxiesContext, subscribe: true })
+ private readonly proxies?: OrgProxiesContext;
+
+ @consume({ context: orgCrawlerChannelsContext, subscribe: true })
+ private readonly crawlerChannels?: OrgCrawlerChannelsContext;
@consume({ context: docsUrlContext })
private readonly docsUrl?: DocsUrlContext;
@@ -292,10 +301,7 @@ export class WorkflowEditor extends BtrixElement {
initialSeedFile?: StorageSeedFile;
@state()
- private showCrawlerChannels = false;
-
- @state()
- private tagOptions: WorkflowTag[] = [];
+ private tagOptions: TagCount[] = [];
@state()
private isSubmitting = false;
@@ -333,7 +339,7 @@ export class WorkflowEditor extends BtrixElement {
});
// For fuzzy search:
- private readonly fuse = new Fuse([], {
+ private readonly fuse = new Fuse([], {
keys: ["tag"],
shouldSort: false,
threshold: 0.2, // stricter; default is 0.6
@@ -416,6 +422,15 @@ export class WorkflowEditor extends BtrixElement {
// https://github.com/webrecorder/browsertrix-crawler/blob/v1.5.8/package.json#L23
private readonly cssParser = createParser();
+ private readonly profileTask = new Task(this, {
+ task: async ([formState], { signal }) => {
+ if (!formState.browserProfile) return;
+
+ return this.getProfile(formState.browserProfile.id, signal);
+ },
+ args: () => [this.formState] as const,
+ });
+
connectedCallback(): void {
this.initializeEditor();
super.connectedCallback();
@@ -482,7 +497,19 @@ export class WorkflowEditor extends BtrixElement {
(changedProperties.get("progressState") as ProgressState | undefined)
?.activeTab
) {
- window.location.hash = this.progressState.activeTab;
+ const prevActiveTab = (
+ changedProperties.get("progressState") as ProgressState | undefined
+ )?.activeTab;
+
+ if (this.progressState.activeTab !== prevActiveTab) {
+ if (prevActiveTab) {
+ window.location.hash = this.progressState.activeTab;
+ } else {
+ const url = new URL(window.location.href);
+ url.hash = this.progressState.activeTab;
+ window.location.replace(url);
+ }
+ }
}
const prevFormState = changedProperties.get("formState") as
| FormState
@@ -1965,31 +1992,80 @@ https://archiveweb.page/images/${"logo.svg"}`}
private renderBrowserSettings() {
if (!this.formState.lang) throw new Error("missing formstate.lang");
+
+ const proxies = this.proxies;
+ const profileProxyId =
+ this.formState.browserProfile?.proxyId ||
+ (this.formState.browserProfile?.id && this.formState.proxyId);
+
+ const priorityOrigins = () => {
+ if (!this.formState.urlList && !this.formState.primarySeedUrl) {
+ return [];
+ }
+
+ const crawlUrls = urlListToArray(this.formState.urlList);
+
+ if (this.formState.primarySeedUrl) {
+ crawlUrls.unshift(this.formState.primarySeedUrl);
+ }
+
+ return crawlUrls
+ .map((url) => {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "");
+ } catch {
+ return "";
+ }
+ })
+ .filter((url) => url);
+ };
+
return html`
${inputCol(html`
+ .profileName=${this.formState.browserProfile?.name}
+ .suggestOrigins=${guard(
+ [this.formState.primarySeedUrl, this.formState.urlList],
+ priorityOrigins,
+ )}
+ @on-change=${(e: SelectBrowserProfileChangeEvent) => {
+ const profile = e.detail.value;
+
this.updateFormState({
- browserProfile: e.detail.value ?? null,
- })}
+ browserProfile: profile ?? null,
+ proxyId: profile?.proxyId ?? null,
+ });
+ }}
>
`)}
${this.renderHelpTextCol(infoTextFor["browserProfile"])}
- ${this.proxies?.servers.length
+ ${proxies?.servers.length
? [
inputCol(html`
this.updateFormState({
proxyId: e.detail.value,
})}
- >
+ >
+ ${when(
+ profileProxyId,
+ () => html`
+ ${msg("Set by profile")}
+ `,
+ )}
+
`),
this.renderHelpTextCol(infoTextFor["proxyId"]),
]
@@ -2022,20 +2098,18 @@ https://archiveweb.page/images/${"logo.svg"}`}
content: msg("See caveats"),
})}.`,
)}
- ${inputCol(html`
-
- this.updateFormState({
- crawlerChannel: e.detail.value,
- })}
- @on-update=${(e: SelectCrawlerUpdateEvent) =>
- (this.showCrawlerChannels = e.detail.show)}
- >
- `)}
- ${this.showCrawlerChannels
- ? this.renderHelpTextCol(infoTextFor["crawlerChannel"])
- : html``}
+ ${when(this.crawlerChannels && this.crawlerChannels.length > 1, () => [
+ inputCol(html`
+
+ this.updateFormState({
+ crawlerChannel: e.detail.value,
+ })}
+ >
+ `),
+ this.renderHelpTextCol(infoTextFor["crawlerChannel"]),
+ ])}
${inputCol(html`
${msg("Block ads by domain")}
@@ -2425,7 +2499,7 @@ https://archiveweb.page/images/${"logo.svg"}`}
e.preventDefault();
this.dispatchEvent(
- new CustomEvent(
+ new CustomEvent(
"btrix-user-guide-show",
{
detail: { path },
@@ -2816,7 +2890,7 @@ https://archiveweb.page/images/${"logo.svg"}`}
if (this.appState.userGuideOpen) {
this.dispatchEvent(
- new CustomEvent(
+ new CustomEvent(
"btrix-user-guide-show",
{
detail: {
@@ -3120,10 +3194,16 @@ https://archiveweb.page/images/${"logo.svg"}`}
}
private async onReset() {
+ const fromBrowserProfile =
+ !this.initialSeeds &&
+ !this.initialSeedFile &&
+ this.initialWorkflow?.profileid;
+
this.navigate.to(
- `${this.navigate.orgBasePath}/workflows${this.configId ? `/${this.configId}/${WorkflowTab.Settings}` : ""}`,
+ fromBrowserProfile
+ ? `${this.navigate.orgBasePath}/${OrgTab.BrowserProfiles}/profile/${fromBrowserProfile}`
+ : `${this.navigate.orgBasePath}/${OrgTab.Workflows}${this.configId ? `/${this.configId}/${WorkflowTab.Settings}` : ""}`,
);
- // this.initializeEditor();
}
private validateUrlList(
@@ -3174,7 +3254,7 @@ https://archiveweb.page/images/${"logo.svg"}`}
private async fetchTags() {
this.tagOptions = [];
try {
- const { tags } = await this.api.fetch(
+ const { tags } = await this.api.fetch(
`/orgs/${this.orgId}/crawlconfigs/tagCounts`,
);
@@ -3231,7 +3311,7 @@ https://archiveweb.page/images/${"logo.svg"}`}
},
crawlerChannel:
this.formState.crawlerChannel || CrawlerChannelImage.Default,
- proxyId: this.formState.proxyId,
+ proxyId: this.formState.browserProfile?.proxyId || this.formState.proxyId,
};
return config;
@@ -3388,4 +3468,13 @@ https://archiveweb.page/images/${"logo.svg"}`}
console.debug(e);
}
}
+
+ private async getProfile(profileId: string, signal: AbortSignal) {
+ const data = await this.api.fetch(
+ `/orgs/${this.orgId}/profiles/${profileId}`,
+ { signal },
+ );
+
+ return data;
+ }
}
diff --git a/frontend/src/features/crawl-workflows/workflow-list.ts b/frontend/src/features/crawl-workflows/workflow-list.ts
index 00a9327e81..aabf0e04b5 100644
--- a/frontend/src/features/crawl-workflows/workflow-list.ts
+++ b/frontend/src/features/crawl-workflows/workflow-list.ts
@@ -25,15 +25,30 @@ import { ShareableNotice } from "./templates/shareable-notice";
import { BtrixElement } from "@/classes/BtrixElement";
import type { OverflowDropdown } from "@/components/ui/overflow-dropdown";
-import { WorkflowTab } from "@/routes";
+import { OrgTab, WorkflowTab } from "@/routes";
import { noData } from "@/strings/ui";
import type { ListWorkflow } from "@/types/crawler";
import { humanizeSchedule } from "@/utils/cron";
import { srOnly, truncate } from "@/utils/css";
import { pluralOf } from "@/utils/pluralize";
-// postcss-lit-disable-next-line
-const mediumBreakpointCss = css`30rem`;
+export type WorkflowColumnName =
+ | "name"
+ | "latest-crawl"
+ | "total-crawls"
+ | "modified"
+ | "actions";
+
+const columnWidths = {
+ // TODO Consolidate with table.stylesheet.css
+ // https://github.com/webrecorder/browsertrix/issues/3001
+ name: "[clickable-start] minmax(18rem, 1fr)",
+ "latest-crawl": "minmax(15rem, 18rem)",
+ "total-crawls": "minmax(6rem, 9rem)",
+ modified: "minmax(12rem, 15rem)",
+ actions: "[clickable-end] 3rem",
+} as const satisfies Record;
+
// postcss-lit-disable-next-line
const largeBreakpointCss = css`60rem`;
// postcss-lit-disable-next-line
@@ -41,16 +56,23 @@ const rowCss = css`
.row {
display: grid;
grid-template-columns: 1fr;
+ position: relative;
}
- @media only screen and (min-width: ${mediumBreakpointCss}) {
- .row {
- grid-template-columns: repeat(2, 1fr);
- }
+ .action {
+ position: absolute;
+ top: 0;
+ right: 0;
}
+
@media only screen and (min-width: ${largeBreakpointCss}) {
.row {
- grid-template-columns: 1fr 17rem 10rem 11rem 3rem;
+ grid-template-columns: var(--btrix-workflow-list-columns);
+ white-space: nowrap;
+ }
+
+ .action {
+ position: relative;
}
}
@@ -225,39 +247,74 @@ export class WorkflowListItem extends BtrixElement {
border-left: 1px solid var(--sl-panel-border-color);
}
}
+
+ /*
+ * TODO Consolidate with table.stylesheet.css
+ * https://github.com/webrecorder/browsertrix/issues/3001
+ */
+ .rowClickTarget--cell {
+ display: grid;
+ grid-template-columns: subgrid;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+
+ .rowClickTarget {
+ max-width: 100%;
+ }
+
+ .col sl-tooltip > *,
+ .col btrix-popover > * {
+ /* Place above .rowClickTarget::after overlay */
+ z-index: 2;
+ position: relative;
+ }
+
+ .rowClickTarget::after {
+ content: "";
+ display: block;
+ position: absolute;
+ inset: 0;
+ grid-column: clickable-start / clickable-end;
+ }
+
+ .rowClickTarget:focus-visible {
+ outline: var(--sl-focus-ring);
+ outline-offset: -0.25rem;
+ border-radius: 0.5rem;
+ }
`,
];
@property({ type: Object })
workflow?: ListWorkflow;
+ /**
+ * Limit columns displayed
+ * @TODO Convert to btrix-data-grid to make columns configurable
+ */
+ @property({ type: Array })
+ columns?: WorkflowColumnName[];
+
@query(".row")
row!: HTMLElement;
@query("btrix-overflow-dropdown")
dropdownMenu!: OverflowDropdown;
- render() {
- return html` {
- if (e.target === this.dropdownMenu) {
- return;
- }
- e.preventDefault();
- await this.updateComplete;
- const failedStates = ["failed", "failed_not_logged_in"];
- const href = `/orgs/${this.orgSlugState}/workflows/${this.workflow?.id}/${failedStates.includes(this.workflow?.lastCrawlState || "") ? WorkflowTab.Logs : WorkflowTab.LatestCrawl}`;
- this.navigate.to(href);
- }}
- >
-
-
+ @query("a")
+ private readonly anchor?: HTMLAnchorElement | null;
+
+ private readonly columnTemplate = {
+ name: () => {
+ const href = `/orgs/${this.orgSlugState}/${OrgTab.Workflows}/${this.workflow?.id}/${this.workflow?.lastCrawlState?.startsWith("failed") ? WorkflowTab.Logs : WorkflowTab.LatestCrawl}`;
+
+ return html`
-
+
+
${this.safeRender((workflow) => {
if (workflow.schedule) {
return humanizeSchedule(workflow.schedule, {
@@ -270,9 +327,12 @@ export class WorkflowListItem extends BtrixElement {
return msg("---");
})}
-
-
${this.safeRender(this.renderLatestCrawl)}
-
+
`;
+ },
+ "latest-crawl": () =>
+ html`
${this.safeRender(this.renderLatestCrawl)}
`,
+ "total-crawls": () =>
+ html`
${this.safeRender((workflow) => {
if (
@@ -316,9 +376,11 @@ export class WorkflowListItem extends BtrixElement {
`${this.localize.number(workflow.crawlCount, { notation: "compact" })} ${pluralOf("crawls", workflow.crawlCount)}`,
)}
-
-
${this.safeRender(this.renderModifiedBy)}
-
+
`,
+ modified: () =>
+ html`
${this.safeRender(this.renderModifiedBy)}
`,
+ actions: () =>
+ html`
-
+
`,
+ } satisfies Record
TemplateResult>;
+
+ render() {
+ return html`
+ ${this.columns
+ ? this.columns.map((col) => this.columnTemplate[col]())
+ : Object.values(this.columnTemplate).map((render) => render())}
`;
}
@@ -435,7 +504,7 @@ export class WorkflowListItem extends BtrixElement {
return html`
-
+
@@ -450,7 +519,7 @@ export class WorkflowListItem extends BtrixElement {
return html`
-
+
${workflow.modifiedByName
? html`
${nameSuffix}
`;
};
+
+ /*
+ * TODO Remove when refactored to `btrix-table`
+ * https://github.com/webrecorder/browsertrix/issues/3001
+ */
+ private readonly redirectEventToAnchor = (e: MouseEvent) => {
+ if (!e.defaultPrevented) {
+ const newEvent = new MouseEvent(e.type, e);
+ e.stopPropagation();
+
+ this.anchor?.dispatchEvent(newEvent);
+ }
+ };
}
@customElement("btrix-workflow-list")
@@ -551,18 +633,41 @@ export class WorkflowList extends LitElement {
`,
];
+ /**
+ * Limit columns displayed
+ * * @TODO Convert to btrix-data-grid to make columns configurable
+ */
+ @property({ type: Array, noAccessor: true })
+ columns?: WorkflowColumnName[];
+
@queryAssignedElements({ selector: "btrix-workflow-list-item" })
- listItems!: HTMLElement[];
+ listItems!: WorkflowListItem[];
+
+ static ColumnTemplate = {
+ name: html`${msg(html`Name & Schedule`)}
`,
+ "latest-crawl": html`${msg("Latest Crawl")}
`,
+ "total-crawls": html`${msg("Total Size")}
`,
+ modified: html`${msg("Last Modified")}
`,
+ actions: html`
+ ${msg("Actions")}
+
`,
+ } satisfies Record;
+
+ connectedCallback(): void {
+ this.style.setProperty(
+ "--btrix-workflow-list-columns",
+ this.columns?.map((col) => columnWidths[col]).join(" ") ||
+ Object.values(columnWidths).join(" "),
+ );
+
+ super.connectedCallback();
+ }
render() {
- return html`