From f27d8b839b3f1666d7a477166ed8c148e9378fd5 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Fri, 7 Nov 2025 10:09:14 -0800 Subject: [PATCH 01/12] fix: Improve crawler channel and proxy render performance (#2955) - Improves fetch performance for crawler proxies and channels, fixing `crawler-channels` being called twice wherever channel selector is rendered - Fixes empty space when only single channel is available - Hides `(Exact Crawler Version)` text when version is not available --- frontend/src/components/ui/config-details.ts | 2 +- frontend/src/components/ui/select-crawler.ts | 148 +++++------------- frontend/src/context/org-crawler-channels.ts | 8 + frontend/src/context/org-proxies.ts | 8 + frontend/src/context/org.ts | 7 - .../new-browser-profile-dialog.ts | 29 ++-- .../crawl-workflows/workflow-editor.ts | 59 +++---- frontend/src/pages/org/index.ts | 117 +++++++++----- .../settings/components/crawling-defaults.ts | 42 +++-- frontend/src/types/crawler.ts | 6 +- 10 files changed, 223 insertions(+), 203 deletions(-) create mode 100644 frontend/src/context/org-crawler-channels.ts create mode 100644 frontend/src/context/org-proxies.ts delete mode 100644 frontend/src/context/org.ts diff --git a/frontend/src/components/ui/config-details.ts b/frontend/src/components/ui/config-details.ts index 63f6e76991..6d53d36688 100644 --- a/frontend/src/components/ui/config-details.ts +++ b/frontend/src/components/ui/config-details.ts @@ -257,7 +257,7 @@ export class ConfigDetails extends BtrixElement { crawlConfig?.browserWindows ? `${crawlConfig.browserWindows}` : "", )} ${this.renderSetting( - msg("Crawler Channel (Exact Crawler Version)"), + `${msg("Crawler Channel")} ${crawlConfig?.image ? msg("(Exact Crawler Version)") : ""}`.trim(), capitalize( crawlConfig?.crawlerChannel || CrawlerChannelImage.Default, ) + (crawlConfig?.image ? ` (${crawlConfig.image})` : ""), diff --git a/frontend/src/components/ui/select-crawler.ts b/frontend/src/components/ui/select-crawler.ts index 217b9dacf8..2328080dea 100644 --- a/frontend/src/components/ui/select-crawler.ts +++ b/frontend/src/components/ui/select-crawler.ts @@ -1,10 +1,15 @@ +import { consume } from "@lit/context"; import { localized, msg } from "@lit/localize"; import { type SlSelect } from "@shoelace-style/shoelace"; -import { html, type PropertyValues } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import capitalize from "lodash/fp/capitalize"; +import { + orgCrawlerChannelsContext, + type OrgCrawlerChannelsContext, +} from "@/context/org-crawler-channels"; import { CrawlerChannelImage, type CrawlerChannel } from "@/pages/org/types"; import LiteElement from "@/utils/LiteElement"; @@ -20,13 +25,11 @@ type SelectCrawlerUpdateDetail = { export type SelectCrawlerUpdateEvent = CustomEvent; -type CrawlerChannelsAPIResponse = { - channels: CrawlerChannel[]; -}; - /** * Crawler channel select dropdown * + * @TODO Convert to form control + * * Usage example: * ```ts * * ``` * - * @event on-change + * @fires on-change */ @customElement("btrix-select-crawler") @localized() export class SelectCrawler extends LiteElement { + @consume({ context: orgCrawlerChannelsContext, subscribe: true }) + private readonly crawlerChannels?: OrgCrawlerChannelsContext; + @property({ type: String }) size?: SlSelect["size"]; @property({ type: String }) - crawlerChannel?: string; - - @state() - private selectedCrawler?: CrawlerChannel; - - @state() - private crawlerChannels?: CrawlerChannel[]; - - willUpdate(changedProperties: PropertyValues) { - if (changedProperties.has("crawlerChannel")) { - void this.updateSelectedCrawlerChannel(); - } - } - - protected firstUpdated() { - void this.updateSelectedCrawlerChannel(); - } + crawlerChannel?: CrawlerChannel["id"]; render() { - if (this.crawlerChannels && this.crawlerChannels.length < 2) { - return html``; - } + const selectedCrawler = this.getSelectedChannel(); return html` { - // Refetch to keep list up to date - void this.fetchCrawlerChannels(); - }} @sl-hide=${this.stopProp} @sl-after-hide=${this.stopProp} + ?disabled=${!this.crawlerChannels || this.crawlerChannels.length === 1} > ${this.crawlerChannels?.map( (crawler) => @@ -88,99 +73,48 @@ export class SelectCrawler extends LiteElement { ${capitalize(crawler.id)} `, )} - ${this.selectedCrawler - ? html` -
- ${msg("Version:")} - ${this.selectedCrawler.image} -
- ` - : ``} +
+ ${msg("Version:")} + ${selectedCrawler + ? html` + ${selectedCrawler.image} + ` + : nothing} +
`; } - private onChange(e: Event) { - this.stopProp(e); + private getSelectedChannel() { + if (!this.crawlerChannels || !this.crawlerChannel) return null; - this.selectedCrawler = this.crawlerChannels?.find( - ({ id }) => id === (e.target as SlSelect).value, - ); + if (this.crawlerChannel) { + return this.crawlerChannels.find(({ id }) => id === this.crawlerChannel); + } - this.dispatchEvent( - new CustomEvent("on-change", { - detail: { - value: this.selectedCrawler?.id, - }, - }), + return ( + this.crawlerChannels.find( + ({ id }) => id === CrawlerChannelImage.Default, + ) ?? null ); } - private async updateSelectedCrawlerChannel() { - await this.fetchCrawlerChannels(); - await this.updateComplete; - - if (!this.crawlerChannels) return; - - if (this.crawlerChannel && !this.selectedCrawler) { - this.selectedCrawler = this.crawlerChannels.find( - ({ id }) => id === this.crawlerChannel, - ); - } - - if (!this.selectedCrawler) { - this.crawlerChannel = CrawlerChannelImage.Default; - this.dispatchEvent( - new CustomEvent("on-change", { - detail: { - value: CrawlerChannelImage.Default, - }, - }), - ); - this.selectedCrawler = this.crawlerChannels.find( - ({ id }) => id === this.crawlerChannel, - ); - } + private onChange(e: Event) { + this.stopProp(e); - await this.updateComplete; + const selectedCrawler = this.crawlerChannels?.find( + ({ id }) => id === (e.target as SlSelect).value, + ); this.dispatchEvent( - new CustomEvent("on-update", { + new CustomEvent("on-change", { detail: { - show: this.crawlerChannels.length > 1, + value: selectedCrawler?.id, }, }), ); } - /** - * Fetch crawler channels and update internal state - */ - private async fetchCrawlerChannels(): Promise { - try { - const channels = await this.getCrawlerChannels(); - this.crawlerChannels = channels; - } catch (e) { - this.notify({ - message: msg("Sorry, couldn't retrieve crawler channels at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "crawler-channel-retrieve-error", - }); - } - } - - private async getCrawlerChannels(): Promise { - const data: CrawlerChannelsAPIResponse = - await this.apiFetch( - `/orgs/${this.orgId}/crawlconfigs/crawler-channels`, - ); - - return data.channels; - } - /** * Stop propgation of sl-select events. * Prevents bug where sl-dialog closes when dropdown closes diff --git a/frontend/src/context/org-crawler-channels.ts b/frontend/src/context/org-crawler-channels.ts new file mode 100644 index 0000000000..711938ecb4 --- /dev/null +++ b/frontend/src/context/org-crawler-channels.ts @@ -0,0 +1,8 @@ +import { createContext } from "@lit/context"; + +import type { CrawlerChannel } from "@/types/crawler"; + +export type OrgCrawlerChannelsContext = CrawlerChannel[] | null; + +export const orgCrawlerChannelsContext = + createContext("org-crawler-channels"); diff --git a/frontend/src/context/org-proxies.ts b/frontend/src/context/org-proxies.ts new file mode 100644 index 0000000000..5bb26e0e81 --- /dev/null +++ b/frontend/src/context/org-proxies.ts @@ -0,0 +1,8 @@ +import { createContext } from "@lit/context"; + +import type { ProxiesAPIResponse } from "@/types/crawler"; + +export type OrgProxiesContext = ProxiesAPIResponse | null; + +export const orgProxiesContext = + createContext("org-proxies"); diff --git a/frontend/src/context/org.ts b/frontend/src/context/org.ts deleted file mode 100644 index f526290d38..0000000000 --- a/frontend/src/context/org.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createContext } from "@lit/context"; - -import type { ProxiesAPIResponse } from "@/types/crawler"; - -export type ProxiesContext = ProxiesAPIResponse | null; - -export const proxiesContext = createContext("proxies"); diff --git a/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts b/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts index e7790022f0..5ba95ecb83 100644 --- a/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts +++ b/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts @@ -15,7 +15,11 @@ import { BtrixElement } from "@/classes/BtrixElement"; import type { Dialog } from "@/components/ui/dialog"; import { type SelectCrawlerChangeEvent } from "@/components/ui/select-crawler"; import { type SelectCrawlerProxyChangeEvent } from "@/components/ui/select-crawler-proxy"; -import { CrawlerChannelImage, type Proxy } from "@/types/crawler"; +import { + CrawlerChannelImage, + type CrawlerChannel, + type Proxy, +} from "@/types/crawler"; @customElement("btrix-new-browser-profile-dialog") @localized() @@ -29,6 +33,9 @@ export class NewBrowserProfileDialog extends BtrixElement { @property({ type: Array }) proxyServers?: Proxy[]; + @property({ type: Array }) + crawlerChannels?: CrawlerChannel[]; + @property({ type: Boolean }) open = false; @@ -36,7 +43,7 @@ export class NewBrowserProfileDialog extends BtrixElement { private isSubmitting = false; @state() - private crawlerChannel: string = CrawlerChannelImage.Default; + private crawlerChannel: CrawlerChannel["id"] = CrawlerChannelImage.Default; @state() private proxyId: string | null = null; @@ -57,7 +64,7 @@ export class NewBrowserProfileDialog extends BtrixElement { this.defaultCrawlerChannel ) { this.crawlerChannel = - (this.crawlerChannel !== (CrawlerChannelImage.Default as string) && + (this.crawlerChannel !== CrawlerChannelImage.Default && this.crawlerChannel) || this.defaultCrawlerChannel; } @@ -91,13 +98,15 @@ export class NewBrowserProfileDialog extends BtrixElement { > -
- - (this.crawlerChannel = e.detail.value!)} - > -
+ ${this.crawlerChannels && this.crawlerChannels.length > 1 + ? html`
+ + (this.crawlerChannel = e.detail.value!)} + > +
` + : nothing} ${this.proxyServers?.length ? html`
diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index 4236b0ff13..88da94c53d 100644 --- a/frontend/src/features/crawl-workflows/workflow-editor.ts +++ b/frontend/src/features/crawl-workflows/workflow-editor.ts @@ -50,10 +50,7 @@ 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"; @@ -61,7 +58,14 @@ 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, @@ -263,8 +267,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; @@ -291,9 +298,6 @@ export class WorkflowEditor extends BtrixElement { @property({ type: Object }) initialSeedFile?: StorageSeedFile; - @state() - private showCrawlerChannels = false; - @state() private tagOptions: WorkflowTag[] = []; @@ -1965,6 +1969,9 @@ https://archiveweb.page/images/${"logo.svg"}`} private renderBrowserSettings() { if (!this.formState.lang) throw new Error("missing formstate.lang"); + + const proxies = this.proxies; + return html` ${inputCol(html` `)} ${this.renderHelpTextCol(infoTextFor["browserProfile"])} - ${this.proxies?.servers.length + ${proxies?.servers.length ? [ inputCol(html` this.updateFormState({ @@ -2022,20 +2029,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")} diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index bc0c540129..a02da08ed8 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -1,5 +1,6 @@ import { provide } from "@lit/context"; import { localized, msg, str } from "@lit/localize"; +import { Task } from "@lit/task"; import { html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { choose } from "lit/directives/choose.js"; @@ -17,7 +18,14 @@ import type { } from "./settings/settings"; import { BtrixElement } from "@/classes/BtrixElement"; -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 { SearchOrgContextController } from "@/context/search-org/SearchOrgContextController"; import { searchOrgContextKey } from "@/context/search-org/types"; import type { QuotaUpdateDetail } from "@/controllers/api"; @@ -25,7 +33,10 @@ import needLogin from "@/decorators/needLogin"; import type { CollectionSavedEvent } from "@/features/collections/collection-create-dialog"; import type { SelectJobTypeEvent } from "@/features/crawl-workflows/new-workflow-dialog"; import { CommonTab, OrgTab, RouteNamespace, WorkflowTab } from "@/routes"; -import type { ProxiesAPIResponse } from "@/types/crawler"; +import type { + CrawlerChannelsAPIResponse, + ProxiesAPIResponse, +} from "@/types/crawler"; import type { UserOrg } from "@/types/user"; import { isApiError } from "@/utils/api"; import type { ViewState } from "@/utils/APIRouter"; @@ -101,9 +112,11 @@ const UUID_REGEX = @localized() @needLogin export class Org extends BtrixElement { - @provide({ context: proxiesContext }) - @state() - proxies: ProxiesContext = null; + @provide({ context: orgProxiesContext }) + proxies: OrgProxiesContext = null; + + @provide({ context: orgCrawlerChannelsContext }) + crawlerChannels: OrgCrawlerChannelsContext = null; @property({ type: Object }) viewStateData?: ViewState["data"]; @@ -129,6 +142,21 @@ export class Org extends BtrixElement { private readonly [searchOrgContextKey] = new SearchOrgContextController(this); + private readonly proxiesTask = new Task(this, { + task: async ([id], { signal }) => { + return (this.proxies = await this.getOrgProxies(id, signal)); + }, + args: () => [this.orgId] as const, + }); + + private readonly crawlerChannelsTask = new Task(this, { + task: async ([id], { signal }) => { + const { channels } = await this.getOrgCrawlerChannels(id, signal); + return (this.crawlerChannels = channels); + }, + args: () => [this.orgId] as const, + }); + connectedCallback() { if ( !this.orgTab || @@ -167,7 +195,7 @@ export class Org extends BtrixElement { ) { if (this.userOrg) { void this.updateOrg(); - void this.updateOrgProxies(); + this.updateOrgProxies(); } else { // Couldn't find org with slug, redirect to first org const org = this.userInfo.orgs[0] as UserOrg | undefined; @@ -185,6 +213,16 @@ export class Org extends BtrixElement { // Get most up to date org data void this.updateOrg(); void this[searchOrgContextKey].refresh(); + + // Refresh crawler configs for form data + if ( + this.appState.isCrawler && + [OrgTab.Workflows, OrgTab.BrowserProfiles, OrgTab.Settings].includes( + this.orgTab as OrgTab, + ) + ) { + this.updateOrgProxies(); + } } if (changedProperties.has("openDialogName")) { // Sync URL to create dialog @@ -236,12 +274,8 @@ export class Org extends BtrixElement { } } - private async updateOrgProxies() { - try { - this.proxies = await this.getOrgProxies(this.orgId); - } catch (e) { - console.debug(e); - } + private updateOrgProxies() { + void this.proxiesTask.run(); } async firstUpdated() { @@ -262,8 +296,6 @@ export class Org extends BtrixElement { // Sync URL to create dialog const dialogName = this.getDialogName(); if (dialogName) this.openDialog(dialogName); - - void this.updateOrgProxies(); } private getDialogName() { @@ -433,6 +465,12 @@ export class Org extends BtrixElement { if (!this.isCreateDialogVisible) { return; } + + const org = this.org; + const proxies = this.proxiesTask.value; + const crawlerChannels = this.crawlerChannelsTask.value; + const showBrowserProfileDialog = org && proxies && crawlerChannels; + return html`
{ @@ -454,27 +492,23 @@ export class Org extends BtrixElement { }} > - ${when(this.org, (org) => - when( - this.proxies, - (proxies) => html` - (this.openDialogName = undefined)} - > - - `, - ), - )} + ${showBrowserProfileDialog + ? html` (this.openDialogName = undefined)} + > + ` + : nothing} { + private async getOrgProxies( + orgId: string, + signal?: AbortSignal, + ): Promise { return this.api.fetch( `/orgs/${orgId}/crawlconfigs/crawler-proxies`, + { signal }, + ); + } + + private async getOrgCrawlerChannels(orgId: string, signal?: AbortSignal) { + return this.api.fetch( + `/orgs/${orgId}/crawlconfigs/crawler-channels`, + { signal }, ); } diff --git a/frontend/src/pages/org/settings/components/crawling-defaults.ts b/frontend/src/pages/org/settings/components/crawling-defaults.ts index a0ae5f4757..38284065d1 100644 --- a/frontend/src/pages/org/settings/components/crawling-defaults.ts +++ b/frontend/src/pages/org/settings/components/crawling-defaults.ts @@ -12,7 +12,14 @@ import type { Entries } from "type-fest"; import { BtrixElement } from "@/classes/BtrixElement"; import type { LanguageSelect } from "@/components/ui/language-select"; import type { SelectCrawlerProxy } from "@/components/ui/select-crawler-proxy"; -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 type { CustomBehaviorsTable } from "@/features/crawl-workflows/custom-behaviors-table"; import type { QueueExclusionTable } from "@/features/crawl-workflows/queue-exclusion-table"; import { columns, type Cols } from "@/layouts/columns"; @@ -54,8 +61,11 @@ export class OrgSettingsCrawlWorkflows 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; @state() private defaults: WorkflowDefaults = appDefaults; @@ -209,6 +219,9 @@ export class OrgSettingsCrawlWorkflows extends BtrixElement { `, }; + const proxies = this.proxies; + const crawlerChannels = this.crawlerChannels; + const browserSettings = { browserProfile: html` `, - proxyId: this.proxies?.servers.length + proxyId: proxies?.servers.length ? html` ` : undefined, - crawlerChannel: html` - - `, + crawlerChannel: + crawlerChannels && crawlerChannels.length > 1 + ? html` + + ` + : undefined, blockAds: html` Date: Tue, 11 Nov 2025 09:59:08 -0800 Subject: [PATCH 02/12] feat: Browser profiles list page UI consistency (#2971) - Fixes browser profile list UI inconsistencies - Displays whether profile is in use in list - Adds "Mine" filter --- frontend/src/controllers/localize.ts | 10 +- .../archived-item-list-item.ts | 2 +- .../crawls/crawl-list/crawl-list-item.ts | 2 +- .../src/pages/org/browser-profiles-list.ts | 630 ++++++++++-------- frontend/src/pages/org/collections-list.ts | 8 +- frontend/src/types/crawler.ts | 11 +- 6 files changed, 381 insertions(+), 282 deletions(-) diff --git a/frontend/src/controllers/localize.ts b/frontend/src/controllers/localize.ts index 83d94f67a1..0690580e56 100644 --- a/frontend/src/controllers/localize.ts +++ b/frontend/src/controllers/localize.ts @@ -1,9 +1,11 @@ import { LocalizeController as SlLocalizeController } from "@shoelace-style/localize"; import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; import type { Options as PrettyMsOptions } from "pretty-ms"; import localize from "@/utils/localize"; import roundDuration from "@/utils/round-duration"; +import { tw } from "@/utils/tailwind"; export class LocalizeController extends SlLocalizeController { /** @@ -21,7 +23,7 @@ export class LocalizeController extends SlLocalizeController { */ readonly relativeDate = ( dateStr: string, - { prefix }: { prefix?: string } = {}, + { prefix, capitalize }: { prefix?: string; capitalize?: boolean } = {}, ) => { const date = new Date(dateStr); const diff = new Date().getTime() - date.getTime(); @@ -51,7 +53,11 @@ export class LocalizeController extends SlLocalizeController { day: "numeric", }) : seconds > 60 - ? html`` + ? html`` : `<${this.relativeTime(-1, "minute", { style: "narrow" })}`} diff --git a/frontend/src/features/archived-items/archived-item-list/archived-item-list-item.ts b/frontend/src/features/archived-items/archived-item-list/archived-item-list-item.ts index 792fb36b3a..b8140d993e 100644 --- a/frontend/src/features/archived-items/archived-item-list/archived-item-list-item.ts +++ b/frontend/src/features/archived-items/archived-item-list/archived-item-list-item.ts @@ -107,7 +107,7 @@ export class ArchivedItemListItem extends BtrixElement { return html` ${this.checkbox diff --git a/frontend/src/features/crawls/crawl-list/crawl-list-item.ts b/frontend/src/features/crawls/crawl-list/crawl-list-item.ts index cb35150125..5c5b8be885 100644 --- a/frontend/src/features/crawls/crawl-list/crawl-list-item.ts +++ b/frontend/src/features/crawls/crawl-list/crawl-list-item.ts @@ -104,7 +104,7 @@ export class CrawlListItem extends BtrixElement { return html` { if (e.target === this.dropdownMenu) { diff --git a/frontend/src/pages/org/browser-profiles-list.ts b/frontend/src/pages/org/browser-profiles-list.ts index e0db104ea8..cf9d4b5629 100644 --- a/frontend/src/pages/org/browser-profiles-list.ts +++ b/frontend/src/pages/org/browser-profiles-list.ts @@ -1,7 +1,7 @@ -import { localized, msg, str } from "@lit/localize"; -import clsx from "clsx"; -import { css, nothing, type PropertyValues } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { localized, msg } from "@lit/localize"; +import { Task } from "@lit/task"; +import { html, nothing } from "lit"; +import { customElement, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; import queryString from "query-string"; @@ -10,91 +10,160 @@ import type { Profile } from "./types"; import type { SelectNewDialogEvent } from "."; import { BtrixElement } from "@/classes/BtrixElement"; +import type { + BtrixFilterChipChangeEvent, + FilterChip, +} from "@/components/ui/filter-chip"; import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; -import { - SortDirection, - type SortValues, -} from "@/components/ui/table/table-header-cell"; import { ClipboardController } from "@/controllers/clipboard"; -import { pageHeader } from "@/layouts/pageHeader"; +import { SearchParamsValue } from "@/controllers/searchParamsValue"; +import { emptyMessage } from "@/layouts/emptyMessage"; +import { page } from "@/layouts/page"; +import { OrgTab } from "@/routes"; import type { APIPaginatedList, APIPaginationQuery, APISortQuery, } from "@/types/api"; import type { Browser } from "@/types/browser"; +import { SortDirection as SortDirectionEnum } from "@/types/utils"; import { isApiError } from "@/utils/api"; -import { html } from "@/utils/LiteElement"; import { isArchivingDisabled } from "@/utils/orgs"; -import { tw } from "@/utils/tailwind"; +const SORT_DIRECTIONS = ["asc", "desc"] as const; +type SortDirection = (typeof SORT_DIRECTIONS)[number]; +type SortField = "name" | "url" | "modified"; +type SortBy = { + field: SortField; + direction: SortDirection; +}; + +const sortableFields: Record< + SortField, + { label: string; defaultDirection?: SortDirection } +> = { + name: { + label: msg("Name"), + defaultDirection: "desc", + }, + url: { + label: msg("Starting URL"), + defaultDirection: "asc", + }, + modified: { + label: msg("Last Modified"), + defaultDirection: "desc", + }, +}; + +const DEFAULT_SORT_BY = { + field: "modified", + direction: sortableFields.modified.defaultDirection || "desc", +} as const satisfies SortBy; const INITIAL_PAGE_SIZE = 20; +const FILTER_BY_CURRENT_USER_STORAGE_KEY = "btrix.filterByCurrentUser.crawls"; + +const columnsCss = [ + "min-content", // Status + "[clickable-start] minmax(min-content, 1fr)", // Name + "minmax(max-content, 1fr)", // Origins + "minmax(min-content, 22ch)", // Last modified + "[clickable-end] min-content", // Actions +].join(" "); -/** - * Usage: - * ```ts - * - * ``` - */ @customElement("btrix-browser-profiles-list") @localized() export class BrowserProfilesList extends BtrixElement { - @property({ type: Boolean }) - isCrawler = false; - @state() - browserProfiles?: APIPaginatedList; - - @state() - sort: Required = { - sortBy: "modified", - sortDirection: -1, + private pagination: Required = { + page: parsePage(new URLSearchParams(location.search).get("page")), + pageSize: INITIAL_PAGE_SIZE, }; - @state() - private isLoading = true; - - static styles = css` - btrix-table { - --btrix-table-grid-template-columns: [clickable-start] minmax(30ch, 50ch) - minmax(30ch, 40ch) repeat(2, 1fr) [clickable-end] min-content; - --btrix-table-cell-gap: var(--sl-spacing-x-small); - --btrix-table-cell-padding-x: var(--sl-spacing-small); - } - - btrix-table-body btrix-table-row:nth-of-type(n + 2) { - --btrix-border-top: 1px solid var(--sl-panel-border-color); - } - - btrix-table-body btrix-table-row:first-of-type { - --btrix-border-radius-top: var(--sl-border-radius-medium); - } - - btrix-table-body btrix-table-row:last-of-type { - --btrix-border-radius-bottom: var(--sl-border-radius-medium); - } - - btrix-table-row { - border-top: var(--btrix-border-top, 0); - border-radius: var(--btrix-border-radius-top, 0) - var(--btrix-border-radius-to, 0) var(--btrix-border-radius-bottom, 0) - var(--btrix-border-radius-bottom, 0); - height: 2.5rem; - } - `; - - protected willUpdate( - changedProperties: PropertyValues & Map, - ) { - if (changedProperties.has("sort")) { - void this.fetchBrowserProfiles(); - } + private readonly orderBy = new SearchParamsValue( + this, + (value, params) => { + if (value.field === DEFAULT_SORT_BY.field) { + params.delete("sortBy"); + } else { + params.set("sortBy", value.field); + } + if (value.direction === sortableFields[value.field].defaultDirection) { + params.delete("sortDir"); + } else { + params.set("sortDir", value.direction); + } + return params; + }, + (params) => { + const field = params.get("sortBy") as SortBy["field"] | null; + if (!field) { + return DEFAULT_SORT_BY; + } + let direction = params.get("sortDir"); + if ( + !direction || + (SORT_DIRECTIONS as readonly string[]).includes(direction) + ) { + direction = + sortableFields[field].defaultDirection || DEFAULT_SORT_BY.direction; + } + return { field, direction: direction as SortDirection }; + }, + ); + + private readonly filterByCurrentUser = new SearchParamsValue( + this, + (value, params) => { + if (value) { + params.set("mine", "true"); + } else { + params.delete("mine"); + } + return params; + }, + (params) => params.get("mine") === "true", + { + initial: (initialValue) => + window.sessionStorage.getItem(FILTER_BY_CURRENT_USER_STORAGE_KEY) === + "true" || + initialValue || + false, + }, + ); + + get isCrawler() { + return this.appState.isCrawler; } + private readonly profilesTask = new Task(this, { + task: async ([pagination, orderBy, filterByCurrentUser], { signal }) => { + return this.getProfiles( + { + ...pagination, + userid: filterByCurrentUser ? this.userInfo?.id : undefined, + sortBy: orderBy.field, + sortDirection: + orderBy.direction === "desc" + ? SortDirectionEnum.Descending + : SortDirectionEnum.Ascending, + }, + signal, + ); + }, + args: () => + [ + this.pagination, + this.orderBy.value, + this.filterByCurrentUser.value, + ] as const, + }); + render() { - return html`${pageHeader({ + return page( + { title: msg("Browser Profiles"), + border: false, actions: this.isCrawler ? html` ` : undefined, - classNames: tw`mb-3`, - })} -
${this.renderTable()}
`; + }, + this.renderPage, + ); } - private renderTable() { - const headerCells = [ - { - sortBy: "name", - sortDirection: 1, - className: "pl-3", - label: msg("Name"), - }, - { sortBy: "url", sortDirection: 1, label: msg("Visited URLs") }, - { sortBy: "created", sortDirection: -1, label: msg("Created On") }, - { sortBy: "modified", sortDirection: -1, label: msg("Last Updated") }, - ]; - const sortProps: Record< - SortValues, - { name: string; label: string; className: string } - > = { - none: { - name: "arrow-down-up", - label: msg("Sortable"), - className: tw`text-xs opacity-0 hover:opacity-100 group-hover:opacity-100`, - }, - ascending: { - name: "sort-up-alt", - label: msg("Ascending"), - className: tw`text-base`, - }, - descending: { - name: "sort-down", - label: msg("Descending"), - className: tw`text-base`, - }, - }; + private readonly renderPage = () => { + return html` +
+ ${this.renderControls()} +
+ + ${when( + this.profilesTask.value, + ({ items, total, page, pageSize }) => html` + ${total + ? html` + ${this.renderTable(items)} + ${when( + total > pageSize, + () => html` +
+ { + this.pagination = { + ...this.pagination, + page: e.detail.page, + }; + await this.updateComplete; + + // Scroll to top of list + // TODO once deep-linking is implemented, scroll to top of pushstate + this.scrollIntoView({ behavior: "smooth" }); + }} + > +
+ `, + )} + ` + : this.renderEmpty()} + `, + )} + `; + }; - const getSortIcon = (sortValue: SortValues) => { - const { name, label, className } = sortProps[sortValue]; - return html` - - `; - }; + private renderEmpty() { + const message = msg("Your org doesn’t have any browser profiles yet."); + + if (this.isCrawler) { + return emptyMessage({ + message, + detail: msg( + "Browser profiles let you crawl pages behind paywalls and logins.", + ), + actions: html` + { + this.dispatchEvent( + new CustomEvent( + "select-new-dialog", + { + detail: "browser-profile", + }, + ), + ); + }} + > + + ${msg("Create Browser Profile")} + + `, + }); + } + + return emptyMessage({ message }); + } + private renderControls() { return html` - - - - ${headerCells.map(({ sortBy, sortDirection, label, className }) => { - const isSorting = sortBy === this.sort.sortBy; - const sortValue = - (isSorting && SortDirection.get(this.sort.sortDirection)) || - "none"; - // TODO implement sort render logic in table-header-cell - return html` - { - if (isSorting) { - this.sort = { - ...this.sort, - sortDirection: this.sort.sortDirection * -1, - }; - } else { - this.sort = { - sortBy, - sortDirection, - }; - } - }} - > - ${label} ${getSortIcon(sortValue)} - - `; - })} - - ${msg("Row Actions")} - - - - ${when(this.browserProfiles, ({ total, items }) => - total ? html` ${items.map(this.renderItem)} ` : nothing, - )} - ${when(this.isLoading, this.renderLoading)} - - - - ${when(this.browserProfiles, ({ total, page, pageSize }) => - total - ? html` -
- { - void this.fetchBrowserProfiles({ page: e.detail.page }); - }} - > -
- ` - : html` -
-

- ${msg("No browser profiles yet.")} -

-
- `, - )} +
+
+ + ${msg("Filter by:")} + + ${this.renderFilterControl()} +
+ +
+ + ${this.renderSortControl()} +
+
`; } - private readonly renderLoading = () => - html`
- -
`; + private renderFilterControl() { + return html` + { + const { checked } = e.target as FilterChip; + this.filterByCurrentUser.setValue(Boolean(checked)); + }} + > + ${msg("Mine")} + + `; + } + + private renderSortControl() { + const options = Object.entries(sortableFields).map( + ([value, { label }]) => html` + ${label} + `, + ); + return html` + { + const field = (e.target as HTMLSelectElement).value as SortField; + this.orderBy.setValue({ + field: field, + direction: + sortableFields[field].defaultDirection || + this.orderBy.value.direction, + }); + }} + > + ${options} + + + { + this.orderBy.setValue({ + ...this.orderBy.value, + direction: + this.orderBy.value.direction === "asc" ? "desc" : "asc", + }); + }} + > + + `; + } + + private readonly renderTable = (profiles: Profile[]) => { + return html` + + + + ${msg("Status")} + + ${msg("Name")} + + ${msg("Configured Sites")} + + + ${msg("Last Modified")} + + + ${msg("Row actions")} + + + + ${profiles.map(this.renderItem)} + + + `; + }; private readonly renderItem = (data: Profile) => { + const startingUrl = data.origins[0]; + const otherOrigins = data.origins.slice(1); + return html` - + + + + + + ${data.name} - ${data.name} - -
${data.origins[0]}
- ${data.origins.length > 1 - ? html` - ${data.origins.slice(1).join(", ")} + ${otherOrigins.length + ? html` + +${this.localize.number(otherOrigins.length)} - - ${msg(str`+${data.origins.length - 1}`)} - +
    + ${otherOrigins.map((url) => html`
  • ${url}
  • `)} +
` : nothing}
- - - - - - - - - + + ${this.localize.relativeDate(data.modified || data.created, { + capitalize: true, + })} ${this.renderActions(data)} @@ -397,7 +511,10 @@ export class BrowserProfilesList extends BtrixElement { id: "browser-profile-deleted-status", }); - void this.fetchBrowserProfiles(); + this.pagination = { + ...this.pagination, + page: 1, + }; } catch (e) { let message = msg( html`Sorry, couldn't delete browser profile at this time.`, @@ -432,43 +549,13 @@ export class BrowserProfilesList extends BtrixElement { }); } - /** - * Fetch browser profiles and update internal state - */ - private async fetchBrowserProfiles( - params?: APIPaginationQuery, - ): Promise { - try { - this.isLoading = true; - const data = await this.getProfiles({ - page: - params?.page || - this.browserProfiles?.page || - parsePage(new URLSearchParams(location.search).get("page")), - pageSize: - params?.pageSize || - this.browserProfiles?.pageSize || - INITIAL_PAGE_SIZE, - }); - - this.browserProfiles = data; - } 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", - }); - } finally { - this.isLoading = false; - } - } - - private async getProfiles(params: APIPaginationQuery) { + private async getProfiles( + params: { userid?: string } & APIPaginationQuery & APISortQuery, + signal: AbortSignal, + ) { const query = queryString.stringify( { ...params, - ...this.sort, }, { arrayFormat: "comma", @@ -477,6 +564,7 @@ export class BrowserProfilesList extends BtrixElement { const data = await this.api.fetch>( `/orgs/${this.orgId}/profiles?${query}`, + { signal }, ); return data; diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index c31214354a..ea7e5a69a0 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -550,7 +550,7 @@ export class CollectionsList extends WithSearchOrgContext(BtrixElement) { private readonly renderItem = (col: Collection) => html` ${choose(col.access, [ @@ -563,7 +563,7 @@ export class CollectionsList extends WithSearchOrgContext(BtrixElement) { ].label} > diff --git a/frontend/src/types/crawler.ts b/frontend/src/types/crawler.ts index 1d5353b7c3..9a9259c708 100644 --- a/frontend/src/types/crawler.ts +++ b/frontend/src/types/crawler.ts @@ -127,9 +127,14 @@ export type Profile = { name: string; description: string; created: string; - createdByName: string | null; + createdBy: string | null; // User ID + createdByName: string | null; // User Name modified: string | null; - modifiedByName: string | null; + modifiedBy: string | null; // User ID + modifiedByName: string | null; // User Name + modifiedCrawlDate: string | null; + modifiedCrawlId: string | null; + modifiedCrawlCid: string | null; origins: string[]; profileId: string; baseProfileName: string; @@ -142,7 +147,7 @@ export type Profile = { size: number; replicas: ProfileReplica[] | null; }; - crawlerChannel?: string; + crawlerChannel?: CrawlerChannelImage | AnyString; proxyId?: string; }; From 8413a04ac5cd929708e548cac07b54d3a512b44a Mon Sep 17 00:00:00 2001 From: sua yoo Date: Thu, 13 Nov 2025 10:58:53 -0800 Subject: [PATCH 03/12] feat: Browser profile configuration UI rehaul (#2972) This change is a rewrite of the browser profile detail page UI to support a consistent UX flow for creating and configuring profiles. Additional features: - Displays workflows associated with browser profile and enables filtering by failed due to not logged in - Enables changing crawler channel on profile - Easily add site to profile - Displays when and how a profile was last modified --- frontend/src/components/ui/code/index.ts | 4 +- frontend/src/components/ui/desc-list.ts | 1 + frontend/src/components/ui/dialog.ts | 9 +- .../components/ui/table/table-header-cell.ts | 3 + frontend/src/components/ui/url-input.ts | 6 +- frontend/src/controllers/localize.ts | 5 +- frontend/src/events/btrix-user-guide-show.ts | 7 + .../src/features/archived-items/crawl-logs.ts | 5 +- .../src/features/browser-profiles/index.ts | 3 + .../new-browser-profile-dialog.ts | 273 +++--- .../profile-browser-dialog.ts | 410 +++++++++ .../browser-profiles/profile-browser.ts | 666 ++++++++------ .../profile-metadata-dialog.ts | 200 +++++ .../browser-profiles/start-browser-dialog.ts | 134 +++ .../browser-profiles/templates/badges.ts | 53 ++ .../src/features/browser-profiles/types.ts | 12 + .../collections/collection-edit-dialog.ts | 2 +- .../collections/select-collection-access.ts | 2 +- .../crawl-workflows/workflow-editor.ts | 32 +- .../features/crawl-workflows/workflow-list.ts | 142 ++- frontend/src/index.ts | 7 +- frontend/src/layouts/emptyMessage.ts | 6 +- frontend/src/layouts/page.ts | 10 +- frontend/src/layouts/pageHeader.ts | 2 +- frontend/src/layouts/panel.ts | 21 +- .../src/pages/org/browser-profiles-detail.ts | 805 ----------------- .../src/pages/org/browser-profiles-list.ts | 75 +- .../src/pages/org/browser-profiles-new.ts | 426 --------- .../src/pages/org/browser-profiles/index.ts | 1 + .../src/pages/org/browser-profiles/profile.ts | 848 ++++++++++++++++++ frontend/src/pages/org/collections-list.ts | 2 + frontend/src/pages/org/dashboard.ts | 10 +- frontend/src/pages/org/index.ts | 45 +- frontend/src/pages/public/org.ts | 2 +- frontend/src/routes.ts | 2 +- frontend/src/stories/components/Code.ts | 8 +- frontend/src/theme.stylesheet.css | 6 + frontend/src/types/events.d.ts | 2 - .../crawl-workflows/settingsForDuplicate.ts | 8 +- frontend/src/utils/pluralize.ts | 52 ++ frontend/src/utils/workflow.ts | 6 +- 41 files changed, 2511 insertions(+), 1802 deletions(-) create mode 100644 frontend/src/events/btrix-user-guide-show.ts create mode 100644 frontend/src/features/browser-profiles/profile-browser-dialog.ts create mode 100644 frontend/src/features/browser-profiles/profile-metadata-dialog.ts create mode 100644 frontend/src/features/browser-profiles/start-browser-dialog.ts create mode 100644 frontend/src/features/browser-profiles/templates/badges.ts create mode 100644 frontend/src/features/browser-profiles/types.ts delete mode 100644 frontend/src/pages/org/browser-profiles-detail.ts delete mode 100644 frontend/src/pages/org/browser-profiles-new.ts create mode 100644 frontend/src/pages/org/browser-profiles/index.ts create mode 100644 frontend/src/pages/org/browser-profiles/profile.ts diff --git a/frontend/src/components/ui/code/index.ts b/frontend/src/components/ui/code/index.ts index 081e07b665..9af0351ac5 100644 --- a/frontend/src/components/ui/code/index.ts +++ b/frontend/src/components/ui/code/index.ts @@ -71,7 +71,7 @@ export class Code extends TailwindElement { language: Language = Language.XML; @property({ type: Boolean }) - wrap = true; + noWrap = false; async connectedCallback() { const languageFn = (await langaugeFiles[this.language]).default; @@ -94,7 +94,7 @@ export class Code extends TailwindElement { part="base" class=${clsx( tw`font-monospace m-0 text-neutral-600`, - this.wrap ? tw`whitespace-pre-wrap` : tw`whitespace-nowrap`, + this.noWrap ? tw`whitespace-nowrap` : tw`whitespace-pre-wrap`, )} >${staticHtml`${unsafeStatic(htmlStr)}`}`; } diff --git a/frontend/src/components/ui/desc-list.ts b/frontend/src/components/ui/desc-list.ts index a21ab6d8be..03d0ac58c0 100644 --- a/frontend/src/components/ui/desc-list.ts +++ b/frontend/src/components/ui/desc-list.ts @@ -127,6 +127,7 @@ export class DescList extends LitElement { vertical: !this.horizontal, horizontal: this.horizontal, })} + part="base" > `; diff --git a/frontend/src/components/ui/dialog.ts b/frontend/src/components/ui/dialog.ts index 5bd89544b4..d17cfffa29 100644 --- a/frontend/src/components/ui/dialog.ts +++ b/frontend/src/components/ui/dialog.ts @@ -10,6 +10,10 @@ import { * with custom CSS * * Usage: see https://shoelace.style/components/dialog + * + * @attr label + * @attr open + * @attr noHeader */ @customElement("btrix-dialog") export class Dialog extends SlDialog { @@ -27,8 +31,7 @@ export class Dialog extends SlDialog { } .dialog__header { - background-color: var(--sl-color-neutral-50); - border-bottom: 1px solid var(--sl-color-neutral-100); + border-bottom: 1px solid var(--sl-panel-border-color); } .dialog__title { @@ -50,7 +53,7 @@ export class Dialog extends SlDialog { .dialog__footer { padding-top: var(--sl-spacing-small); padding-bottom: var(--sl-spacing-small); - border-top: 1px solid var(--sl-color-neutral-100); + border-top: 1px solid var(--sl-panel-border-color); } `, ]; diff --git a/frontend/src/components/ui/table/table-header-cell.ts b/frontend/src/components/ui/table/table-header-cell.ts index d522af2340..0c14ae7687 100644 --- a/frontend/src/components/ui/table/table-header-cell.ts +++ b/frontend/src/components/ui/table/table-header-cell.ts @@ -16,6 +16,9 @@ export class TableHeaderCell extends TableCell { @property({ type: String, reflect: true, noAccessor: true }) role = "columnheader"; + @property({ type: String, reflect: true, noAccessor: true }) + scope?: "row" | "col"; + @property({ type: String, reflect: true }) ariaSort: SortValues = "none"; diff --git a/frontend/src/components/ui/url-input.ts b/frontend/src/components/ui/url-input.ts index 4fc3108b82..3a1dd916ac 100644 --- a/frontend/src/components/ui/url-input.ts +++ b/frontend/src/components/ui/url-input.ts @@ -42,6 +42,11 @@ export class UrlInput extends SlInput { this.addEventListener("sl-change", this.onChange); } + setCustomValidity(message: string): void { + super.setCustomValidity(message); + if (!this.hideHelpText) this.helpText = message; + } + disconnectedCallback(): void { super.disconnectedCallback(); @@ -61,7 +66,6 @@ export class UrlInput extends SlInput { if (value && !validURL(value)) { const text = msg("Please enter a valid URL."); - if (!this.hideHelpText) this.helpText = text; this.setCustomValidity(text); } else if ( value && diff --git a/frontend/src/controllers/localize.ts b/frontend/src/controllers/localize.ts index 0690580e56..328d780a14 100644 --- a/frontend/src/controllers/localize.ts +++ b/frontend/src/controllers/localize.ts @@ -37,9 +37,10 @@ export class LocalizeController extends SlLocalizeController { year: "numeric", month: "long", day: "numeric", - hour: "2-digit", - minute: "2-digit", + hour: "numeric", + minute: "numeric", timeZoneName: "short", + weekday: "long", })} hoist placement="bottom" diff --git a/frontend/src/events/btrix-user-guide-show.ts b/frontend/src/events/btrix-user-guide-show.ts new file mode 100644 index 0000000000..36d98a9712 --- /dev/null +++ b/frontend/src/events/btrix-user-guide-show.ts @@ -0,0 +1,7 @@ +export type BtrixUserGuideShowEvent = CustomEvent<{ path?: string }>; + +declare global { + interface GlobalEventHandlersEventMap { + "btrix-user-guide-show": BtrixUserGuideShowEvent; + } +} diff --git a/frontend/src/features/archived-items/crawl-logs.ts b/frontend/src/features/archived-items/crawl-logs.ts index 4fa366ad58..f9d13479e6 100644 --- a/frontend/src/features/archived-items/crawl-logs.ts +++ b/frontend/src/features/archived-items/crawl-logs.ts @@ -222,7 +222,10 @@ export class CrawlLogs extends BtrixElement { `, () => this.filter - ? emptyMessage({ message: emptyMessageFor[this.filter] }) + ? emptyMessage({ + classNames: tw`border-y`, + message: emptyMessageFor[this.filter], + }) : nothing, )} `; diff --git a/frontend/src/features/browser-profiles/index.ts b/frontend/src/features/browser-profiles/index.ts index 7e843968d9..ab2f570127 100644 --- a/frontend/src/features/browser-profiles/index.ts +++ b/frontend/src/features/browser-profiles/index.ts @@ -1,3 +1,6 @@ import("./new-browser-profile-dialog"); +import("./profile-browser-dialog"); import("./profile-browser"); +import("./profile-metadata-dialog"); import("./select-browser-profile"); +import("./start-browser-dialog"); diff --git a/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts b/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts index 5ba95ecb83..d6769933be 100644 --- a/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts +++ b/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts @@ -1,4 +1,4 @@ -import { localized, msg, str } from "@lit/localize"; +import { localized, msg } from "@lit/localize"; import { type SlInput } from "@shoelace-style/shoelace"; import { html, nothing, type PropertyValues } from "lit"; import { @@ -9,7 +9,7 @@ import { state, } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; -import queryString from "query-string"; +import { when } from "lit/directives/when.js"; import { BtrixElement } from "@/classes/BtrixElement"; import type { Dialog } from "@/components/ui/dialog"; @@ -24,6 +24,9 @@ import { @customElement("btrix-new-browser-profile-dialog") @localized() export class NewBrowserProfileDialog extends BtrixElement { + @property({ type: String }) + defaultUrl?: string; + @property({ type: String }) defaultProxyId?: string; @@ -40,7 +43,13 @@ export class NewBrowserProfileDialog extends BtrixElement { open = false; @state() - private isSubmitting = false; + browserOpen = false; + + @state() + private name?: string; + + @state() + private url?: string; @state() private crawlerChannel: CrawlerChannel["id"] = CrawlerChannelImage.Default; @@ -54,6 +63,17 @@ export class NewBrowserProfileDialog extends BtrixElement { @queryAsync("#browserProfileForm") private readonly form!: Promise; + connectedCallback(): void { + super.connectedCallback(); + + if (this.org?.crawlingDefaults) { + if (!this.defaultProxyId) + this.defaultProxyId = this.org.crawlingDefaults.proxyId; + if (!this.defaultCrawlerChannel) + this.defaultCrawlerChannel = this.org.crawlingDefaults.crawlerChannel; + } + } + protected willUpdate(changedProperties: PropertyValues): void { if (changedProperties.has("defaultProxyId") && this.defaultProxyId) { this.proxyId = this.proxyId || this.defaultProxyId; @@ -71,78 +91,109 @@ export class NewBrowserProfileDialog extends BtrixElement { } render() { - return html` { - const nameInput = (await this.form).querySelector( - 'sl-input[name="url"]', - ); - if (nameInput) { - e.preventDefault(); - nameInput.focus(); - } - }} - > -
{ + const nameInput = (await this.form).querySelector( + "btrix-url-input", + ); + if (nameInput) { + e.preventDefault(); + nameInput.focus(); + } + }} > - - - - ${this.crawlerChannels && this.crawlerChannels.length > 1 - ? html`
- - (this.crawlerChannel = e.detail.value!)} - > -
` - : nothing} - ${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")} -
-
`; + + + + ${this.crawlerChannels && this.crawlerChannels.length > 1 + ? html`
+ + (this.crawlerChannel = e.detail.value!)} + > +
` + : nothing} + ${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 Browser")} + +
+ + + ${when( + this.url, + (url) => + html` {}} + @sl-after-hide=${() => {}} + > + `, + )} + `; } private async hideDialog() { @@ -155,71 +206,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..36b94e96b9 --- /dev/null +++ b/frontend/src/features/browser-profiles/profile-browser-dialog.ts @@ -0,0 +1,410 @@ +import { localized, msg } from "@lit/localize"; +import { Task, TaskStatus } from "@lit/task"; +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 { 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 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"; + +/** + * @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 isBrowserLoaded = 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, profile], { 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( + { profileId: profile?.id, ...config }, + signal, + ); + + return browserid; + }, + args: () => [this.open, this.config, this.profile] as const, + }); + + 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 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, this.config?.url] + .filter((v) => v) + .join(" — ")} +

+ ${when( + (this.profile || this.config) && { + ...this.profile, + ...this.config, + }, + badges, + )} +
+
+
+
+ + void this.profileBrowser?.enterFullscreen()} + > + + + this.profileBrowser?.toggleOrigins()} + > + +
+ + ${when( + isCrawler, + () => html` + +
+ void this.submit()} + > + ${creatingNew ? msg("Create Profile") : msg("Save Profile")} + +
+
+ `, + )} +
+
+ +
+ ${this.browserIdTask.render({ + complete: (browserId) => + browserId + ? html`` + : nothing, + })} +
+
`; + } + + private readonly closeBrowser = () => { + this.isBrowserLoaded = false; + }; + + private readonly onBrowserLoad = () => { + this.isBrowserLoaded = true; + }; + + private readonly onBrowserReload = () => { + this.isBrowserLoaded = false; + }; + + private readonly onBrowserError = () => { + this.isBrowserLoaded = false; + }; + + private readonly onBrowserConnectionChange = ( + e: CustomEvent, + ) => { + this.isBrowserLoaded = e.detail.connected; + }; + + 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..4f88f643a5 100644 --- a/frontend/src/features/browser-profiles/profile-browser.ts +++ b/frontend/src/features/browser-profiles/profile-browser.ts @@ -1,10 +1,18 @@ import { localized, msg, str } from "@lit/localize"; +import { Task, TaskStatus } from "@lit/task"; import { html, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; +import { cache } from "lit/directives/cache.js"; import { when } from "lit/directives/when.js"; import { BtrixElement } from "@/classes/BtrixElement"; +import { emptyMessage } from "@/layouts/emptyMessage"; import { isApiError, type APIError } from "@/utils/api"; +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"]; @@ -21,6 +29,10 @@ export type BrowserConnectionChange = { connected: boolean; }; +const isPolling = (value: unknown): value is number => { + return typeof value === "number"; +}; + /** * View embedded profile browser * @@ -37,6 +49,9 @@ export type BrowserConnectionChange = { * @fires btrix-browser-error * @fires btrix-browser-reload * @fires btrix-browser-connection-change + * @cssPart base + * @cssPart browser + * @cssPart iframe */ @customElement("btrix-profile-browser") @localized() @@ -47,20 +62,8 @@ export class ProfileBrowser extends BtrixElement { @property({ type: String }) initialNavigateUrl?: string; - @property({ type: Array }) - origins?: string[]; - - @state() - private iframeSrc?: string; - - @state() - private isIframeLoaded = false; - - @state() - private browserNotAvailable = false; - - @state() - private browserDisconnected = false; + @property({ type: Boolean }) + hideControls = false; @state() private isFullscreen = false; @@ -68,54 +71,177 @@ 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 data = await this.pingBrowser(browser.id, signal); + + return data.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 +250,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 +264,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), + })}
- ${this.renderOrigins()} ${this.renderNewOrigins()} + ${when( + this.originsTask.value, + (origins) => html` + ${this.renderOrigins(origins)} + ${this.renderNewOrigins( + origins.filter( + (url: string) => + !origins.includes(url) && + !origins.includes(url.replace(/\/$/, "")), + ), + )} + `, + () => + this.browserTask.status === TaskStatus.PENDING + ? emptyMessage({ + message: msg( + "Sites will be shown here once the browser is done loading.", + ), + }) + : emptyMessage({ + message: msg("No sites configured yet."), + }), + )}
@@ -187,14 +385,14 @@ export class ProfileBrowser extends BtrixElement { `; } - private renderControlBar() { + private readonly renderControlBar = () => { if (this.isFullscreen) { return html`
${this.renderSidebarButton()} - + void document.exitFullscreen()} @@ -204,22 +402,28 @@ export class ProfileBrowser extends BtrixElement { `; } + if (this.hideControls) return; + return html`
-
+
${msg("Interactive Browser")} - - - + +
${this.renderSidebarButton()} - + void this.enterFullscreen()} @@ -228,210 +432,141 @@ 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` - + (this.showOriginSidebar = !this.showOriginSidebar)} + @click=${() => this.toggleOrigins()} aria-pressed=${this.showOriginSidebar} > `; } - private renderOrigins() { + private renderOrigins(origins: string[]) { return html` -

- ${msg("Visited Sites")} - -

+
+
+

${msg("Saved Sites")}

+ +
+ (this.showOriginSidebar = false)} + > + +
    - ${this.origins?.map((url) => this.renderOriginItem(url))} + ${origins.map((url) => this.renderOriginItem(url))}
`; } - private renderNewOrigins() { - if (!this.newOrigins?.length) return; + private renderNewOrigins(origins: string[]) { + if (!origins.length) return; return html` -

- ${msg("New Sites")} - -

+
+
+

${msg("New Sites")}

+ +
+
    - ${this.newOrigins.map((url) => this.renderOriginItem(url))} + ${origins.map((url) => this.renderOriginItem(url))}
`; } private renderOriginItem(url: string) { + const iframeSrc = + !isPolling(this.browserTask.value) && this.browserTask.value?.url; + return html`
  • (this.iframeSrc ? this.navigateBrowser({ url }) : {})} + @click=${() => (iframeSrc ? this.navigateBrowser({ url }) : {})} >
    ${url}
    - ${this.iframeSrc - ? html`` + ${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 +575,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 +594,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 +618,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..b331d1ff4a --- /dev/null +++ b/frontend/src/features/browser-profiles/profile-metadata-dialog.ts @@ -0,0 +1,200 @@ +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 { 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; + + 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 params = serialize(this.form) as { + name: string; + description: string; + }; + + 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` +
    { + e.preventDefault(); + + if (this.form?.checkValidity()) { + void this.submitTask.run(); + } + }} + @reset=${() => void this.dialog?.hide()} + > + + + + + ${ + // + undefined + } +
    +
    + ${msg("Cancel")} + ${msg("Save")} +
    + `; + } +} 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..2e57714c69 --- /dev/null +++ b/frontend/src/features/browser-profiles/start-browser-dialog.ts @@ -0,0 +1,134 @@ +import { consume } from "@lit/context"; +import { localized, msg } from "@lit/localize"; +import type { SlButton } from "@shoelace-style/shoelace"; +import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; +import { html } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; +import { when } from "lit/directives/when.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { Dialog } from "@/components/ui/dialog"; +import type { UrlInput } from "@/components/ui/url-input"; +import { + orgCrawlerChannelsContext, + type OrgCrawlerChannelsContext, +} from "@/context/org-crawler-channels"; +import type { Profile } from "@/types/crawler"; + +type StartBrowserEventDetail = { + url?: string; + crawlerChannel?: Profile["crawlerChannel"]; +}; + +export type BtrixStartBrowserEvent = CustomEvent; + +/** + * 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: orgCrawlerChannelsContext, subscribe: true }) + private readonly orgCrawlerChannels?: OrgCrawlerChannelsContext; + + @property({ type: Object }) + profile?: Profile; + + @property({ type: String }) + startUrl?: string; + + @property({ type: Boolean }) + open = false; + + @query("btrix-dialog") + private readonly dialog?: Dialog | null; + + @query("form") + private readonly form?: HTMLFormElement | null; + + @query("#submit-button") + private readonly submitButton?: SlButton | null; + + render() { + return html` { + await this.updateComplete; + + if (this.startUrl) { + this.submitButton?.focus(); + } else { + this.dialog?.querySelector("btrix-url-input")?.focus(); + } + }} + @sl-after-hide=${async () => { + if (this.form) { + this.form.reset(); + + const input = this.form.querySelector("btrix-url-input"); + if (input) { + input.value = ""; + input.setCustomValidity(""); + } + } + + void this.dialog?.hide(); + }} + > +
    { + e.preventDefault(); + + const form = e.target as HTMLFormElement; + + if (!form.checkValidity()) return; + + const values = serialize(form); + const url = values["starting-url"] as string; + const crawlerChannel = values["crawlerChannel"] as string | undefined; + + this.dispatchEvent( + new CustomEvent("btrix-start-browser", { + detail: { url, crawlerChannel }, + }), + ); + }} + > + + + ${when( + this.orgCrawlerChannels && this.orgCrawlerChannels.length > 1, + () => html` +
    + + +
    + `, + )} + +
    + void this.dialog?.hide()} + >${msg("Cancel")} + this.dialog?.submit()} + > + ${msg("Start Browser")} + +
    +
    `; + } +} 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..303b2810dd --- /dev/null +++ b/frontend/src/features/browser-profiles/templates/badges.ts @@ -0,0 +1,53 @@ +import { msg } from "@lit/localize"; +import { html, nothing } from "lit"; +import { when } from "lit/directives/when.js"; +import capitalize from "lodash/fp/capitalize"; + +import { CrawlerChannelImage, 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, + (channel) => + html` + + + ${capitalize(channel)} + + `, + )} + ${when( + profile.proxyId, + (proxy) => + html` + + + ${proxy} + + `, + )} +
    `; +}; + +export const badgesSkeleton = () => + html``; 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/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index 88da94c53d..c8ef39aec2 100644 --- a/frontend/src/features/crawl-workflows/workflow-editor.ts +++ b/frontend/src/features/crawl-workflows/workflow-editor.ts @@ -71,6 +71,7 @@ import { 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"; @@ -79,11 +80,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"; @@ -486,7 +486,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 @@ -2430,7 +2442,7 @@ https://archiveweb.page/images/${"logo.svg"}`} e.preventDefault(); this.dispatchEvent( - new CustomEvent( + new CustomEvent( "btrix-user-guide-show", { detail: { path }, @@ -2821,7 +2833,7 @@ https://archiveweb.page/images/${"logo.svg"}`} if (this.appState.userGuideOpen) { this.dispatchEvent( - new CustomEvent( + new CustomEvent( "btrix-user-guide-show", { detail: { @@ -3125,10 +3137,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( diff --git a/frontend/src/features/crawl-workflows/workflow-list.ts b/frontend/src/features/crawl-workflows/workflow-list.ts index 00a9327e81..85ae28e311 100644 --- a/frontend/src/features/crawl-workflows/workflow-list.ts +++ b/frontend/src/features/crawl-workflows/workflow-list.ts @@ -32,6 +32,21 @@ import { humanizeSchedule } from "@/utils/cron"; import { srOnly, truncate } from "@/utils/css"; import { pluralOf } from "@/utils/pluralize"; +export type WorkflowColumnName = + | "name" + | "latest-crawl" + | "total-crawls" + | "modified" + | "actions"; + +const columnWidths = { + name: "minmax(18rem, 1fr)", + "latest-crawl": "minmax(15rem, 18rem)", + "total-crawls": "minmax(6rem, 9rem)", + modified: "minmax(12rem, 15rem)", + actions: "3rem", +} as const satisfies Record; + // postcss-lit-disable-next-line const mediumBreakpointCss = css`30rem`; // postcss-lit-disable-next-line @@ -41,6 +56,13 @@ const rowCss = css` .row { display: grid; grid-template-columns: 1fr; + position: relative; + } + + .action { + position: absolute; + top: 0; + right: 0; } @media only screen and (min-width: ${mediumBreakpointCss}) { @@ -50,7 +72,12 @@ const rowCss = css` } @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; } } @@ -231,33 +258,27 @@ export class WorkflowListItem extends BtrixElement { @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); - }} - > -
    + private readonly columnTemplate = { + name: () => + html`
    ${when(this.workflow?.shareable, ShareableNotice)} ${this.safeRender(this.renderName)}
    -
    +
    ${this.safeRender((workflow) => { if (workflow.schedule) { return humanizeSchedule(workflow.schedule, { @@ -270,9 +291,11 @@ 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 +339,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`
    { + 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); + }} + > + ${this.columns + ? this.columns.map((col) => this.columnTemplate[col]()) + : Object.values(this.columnTemplate).map((render) => render())}
    `; } @@ -551,18 +596,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`
    -
    ${msg(html`Name & Schedule`)}
    -
    ${msg("Latest Crawl")}
    -
    ${msg("Total Size")}
    -
    ${msg("Last Modified")}
    -
    - ${msg("Actions")} -
    + return html`
    + ${this.columns + ? this.columns.map((col) => WorkflowList.ColumnTemplate[col]) + : Object.values(WorkflowList.ColumnTemplate)}
    @@ -574,6 +642,12 @@ export class WorkflowList extends LitElement { if (!el.attributes.getNamedItem("role")) { el.setAttribute("role", "listitem"); } + + if (this.columns) { + if (!el["columns"]) { + el["columns"] = this.columns; + } + } }); } } diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 0a3674c32d..684123676f 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -21,6 +21,7 @@ import "./pages"; import { docsUrlContext } from "./context/docs-url"; import { viewStateContext } from "./context/view-state"; +import type { BtrixUserGuideShowEvent } from "./events/btrix-user-guide-show"; import { OrgTab, RouteNamespace } from "./routes"; import type { UserInfo, UserOrg } from "./types/user"; import { pageView, type AnalyticsTrackProps } from "./utils/analytics"; @@ -63,10 +64,6 @@ export type APIUser = { orgs: UserOrg[]; }; -export interface UserGuideEventMap { - "btrix-user-guide-show": CustomEvent<{ path?: string }>; -} - @customElement("browsertrix-app") @localized() export class App extends BtrixElement { @@ -169,7 +166,7 @@ export class App extends BtrixElement { private attachUserGuideListeners() { this.addEventListener( "btrix-user-guide-show", - (e: UserGuideEventMap["btrix-user-guide-show"]) => { + (e: BtrixUserGuideShowEvent) => { e.stopPropagation(); void this.showUserGuide(e.detail.path); }, diff --git a/frontend/src/layouts/emptyMessage.ts b/frontend/src/layouts/emptyMessage.ts index ddaad6673f..06f33a6756 100644 --- a/frontend/src/layouts/emptyMessage.ts +++ b/frontend/src/layouts/emptyMessage.ts @@ -7,16 +7,18 @@ export function emptyMessage({ message, detail, actions, + classNames, }: { message: TemplateResult | string; detail?: TemplateResult | string; actions?: TemplateResult; + classNames?: typeof tw | string; }) { return html` -
    +

    @@ -39,10 +39,12 @@ export function page( >

    ${header.breadcrumbs ? html` ${pageNav(header.breadcrumbs)} ` : nothing} - ${pageHeader(header)} -
    ${render()}
    + ${header.title ? pageHeader(header) : nothing} + ${header.title + ? html`
    ${render()}
    ` + : render()}
    `; } diff --git a/frontend/src/layouts/pageHeader.ts b/frontend/src/layouts/pageHeader.ts index 2446b4a0e3..3b6f7478e2 100644 --- a/frontend/src/layouts/pageHeader.ts +++ b/frontend/src/layouts/pageHeader.ts @@ -128,7 +128,7 @@ export function pageHeader({ classNames, )} > -
    +
    ${prefix}${pageTitle(title)}${suffix}
    diff --git a/frontend/src/layouts/panel.ts b/frontend/src/layouts/panel.ts index 6bfe56fb2a..e295647d1f 100644 --- a/frontend/src/layouts/panel.ts +++ b/frontend/src/layouts/panel.ts @@ -1,8 +1,11 @@ +import clsx from "clsx"; import { html, type TemplateResult } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; import { pageHeading } from "./page"; +import { tw } from "@/utils/tailwind"; + export function panelHeader({ heading, actions, @@ -20,8 +23,18 @@ export function panelHeader({ `; } -export function panelBody({ content }: { content: TemplateResult }) { - return html`
    ${content}
    `; +export function panelBody({ + content, + classNames, +}: { + content: TemplateResult; + classNames?: typeof tw | string; +}) { + return html`
    + ${content} +
    `; } /** @@ -39,6 +52,8 @@ export function panel({ className?: string; } & Parameters[0]) { return html`
    - ${panelHeader({ heading, actions })} ${body} + ${panelHeader({ heading, actions })} + + ${body}
    `; } diff --git a/frontend/src/pages/org/browser-profiles-detail.ts b/frontend/src/pages/org/browser-profiles-detail.ts deleted file mode 100644 index d57abb7a23..0000000000 --- a/frontend/src/pages/org/browser-profiles-detail.ts +++ /dev/null @@ -1,805 +0,0 @@ -import { localized, msg, str } from "@lit/localize"; -import { html, nothing } 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 capitalize from "lodash/fp/capitalize"; -import queryString from "query-string"; - -import type { Profile } from "./types"; - -import { BtrixElement } from "@/classes/BtrixElement"; -import type { Dialog } from "@/components/ui/dialog"; -import { ClipboardController } from "@/controllers/clipboard"; -import type { BrowserConnectionChange } from "@/features/browser-profiles/profile-browser"; -import { pageNav } from "@/layouts/pageHeader"; -import { isApiError } from "@/utils/api"; -import { maxLengthValidator } from "@/utils/form"; -import { isArchivingDisabled } from "@/utils/orgs"; -import { richText } from "@/utils/rich-text"; - -const DESCRIPTION_MAXLENGTH = 500; - -/** - * Usage: - * ```ts - * - * ``` - */ -@customElement("btrix-browser-profiles-detail") -@localized() -export class BrowserProfilesDetail extends BtrixElement { - @property({ type: String }) - profileId!: string; - - @property({ type: Boolean }) - isCrawler = false; - - @state() - private profile?: Profile; - - @state() - private isBrowserLoading = false; - - @state() - private isBrowserLoaded = false; - - @state() - private isSubmittingBrowserChange = false; - - @state() - private isSubmittingProfileChange = false; - - @state() - private browserId?: string; - - @state() - private isEditDialogOpen = false; - - @state() - private isEditDialogContentVisible = false; - - @query("#profileBrowserContainer") - private readonly profileBrowserContainer?: HTMLElement | null; - - @query("#discardChangesDialog") - private readonly discardChangesDialog?: Dialog | null; - - private readonly validateNameMax = maxLengthValidator(50); - private readonly validateDescriptionMax = maxLengthValidator(500); - - private updatedProfileTimer?: number; - - disconnectedCallback() { - window.clearTimeout(this.updatedProfileTimer); - if (this.browserId) { - void this.deleteBrowser(this.browserId); - } - super.disconnectedCallback(); - } - - firstUpdated() { - void this.fetchProfile(); - } - - render() { - const isBackedUp = - this.profile?.resource?.replicas && - this.profile.resource.replicas.length > 0; - const none = html`${msg("None")}`; - - return html`
    ${this.renderBreadcrumbs()}
    - -
    -

    - ${this.profile?.name - ? html`${this.profile.name}` - : html``} -

    -
    - ${this.profile - ? this.renderMenu() - : html``} -
    -
    - -
    - - - ${this.profile - ? this.profile.crawlerChannel - ? capitalize(this.profile.crawlerChannel) - : none - : nothing} - - - ${this.profile - ? html` - - ` - : nothing} - - - ${this.profile - ? html` ` - : nothing} - - ${ - // NOTE older profiles may not have "modified/created by" data - this.profile?.modifiedByName || this.profile?.createdByName - ? html` - - ${this.profile.modifiedByName || this.profile.createdByName} - - ` - : nothing - } - -
    - -
    -
    -
    -

    - ${msg("Browser Profile")} -

    - - - - - ${this.profile?.inUse - ? html` - - - ${msg("In Use")} - - - - ` - : html` - - `} -
    - - ${when(this.isCrawler, () => - this.browserId || this.isBrowserLoading - ? html` -
    -
    - - (this.isBrowserLoaded = true)} - @btrix-browser-error=${this.onBrowserError} - @btrix-browser-reload=${this.startBrowserPreview} - @btrix-browser-connection-change=${this - .onBrowserConnectionChange} - > -
    - ${this.renderBrowserProfileControls()} -
    -
    -
    - ` - : html`
    -

    - ${msg( - "View or edit the current browser profile configuration.", - )} -

    - - - ${msg("Configure Browser Profile")} - -
    `, - )} -
    - ${when( - !(this.browserId || this.isBrowserLoading), - this.renderVisitedSites, - )} -
    - -
    -
    -

    - ${msg("Description")} -

    - ${when( - this.isCrawler, - () => html` - (this.isEditDialogOpen = true)} - label=${msg("Edit description")} - > - `, - )} -
    - -
    ${this.profile - ? this.profile.description - ? richText(this.profile.description) - : html` -
    -  ${msg("No description added.")} -
    - ` - : nothing}
    -
    - - - ${msg( - "Are you sure you want to discard changes to this browser profile?", - )} -
    - void this.discardChangesDialog?.hide()} - > - ${msg("No, Continue Editing")} - - { - void this.cancelEditBrowser(); - void this.discardChangesDialog?.hide(); - }} - >${msg("Yes, Discard Changes")} - -
    -
    - - (this.isEditDialogOpen = false)} - @sl-show=${() => (this.isEditDialogContentVisible = true)} - @sl-after-hide=${() => (this.isEditDialogContentVisible = false)} - > - ${this.isEditDialogContentVisible ? this.renderEditProfile() : nothing} - `; - } - - private renderBreadcrumbs() { - const breadcrumbs = [ - { - href: `${this.navigate.orgBasePath}/browser-profiles`, - content: msg("Browser Profiles"), - }, - { - content: this.profile?.name, - }, - ]; - - return pageNav(breadcrumbs); - } - - private readonly renderVisitedSites = () => { - return html` -
    -
    -

    - ${msg("Visited Sites")} -

    -
    -
    -
      - ${this.profile?.origins.map((origin) => html`
    • ${origin}
    • `)} -
    -
    -
    - `; - }; - - private renderBrowserProfileControls() { - return html` -
    - - ${msg("Cancel")} - -
    - - ${msg("Save Browser Profile")} - -
    -
    - `; - } - - private renderMenu() { - const archivingDisabled = isArchivingDisabled(this.org); - - return html` - - - ${msg("Actions")} - - - (this.isEditDialogOpen = true)}> - - ${msg("Edit Metadata")} - - - - ${msg("Configure Browser Profile")} - - void this.duplicateProfile()} - > - - ${msg("Duplicate Profile")} - - - ClipboardController.copyToClipboard(this.profileId)} - > - - ${msg("Copy Profile ID")} - - - void this.deleteProfile()} - > - - ${msg("Delete Profile")} - - - - `; - } - - private renderEditProfile() { - if (!this.profile) return; - - return html` -
    -
    - -
    - -
    - -
    - -
    - (this.isEditDialogOpen = false)} - >${msg("Cancel")} - ${msg("Save Changes")} -
    -
    - `; - } - - private async onBrowserError() { - this.isBrowserLoaded = false; - } - - private async onBrowserConnectionChange( - e: CustomEvent, - ) { - this.isBrowserLoaded = e.detail.connected; - } - - private async onCancel() { - if (this.isBrowserLoaded) { - void this.discardChangesDialog?.show(); - } else { - void this.cancelEditBrowser(); - } - } - - private async startBrowserPreview() { - if (!this.profile) return; - - this.isBrowserLoading = true; - - const url = this.profile.origins[0]; - - try { - const data = await this.createBrowser({ url }); - - this.browserId = data.browserid; - this.isBrowserLoading = false; - - await this.updateComplete; - - this.profileBrowserContainer?.scrollIntoView({ behavior: "smooth" }); - } catch (e) { - this.isBrowserLoading = false; - - this.notify.toast({ - message: msg("Sorry, couldn't preview browser profile at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-error", - }); - } - } - - private async cancelEditBrowser() { - const prevBrowserId = this.browserId; - - this.isBrowserLoaded = false; - this.browserId = undefined; - - if (prevBrowserId) { - try { - await this.deleteBrowser(prevBrowserId); - } catch (e) { - // TODO Investigate DELETE is returning 404 - console.debug(e); - } - } - } - - private async duplicateProfile() { - if (!this.profile) return; - - this.isBrowserLoading = true; - - const url = this.profile.origins[0]; - - try { - const data = await this.createBrowser({ url }); - - this.notify.toast({ - message: msg("Starting up browser with current profile..."), - variant: "success", - icon: "check2-circle", - }); - - this.navigate.to( - `${this.navigate.orgBasePath}/browser-profiles/profile/browser/${ - data.browserid - }?${queryString.stringify({ - url, - name: this.profile.name, - description: this.profile.description.slice(0, DESCRIPTION_MAXLENGTH), - profileId: this.profile.id, - crawlerChannel: this.profile.crawlerChannel, - proxyId: this.profile.proxyId, - })}`, - ); - } catch (e) { - this.isBrowserLoading = false; - - this.notify.toast({ - message: msg("Sorry, couldn't create browser profile at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-error", - }); - } - } - - private async deleteProfile() { - const profileName = this.profile!.name; - - try { - await this.api.fetch( - `/orgs/${this.orgId}/profiles/${this.profile!.id}`, - { - method: "DELETE", - }, - ); - - this.navigate.to(`${this.navigate.orgBasePath}/browser-profiles`); - - this.notify.toast({ - message: msg(html`Deleted ${profileName}.`), - variant: "success", - icon: "check2-circle", - }); - } catch (e) { - let message = msg( - html`Sorry, couldn't delete browser profile at this time.`, - ); - - if (isApiError(e)) { - if (e.message === "profile_in_use") { - message = msg( - html`Could not delete ${profileName}, currently in - use. Please remove browser profile from all crawl workflows to - continue.`, - ); - } - } - this.notify.toast({ - message: message, - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-error", - }); - } - } - - private async createBrowser({ url }: { url: string }) { - const params = { - url, - profileId: this.profile!.id, - }; - - return this.api.fetch<{ browserid: string }>( - `/orgs/${this.orgId}/profiles/browser`, - { - method: "POST", - body: JSON.stringify(params), - }, - ); - } - - private async deleteBrowser(id: string) { - return this.api.fetch(`/orgs/${this.orgId}/profiles/browser/${id}`, { - method: "DELETE", - }); - } - - /** - * Fetch browser profile and update internal state - */ - private async fetchProfile(): Promise { - try { - const data = await this.getProfile(); - - this.profile = data; - } catch (e) { - this.notify.toast({ - message: msg("Sorry, couldn't retrieve browser profiles at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-error", - }); - } - } - - private async getProfile() { - const data = await this.api.fetch( - `/orgs/${this.orgId}/profiles/${this.profileId}`, - ); - - return data; - } - - private async saveBrowser() { - if (!this.browserId) return; - - this.isSubmittingBrowserChange = true; - - const params = { - name: this.profile!.name, - browserid: this.browserId, - }; - - try { - let retriesLeft = 300; - - while (retriesLeft > 0) { - const data = await this.api.fetch<{ - updated?: boolean; - detail?: string; - }>(`/orgs/${this.orgId}/profiles/${this.profileId}`, { - method: "PATCH", - body: JSON.stringify(params), - }); - if (data.updated !== undefined) { - break; - } - if (data.detail === "waiting_for_browser") { - await new Promise((resolve) => { - this.updatedProfileTimer = window.setTimeout(resolve, 2000); - }); - } else { - throw new Error("unknown response"); - } - - retriesLeft -= 1; - } - - if (!retriesLeft) { - throw new Error("too many retries waiting for browser"); - } - - this.notify.toast({ - message: msg("Successfully saved browser profile."), - variant: "success", - icon: "check2-circle", - id: "browser-profile-save-status", - }); - - this.browserId = undefined; - } catch (e) { - console.debug(e); - - this.notify.toast({ - message: msg("Sorry, couldn't save browser profile at this time."), - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-save-status", - }); - } - - this.isSubmittingBrowserChange = false; - } - - private async onSubmitEdit(e: SubmitEvent) { - e.preventDefault(); - - const formEl = e.target as HTMLFormElement; - if (!(await this.checkFormValidity(formEl))) return; - - const formData = new FormData(formEl); - const name = formData.get("name") as string; - const description = formData.get("description") as string; - - const params = { - name, - description, - }; - - this.isSubmittingProfileChange = true; - - try { - const data = await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/profiles/${this.profileId}`, - { - method: "PATCH", - body: JSON.stringify(params), - }, - ); - - if (data.updated) { - this.notify.toast({ - message: msg("Successfully saved browser profile."), - variant: "success", - icon: "check2-circle", - id: "browser-profile-save-status", - }); - - this.profile = { - ...this.profile, - ...params, - } as Profile; - this.isEditDialogOpen = false; - } else { - throw data; - } - } 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", - }); - } - - this.isSubmittingProfileChange = false; - } - - async checkFormValidity(formEl: HTMLFormElement) { - await this.updateComplete; - return !formEl.querySelector("[data-invalid]"); - } -} diff --git a/frontend/src/pages/org/browser-profiles-list.ts b/frontend/src/pages/org/browser-profiles-list.ts index cf9d4b5629..f31682852b 100644 --- a/frontend/src/pages/org/browser-profiles-list.ts +++ b/frontend/src/pages/org/browser-profiles-list.ts @@ -32,7 +32,7 @@ import { isArchivingDisabled } from "@/utils/orgs"; const SORT_DIRECTIONS = ["asc", "desc"] as const; type SortDirection = (typeof SORT_DIRECTIONS)[number]; -type SortField = "name" | "url" | "modified"; +type SortField = "name" | "url" | "modified" | "created"; type SortBy = { field: SortField; direction: SortDirection; @@ -51,7 +51,11 @@ const sortableFields: Record< defaultDirection: "asc", }, modified: { - label: msg("Last Modified"), + label: msg("Modified By User"), + defaultDirection: "desc", + }, + created: { + label: msg("Date Created"), defaultDirection: "desc", }, }; @@ -74,6 +78,12 @@ const columnsCss = [ @customElement("btrix-browser-profiles-list") @localized() export class BrowserProfilesList extends BtrixElement { + @state() + private selectedProfile?: Profile; + + @state() + private openDialog?: "duplicate"; + @state() private pagination: Required = { page: parsePage(new URLSearchParams(location.search).get("page")), @@ -227,9 +237,29 @@ export class BrowserProfilesList extends BtrixElement { : this.renderEmpty()} `, )} + ${when(this.selectedProfile, this.renderDuplicateDialog)} `; }; + private readonly renderDuplicateDialog = (profile: Profile) => { + return html` { + this.selectedProfile = undefined; + this.openDialog = undefined; + }} + > + `; + }; + private renderEmpty() { const message = msg("Your org doesn’t have any browser profiles yet."); @@ -377,6 +407,11 @@ export class BrowserProfilesList extends BtrixElement { }; private readonly renderItem = (data: Profile) => { + const modifiedByAnyDate = + [data.modifiedCrawlDate, data.modified, data.created].reduce( + (a, b) => (b && a && b > a ? b : a), + data.created, + ) || data.created; const startingUrl = data.origins[0]; const otherOrigins = data.origins.slice(1); @@ -417,9 +452,7 @@ export class BrowserProfilesList extends BtrixElement { : nothing} - ${this.localize.relativeDate(data.modified || data.created, { - capitalize: true, - })} + ${this.localize.relativeDate(modifiedByAnyDate)} ${this.renderActions(data)} @@ -464,35 +497,9 @@ export class BrowserProfilesList extends BtrixElement { } private async duplicateProfile(profile: Profile) { - const url = profile.origins[0]; - - try { - const data = await this.createBrowser({ url }); - - this.notify.toast({ - message: msg("Starting up browser with selected profile..."), - variant: "success", - icon: "check2-circle", - }); - - this.navigate.to( - `${this.navigate.orgBasePath}/browser-profiles/profile/browser/${ - data.browserid - }?${queryString.stringify({ - url, - name: profile.name, - description: profile.description, - profileId: profile.id, - crawlerChannel: profile.crawlerChannel, - })}`, - ); - } catch (e) { - this.notify.toast({ - message: msg("Sorry, couldn't create browser profile at this time."), - variant: "danger", - icon: "exclamation-octagon", - }); - } + this.selectedProfile = profile; + await this.updateComplete; + this.openDialog = "duplicate"; } private async deleteProfile(profile: Profile) { diff --git a/frontend/src/pages/org/browser-profiles-new.ts b/frontend/src/pages/org/browser-profiles-new.ts deleted file mode 100644 index 5ee7bc9bdc..0000000000 --- a/frontend/src/pages/org/browser-profiles-new.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { localized, msg, str } from "@lit/localize"; -import { html } from "lit"; -import { customElement, property, query, state } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; -import queryString from "query-string"; - -import { CrawlerChannelImage } from "./types"; - -import { BtrixElement } from "@/classes/BtrixElement"; -import type { Dialog } from "@/components/ui/dialog"; -import type { BrowserConnectionChange } from "@/features/browser-profiles/profile-browser"; -import { pageNav, type Breadcrumb } from "@/layouts/pageHeader"; -import { isApiError } from "@/utils/api"; - -/** - * Usage: - * ```ts - * - * ``` - */ -@customElement("btrix-browser-profiles-new") -@localized() -export class BrowserProfilesNew extends BtrixElement { - @property({ type: String }) - browserId!: string; - - @property({ type: Object, attribute: false }) - browserParams: { - name: string; - url: string; - description?: string; - crawlerChannel?: string; - profileId?: string | null; - navigateUrl?: string; - proxyId: string | null; - } = { - name: "", - url: "", - proxyId: null, - }; - - @state() - private isSubmitting = false; - - @state() - private isDialogVisible = false; - - @state() - private isBrowserLoaded = false; - - @query("#discardDialog") - private readonly discardDialog?: Dialog | null; - - disconnectedCallback(): void { - void this.closeBrowser(); - super.disconnectedCallback(); - } - - render() { - return html` -
    ${this.renderBreadcrumbs()}
    - -
    -

    - ${msg("New Browser Profile")} -

    -
    - -

    - ${msg( - "Workflows that use this browser profile will behave as if they have logged into the same websites and have the same web cookies.", - )} -
    - ${msg(html` - It is highly recommended to create dedicated accounts to use when - crawling. For details, refer to - - ${msg("browser profile best practices")}. - `)} -

    - - ${this.browserParams.profileId - ? html` -
    - ${msg( - html`Extending ${this.browserParams.name}`, - )} -
    - ` - : ""} - -
    - (this.isBrowserLoaded = true)} - @btrix-browser-reload=${this.onBrowserReload} - @btrix-browser-error=${this.onBrowserError} - @btrix-browser-connection-change=${this.onBrowserConnectionChange} - > - -
    - ${this.renderBrowserProfileControls()} -
    -
    - - (this.isDialogVisible = false)} - > - ${this.renderForm()} - - - - ${msg("Are you sure you want to cancel creating a browser profile?")} -
    - void this.discardDialog?.hide()} - > - ${msg("No, Continue Browsing")} - - { - void this.discardDialog?.hide(); - void this.closeBrowser(); - }} - >${msg("Yes, Cancel")} - -
    -
    - `; - } - - private renderBreadcrumbs() { - const breadcrumbs: Breadcrumb[] = [ - { - href: `${this.navigate.orgBasePath}/browser-profiles`, - content: msg("Browser Profiles"), - }, - ]; - - if (this.browserParams.profileId) { - breadcrumbs.push( - { - href: `${this.navigate.orgBasePath}/browser-profiles/profile/${this.browserParams.profileId}`, - content: this.browserParams.name - ? this.browserParams.name - : msg("Browser Profile"), - }, - { - content: msg("Duplicate Profile"), - }, - ); - } - - return pageNav(breadcrumbs); - } - - private async onBrowserError() { - this.isBrowserLoaded = false; - } - - private async onBrowserConnectionChange( - e: CustomEvent, - ) { - this.isBrowserLoaded = e.detail.connected; - } - - private onCancel() { - if (!this.isBrowserLoaded) { - void this.closeBrowser(); - } else { - void this.discardDialog?.show(); - } - } - - private async closeBrowser() { - this.isBrowserLoaded = false; - - if (this.browserId) { - await this.deleteBrowser(this.browserId); - } - this.navigate.to(`${this.navigate.orgBasePath}/browser-profiles`); - } - - private renderBrowserProfileControls() { - return html` -
    - - ${msg("Cancel")} - -
    - (this.isDialogVisible = true)} - > - ${msg("Save New Profile...")} - -
    -
    - `; - } - - private renderForm() { - return html`
    -
    - - - - -
    - (this.isDialogVisible = false)} - > - ${msg("Back")} - - - - ${msg("Save Profile")} - -
    -
    -
    `; - } - - private async onBrowserReload() { - const { url } = this.browserParams; - if (!url) { - console.debug("no start url"); - return; - } - - const crawlerChannel = - this.browserParams.crawlerChannel || CrawlerChannelImage.Default; - const proxyId = this.browserParams.proxyId; - const data = await this.createBrowser({ - url, - crawlerChannel, - proxyId, - }); - - this.navigate.to( - `${this.navigate.orgBasePath}/browser-profiles/profile/browser/${ - data.browserid - }?${queryString.stringify({ - url, - name: this.browserParams.name || msg("My Profile"), - crawlerChannel, - proxyId, - })}`, - ); - } - - private async onSubmit(event: SubmitEvent) { - event.preventDefault(); - this.isSubmitting = true; - - const formData = new FormData(event.target as HTMLFormElement); - const params = { - browserid: this.browserId, - name: formData.get("name"), - description: formData.get("description"), - crawlerChannel: this.browserParams.crawlerChannel, - proxyId: this.browserParams.proxyId, - }; - - try { - let data; - let retriesLeft = 300; - - while (retriesLeft > 0) { - data = await this.api.fetch<{ id?: string; detail?: string }>( - `/orgs/${this.orgId}/profiles`, - { - method: "POST", - body: JSON.stringify(params), - }, - ); - 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"); - } - - this.notify.toast({ - message: msg("Successfully created browser profile."), - variant: "success", - icon: "check2-circle", - id: "browser-profile-save-status", - }); - - this.navigate.to( - `${this.navigate.orgBasePath}/browser-profiles/profile/${data.id}`, - ); - } catch (e) { - console.debug(e); - - this.isSubmitting = false; - - let message = msg("Sorry, couldn't create 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 create browser profiles.", - ); - } - } - - this.notify.toast({ - message: message, - variant: "danger", - icon: "exclamation-octagon", - id: "browser-profile-save-status", - }); - } - } - 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), - }, - ); - } - - private async deleteBrowser(id: string) { - try { - const data = await this.api.fetch( - `/orgs/${this.orgId}/profiles/browser/${id}`, - { - method: "DELETE", - }, - ); - - return data; - } catch (e) { - // TODO Investigate DELETE returning 404 - console.debug(e); - } - } -} diff --git a/frontend/src/pages/org/browser-profiles/index.ts b/frontend/src/pages/org/browser-profiles/index.ts new file mode 100644 index 0000000000..be1f854d48 --- /dev/null +++ b/frontend/src/pages/org/browser-profiles/index.ts @@ -0,0 +1 @@ +import(/* webpackChunkName: "org" */ "./profile"); diff --git a/frontend/src/pages/org/browser-profiles/profile.ts b/frontend/src/pages/org/browser-profiles/profile.ts new file mode 100644 index 0000000000..3945384788 --- /dev/null +++ b/frontend/src/pages/org/browser-profiles/profile.ts @@ -0,0 +1,848 @@ +import { localized, msg } from "@lit/localize"; +import { Task } from "@lit/task"; +import type { SlMenuItem } from "@shoelace-style/shoelace"; +import { html, type TemplateResult } from "lit"; +import { customElement, property, 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 { + BtrixFilterChipChangeEvent, + FilterChip, +} from "@/components/ui/filter-chip"; +import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; +import { ClipboardController } from "@/controllers/clipboard"; +import { CrawlStatus } from "@/features/archived-items/crawl-status"; +import type { BtrixStartBrowserEvent } from "@/features/browser-profiles/start-browser-dialog"; +import { + badges, + badgesSkeleton, +} from "@/features/browser-profiles/templates/badges"; +import type { WorkflowColumnName } from "@/features/crawl-workflows/workflow-list"; +import { emptyMessage } from "@/layouts/emptyMessage"; +import { labelWithIcon } from "@/layouts/labelWithIcon"; +import { page } from "@/layouts/page"; +import { panel, panelBody } from "@/layouts/panel"; +import { OrgTab, WorkflowTab } from "@/routes"; +import { noData, stringFor } from "@/strings/ui"; +import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; +import type { Profile, Workflow } from "@/types/crawler"; +import type { CrawlState } from "@/types/crawlState"; +import { SortDirection } from "@/types/utils"; +import { isApiError } from "@/utils/api"; +import { settingsForDuplicate } from "@/utils/crawl-workflows/settingsForDuplicate"; +import { isNotEqual } from "@/utils/is-not-equal"; +import { isArchivingDisabled } from "@/utils/orgs"; +import { pluralOf } from "@/utils/pluralize"; +import { tw } from "@/utils/tailwind"; +import type { SectionsEnum } from "@/utils/workflow"; + +const INITIAL_PAGE_SIZE = 5; +const WORKFLOW_PAGE_QUERY = "workflowsPage"; + +const workflowColumns = [ + "name", + "latest-crawl", + "actions", +] as const satisfies WorkflowColumnName[]; + +@customElement("btrix-browser-profiles-profile-page") +@localized() +export class BrowserProfilesProfilePage extends BtrixElement { + @property({ type: String }) + profileId = ""; + + @state({ hasChanged: isNotEqual }) + private workflowParams: Required & { + lastCrawlState?: CrawlState[]; + } = { + page: parsePage( + new URLSearchParams(location.search).get(WORKFLOW_PAGE_QUERY), + ), + pageSize: INITIAL_PAGE_SIZE, + }; + + @state() + private openDialog?: + | "metadata" + | "metadata-name" + | "metadata-description" + | "start-browser" + | "start-browser-add-site" + | "browser" + | "duplicate"; + + @state() + private browserLoadUrl?: string; + + @state() + private browserLoadCrawlerChannel?: Profile["crawlerChannel"]; + + private get profile() { + return this.profileTask.value; + } + + private readonly profileTask = new Task(this, { + task: async ([profileId], { signal }) => { + const profile = await this.getProfile(profileId, signal); + + return profile; + }, + args: () => [this.profileId] as const, + }); + + private readonly workflowsTask = new Task(this, { + task: async ([profileId, workflowParams], { signal }) => { + return this.getWorkflows({ ...workflowParams, profileId }, signal); + }, + args: () => [this.profileId, this.workflowParams] as const, + }); + + render() { + const header = { + breadcrumbs: [ + { + href: `${this.navigate.orgBasePath}/${OrgTab.BrowserProfiles}`, + content: msg("Browser Profiles"), + }, + { + content: this.profile?.name, + }, + ], + title: this.profile?.name, + suffix: when( + this.profile && this.appState.isCrawler, + () => + html` + + `, + ), + secondary: when(this.profile, badges, badgesSkeleton), + actions: this.renderActions(), + } satisfies Parameters[0]; + + const duplicating = this.openDialog === "duplicate"; + + return html`${page(header, this.renderPage)} + + void this.profileTask.run()} + @sl-after-hide=${this.closeBrowser} + > + + + ${when( + this.profile, + (profile) => + html` (this.openDialog = undefined)} + @btrix-updated=${() => { + void this.profileTask.run(); + this.openDialog = undefined; + }} + > + `, + )} `; + } + + private renderActions() { + const archivingDisabled = isArchivingDisabled(this.org); + const isCrawler = this.appState.isCrawler; + const menuItemClick = (cb: () => void) => (e: MouseEvent) => { + if (e.defaultPrevented || (e.currentTarget as SlMenuItem).disabled) + return; + cb(); + }; + + return html` + + + ${msg("Actions")} + + + ${when( + isCrawler, + () => html` + (this.openDialog = "metadata")}> + + ${msg("Edit Metadata")} + + (this.openDialog = "start-browser"), + )} + > + + ${msg("Configure Profile")} + + void this.duplicateProfile())} + > + + ${msg("Duplicate Profile")} + + + + + ${msg("New Workflow with Profile")} + + + `, + )} + ClipboardController.copyToClipboard(this.profileId)} + > + + ${msg("Copy Profile ID")} + + ${when(isCrawler, () => { + const disabled = this.profile?.inUse; + return html` + + void this.deleteProfile())} + > + + ${msg("Delete Profile")} + + `; + })} + + + `; + } + + private readonly renderPage = () => { + return html` +
    +
    + ${this.renderProfile()} ${this.renderUsage()} +
    + +
    + ${this.renderOverview()} +
    +
    + `; + }; + + private renderProfile() { + const archivingDisabled = isArchivingDisabled(this.org); + const isCrawler = this.appState.isCrawler; + + return panel({ + heading: msg("Configured Sites"), + actions: isCrawler + ? html` + (this.openDialog = "start-browser")} + ?disabled=${archivingDisabled} + > + ` + : undefined, + body: html`${this.renderOrigins()} + ${when( + isCrawler, + () => html` + (this.openDialog = "start-browser-add-site")} + > + + ${msg("Add Site")} + `, + )} + ${when( + this.profileTask.value, + (profile) => html` + { + void this.openBrowser(e.detail); + }} + @sl-after-hide=${() => { + if (this.openDialog?.startsWith("start-browser")) { + this.openDialog = undefined; + } + }} + > + `, + )} `, + }); + } + + private renderOrigins() { + const originsSkeleton = () => + html`
    `; + + const origins = (profile: Profile) => + profile.origins.map( + (origin) => html` +
  • + + +
    + + + + + +
    +
  • + `, + ); + + return when( + this.profile, + (profile) => html` +
    +
      + ${origins(profile)} +
    +
    + `, + originsSkeleton, + ); + } + + private renderOverview() { + const none = html`${stringFor.none}`; + const profile = this.profile; + const modifiedByAnyDate = profile + ? [profile.modifiedCrawlDate, profile.modified, profile.created].reduce( + (a, b) => (b && a && b > a ? b : a), + profile.created, + ) + : null; + + return panel({ + heading: msg("Overview"), + actions: this.appState.isCrawler + ? html` + (this.openDialog = "metadata-description")} + > + ` + : undefined, + body: html` + + + ${this.renderDetail((profile) => + profile.description + ? html` + +
    ${profile.description}
    + ` + : none, + )} +
    + ${ + // + // ${this.renderDetail(() => html`${none}`)} + // + undefined + } +
    + + + + ${this.renderDetail((profile) => + this.localize.bytes(profile.resource?.size || 0), + )} + + + ${this.renderDetail( + (profile) => + `${this.localize.number(profile.origins.length)} ${pluralOf("domains", profile.origins.length)}`, + )} + + + ${this.renderDetail((profile) => + this.localize.relativeDate(modifiedByAnyDate || profile.created), + )} + + + ${this.renderDetail((profile) => { + let modifier: string | TemplateResult = + profile.createdByName || msg("User"); + + switch (modifiedByAnyDate) { + case profile.modifiedCrawlDate: + modifier = html` + ${msg("Workflow Crawl")} + `; + break; + case profile.modified: + modifier = profile.modifiedByName || modifier; + break; + default: + break; + } + + return modifier; + })} + + ${when( + this.profile, + (profile) => + modifiedByAnyDate === profile.modifiedCrawlDate + ? html` + ${profile.modifiedByName} + (${this.localize.relativeDate( + profile.modified || profile.created, + )}) + ` + : html` + ${this.localize.date(profile.created, { + dateStyle: "medium", + })} + `, + () => + html` + + + `, + )} + + + ${this.renderDetail((profile) => + profile.created + ? this.localize.date(profile.created, { + dateStyle: "medium", + }) + : noData, + )} + + + ${this.renderDetail((profile) => { + const isBackedUp = + profile.resource?.replicas && + profile.resource.replicas.length > 0; + return labelWithIcon({ + label: isBackedUp ? msg("Backed Up") : msg("Not Backed Up"), + icon: html``, + }); + })} + + + `, + }); + } + + private renderUsage() { + const workflowListSkeleton = () => + html``; + + return panel({ + heading: msg("Related Workflows"), + body: when( + this.profile, + (profile) => + profile.inUse + ? html` +
    + + + ${this.workflowsTask.value + ? `${this.localize.number(this.workflowsTask.value.total)} ${pluralOf("workflows", this.workflowsTask.value.total)}` + : html``} + + + ${profile.modifiedCrawlId && profile.modifiedCrawlDate + ? html` +
    + ${this.localize.relativeDate( + profile.modifiedCrawlDate, + )} + + + +
    + ` + : msg("Never")} +
    +
    +
    + + ${this.workflowsTask.render({ + initial: workflowListSkeleton, + pending: () => + this.workflowsTask.value + ? this.renderWorkflows(this.workflowsTask.value) + : workflowListSkeleton(), + complete: this.renderWorkflows, + })} + ` + : panelBody({ + content: emptyMessage({ + message: msg( + "This profile is not in use by any crawl workflows.", + ), + actions: html`
    + + + ${msg("Manage Workflows")} + + + + ${msg("New Workflow with Profile")} + +
    `, + }), + }), + workflowListSkeleton, + ), + }); + } + + private readonly renderWorkflows = ( + workflows: APIPaginatedList, + ) => { + const failedStates = [ + "failed", + "failed_not_logged_in", + ] satisfies CrawlState[]; + + return html` +
    +
    + + ${msg("Filter by:")} + + + + (failedStates as CrawlState[]).includes(state), + )} + @btrix-change=${(e: BtrixFilterChipChangeEvent) => { + const { checked } = e.target as FilterChip; + + this.workflowParams = { + ...this.workflowParams, + lastCrawlState: checked ? failedStates : undefined, + }; + }} + > + ${CrawlStatus.getContent({ state: "failed" }).label} + +
    +
    + + ${workflows.total + ? html` + + ${workflows.items.map( + (workflow) => + html` + + ${when( + this.appState.isCrawler, + () => html` + + + ${msg("Edit Workflow Settings")} + + + `, + )} + + + ${msg("Go to Workflow")} + + + `, + )} + + +
    + { + this.workflowParams = { + ...this.workflowParams, + page: e.detail.page, + }; + }} + > + +
    + ` + : emptyMessage({ + classNames: tw`border-y`, + message: msg("No matching workflows found."), + actions: html` + + (this.workflowParams = { + ...this.workflowParams, + lastCrawlState: undefined, + })} + > + + ${msg("Clear filters")} + `, + })} + `; + }; + + private readonly renderDetail = ( + render: (profile: Profile) => string | TemplateResult, + ) => + when( + this.profile, + render, + () => html``, + ); + + private async getFirstBrowserUrl() { + if (!this.profile) { + await this.profileTask.taskComplete; + } + + return this.profile?.origins[0]; + } + + private readonly newWorkflow = () => { + this.navigate.to( + `${this.navigate.orgBasePath}/${OrgTab.Workflows}/new#${"browserSettings" satisfies SectionsEnum}`, + settingsForDuplicate({ + workflow: { + profileid: this.profileId, + proxyId: this.profile?.proxyId, + crawlerChannel: this.profile?.crawlerChannel, + }, + }), + ); + + this.notify.toast({ + message: msg("Copied browser settings to new workflow."), + variant: "success", + icon: "check2-circle", + id: "workflow-copied-status", + duration: 8000, + }); + }; + + private readonly openBrowser = async (opts: { + url?: string; + crawlerChannel?: string; + }) => { + let { url } = opts; + + if (!url) { + url = await this.getFirstBrowserUrl(); + } + this.browserLoadUrl = url; + this.browserLoadCrawlerChannel = opts.crawlerChannel; + this.openDialog = "browser"; + }; + + private async getProfile(profileId: string, signal: AbortSignal) { + return await this.api.fetch( + `/orgs/${this.orgId}/profiles/${profileId}`, + { signal }, + ); + } + + private readonly closeBrowser = () => { + this.browserLoadUrl = undefined; + this.browserLoadCrawlerChannel = undefined; + this.openDialog = undefined; + }; + + private async duplicateProfile() { + this.browserLoadUrl = await this.getFirstBrowserUrl(); + this.openDialog = "duplicate"; + } + + private async deleteProfile() { + const name_of_browser_profile = this.profile?.name + ? html`${this.profile.name}` + : undefined; + + try { + await this.api.fetch( + `/orgs/${this.orgId}/profiles/${this.profileId}`, + { + method: "DELETE", + }, + ); + + this.notify.toast({ + message: name_of_browser_profile + ? msg(html`Deleted ${name_of_browser_profile}.`) + : msg("Browser profile deleted."), + variant: "success", + icon: "check2-circle", + id: "browser-profile-status", + }); + + this.navigate.to( + `${this.navigate.orgBasePath}/${OrgTab.BrowserProfiles}`, + ); + } catch (e) { + let title: string | undefined; + let message: string | TemplateResult = msg( + "Sorry, couldn't delete browser profile at this time.", + ); + + if (isApiError(e)) { + if (e.message === "profile_in_use") { + title = msg("Cannot delete browser profile in use"); + message = name_of_browser_profile + ? msg( + html`Please remove ${name_of_browser_profile} from all crawl + workflows to continue.`, + ) + : msg( + "Please remove this browser profile from all crawl workflows to continue.", + ); + } + } + + this.notify.toast({ + title, + message: message, + variant: "danger", + icon: "exclamation-octagon", + duration: 10000, + id: "browser-profile-status", + }); + } + } + + private async getWorkflows( + params: { profileId: string } & APIPaginationQuery, + signal: AbortSignal, + ) { + const query = queryString.stringify({ + ...params, + profileIds: [params.profileId], + sortBy: "lastRun", + sortDirection: SortDirection.Descending, + }); + + const data = await this.api.fetch>( + `/orgs/${this.orgId}/crawlconfigs?${query}`, + { signal }, + ); + + return data; + } +} diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index ea7e5a69a0..336eeeb88f 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -521,6 +521,7 @@ export class CollectionsList extends WithSearchOrgContext(BtrixElement) { this.isCrawler, () => emptyMessage({ + classNames: tw`border-y`, message, detail: msg( "Collections let you easily organize, replay, and share multiple crawls.", @@ -542,6 +543,7 @@ export class CollectionsList extends WithSearchOrgContext(BtrixElement) { }), () => emptyMessage({ + classNames: tw`border-y`, message, }), )} diff --git a/frontend/src/pages/org/dashboard.ts b/frontend/src/pages/org/dashboard.ts index 33f10c6899..1780b0541c 100644 --- a/frontend/src/pages/org/dashboard.ts +++ b/frontend/src/pages/org/dashboard.ts @@ -160,7 +160,7 @@ export class Dashboard extends BtrixElement { class="flex items-center gap-1.5 text-pretty text-neutral-700" > @@ -300,9 +300,7 @@ export class Dashboard extends BtrixElement { ? this.renderMiscStorage(metrics) : nothing} - + ${this.renderStat({ value: metrics.archivedItemCount, singleLabel: msg("Archived Item"), @@ -364,9 +362,7 @@ export class Dashboard extends BtrixElement { class: tw`text-violet-600`, }, })} - + ${this.renderStat({ value: metrics.crawlPageCount, singleLabel: msg("Page Crawled"), diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index a02da08ed8..7ecb624afd 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -54,14 +54,13 @@ import "./archived-item-detail"; import "./archived-items"; import "./collections-list"; import "./collection-detail"; -import "./browser-profiles-detail"; import "./browser-profiles-list"; import "./settings/settings"; import "./dashboard"; +import "./browser-profiles"; import(/* webpackChunkName: "org" */ "./archived-item-qa/archived-item-qa"); import(/* webpackChunkName: "org" */ "./workflows-new"); -import(/* webpackChunkName: "org" */ "./browser-profiles-new"); const RESOURCE_NAMES = ["workflow", "collection", "browser-profile", "upload"]; type ResourceName = (typeof RESOURCE_NAMES)[number]; @@ -86,15 +85,12 @@ export type OrgParams = { qaTab?: QATab; }; [OrgTab.BrowserProfiles]: { - browserProfileId?: string; + profileId?: string; browserId?: string; new?: ResourceName; name?: string; url?: string; - description?: string; crawlerChannel?: string; - profileId?: string; - navigateUrl?: string; proxyId?: string; }; [OrgTab.Collections]: ArchivedItemPageParams & { @@ -469,7 +465,7 @@ export class Org extends BtrixElement { const org = this.org; const proxies = this.proxiesTask.value; const crawlerChannels = this.crawlerChannelsTask.value; - const showBrowserProfileDialog = org && proxies && crawlerChannels; + const crawlingDefaultsReady = org && proxies && crawlerChannels; return html`
    - ${showBrowserProfileDialog + ${crawlingDefaultsReady ? html` (this.openDialogName = undefined)} > @@ -646,26 +635,10 @@ export class Org extends BtrixElement { private readonly renderBrowserProfiles = () => { const params = this.params as OrgParams["browser-profiles"]; - if (params.browserProfileId) { - return html``; - } - - if (params.browserId) { - return html``; + if (params.profileId) { + return html``; } return html` diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 3dabc5eda2..00b71f34af 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -46,7 +46,7 @@ export const ROUTES = { `(/${OrgTab.Workflows}(/${WorkflowTab.Crawls})(/${CommonTab.New})(/:workflowId(/:workflowTab)(/${WorkflowTab.Crawls}${archivedItemPath})))`, `(/${OrgTab.Items}(/:itemType(${archivedItemPath})))`, `(/${OrgTab.Collections}(/${CommonTab.New})(/${CommonTab.View}/:collectionId(/:collectionTab)))`, - `(/${OrgTab.BrowserProfiles}(/profile(/browser/:browserId)(/:browserProfileId)))`, + `(/${OrgTab.BrowserProfiles}(/profile(/:profileId)(/browser/:browserId)))`, `(/${OrgTab.Settings}(/:settingsTab))`, ].join(""), publicOrgs: `/${RouteNamespace.PublicOrgs}(/)`, diff --git a/frontend/src/stories/components/Code.ts b/frontend/src/stories/components/Code.ts index d57d13d828..2a768c69fc 100644 --- a/frontend/src/stories/components/Code.ts +++ b/frontend/src/stories/components/Code.ts @@ -9,12 +9,16 @@ type Language = `${Code["language"]}`; export type RenderProps = Omit & { language: Language }; -export const renderCode = ({ language, value, wrap }: Partial) => { +export const renderCode = ({ + language, + value, + noWrap, +}: Partial) => { return html` `; diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index a761fdf36d..beb5a3c7c1 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -122,6 +122,12 @@ } @layer components { + sl-skeleton { + --border-radius: var(--sl-border-radius-small); + --color: var(--sl-color-neutral-50); + --sheen-color: var(--sl-color-neutral-100); + } + sl-avatar::part(base) { transition: var(--sl-transition-x-fast) background-color; } diff --git a/frontend/src/types/events.d.ts b/frontend/src/types/events.d.ts index 7b08caaa2d..fda2b2c6c4 100644 --- a/frontend/src/types/events.d.ts +++ b/frontend/src/types/events.d.ts @@ -2,7 +2,6 @@ import { type APIEventMap } from "@/controllers/api"; import { type CopiedEventMap } from "@/controllers/clipboard"; import { type NavigateEventMap } from "@/controllers/navigate"; import { type NotifyEventMap } from "@/controllers/notify"; -import { type UserGuideEventMap } from "@/index"; import { type AuthEventMap } from "@/utils/AuthService"; /** @@ -15,6 +14,5 @@ declare global { NotifyEventMap, AuthEventMap, APIEventMap, - UserGuideEventMap, CopiedEventMap {} } diff --git a/frontend/src/utils/crawl-workflows/settingsForDuplicate.ts b/frontend/src/utils/crawl-workflows/settingsForDuplicate.ts index f664f62845..5ecf3b9247 100644 --- a/frontend/src/utils/crawl-workflows/settingsForDuplicate.ts +++ b/frontend/src/utils/crawl-workflows/settingsForDuplicate.ts @@ -16,7 +16,7 @@ import { } from "@/types/workflow"; export type DuplicateWorkflowSettings = { - workflow: WorkflowParams; + workflow: Partial; scopeType?: ScopeType | NewWorkflowOnlyScopeType; seeds?: Seed[]; seedFile?: StorageSeedFile; @@ -27,11 +27,11 @@ export function settingsForDuplicate({ seeds, seedFile, }: { - workflow: Workflow; + workflow: Partial; seeds?: APIPaginatedList; seedFile?: StorageSeedFile; }): DuplicateWorkflowSettings { - const workflowParams: WorkflowParams = { + const workflowParams: Partial = { ...workflow, name: workflow.name ? msg(str`${workflow.name} Copy`) : "", }; @@ -42,7 +42,7 @@ export function settingsForDuplicate({ scopeType: seedFile || (seedItems?.length && seedItems.length > 1) ? NewWorkflowOnlyScopeType.PageList - : workflowParams.config.scopeType, + : workflowParams.config?.scopeType, workflow: workflowParams, seeds: seedItems, seedFile, diff --git a/frontend/src/utils/pluralize.ts b/frontend/src/utils/pluralize.ts index 08862130c8..83afe273c2 100644 --- a/frontend/src/utils/pluralize.ts +++ b/frontend/src/utils/pluralize.ts @@ -299,6 +299,58 @@ const plurals = { id: "changes.plural.other", }), }, + workflows: { + zero: msg("workflows", { + desc: 'plural form of "workflow" for zero workflows', + id: "workflows.plural.zero", + }), + one: msg("workflow", { + desc: 'singular form for "workflow"', + id: "workflows.plural.one", + }), + two: msg("workflows", { + desc: 'plural form of "workflow" for two workflows', + id: "workflows.plural.two", + }), + few: msg("workflows", { + desc: 'plural form of "workflow" for few workflows', + id: "workflows.plural.few", + }), + many: msg("workflows", { + desc: 'plural form of "workflow" for many workflows', + id: "workflows.plural.many", + }), + other: msg("workflows", { + desc: 'plural form of "workflow" for multiple/other workflows', + id: "workflows.plural.other", + }), + }, + domains: { + zero: msg("domains", { + desc: 'plural form of "domain" for zero domains', + id: "domains.plural.zero", + }), + one: msg("domain", { + desc: 'singular form for "domain"', + id: "domains.plural.one", + }), + two: msg("domains", { + desc: 'plural form of "domain" for two domains', + id: "domains.plural.two", + }), + few: msg("domains", { + desc: 'plural form of "domain" for few domains', + id: "domains.plural.few", + }), + many: msg("domains", { + desc: 'plural form of "domain" for many domains', + id: "domains.plural.many", + }), + other: msg("domains", { + desc: 'plural form of "domain" for multiple/other domains', + id: "domains.plural.other", + }), + }, }; export const pluralOf = (word: keyof typeof plurals, count: number) => { diff --git a/frontend/src/utils/workflow.ts b/frontend/src/utils/workflow.ts index f2cbb7ebd1..14621dbbef 100644 --- a/frontend/src/utils/workflow.ts +++ b/frontend/src/utils/workflow.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { getAppSettings, type AppSettings } from "./app"; import type { Tags } from "@/components/ui/tag-input"; -import type { UserGuideEventMap } from "@/index"; +import type { BtrixUserGuideShowEvent } from "@/events/btrix-user-guide-show"; import { Behavior, CrawlerChannelImage, @@ -73,12 +73,12 @@ export const workflowTabToGuideHash: Record = { export function makeUserGuideEvent( section: SectionsEnum, -): UserGuideEventMap["btrix-user-guide-show"] { +): BtrixUserGuideShowEvent { const userGuideHash = (workflowTabToGuideHash[section] as GuideHash | undefined) || GuideHash.Scope; - return new CustomEvent( + return new CustomEvent( "btrix-user-guide-show", { detail: { From 4db1e9d3db6a5c870264b280333d9c23af99a239 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Thu, 13 Nov 2025 11:16:16 -0800 Subject: [PATCH 04/12] feat: Add tags to profile + consolidate tag filter (#2976) - Allows users to set tags on browser profiles - Allows users to filter browser profiles by tag - Refactors `workflow-tag-filter` and `archived-item-tag-filter` into single `tag-filter` --- frontend/src/components/ui/index.ts | 3 +- .../src/components/ui/tag-filter/index.ts | 1 + .../ui/tag-filter/tag-filter.ts} | 79 +++-- .../src/components/ui/tag-filter/types.ts | 24 ++ frontend/src/components/ui/tag-input.ts | 4 + .../features/archived-items/file-uploader.ts | 8 +- frontend/src/features/archived-items/index.ts | 1 - .../archived-items/item-metadata-editor.ts | 8 +- .../profile-metadata-dialog.ts | 24 +- .../src/features/crawl-workflows/index.ts | 1 - .../crawl-workflows/workflow-editor.ts | 9 +- .../crawl-workflows/workflow-tag-filter.ts | 313 ------------------ frontend/src/pages/org/archived-items.ts | 14 +- .../src/pages/org/browser-profiles-list.ts | 158 +++++++-- .../src/pages/org/browser-profiles/profile.ts | 49 ++- frontend/src/pages/org/crawls.ts | 11 +- frontend/src/pages/org/workflows-list.ts | 9 +- .../stories/components/FilterChip.stories.ts | 2 +- frontend/src/types/crawler.ts | 1 + frontend/src/types/workflow.ts | 11 +- 20 files changed, 297 insertions(+), 433 deletions(-) create mode 100644 frontend/src/components/ui/tag-filter/index.ts rename frontend/src/{features/archived-items/archived-item-tag-filter.ts => components/ui/tag-filter/tag-filter.ts} (88%) create mode 100644 frontend/src/components/ui/tag-filter/types.ts delete mode 100644 frontend/src/features/crawl-workflows/workflow-tag-filter.ts diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 089b259263..9c631de3a0 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -41,7 +41,8 @@ import("./select-crawler-proxy"); import("./select-crawler"); import("./syntax-input"); import("./table"); -import("./tag-input"); import("./tag"); +import("./tag-filter"); +import("./tag-input"); import("./time-input"); import("./user-language-select"); diff --git a/frontend/src/components/ui/tag-filter/index.ts b/frontend/src/components/ui/tag-filter/index.ts new file mode 100644 index 0000000000..2c16877957 --- /dev/null +++ b/frontend/src/components/ui/tag-filter/index.ts @@ -0,0 +1 @@ +import "./tag-filter"; diff --git a/frontend/src/features/archived-items/archived-item-tag-filter.ts b/frontend/src/components/ui/tag-filter/tag-filter.ts similarity index 88% rename from frontend/src/features/archived-items/archived-item-tag-filter.ts rename to frontend/src/components/ui/tag-filter/tag-filter.ts index dc6bf82146..87c2f25796 100644 --- a/frontend/src/features/archived-items/archived-item-tag-filter.ts +++ b/frontend/src/components/ui/tag-filter/tag-filter.ts @@ -20,37 +20,39 @@ import { repeat } from "lit/directives/repeat.js"; import queryString from "query-string"; import { isFocusable } from "tabbable"; +import type { + BtrixChangeTagFilterEvent, + TagCount, + TagCounts, + TagType, +} from "./types"; + import { BtrixElement } from "@/classes/BtrixElement"; -import type { BtrixChangeEvent } from "@/events/btrix-change"; -import type { ArchivedItem } from "@/types/crawler"; -import { type WorkflowTag, type WorkflowTags } from "@/types/workflow"; import { stopProp } from "@/utils/events"; import { isNotEqual } from "@/utils/is-not-equal"; import { tw } from "@/utils/tailwind"; const MAX_TAGS_IN_LABEL = 5; - -type ChangeArchivedItemTagEventDetails = - | { tags: string[]; type: "and" | "or" } - | undefined; - -export type BtrixChangeArchivedItemTagFilterEvent = - BtrixChangeEvent; +const apiPathForTagType: Record = { + workflow: "crawlconfigs", + "workflow-crawl": "crawls", + "archived-item": "all-crawls", + "archived-item-crawl": "crawls", + upload: "uploads", + profile: "profiles", +}; /** * @fires btrix-change */ -@customElement("btrix-archived-item-tag-filter") +@customElement("btrix-tag-filter") @localized() -export class ArchivedItemTagFilter extends BtrixElement { - @property({ type: Array }) - tags?: string[]; - +export class TagFilter extends BtrixElement { @property({ type: String }) - itemType?: ArchivedItem["type"]; + tagType?: TagType; - @property({ type: Boolean }) - includeNotSuccessful = false; + @property({ type: Array }) + tags?: string[]; @state() private searchString = ""; @@ -61,7 +63,7 @@ export class ArchivedItemTagFilter extends BtrixElement { @queryAll("sl-checkbox") private readonly checkboxes!: NodeListOf; - private readonly fuse = new Fuse([], { + private readonly fuse = new Fuse([], { keys: ["tag"], }); @@ -82,14 +84,22 @@ export class ArchivedItemTagFilter extends BtrixElement { } private readonly orgTagsTask = new Task(this, { - task: async ([itemType], { signal }) => { - const query = queryString.stringify({ - onlySuccessful: !this.includeNotSuccessful, - crawlType: itemType, - }); - - const { tags } = await this.api.fetch( - `/orgs/${this.orgId}/all-crawls/tagCounts?${query}`, + task: async ([tagType], { signal }) => { + if (!tagType) { + console.debug("no tagType"); + return; + } + + let query = ""; + + if (tagType === "workflow-crawl") { + query = queryString.stringify({ + onlySuccessful: false, + }); + } + + const { tags } = await this.api.fetch( + `/orgs/${this.orgId}/${apiPathForTagType[tagType]}/tagCounts${query && `?${query}`}`, { signal }, ); @@ -98,7 +108,7 @@ export class ArchivedItemTagFilter extends BtrixElement { // Match fuse shape return tags.map((item) => ({ item })); }, - args: () => [this.itemType] as const, + args: () => [this.tagType] as const, }); render() { @@ -149,9 +159,8 @@ export class ArchivedItemTagFilter extends BtrixElement { this.checkboxes.forEach((checkbox) => { checkbox.checked = false; }); - + this.selected = new Map(); this.type = "or"; - void this.dispatchChange(); }} >${msg("Clear")} { + if (!tags) return; + let options = tags; if (tags.length && this.searchString) { @@ -267,8 +278,8 @@ export class ArchivedItemTagFilter extends BtrixElement { `; } - private renderList(opts: { item: WorkflowTag }[]) { - const tag = (tag: WorkflowTag) => { + private renderList(opts: { item: TagCount }[]) { + const tag = (tag: TagCount) => { const checked = this.selected.get(tag.tag) === true; return html` @@ -314,9 +325,7 @@ export class ArchivedItemTagFilter extends BtrixElement { .filter(([_tag, selected]) => selected) .map(([tag]) => tag); this.dispatchEvent( - new CustomEvent< - BtrixChangeEvent["detail"] - >("btrix-change", { + new CustomEvent("btrix-change", { detail: { value: selectedTags.length ? { tags: selectedTags, type: this.type } diff --git a/frontend/src/components/ui/tag-filter/types.ts b/frontend/src/components/ui/tag-filter/types.ts new file mode 100644 index 0000000000..5e9933bd46 --- /dev/null +++ b/frontend/src/components/ui/tag-filter/types.ts @@ -0,0 +1,24 @@ +import type { BtrixChangeEvent } from "@/events/btrix-change"; + +export type TagType = + | "workflow" + | "workflow-crawl" + | "archived-item" + | "archived-item-crawl" + | "upload" + | "profile"; + +export type TagCount = { + tag: string; + count: number; +}; + +export type TagCounts = { + tags: TagCount[]; +}; + +export type ChangeTagEventDetails = + | { tags: string[]; type: "and" | "or" } + | undefined; + +export type BtrixChangeTagFilterEvent = BtrixChangeEvent; diff --git a/frontend/src/components/ui/tag-input.ts b/frontend/src/components/ui/tag-input.ts index 45b9da44c0..9540b1993e 100644 --- a/frontend/src/components/ui/tag-input.ts +++ b/frontend/src/components/ui/tag-input.ts @@ -150,6 +150,10 @@ export class TagInput extends LitElement { @query("sl-popup") private readonly combobox!: SlPopup; + public getTags() { + return this.tags; + } + connectedCallback() { if (this.initialTags) { this.tags = this.initialTags; diff --git a/frontend/src/features/archived-items/file-uploader.ts b/frontend/src/features/archived-items/file-uploader.ts index 7605c2d5fb..ea1892ef16 100644 --- a/frontend/src/features/archived-items/file-uploader.ts +++ b/frontend/src/features/archived-items/file-uploader.ts @@ -11,13 +11,13 @@ import queryString from "query-string"; import { BtrixElement } from "@/classes/BtrixElement"; import type { FileRemoveEvent } from "@/components/ui/file-list"; import type { BtrixFileChangeEvent } from "@/components/ui/file-list/events"; +import type { TagCount, TagCounts } from "@/components/ui/tag-filter/types"; import type { TagInputEvent, Tags, TagsChangeEvent, } from "@/components/ui/tag-input"; import { type CollectionsChangeEvent } from "@/features/collections/collections-add"; -import { type WorkflowTag, type WorkflowTags } from "@/types/workflow"; import { APIError } from "@/utils/api"; import { maxLengthValidator } from "@/utils/form"; @@ -71,7 +71,7 @@ export class FileUploader extends BtrixElement { private collectionIds: string[] = []; @state() - private tagOptions: WorkflowTag[] = []; + private tagOptions: TagCount[] = []; @state() private tagsToSave: Tags = []; @@ -86,7 +86,7 @@ export class FileUploader extends BtrixElement { private readonly form!: Promise; // 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 @@ -362,7 +362,7 @@ export class FileUploader extends BtrixElement { private async fetchTags() { try { - const { tags } = await this.api.fetch( + const { tags } = await this.api.fetch( `/orgs/${this.orgId}/crawlconfigs/tagCounts`, ); diff --git a/frontend/src/features/archived-items/index.ts b/frontend/src/features/archived-items/index.ts index 4cacf68acb..c6e1989cb2 100644 --- a/frontend/src/features/archived-items/index.ts +++ b/frontend/src/features/archived-items/index.ts @@ -1,6 +1,5 @@ import("./archived-item-list"); import("./archived-item-state-filter"); -import("./archived-item-tag-filter"); import("./crawl-log-table"); import("./crawl-logs"); import("./delete-item-dialog"); diff --git a/frontend/src/features/archived-items/item-metadata-editor.ts b/frontend/src/features/archived-items/item-metadata-editor.ts index fc067418e4..7bd62740ce 100644 --- a/frontend/src/features/archived-items/item-metadata-editor.ts +++ b/frontend/src/features/archived-items/item-metadata-editor.ts @@ -7,6 +7,7 @@ import { customElement, property, query, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; import { BtrixElement } from "@/classes/BtrixElement"; +import type { TagCount, TagCounts } from "@/components/ui/tag-filter/types"; import type { TagInputEvent, Tags, @@ -17,7 +18,6 @@ import type { CollectionsChangeEvent, } from "@/features/collections/collections-add"; import type { ArchivedItem } from "@/types/crawler"; -import { type WorkflowTag, type WorkflowTags } from "@/types/workflow"; import { isSuccessfullyFinished } from "@/utils/crawler"; import { maxLengthValidator } from "@/utils/form"; @@ -54,7 +54,7 @@ export class CrawlMetadataEditor extends BtrixElement { private includeName = false; @state() - private tagOptions: WorkflowTag[] = []; + private tagOptions: TagCount[] = []; @state() private tagsToSave: Tags = []; @@ -69,7 +69,7 @@ export class CrawlMetadataEditor extends BtrixElement { public readonly collectionInput?: CollectionsAdd | null; // 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 @@ -191,7 +191,7 @@ export class CrawlMetadataEditor extends BtrixElement { private async fetchTags() { if (!this.crawl) return; try { - const { tags } = await this.api.fetch( + const { tags } = await this.api.fetch( `/orgs/${this.crawl.oid}/crawlconfigs/tagCounts`, ); diff --git a/frontend/src/features/browser-profiles/profile-metadata-dialog.ts b/frontend/src/features/browser-profiles/profile-metadata-dialog.ts index b331d1ff4a..d8eefe6380 100644 --- a/frontend/src/features/browser-profiles/profile-metadata-dialog.ts +++ b/frontend/src/features/browser-profiles/profile-metadata-dialog.ts @@ -7,6 +7,7 @@ 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"; @@ -42,6 +43,9 @@ export class ProfileMetadataDialog extends BtrixElement { @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); @@ -53,11 +57,15 @@ export class ProfileMetadataDialog extends BtrixElement { return; } - const params = serialize(this.form) as { + 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}`, @@ -170,16 +178,10 @@ export class ProfileMetadataDialog extends BtrixElement { @sl-input=${this.validateDescriptionMax.validate} > - ${ - // - undefined - } +
    ([], { + private readonly fuse = new Fuse([], { keys: ["tag"], shouldSort: false, threshold: 0.2, // stricter; default is 0.6 @@ -3197,7 +3196,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`, ); diff --git a/frontend/src/features/crawl-workflows/workflow-tag-filter.ts b/frontend/src/features/crawl-workflows/workflow-tag-filter.ts deleted file mode 100644 index 8ed3a974fe..0000000000 --- a/frontend/src/features/crawl-workflows/workflow-tag-filter.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { localized, msg, str } from "@lit/localize"; -import { Task } from "@lit/task"; -import type { - SlChangeEvent, - SlCheckbox, - SlInput, - SlInputEvent, -} from "@shoelace-style/shoelace"; -import clsx from "clsx"; -import Fuse from "fuse.js"; -import { html, nothing, type PropertyValues } from "lit"; -import { - customElement, - property, - query, - queryAll, - state, -} from "lit/decorators.js"; -import { repeat } from "lit/directives/repeat.js"; -import { isFocusable } from "tabbable"; - -import { BtrixElement } from "@/classes/BtrixElement"; -import type { BtrixChangeEvent } from "@/events/btrix-change"; -import { type WorkflowTag, type WorkflowTags } from "@/types/workflow"; -import { stopProp } from "@/utils/events"; -import { isNotEqual } from "@/utils/is-not-equal"; -import { tw } from "@/utils/tailwind"; - -const MAX_TAGS_IN_LABEL = 5; - -type ChangeWorkflowTagEventDetails = - | { tags: string[]; type: "and" | "or" } - | undefined; - -export type BtrixChangeWorkflowTagFilterEvent = - BtrixChangeEvent; - -/** - * @fires btrix-change - */ -@customElement("btrix-workflow-tag-filter") -@localized() -export class WorkflowTagFilter extends BtrixElement { - @property({ type: Array }) - tags?: string[]; - - @state() - private searchString = ""; - - @query("sl-input") - private readonly input?: SlInput | null; - - @queryAll("sl-checkbox") - private readonly checkboxes!: NodeListOf; - - private readonly fuse = new Fuse([], { - keys: ["tag"], - }); - - @state({ hasChanged: isNotEqual }) - selected = new Map(); - - @state() - type: "and" | "or" = "or"; - - protected willUpdate(changedProperties: PropertyValues): void { - if (changedProperties.has("tags")) { - if (this.tags) { - this.selected = new Map(this.tags.map((tag) => [tag, true])); - } else if (changedProperties.get("tags")) { - this.selected = new Map(); - } - } - } - - private readonly orgTagsTask = new Task(this, { - task: async () => { - const { tags } = await this.api.fetch( - `/orgs/${this.orgId}/crawlconfigs/tagCounts`, - ); - - this.fuse.setCollection(tags); - - // Match fuse shape - return tags.map((item) => ({ item })); - }, - args: () => [] as const, - }); - - render() { - return html` - { - if (this.input && !this.input.disabled) { - this.input.focus(); - } - }} - @sl-after-hide=${() => { - this.searchString = ""; - }} - > - ${this.tags?.length - ? html`${msg("Tagged")} - ${this.renderTagsInLabel(this.tags)}` - : msg("Tags")} - -
    -
    - -
    - ${msg("Filter by Tags")} -
    - ${this.tags?.length - ? html` { - this.checkboxes.forEach((checkbox) => { - checkbox.checked = false; - }); - this.selected = new Map(); - this.type = "or"; - void this.dispatchChange(); - }} - >${msg("Clear")}` - : nothing} -
    - -
    - ${this.renderSearch()} - { - this.type = (event.target as HTMLInputElement).value as - | "or" - | "and"; - void this.dispatchChange(); - }} - @sl-after-hide=${stopProp} - > - - - - ${msg("Any")} - - - - - - ${msg("All")} - - - -
    -
    - - ${this.orgTagsTask.render({ - complete: (tags) => { - let options = tags; - - if (tags.length && this.searchString) { - options = this.fuse.search(this.searchString); - } - - if (options.length) { - return this.renderList(options); - } - - return html`
    - ${this.searchString - ? msg("No matching tags found.") - : msg("No tags found.")} -
    `; - }, - })} -
    -
    - `; - } - - private renderTagsInLabel(tags: string[]) { - const formatter2 = this.localize.list( - tags.length > MAX_TAGS_IN_LABEL - ? [ - ...tags.slice(0, MAX_TAGS_IN_LABEL), - msg( - str`${this.localize.number(tags.length - MAX_TAGS_IN_LABEL)} more`, - ), - ] - : tags, - { type: this.type === "and" ? "conjunction" : "disjunction" }, - ); - - return formatter2.map((part, index, array) => - part.type === "literal" - ? html`${part.value}` - : tags.length > MAX_TAGS_IN_LABEL && index === array.length - 1 - ? html` ${part.value} ` - : html`${part.value}`, - ); - } - - private renderSearch() { - return html` - - - (this.searchString = (e.target as SlInput).value)} - @keydown=${(e: KeyboardEvent) => { - // Prevent moving to next tabbable element since dropdown should close - if (e.key === "Tab") e.preventDefault(); - if (e.key === "ArrowDown" && isFocusable(this.checkboxes[0])) { - this.checkboxes[0].focus(); - } - }} - > - ${this.orgTagsTask.render({ - pending: () => html``, - complete: () => html``, - })} - - `; - } - - private renderList(opts: { item: WorkflowTag }[]) { - const tag = (tag: WorkflowTag) => { - const checked = this.selected.get(tag.tag) === true; - - return html` -
  • - ${tag.tag} - ${tag.count} - -
  • - `; - }; - - return html` -
      { - const { checked, value } = e.target as SlCheckbox; - - this.selected = new Map([...this.selected, [value, checked]]); - void this.dispatchChange(); - }} - > - ${repeat( - opts, - ({ item }) => item, - ({ item }) => tag(item), - )} -
    - `; - } - - private async dispatchChange() { - await this.updateComplete; - - const selectedTags = Array.from(this.selected.entries()) - .filter(([_tag, selected]) => selected) - .map(([tag]) => tag); - this.dispatchEvent( - new CustomEvent< - BtrixChangeEvent["detail"] - >("btrix-change", { - detail: { - value: selectedTags.length - ? { tags: selectedTags, type: this.type } - : undefined, - }, - }), - ); - } -} diff --git a/frontend/src/pages/org/archived-items.ts b/frontend/src/pages/org/archived-items.ts index b57d11133e..03e1e2d27b 100644 --- a/frontend/src/pages/org/archived-items.ts +++ b/frontend/src/pages/org/archived-items.ts @@ -20,10 +20,10 @@ import { type PageChangeEvent, type Pagination, } from "@/components/ui/pagination"; +import type { BtrixChangeTagFilterEvent } from "@/components/ui/tag-filter/types"; import { ClipboardController } from "@/controllers/clipboard"; import { SearchParamsValue } from "@/controllers/searchParamsValue"; import { type BtrixChangeArchivedItemStateFilterEvent } from "@/features/archived-items/archived-item-state-filter"; -import { type BtrixChangeArchivedItemTagFilterEvent } from "@/features/archived-items/archived-item-tag-filter"; import { CrawlStatus } from "@/features/archived-items/crawl-status"; import { pageHeader } from "@/layouts/pageHeader"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; @@ -618,14 +618,18 @@ export class CrawlsList extends BtrixElement { }} > - { + @btrix-change=${(e: BtrixChangeTagFilterEvent) => { this.filterByTags.setValue(e.detail.value?.tags || []); this.filterByTagsType.setValue(e.detail.value?.type || "or"); }} - > + > ${this.userInfo?.id ? html`( + this, + (value, params) => { + params.delete("tags"); + value?.forEach((v) => { + params.append("tags", v); + }); + return params; + }, + (params) => params.getAll("tags"), + ); + + private readonly filterByTagsType = new SearchParamsValue<"and" | "or">( + this, + (value, params) => { + if (value === "and") { + params.set("tagsType", value); + } else { + params.delete("tagsType"); + } + return params; + }, + (params) => (params.get("tagsType") === "and" ? "and" : "or"), + ); + private readonly filterByCurrentUser = new SearchParamsValue( this, (value, params) => { @@ -142,16 +169,39 @@ export class BrowserProfilesList extends BtrixElement { }, ); + private get hasFiltersSet() { + return [ + this.filterByCurrentUser.value || undefined, + this.filterByTags.value?.length || undefined, + ].some((v) => v !== undefined); + } + get isCrawler() { return this.appState.isCrawler; } + private clearFilters() { + this.filterByCurrentUser.setValue(false); + this.filterByTags.setValue([]); + } + private readonly profilesTask = new Task(this, { - task: async ([pagination, orderBy, filterByCurrentUser], { signal }) => { + task: async ( + [ + pagination, + orderBy, + filterByCurrentUser, + filterByTags, + filterByTagsType, + ], + { signal }, + ) => { return this.getProfiles( { ...pagination, userid: filterByCurrentUser ? this.userInfo?.id : undefined, + tags: filterByTags, + tagMatch: filterByTagsType, sortBy: orderBy.field, sortDirection: orderBy.direction === "desc" @@ -166,9 +216,32 @@ export class BrowserProfilesList extends BtrixElement { this.pagination, this.orderBy.value, this.filterByCurrentUser.value, + this.filterByTags.value, + this.filterByTagsType.value, ] as const, }); + protected willUpdate(changedProperties: PropertyValues): void { + if ( + changedProperties.has("orderBy.internalValue") || + changedProperties.has("filterByCurrentUser.internalValue") || + changedProperties.has("filterByTags.internalValue") || + changedProperties.has("filterByTagsType.internalValue") + ) { + this.pagination = { + ...this.pagination, + page: 1, + }; + } + + if (changedProperties.has("filterByCurrentUser.internalValue")) { + window.sessionStorage.setItem( + FILTER_BY_CURRENT_USER_STORAGE_KEY, + this.filterByCurrentUser.value.toString(), + ); + } + } + render() { return page( { @@ -261,6 +334,16 @@ export class BrowserProfilesList extends BtrixElement { }; private renderEmpty() { + if (this.hasFiltersSet) { + return emptyMessage({ + message: msg("No matching profiles found."), + actions: html` + + ${msg("Create filters")} + `, + }); + } + const message = msg("Your org doesn’t have any browser profiles yet."); if (this.isCrawler) { @@ -299,7 +382,7 @@ export class BrowserProfilesList extends BtrixElement { ${msg("Filter by:")} - ${this.renderFilterControl()} + ${this.renderFilterControls()}
    @@ -312,8 +395,18 @@ export class BrowserProfilesList extends BtrixElement { `; } - private renderFilterControl() { + private renderFilterControls() { return html` + { + this.filterByTags.setValue(e.detail.value?.tags || []); + this.filterByTagsType.setValue(e.detail.value?.type || "or"); + }} + > + { @@ -323,6 +416,21 @@ export class BrowserProfilesList extends BtrixElement { > ${msg("Mine")} + + ${when( + this.hasFiltersSet, + () => html` + + + ${msg("Clear All")} + + `, + )} `; } @@ -387,6 +495,7 @@ export class BrowserProfilesList extends BtrixElement { ${msg("Status")} ${msg("Name")} + ${msg("Tags")} ${msg("Configured Sites")} @@ -414,6 +523,8 @@ export class BrowserProfilesList extends BtrixElement { ) || data.created; const startingUrl = data.origins[0]; const otherOrigins = data.origins.slice(1); + const firstTags = data.tags.slice(0, MAX_TAGS); + const otherTags = data.tags.slice(MAX_TAGS); return html` ${data.name} + +
    + ${firstTags.map((tag) => html`${tag}`)} +
    + ${otherTags.length + ? html` + +${this.localize.number(otherTags.length)} +
    + ${otherTags.map((tag) => html`${tag}`)} +
    +
    ` + : nothing} +
    ${otherOrigins.length @@ -545,19 +671,13 @@ export class BrowserProfilesList extends BtrixElement { } } - private async createBrowser({ url }: { url: string }) { - const params = { - url, - }; - - return this.api.fetch(`/orgs/${this.orgId}/profiles/browser`, { - method: "POST", - body: JSON.stringify(params), - }); - } - private async getProfiles( - params: { userid?: string } & APIPaginationQuery & APISortQuery, + params: { + userid?: string; + tags?: string[]; + tagMatch?: string; + } & APIPaginationQuery & + APISortQuery, signal: AbortSignal, ) { const query = queryString.stringify( @@ -565,7 +685,7 @@ export class BrowserProfilesList extends BtrixElement { ...params, }, { - arrayFormat: "comma", + arrayFormat: "none", // For tags }, ); diff --git a/frontend/src/pages/org/browser-profiles/profile.ts b/frontend/src/pages/org/browser-profiles/profile.ts index 3945384788..9fb6ae2c53 100644 --- a/frontend/src/pages/org/browser-profiles/profile.ts +++ b/frontend/src/pages/org/browser-profiles/profile.ts @@ -20,6 +20,7 @@ import { badges, badgesSkeleton, } from "@/features/browser-profiles/templates/badges"; +import type { ProfileUpdatedEvent } from "@/features/browser-profiles/types"; import type { WorkflowColumnName } from "@/features/crawl-workflows/workflow-list"; import { emptyMessage } from "@/layouts/emptyMessage"; import { labelWithIcon } from "@/layouts/labelWithIcon"; @@ -80,14 +81,24 @@ export class BrowserProfilesProfilePage extends BtrixElement { @state() private browserLoadCrawlerChannel?: Profile["crawlerChannel"]; + /** + * Render updated fields before refreshing data from API + */ + @state() + private updatedProfileParams?: ProfileUpdatedEvent["detail"]; + private get profile() { - return this.profileTask.value; + return this.profileTask.value + ? { ...this.profileTask.value, ...this.updatedProfileParams } + : undefined; } private readonly profileTask = new Task(this, { task: async ([profileId], { signal }) => { const profile = await this.getProfile(profileId, signal); + this.updatedProfileParams = undefined; + return profile; }, args: () => [this.profileId] as const, @@ -148,15 +159,13 @@ export class BrowserProfilesProfilePage extends BtrixElement { : undefined} ?open=${this.openDialog === "browser" || duplicating} ?duplicating=${duplicating} - @btrix-updated=${duplicating - ? undefined - : () => void this.profileTask.run()} + @btrix-updated=${duplicating ? undefined : this.onUpdated} @sl-after-hide=${this.closeBrowser} > ${when( - this.profile, + this.profileTask.value, (profile) => html` (this.openDialog = undefined)} - @btrix-updated=${() => { - void this.profileTask.run(); + @btrix-updated=${(e: ProfileUpdatedEvent) => { + void this.onUpdated(e); this.openDialog = undefined; }} > @@ -413,12 +422,17 @@ export class BrowserProfilesProfilePage extends BtrixElement { : none, )} - ${ - // - // ${this.renderDetail(() => html`${none}`)} - // - undefined - } + + ${this.renderDetail((profile) => + profile.tags.length + ? html`
    + ${profile.tags.map( + (tag) => html`${tag}`, + )} +
    ` + : none, + )} +
    @@ -711,13 +725,18 @@ export class BrowserProfilesProfilePage extends BtrixElement { ); private async getFirstBrowserUrl() { - if (!this.profile) { + if (!this.profileTask.value) { await this.profileTask.taskComplete; } - return this.profile?.origins[0]; + return this.profileTask.value?.origins[0]; } + private readonly onUpdated = async (e: ProfileUpdatedEvent) => { + this.updatedProfileParams = e.detail; + void this.profileTask.run(); + }; + private readonly newWorkflow = () => { this.navigate.to( `${this.navigate.orgBasePath}/${OrgTab.Workflows}/new#${"browserSettings" satisfies SectionsEnum}`, diff --git a/frontend/src/pages/org/crawls.ts b/frontend/src/pages/org/crawls.ts index 0ea3e73d6e..685be499cd 100644 --- a/frontend/src/pages/org/crawls.ts +++ b/frontend/src/pages/org/crawls.ts @@ -21,13 +21,13 @@ import { type Pagination, } from "@/components/ui/pagination"; import type { SelectEvent } from "@/components/ui/search-combobox"; +import type { BtrixChangeTagFilterEvent } from "@/components/ui/tag-filter/types"; import { ClipboardController } from "@/controllers/clipboard"; import { SearchParamsValue } from "@/controllers/searchParamsValue"; import { WorkflowSearch, type SearchFields, } from "@/features/crawl-workflows/workflow-search"; -import type { BtrixChangeWorkflowTagFilterEvent } from "@/features/crawl-workflows/workflow-tag-filter"; import { type BtrixChangeCrawlStateFilterEvent } from "@/features/crawls/crawl-state-filter"; import { OrgTab } from "@/routes"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; @@ -495,16 +495,15 @@ export class OrgCrawls extends BtrixElement { }} > - { + @btrix-change=${(e: BtrixChangeTagFilterEvent) => { this.filterByTags.setValue(e.detail.value?.tags || []); this.filterByTagsType.setValue(e.detail.value?.type || "or"); }} - > + > ${this.userInfo?.id ? html` - { + @btrix-change=${(e: BtrixChangeTagFilterEvent) => { this.filterByTags.setValue(e.detail.value?.tags || []); this.filterByTagsType.setValue(e.detail.value?.type || "or"); }} - > + > ` for a more complex example. + * See `` for a more complex example. */ export const SelectFilter: Story = { args: { diff --git a/frontend/src/types/crawler.ts b/frontend/src/types/crawler.ts index 9a9259c708..b68c0535d1 100644 --- a/frontend/src/types/crawler.ts +++ b/frontend/src/types/crawler.ts @@ -126,6 +126,7 @@ export type Profile = { id: string; name: string; description: string; + tags: string[]; created: string; createdBy: string | null; // User ID createdByName: string | null; // User Name diff --git a/frontend/src/types/workflow.ts b/frontend/src/types/workflow.ts index e0bd1cb650..e838981e3a 100644 --- a/frontend/src/types/workflow.ts +++ b/frontend/src/types/workflow.ts @@ -1,5 +1,6 @@ import type { StorageFile } from "./storage"; +import type { TagCount, TagCounts } from "@/components/ui/tag-filter/types"; import { ScopeType } from "@/types/crawler"; export enum NewWorkflowOnlyScopeType { @@ -8,14 +9,8 @@ export enum NewWorkflowOnlyScopeType { export const WorkflowScopeType = { ...ScopeType, ...NewWorkflowOnlyScopeType }; -export type WorkflowTag = { - tag: string; - count: number; -}; - -export type WorkflowTags = { - tags: WorkflowTag[]; -}; +export type WorkflowTag = TagCount; +export type WorkflowTags = TagCounts; export type StorageSeedFile = StorageFile & { firstSeed: string; From f8901a94b52c54f6a198a3d5f0adbb50f38eff9b Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 19 Nov 2025 10:45:32 -0800 Subject: [PATCH 05/12] feat: Allow users to replace profile browser (#2986) - Updates browser profile dialogs to organize settings into separate "Crawler Settings" section - Allows users to replace saved sites with new site - Fixes browser site list loading state - Consistently displays profile URLs --- frontend/src/assets/icons/window-gear.svg | 7 + frontend/src/components/ui/code/index.ts | 9 +- .../src/components/ui/select-crawler-proxy.ts | 4 +- frontend/src/components/ui/select-crawler.ts | 34 ++- frontend/src/components/ui/url-input.ts | 13 +- .../new-browser-profile-dialog.ts | 86 +++---- .../profile-browser-dialog.ts | 113 ++++++--- .../browser-profiles/profile-browser.ts | 164 +++++++------ .../browser-profiles/start-browser-dialog.ts | 217 +++++++++++++++--- frontend/src/layouts/emptyMessage.ts | 9 + frontend/src/layouts/page.ts | 2 +- frontend/src/layouts/panel.ts | 2 +- .../src/pages/org/browser-profiles-list.ts | 2 +- .../src/pages/org/browser-profiles/profile.ts | 130 ++++++----- frontend/src/pages/org/index.ts | 9 +- frontend/src/theme.stylesheet.css | 8 +- 16 files changed, 569 insertions(+), 240 deletions(-) create mode 100644 frontend/src/assets/icons/window-gear.svg diff --git a/frontend/src/assets/icons/window-gear.svg b/frontend/src/assets/icons/window-gear.svg new file mode 100644 index 0000000000..edd6e8093b --- /dev/null +++ b/frontend/src/assets/icons/window-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/components/ui/code/index.ts b/frontend/src/components/ui/code/index.ts index 9af0351ac5..24f13919ea 100644 --- a/frontend/src/components/ui/code/index.ts +++ b/frontend/src/components/ui/code/index.ts @@ -3,6 +3,7 @@ import type { LanguageFn } from "highlight.js"; import hljs from "highlight.js/lib/core"; import { css, html } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; import { html as staticHtml, unsafeStatic } from "lit/static-html.js"; import { TailwindElement } from "@/classes/TailwindElement"; @@ -73,6 +74,9 @@ export class Code extends TailwindElement { @property({ type: Boolean }) noWrap = false; + @property({ type: Boolean }) + truncate = false; + async connectedCallback() { const languageFn = (await langaugeFiles[this.language]).default; @@ -95,7 +99,10 @@ export class Code extends TailwindElement { class=${clsx( tw`font-monospace m-0 text-neutral-600`, this.noWrap ? tw`whitespace-nowrap` : tw`whitespace-pre-wrap`, + this.truncate && tw`truncate`, )} - >${staticHtml`${unsafeStatic(htmlStr)}`}`; + >${staticHtml`${unsafeStatic(htmlStr)}`}`; } } diff --git a/frontend/src/components/ui/select-crawler-proxy.ts b/frontend/src/components/ui/select-crawler-proxy.ts index 12362a179b..abe19b5a76 100644 --- a/frontend/src/components/ui/select-crawler-proxy.ts +++ b/frontend/src/components/ui/select-crawler-proxy.ts @@ -102,7 +102,7 @@ export class SelectCrawlerProxy extends BtrixElement { ? html`
    ${msg("Description:")} - ${this.selectedProxy.description || ""}
    @@ -112,7 +112,7 @@ export class SelectCrawlerProxy extends BtrixElement { ? html`
    ${msg("Description:")} - ${this.defaultProxy.description || ""}
    diff --git a/frontend/src/components/ui/select-crawler.ts b/frontend/src/components/ui/select-crawler.ts index 2328080dea..b30d3de05b 100644 --- a/frontend/src/components/ui/select-crawler.ts +++ b/frontend/src/components/ui/select-crawler.ts @@ -1,8 +1,8 @@ import { consume } from "@lit/context"; import { localized, msg } from "@lit/localize"; import { type SlSelect } from "@shoelace-style/shoelace"; -import { html, nothing } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { html, nothing, type PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import capitalize from "lodash/fp/capitalize"; @@ -51,6 +51,15 @@ export class SelectCrawler extends LiteElement { @property({ type: String }) crawlerChannel?: CrawlerChannel["id"]; + @state() + public value: CrawlerChannel["id"] = ""; + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("crawlerChannel")) { + this.value = this.crawlerChannel || ""; + } + } + render() { const selectedCrawler = this.getSelectedChannel(); @@ -58,7 +67,7 @@ export class SelectCrawler extends LiteElement { ${selectedCrawler.image} + ${selectedCrawler.image} ` : nothing}
    @@ -86,10 +97,12 @@ export class SelectCrawler extends LiteElement { } private getSelectedChannel() { - if (!this.crawlerChannels || !this.crawlerChannel) return null; + const channelId = this.value; - if (this.crawlerChannel) { - return this.crawlerChannels.find(({ id }) => id === this.crawlerChannel); + if (!this.crawlerChannels || !channelId) return null; + + if (channelId) { + return this.crawlerChannels.find(({ id }) => id === channelId); } return ( @@ -102,9 +115,10 @@ export class SelectCrawler extends LiteElement { private onChange(e: Event) { this.stopProp(e); - const selectedCrawler = this.crawlerChannels?.find( - ({ id }) => id === (e.target as SlSelect).value, - ); + const { value } = e.target as SlSelect; + + this.value = value as string; + const selectedCrawler = this.getSelectedChannel(); this.dispatchEvent( new CustomEvent("on-change", { diff --git a/frontend/src/components/ui/url-input.ts b/frontend/src/components/ui/url-input.ts index 3a1dd916ac..62ddf1c01e 100644 --- a/frontend/src/components/ui/url-input.ts +++ b/frontend/src/components/ui/url-input.ts @@ -33,6 +33,9 @@ export class UrlInput extends SlInput { @property({ type: Boolean }) hideHelpText = false; + // Store initial help text for when custom validity message is reset + #helpText?: string; + constructor() { super(); @@ -42,9 +45,15 @@ export class UrlInput extends SlInput { this.addEventListener("sl-change", this.onChange); } + connectedCallback(): void { + super.connectedCallback(); + + this.#helpText = this.helpText; + } + setCustomValidity(message: string): void { super.setCustomValidity(message); - if (!this.hideHelpText) this.helpText = message; + if (!this.hideHelpText) this.helpText = message || this.#helpText || ""; } disconnectedCallback(): void { @@ -57,7 +66,7 @@ export class UrlInput extends SlInput { private readonly onInput = () => { if (!this.checkValidity() && validURL(this.value)) { this.setCustomValidity(""); - if (!this.hideHelpText) this.helpText = ""; + if (!this.hideHelpText) this.helpText = this.#helpText || ""; } }; diff --git a/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts b/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts index d6769933be..d5c1e1f86a 100644 --- a/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts +++ b/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts @@ -63,17 +63,6 @@ export class NewBrowserProfileDialog extends BtrixElement { @queryAsync("#browserProfileForm") private readonly form!: Promise; - connectedCallback(): void { - super.connectedCallback(); - - if (this.org?.crawlingDefaults) { - if (!this.defaultProxyId) - this.defaultProxyId = this.org.crawlingDefaults.proxyId; - if (!this.defaultCrawlerChannel) - this.defaultCrawlerChannel = this.org.crawlingDefaults.crawlerChannel; - } - } - protected willUpdate(changedProperties: PropertyValues): void { if (changedProperties.has("defaultProxyId") && this.defaultProxyId) { this.proxyId = this.proxyId || this.defaultProxyId; @@ -91,9 +80,14 @@ export class NewBrowserProfileDialog extends BtrixElement { } render() { + const channels = this.crawlerChannels; + const proxyServers = this.proxyServers; + const showChannels = channels && channels.length > 1; + const showProxies = proxyServers?.length; + return html` { const nameInput = (await this.form).querySelector( @@ -111,39 +105,17 @@ export class NewBrowserProfileDialog extends BtrixElement { @submit=${this.onSubmit} > - ${this.crawlerChannels && this.crawlerChannels.length > 1 - ? html`
    - - (this.crawlerChannel = e.detail.value!)} - > -
    ` - : nothing} - ${this.proxyServers?.length - ? html` -
    - - (this.proxyId = e.detail.value)} - > -
    - ` - : nothing} - - + ${when( + showChannels || showProxies, + () => html` + + ${msg("Crawler Settings")} + + ${showChannels + ? html`
    + + (this.crawlerChannel = e.detail.value!)} + > +
    ` + : nothing} + ${showProxies + ? html` +
    + + (this.proxyId = e.detail.value)} + > +
    + ` + : nothing} +
    + `, + )} + +
    { + task: async ([open, config], { signal }) => { if (!open || !config) return null; const browserId = this.browserIdTask.value; @@ -63,16 +66,24 @@ export class ProfileBrowserDialog extends BtrixElement { void this.deleteBrowser(browserId, signal); } - const { browserid } = await this.createBrowser( - { profileId: profile?.id, ...config }, - signal, - ); + const { browserid } = await this.createBrowser(config, signal); return browserid; }, - args: () => [this.open, this.config, this.profile] as const, + args: () => [this.open, this.config] as const, }); + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("open")) { + if (!this.open) { + this.showConfirmation = false; + this.isBrowserLoaded = false; + this.#savedBrowserId = undefined; + this.browserIdTask.abort(); + } + } + } + disconnectedCallback(): void { const browserId = this.browserIdTask.value; @@ -151,7 +162,7 @@ export class ProfileBrowserDialog extends BtrixElement { @sl-after-hide=${() => this.closeBrowser()} >
    -
    +
    -
    -

    - ${[this.config?.name || this.profile?.name, this.config?.url] - .filter((v) => v) - .join(" — ")} -

    +
    +
    +

    + ${this.config?.name || this.profile?.name} +

    + ${when( + this.config?.url, + (url) => html` + + + `, + )} +
    ${when( (this.profile || this.config) && { ...this.profile, @@ -184,7 +214,7 @@ export class ProfileBrowserDialog extends BtrixElement { )}
    -
    +
    void this.profileBrowser?.enterFullscreen()} > - +
    - ${when( - isCrawler, - () => html` - -
    +
    + ${when( + isCrawler, + () => html` + + void this.dialog?.hide() + : () => (this.showConfirmation = true)} + > + ${this.showConfirmation ? msg("Yes, Exit") : msg("Exit")} + + + + ${creatingNew ? msg("Create Profile") : msg("Save Profile")} -
    - - `, - )} + + `, + () => html` + void this.dialog?.hide()} + > + ${msg("Exit")} + + `, + )} +
    @@ -233,6 +289,7 @@ export class ProfileBrowserDialog extends BtrixElement { ? html` html` -
    +

    ${msg( @@ -355,28 +361,60 @@ export class ProfileBrowser extends BtrixElement {

    + ${this.renderOriginsList( + { + heading: msg("Saved Sites"), + popoverContent: msg( + "Sites that are configured in the profile.", + ), + }, + this.initialOrigins, + )} ${when( this.originsTask.value, - (origins) => html` - ${this.renderOrigins(origins)} - ${this.renderNewOrigins( - origins.filter( - (url: string) => - !origins.includes(url) && - !origins.includes(url.replace(/\/$/, "")), - ), - )} - `, + (origins) => + this.renderOriginsList( + { + heading: msg("New Sites"), + popoverContent: `${msg( + "Sites that are not in the browser profile yet.", + )} ${msg( + "Finish browsing and save to include data and browsing activity from these sites to the profile.", + )}`, + }, + this.initialOrigins + ? origins.filter( + (url: string) => + !this.initialOrigins?.includes(url) && + !this.initialOrigins?.includes( + url.replace(/\/$/, ""), + ), + ) + : origins, + ), () => - this.browserTask.status === TaskStatus.PENDING - ? emptyMessage({ - message: msg( - "Sites will be shown here once the browser is done loading.", - ), - }) - : emptyMessage({ - message: msg("No sites configured yet."), - }), + this.initialOrigins + ? undefined + : html`
    + ${typeof this.browserTask.value !== "number" && + this.browserTask.value?.id + ? emptyMessage({ + icon: { + name: "slash-circle", + label: msg("None"), + }, + message: msg("No sites configured yet."), + }) + : emptyMessage({ + icon: { + name: "hourglass-split", + label: msg("Waiting"), + }, + message: msg( + "Sites will be shown here once the browser is done loading.", + ), + })} +
    `, )}
    @@ -469,7 +507,7 @@ export class ProfileBrowser extends BtrixElement { private renderSidebarButton() { return html` - +
    -

    ${msg("Saved Sites")}

    - ${heading} + -
    -

    ${msg("New Sites")}

    - -
    -
    -
      - ${origins.map((url) => this.renderOriginItem(url))} -
    - `; - } - private renderOriginItem(url: string) { const iframeSrc = !isPolling(this.browserTask.value) && this.browserTask.value?.url; - return html`
  • (iframeSrc ? this.navigateBrowser({ url }) : {})} - > -
    ${url}
    - ${iframeSrc - ? html`` - : ""} + return html`
  • +
    void this.navigateBrowser({ url }) + : undefined} + > + ${iframeSrc + ? html` + + ` + : ""} + + +
  • `; } diff --git a/frontend/src/features/browser-profiles/start-browser-dialog.ts b/frontend/src/features/browser-profiles/start-browser-dialog.ts index 2e57714c69..8000adc9af 100644 --- a/frontend/src/features/browser-profiles/start-browser-dialog.ts +++ b/frontend/src/features/browser-profiles/start-browser-dialog.ts @@ -1,27 +1,44 @@ import { consume } from "@lit/context"; import { localized, msg } from "@lit/localize"; -import type { SlButton } from "@shoelace-style/shoelace"; +import type { + SlButton, + SlChangeEvent, + SlCheckbox, + SlSelect, +} from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; -import { html } from "lit"; -import { customElement, property, query } from "lit/decorators.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 { 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"; 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. * @@ -30,6 +47,9 @@ export type BtrixStartBrowserEvent = CustomEvent; @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; @@ -37,28 +57,66 @@ export class StartBrowserDialog extends BtrixElement { profile?: Profile; @property({ type: String }) - startUrl?: 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; + 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.startUrl) { + if (this.initialUrl) { this.submitButton?.focus(); } else { this.dialog?.querySelector("btrix-url-input")?.focus(); @@ -67,18 +125,18 @@ export class StartBrowserDialog extends BtrixElement { @sl-after-hide=${async () => { if (this.form) { this.form.reset(); - - const input = this.form.querySelector("btrix-url-input"); - if (input) { - input.value = ""; - input.setCustomValidity(""); - } } - void this.dialog?.hide(); + this.replaceBrowser = false; }} >
    { + if (this.urlInput) { + this.urlInput.value = ""; + this.urlInput.setCustomValidity(""); + } + }} @submit=${async (e: SubmitEvent) => { e.preventDefault(); @@ -87,32 +145,79 @@ export class StartBrowserDialog extends BtrixElement { if (!form.checkValidity()) return; const values = serialize(form); - const url = values["starting-url"] as string; + const url = values[URL_FORM_FIELD_NAME] as string; const crawlerChannel = values["crawlerChannel"] as string | undefined; + const proxyId = this.selectCrawlerProxy?.value; this.dispatchEvent( new CustomEvent("btrix-start-browser", { - detail: { url, crawlerChannel }, + detail: { + url, + crawlerChannel, + proxyId: this.replaceBrowser ? proxyId : undefined, + replaceBrowser: this.replaceBrowser, + }, }), ); }} > - + ${when(this.profile, this.renderUrl)} + + + (this.replaceBrowser = (e.target as SlCheckbox).checked)} + > + ${msg("Reset previous configuration on save")} + ${when( + this.replaceBrowser, + () => html` +
    + + ${msg( + "Data and browsing activity of all previously saved sites will be removed upon saving this browser profile session.", + )} +
    + `, + )} +
    ${when( - this.orgCrawlerChannels && this.orgCrawlerChannels.length > 1, + this.open && (showChannels || showProxies), () => html` -
    - - -
    + + ${msg("Crawler Settings")} + + ${showChannels + ? html`
    + + +
    ` + : nothing} + ${showProxies + ? html`
    + + +
    ` + : nothing} +
    `, )} @@ -131,4 +236,60 @@ export class StartBrowserDialog extends BtrixElement {
    `; } + + private readonly renderUrl = (profile: Profile) => { + return html` { + this.loadUrl = (e.target as SlSelect).value as string; + + await this.updateComplete; + + if (this.open) { + this.urlInput?.focus(); + } + }} + > + ${msg("Saved Sites")} + ${profile.origins.map( + (url) => html` ${url} `, + )} + + ${msg("New Site")} + + +
    + { + 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/layouts/emptyMessage.ts b/frontend/src/layouts/emptyMessage.ts index 06f33a6756..ca3370531a 100644 --- a/frontend/src/layouts/emptyMessage.ts +++ b/frontend/src/layouts/emptyMessage.ts @@ -7,15 +7,24 @@ export function emptyMessage({ message, detail, actions, + icon, classNames, }: { message: TemplateResult | string; detail?: TemplateResult | string; actions?: TemplateResult; + icon?: { name: string; label: string }; classNames?: typeof tw | string; }) { return html`
    + ${icon + ? html`` + : nothing}

    ${header.breadcrumbs ? html` ${pageNav(header.breadcrumbs)} ` : nothing} - ${header.title ? pageHeader(header) : nothing} + ${pageHeader(header)} ${header.title ? html`

    ${render()}
    ` : render()} diff --git a/frontend/src/layouts/panel.ts b/frontend/src/layouts/panel.ts index e295647d1f..1622d24a18 100644 --- a/frontend/src/layouts/panel.ts +++ b/frontend/src/layouts/panel.ts @@ -14,7 +14,7 @@ export function panelHeader({ actions?: TemplateResult; }) { return html` -
    +
    ${typeof heading === "string" ? pageHeading({ content: heading }) : pageHeading(heading)} diff --git a/frontend/src/pages/org/browser-profiles-list.ts b/frontend/src/pages/org/browser-profiles-list.ts index 856ef74dc7..f5da641665 100644 --- a/frontend/src/pages/org/browser-profiles-list.ts +++ b/frontend/src/pages/org/browser-profiles-list.ts @@ -47,7 +47,7 @@ const sortableFields: Record< defaultDirection: "desc", }, url: { - label: msg("Starting URL"), + label: msg("Primary Site"), defaultDirection: "asc", }, modified: { diff --git a/frontend/src/pages/org/browser-profiles/profile.ts b/frontend/src/pages/org/browser-profiles/profile.ts index 9fb6ae2c53..098900c5a9 100644 --- a/frontend/src/pages/org/browser-profiles/profile.ts +++ b/frontend/src/pages/org/browser-profiles/profile.ts @@ -15,6 +15,7 @@ import type { import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import { ClipboardController } from "@/controllers/clipboard"; import { CrawlStatus } from "@/features/archived-items/crawl-status"; +import type { ProfileBrowserDialog } from "@/features/browser-profiles/profile-browser-dialog"; import type { BtrixStartBrowserEvent } from "@/features/browser-profiles/start-browser-dialog"; import { badges, @@ -29,7 +30,11 @@ import { panel, panelBody } from "@/layouts/panel"; import { OrgTab, WorkflowTab } from "@/routes"; import { noData, stringFor } from "@/strings/ui"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; -import type { Profile, Workflow } from "@/types/crawler"; +import { + CrawlerChannelImage, + type Profile, + type Workflow, +} from "@/types/crawler"; import type { CrawlState } from "@/types/crawlState"; import { SortDirection } from "@/types/utils"; import { isApiError } from "@/utils/api"; @@ -76,10 +81,12 @@ export class BrowserProfilesProfilePage extends BtrixElement { | "duplicate"; @state() - private browserLoadUrl?: string; - - @state() - private browserLoadCrawlerChannel?: Profile["crawlerChannel"]; + private browserLoadParams: { + url?: string; + crawlerChannel?: Profile["crawlerChannel"]; + proxyId?: Profile["proxyId"]; + replaceBrowser?: boolean; + } = {}; /** * Render updated fields before refreshing data from API @@ -139,24 +146,43 @@ export class BrowserProfilesProfilePage extends BtrixElement { } satisfies Parameters[0]; const duplicating = this.openDialog === "duplicate"; + const browserLoadUrl = this.browserLoadParams.url; + const browserLoadCrawlerChannel = + this.browserLoadParams.crawlerChannel || CrawlerChannelImage.Default; + + // Custom config will take precedence over profile data + let config: ProfileBrowserDialog["config"] = undefined; + + if (browserLoadUrl) { + config = { + url: browserLoadUrl, + profileId: this.profileId, + crawlerChannel: browserLoadCrawlerChannel, + }; + + if (duplicating) { + config = { + ...config, + name: `${this.profile?.name || browserLoadUrl} ${msg("Copy")}`, + crawlerChannel: this.profile?.crawlerChannel, + proxyId: this.profile?.proxyId, + }; + } else if (this.browserLoadParams.replaceBrowser) { + config = { + ...config, + // Don't specify profile ID if replacing browser instance + profileId: undefined, + // Allow specifying new proxy + proxyId: this.browserLoadParams.proxyId, + }; + } + } return html`${page(header, this.renderPage)} (this.openDialog = "start-browser"), )} > - - ${msg("Configure Profile")} + + ${msg("Load Profile")} - (this.openDialog = "start-browser")} + actions: this.appState.isCrawler + ? html` + (this.openDialog = "start-browser-add-site")} ?disabled=${archivingDisabled} - > - ` + > + + ${msg("Load New URL")} + ` : undefined, body: html`${this.renderOrigins()} - ${when( - isCrawler, - () => html` - (this.openDialog = "start-browser-add-site")} - > - - ${msg("Add Site")} - `, - )} ${when( this.profileTask.value, (profile) => html` void this.openBrowser({ url: origin })} > - - + + { + private readonly openBrowser = async ( + opts: BrowserProfilesProfilePage["browserLoadParams"], + ) => { let { url } = opts; if (!url) { url = await this.getFirstBrowserUrl(); } - this.browserLoadUrl = url; - this.browserLoadCrawlerChannel = opts.crawlerChannel; + + this.browserLoadParams = { + ...opts, + url, + }; this.openDialog = "browser"; }; @@ -780,13 +800,15 @@ export class BrowserProfilesProfilePage extends BtrixElement { } private readonly closeBrowser = () => { - this.browserLoadUrl = undefined; - this.browserLoadCrawlerChannel = undefined; + this.browserLoadParams = {}; this.openDialog = undefined; }; private async duplicateProfile() { - this.browserLoadUrl = await this.getFirstBrowserUrl(); + this.browserLoadParams = { + ...this.browserLoadParams, + url: await this.getFirstBrowserUrl(), + }; this.openDialog = "duplicate"; } diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index 7ecb624afd..261855e125 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -492,7 +492,14 @@ export class Org extends BtrixElement { ? html` (this.openDialogName = undefined)} > diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index beb5a3c7c1..16cdea8be5 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -282,8 +282,12 @@ } /* TODO tailwind sets border-width: 0, see if this can be fixed in tw */ - sl-divider { - border-top-width: var(--sl-panel-border-width); + sl-divider:not([vertical]) { + border-top: solid var(--width) var(--color); + } + + sl-divider[vertical] { + border-left: solid var(--width) var(--color); } /* Add more spacing between radio options */ From 03400cea9b810d512d311dd435d8160a2f47d8b3 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 19 Nov 2025 21:11:19 -0800 Subject: [PATCH 06/12] feat: Load first seed in profile (#2998) - Replaces redundant "Go to workflow" menu item in browser profile workflow list with "Load Crawl Start URL". - Fixes not being able to open a workflow in a new tab (https://github.com/webrecorder/browsertrix/issues/3001) - Adds suggestions to site dropdown --- .../browser-profiles/start-browser-dialog.ts | 71 ++++++++++++- .../features/crawl-workflows/workflow-list.ts | 100 ++++++++++++------ .../src/pages/org/browser-profiles/profile.ts | 22 ++-- frontend/src/pages/org/workflows-list.ts | 9 +- frontend/src/theme.stylesheet.css | 7 +- frontend/src/types/workflow.ts | 7 ++ 6 files changed, 162 insertions(+), 54 deletions(-) diff --git a/frontend/src/features/browser-profiles/start-browser-dialog.ts b/frontend/src/features/browser-profiles/start-browser-dialog.ts index 8000adc9af..877f15acd3 100644 --- a/frontend/src/features/browser-profiles/start-browser-dialog.ts +++ b/frontend/src/features/browser-profiles/start-browser-dialog.ts @@ -1,5 +1,6 @@ import { consume } from "@lit/context"; import { localized, msg } from "@lit/localize"; +import { Task } from "@lit/task"; import type { SlButton, SlChangeEvent, @@ -11,6 +12,7 @@ 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"; @@ -26,6 +28,7 @@ import { type OrgProxiesContext, } from "@/context/org-proxies"; import type { Profile } from "@/types/crawler"; +import type { WorkflowSearchValues } from "@/types/workflow"; type StartBrowserEventDetail = { url?: string; @@ -89,6 +92,48 @@ export class StartBrowserDialog extends BtrixElement { @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; @@ -238,6 +283,15 @@ export class StartBrowserDialog extends BtrixElement { } private readonly renderUrl = (profile: Profile) => { + const option = (url: string) => + html` +
    ${url}
    +
    `; + return html` + ${msg("New Site")} + ${msg("Saved Sites")} - ${profile.origins.map( - (url) => html` ${url} `, + ${profile.origins.map(option)} + ${when(this.workflowOrigins.value, (seeds) => + seeds.length + ? html` + + ${msg("Suggestions from Related Workflows")} + ${seeds.map(option)} + ` + : nothing, )} - - ${msg("New Site")}
    diff --git a/frontend/src/features/crawl-workflows/workflow-list.ts b/frontend/src/features/crawl-workflows/workflow-list.ts index 85ae28e311..24eca599dc 100644 --- a/frontend/src/features/crawl-workflows/workflow-list.ts +++ b/frontend/src/features/crawl-workflows/workflow-list.ts @@ -25,7 +25,7 @@ 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"; @@ -40,15 +40,15 @@ export type WorkflowColumnName = | "actions"; const columnWidths = { - name: "minmax(18rem, 1fr)", + // 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: "3rem", + actions: "[clickable-end] 3rem", } as const satisfies Record; -// postcss-lit-disable-next-line -const mediumBreakpointCss = css`30rem`; // postcss-lit-disable-next-line const largeBreakpointCss = css`60rem`; // postcss-lit-disable-next-line @@ -65,11 +65,6 @@ const rowCss = css` right: 0; } - @media only screen and (min-width: ${mediumBreakpointCss}) { - .row { - grid-template-columns: repeat(2, 1fr); - } - } @media only screen and (min-width: ${largeBreakpointCss}) { .row { grid-template-columns: var(--btrix-workflow-list-columns); @@ -252,6 +247,43 @@ 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 > *, + .col btrix-overflow-dropdown { + /* Place above .rowClickTarget::after overlay */ + z-index: 10; + 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; + } `, ]; @@ -271,13 +303,18 @@ export class WorkflowListItem extends BtrixElement { @query("btrix-overflow-dropdown") dropdownMenu!: OverflowDropdown; + @query("a") + private readonly anchor?: HTMLAnchorElement | null; + private readonly columnTemplate = { - name: () => - html`
    -
    + 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) { @@ -291,7 +328,8 @@ export class WorkflowListItem extends BtrixElement { return msg("---"); })}
    -
    `, +
    `; + }, "latest-crawl": () => html`
    ${this.safeRender(this.renderLatestCrawl)}
    `, "total-crawls": () => @@ -358,20 +396,7 @@ export class WorkflowListItem extends BtrixElement { } satisfies Record TemplateResult>; 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); - }} - > + return html`
    ${this.columns ? this.columns.map((col) => this.columnTemplate[col]()) : Object.values(this.columnTemplate).map((render) => render())} @@ -480,7 +505,7 @@ export class WorkflowListItem extends BtrixElement { return html` -
    +
    ${status}
    ${duration}
    @@ -495,7 +520,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") diff --git a/frontend/src/pages/org/browser-profiles/profile.ts b/frontend/src/pages/org/browser-profiles/profile.ts index 098900c5a9..dc33472519 100644 --- a/frontend/src/pages/org/browser-profiles/profile.ts +++ b/frontend/src/pages/org/browser-profiles/profile.ts @@ -669,6 +669,17 @@ export class BrowserProfilesProfilePage extends BtrixElement { (workflow) => html` + + void this.openBrowser({ url: workflow.firstSeed })} + > + + ${msg("Load Crawl Start URL")} + + ${when( this.appState.isCrawler, () => html` @@ -679,19 +690,8 @@ export class BrowserProfilesProfilePage extends BtrixElement { ${msg("Edit Workflow Settings")} - `, )} - - - ${msg("Go to Workflow")} - `, )} diff --git a/frontend/src/pages/org/workflows-list.ts b/frontend/src/pages/org/workflows-list.ts index 88ad7a362b..4089fc2a5d 100644 --- a/frontend/src/pages/org/workflows-list.ts +++ b/frontend/src/pages/org/workflows-list.ts @@ -38,7 +38,7 @@ import { WorkflowTab } from "@/routes"; import { deleteConfirmation } from "@/strings/ui"; import type { APIPaginatedList, APIPaginationQuery } from "@/types/api"; import { type CrawlState } from "@/types/crawlState"; -import { type StorageSeedFile } from "@/types/workflow"; +import type { StorageSeedFile, WorkflowSearchValues } from "@/types/workflow"; import { isApiError } from "@/utils/api"; import { settingsForDuplicate } from "@/utils/crawl-workflows/settingsForDuplicate"; import { renderName } from "@/utils/crawler"; @@ -994,12 +994,7 @@ export class WorkflowsList extends BtrixElement { private async fetchConfigSearchValues() { try { - const data: { - crawlIds: string[]; - names: string[]; - descriptions: string[]; - firstSeeds: string[]; - } = await this.api.fetch( + const data = await this.api.fetch( `/orgs/${this.orgId}/crawlconfigs/search-values`, ); diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index 16cdea8be5..59842237dd 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -237,11 +237,16 @@ } /* Adjust menu item hover and focus styles */ - sl-menu-item, + sl-option:not([aria-selected="true"]):not(:disabled), + sl-menu-item:not([disabled]), btrix-menu-item-link { @apply part-[base]:text-neutral-700 part-[base]:hover:bg-cyan-50/50 part-[base]:hover:text-cyan-700 part-[base]:focus-visible:bg-cyan-50/50; } + sl-option[aria-selected="true"] { + @apply part-[base]:bg-cyan-50 part-[base]:text-cyan-700; + } + /* Add menu item variants */ .menu-item-success { @apply part-[base]:text-success part-[base]:hover:bg-success-50 part-[base]:hover:text-success-700 part-[base]:focus-visible:bg-success-50; diff --git a/frontend/src/types/workflow.ts b/frontend/src/types/workflow.ts index e838981e3a..d34cf5bfb1 100644 --- a/frontend/src/types/workflow.ts +++ b/frontend/src/types/workflow.ts @@ -16,3 +16,10 @@ export type StorageSeedFile = StorageFile & { firstSeed: string; seedCount: number; }; + +export type WorkflowSearchValues = { + crawlIds: string[]; + names: string[]; + descriptions: string[]; + firstSeeds: string[]; +}; From c10cd6d49d8d991396d151f09a0453fd908652c7 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 24 Nov 2025 13:20:59 -0800 Subject: [PATCH 07/12] fix workflow z index --- frontend/src/features/crawl-workflows/workflow-list.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/crawl-workflows/workflow-list.ts b/frontend/src/features/crawl-workflows/workflow-list.ts index 24eca599dc..aabf0e04b5 100644 --- a/frontend/src/features/crawl-workflows/workflow-list.ts +++ b/frontend/src/features/crawl-workflows/workflow-list.ts @@ -264,10 +264,9 @@ export class WorkflowListItem extends BtrixElement { } .col sl-tooltip > *, - .col btrix-popover > *, - .col btrix-overflow-dropdown { + .col btrix-popover > * { /* Place above .rowClickTarget::after overlay */ - z-index: 10; + z-index: 2; position: relative; } From 67a914e2a573f04b4360183919a68c0768cc727e Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 24 Nov 2025 13:13:38 -0800 Subject: [PATCH 08/12] feat: Add tag constraints (#3006) - Limits tag input to 40 characters - Determines maximum tags displayed by container width --- frontend/src/components/ui/badge.ts | 5 +- frontend/src/components/ui/code/index.ts | 4 +- frontend/src/components/ui/index.ts | 1 + frontend/src/components/ui/tag-container.ts | 137 ++++++++++++++++++ frontend/src/components/ui/tag-input.ts | 3 + frontend/src/components/ui/tag.ts | 5 +- .../src/pages/org/browser-profiles-list.ts | 24 +-- .../components/TagContainer.stories.ts | 37 +++++ .../src/stories/components/TagContainer.ts | 11 ++ 9 files changed, 204 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/ui/tag-container.ts create mode 100644 frontend/src/stories/components/TagContainer.stories.ts create mode 100644 frontend/src/stories/components/TagContainer.ts diff --git a/frontend/src/components/ui/badge.ts b/frontend/src/components/ui/badge.ts index 16bb776352..adc17f2b12 100644 --- a/frontend/src/components/ui/badge.ts +++ b/frontend/src/components/ui/badge.ts @@ -13,7 +13,8 @@ export type BadgeVariant = | "primary" | "cyan" | "blue" - | "high-contrast"; + | "high-contrast" + | "text"; /** * Show numeric value in a label @@ -64,6 +65,7 @@ export class Badge extends TailwindElement { primary: tw`bg-white text-primary ring-primary`, cyan: tw`bg-cyan-50 text-cyan-600 ring-cyan-600`, blue: tw`bg-blue-50 text-blue-600 ring-blue-600`, + text: tw`text-blue-500 ring-blue-600`, }[this.variant], ] : { @@ -75,6 +77,7 @@ export class Badge extends TailwindElement { primary: tw`bg-primary text-neutral-0`, cyan: tw`bg-cyan-50 text-cyan-600`, blue: tw`bg-blue-50 text-blue-600`, + text: tw`text-blue-500`, }[this.variant], this.pill ? [ diff --git a/frontend/src/components/ui/code/index.ts b/frontend/src/components/ui/code/index.ts index 24f13919ea..2955bcd095 100644 --- a/frontend/src/components/ui/code/index.ts +++ b/frontend/src/components/ui/code/index.ts @@ -53,11 +53,11 @@ export class Code extends TailwindElement { } .hljs-path { - color: var(--sl-color-blue-900); + color: var(--sl-color-sky-600); } .hljs-domain { - color: var(--sl-color-blue-600); + color: var(--sl-color-sky-700); } .hljs-string { diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 9c631de3a0..632598bc7c 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -16,6 +16,7 @@ import("./combobox"); import("./config-details"); import("./copy-button"); import("./copy-field"); +import("./tag-container"); import("./data-grid"); import("./details"); import("./file-input"); diff --git a/frontend/src/components/ui/tag-container.ts b/frontend/src/components/ui/tag-container.ts new file mode 100644 index 0000000000..eea43fb312 --- /dev/null +++ b/frontend/src/components/ui/tag-container.ts @@ -0,0 +1,137 @@ +import clsx from "clsx"; +import { css, html, type PropertyValues } from "lit"; +import { + customElement, + property, + query, + queryAll, + state, +} from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import debounce from "lodash/fp/debounce"; + +import { TailwindElement } from "@/classes/TailwindElement"; +import type { Tag } from "@/components/ui/tag"; +import type { UnderlyingFunction } from "@/types/utils"; +import localize from "@/utils/localize"; +import { tw } from "@/utils/tailwind"; + +/** + * Displays all the tags that can be contained to one line. + * Overflowing tags are displayed in a popover. + * + * @cssproperty width + */ +@customElement("btrix-tag-container") +export class TagContainer extends TailwindElement { + static styles = css` + :host { + --width: 100%; + } + `; + + @property({ type: Array }) + tags: string[] = []; + + @query("#container") + private readonly container?: HTMLElement | null; + + @queryAll("btrix-tag") + private readonly tagNodes!: NodeListOf; + + @state() + private displayLimit?: number; + + disconnectedCallback(): void { + this.debouncedCalculate.cancel(); + super.disconnectedCallback(); + } + + protected updated(changedProperties: PropertyValues): void { + if (changedProperties.get("tags")) { + this.debouncedCalculate.cancel(); + this.calculate(); + } + } + + render() { + const maxTags = this.tags.length; + const displayLimit = this.displayLimit; + const remainder = displayLimit && maxTags - displayLimit; + + return html` + } + > +
    +
    + ${this.tags.map( + (tag, i) => + html` displayLimit - 1 + ? "true" + : "false", + )} + >${tag}`, + )} +
    + + + +${localize.number(remainder || maxTags)} +
    + ${this.tags + .slice(displayLimit) + .map((tag) => html`${tag}`)} +
    +
    +
    +
    + `; + } + + private readonly calculate = () => { + const tagNodes = Array.from(this.tagNodes); + + if (!tagNodes.length || !this.container) return; + + const containerRect = this.container.getBoundingClientRect(); + const containerTop = containerRect.top; + + // Reset width + this.style.setProperty("--width", "100%"); + const idx = tagNodes.findIndex( + (el) => el.getBoundingClientRect().top > containerTop, + ); + + if (idx === -1) return; + const lastVisible = tagNodes[idx - 1]; + if (lastVisible as unknown) { + const rect = lastVisible.getBoundingClientRect(); + // Decrease width of container to match end of last visible tag + this.style.setProperty( + "--width", + `${rect.left - containerRect.left + rect.width}px`, + ); + } + + this.displayLimit = idx; + }; + + private readonly debouncedCalculate = debounce(50)(this.calculate); +} diff --git a/frontend/src/components/ui/tag-input.ts b/frontend/src/components/ui/tag-input.ts index 9540b1993e..0260270a04 100644 --- a/frontend/src/components/ui/tag-input.ts +++ b/frontend/src/components/ui/tag-input.ts @@ -16,6 +16,8 @@ import { import { customElement, property, query, state } from "lit/decorators.js"; import debounce from "lodash/fp/debounce"; +import { TAG_MAX_CHARACTERS } from "./tag"; + import type { UnderlyingFunction } from "@/types/utils"; import { type WorkflowTag } from "@/types/workflow"; import { dropdown } from "@/utils/css"; @@ -234,6 +236,7 @@ export class TagInput extends LitElement { role="combobox" aria-controls="dropdown" aria-expanded="${this.dropdownIsOpen === true}" + maxlength=${TAG_MAX_CHARACTERS} /> ` : ``} + `; } diff --git a/frontend/src/controllers/localize.ts b/frontend/src/controllers/localize.ts index 328d780a14..f70bb3aecb 100644 --- a/frontend/src/controllers/localize.ts +++ b/frontend/src/controllers/localize.ts @@ -55,7 +55,11 @@ export class LocalizeController extends SlLocalizeController { }) : seconds > 60 ? html`` diff --git a/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts b/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts index d5c1e1f86a..ff1bc17d08 100644 --- a/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts +++ b/frontend/src/features/browser-profiles/new-browser-profile-dialog.ts @@ -116,50 +116,53 @@ export class NewBrowserProfileDialog extends BtrixElement { > + ${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} + - ${when( - showChannels || showProxies, - () => html` - - ${msg("Crawler Settings")} - - ${showChannels - ? html`
    - - (this.crawlerChannel = e.detail.value!)} - > -
    ` - : nothing} - ${showProxies - ? html` -
    - - (this.proxyId = e.detail.value)} - > -
    - ` - : nothing} -
    - `, - )} + ${showChannels + ? html` + ${msg("Browser Session Settings")} +
    + + (this.crawlerChannel = e.detail.value!)} + >
    ` + : nothing} 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 index 877f15acd3..a9b9fa2a11 100644 --- a/frontend/src/features/browser-profiles/start-browser-dialog.ts +++ b/frontend/src/features/browser-profiles/start-browser-dialog.ts @@ -230,41 +230,33 @@ export class StartBrowserDialog extends BtrixElement { )} - ${when( - this.open && (showChannels || showProxies), - () => html` - - ${msg("Crawler Settings")} - - ${showChannels - ? html`
    - - -
    ` - : nothing} - ${showProxies - ? html`
    - - -
    ` - : nothing} -
    - `, - )} + ${showProxies + ? html`
    + + +
    ` + : nothing} + ${this.open && showChannels + ? html` + ${msg("Browser Session Settings")} +
    + + +
    +
    ` + : nothing}
    void this.dialog?.hide()} @@ -318,7 +310,7 @@ export class StartBrowserDialog extends BtrixElement { ${msg("Suggestions from Related Workflows")} - ${seeds.map(option)} + ${seeds.slice(0, 10).map(option)} ` : nothing, )} diff --git a/frontend/src/features/browser-profiles/templates/badges.ts b/frontend/src/features/browser-profiles/templates/badges.ts index 303b2810dd..5fdea74f6f 100644 --- a/frontend/src/features/browser-profiles/templates/badges.ts +++ b/frontend/src/features/browser-profiles/templates/badges.ts @@ -1,12 +1,15 @@ import { msg } from "@lit/localize"; import { html, nothing } from "lit"; import { when } from "lit/directives/when.js"; -import capitalize from "lodash/fp/capitalize"; -import { CrawlerChannelImage, type Profile } from "@/types/crawler"; +import { type Profile } from "@/types/crawler"; export const usageBadge = (inUse: boolean) => - html` + html` - html` - - - ${capitalize(channel)} - - `, + (channelImage) => html` + + `, )} ${when( profile.proxyId, - (proxy) => - html` - - - ${proxy} - - `, + (proxyId) => 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/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index 68942af1eb..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"; @@ -95,6 +97,7 @@ import { Behavior, CrawlerChannelImage, ScopeType, + type Profile, type Seed, type WorkflowParams, } from "@/types/crawler"; @@ -419,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(); @@ -1982,15 +1994,49 @@ https://archiveweb.page/images/${"logo.svg"}`} 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"])} @@ -2002,12 +2048,24 @@ https://archiveweb.page/images/${"logo.svg"}`} proxies.default_proxy_id ?? undefined, )} .proxyServers=${proxies.servers} - .proxyId="${this.formState.proxyId || ""}" + .proxyId=${profileProxyId || this.formState.proxyId || ""} + .profileProxyId=${profileProxyId} @btrix-change=${(e: SelectCrawlerProxyChangeEvent) => this.updateFormState({ proxyId: e.detail.value, })} - > + > + ${when( + profileProxyId, + () => html` + ${msg("Set by profile")} + `, + )} + `), this.renderHelpTextCol(infoTextFor["proxyId"]), ] @@ -3253,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; @@ -3410,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/crawls/crawler-channel-badge.ts b/frontend/src/features/crawls/crawler-channel-badge.ts new file mode 100644 index 0000000000..adce184bdc --- /dev/null +++ b/frontend/src/features/crawls/crawler-channel-badge.ts @@ -0,0 +1,46 @@ +import { consume } from "@lit/context"; +import { localized } from "@lit/localize"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { TailwindElement } from "@/classes/TailwindElement"; +import { + orgCrawlerChannelsContext, + type OrgCrawlerChannelsContext, +} from "@/context/org-crawler-channels"; +import { CrawlerChannelImage } from "@/types/crawler"; + +@customElement("btrix-crawler-channel-badge") +@localized() +export class CrawlerChannelBadge extends TailwindElement { + @consume({ context: orgCrawlerChannelsContext, subscribe: true }) + private readonly crawlerChannels?: OrgCrawlerChannelsContext; + + @property({ type: String }) + channelId?: CrawlerChannelImage | AnyString; + + render() { + if (!this.channelId || !this.crawlerChannels) return; + + const crawlerChannel = this.crawlerChannels.find( + ({ id }) => id === this.channelId, + ); + + return html` + + + ${this.channelId} + + `; + } +} diff --git a/frontend/src/features/crawls/index.ts b/frontend/src/features/crawls/index.ts index 5254170741..c19583ee20 100644 --- a/frontend/src/features/crawls/index.ts +++ b/frontend/src/features/crawls/index.ts @@ -1,2 +1,4 @@ import("./crawl-list"); import("./crawl-state-filter"); +import("./crawler-channel-badge"); +import("./proxy-badge"); diff --git a/frontend/src/features/crawls/proxy-badge.ts b/frontend/src/features/crawls/proxy-badge.ts new file mode 100644 index 0000000000..dabf5082a8 --- /dev/null +++ b/frontend/src/features/crawls/proxy-badge.ts @@ -0,0 +1,38 @@ +import { consume } from "@lit/context"; +import { localized } from "@lit/localize"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { TailwindElement } from "@/classes/TailwindElement"; +import { + orgProxiesContext, + type OrgProxiesContext, +} from "@/context/org-proxies"; + +@customElement("btrix-proxy-badge") +@localized() +export class ProxyBadge extends TailwindElement { + @consume({ context: orgProxiesContext, subscribe: true }) + private readonly orgProxies?: OrgProxiesContext; + + @property({ type: String }) + proxyId?: string; + + render() { + if (!this.proxyId || !this.orgProxies) return; + + const proxy = this.orgProxies.servers.find(({ id }) => id === this.proxyId); + + return html` + + + ${proxy?.label || this.proxyId} + + `; + } +} diff --git a/frontend/src/pages/org/browser-profiles-list.ts b/frontend/src/pages/org/browser-profiles-list.ts index 394b7a2cbf..4ace718c76 100644 --- a/frontend/src/pages/org/browser-profiles-list.ts +++ b/frontend/src/pages/org/browser-profiles-list.ts @@ -1,6 +1,6 @@ import { localized, msg } from "@lit/localize"; import { Task } from "@lit/task"; -import { html, nothing, type PropertyValues } from "lit"; +import { html, type PropertyValues } from "lit"; import { customElement, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; import queryString from "query-string"; @@ -18,6 +18,7 @@ import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; import type { BtrixChangeTagFilterEvent } from "@/components/ui/tag-filter/types"; import { ClipboardController } from "@/controllers/clipboard"; import { SearchParamsValue } from "@/controllers/searchParamsValue"; +import { originsWithRemainder } from "@/features/browser-profiles/templates/origins-with-remainder"; import { emptyMessage } from "@/layouts/emptyMessage"; import { page } from "@/layouts/page"; import { OrgTab } from "@/routes"; @@ -71,7 +72,7 @@ const columnsCss = [ "min-content", // Status "[clickable-start] minmax(min-content, 1fr)", // Name "30ch", // Tags - "minmax(max-content, 1fr)", // Origins + "40ch", // Origins "minmax(min-content, 20ch)", // Last modified "[clickable-end] min-content", // Actions ].join(" "); @@ -496,7 +497,7 @@ export class BrowserProfilesList extends BtrixElement { ${msg("Name")} ${msg("Tags")} - ${msg("Configured Sites")} + ${msg("Saved Sites")} ${msg("Last Modified")} @@ -520,8 +521,6 @@ export class BrowserProfilesList extends BtrixElement { (a, b) => (b && a && b > a ? b : a), data.created, ) || data.created; - const startingUrl = data.origins[0]; - const otherOrigins = data.origins.slice(1); return html` - - - ${otherOrigins.length - ? html` - +${this.localize.number(otherOrigins.length)} -
      - ${otherOrigins.map((url) => html`
    • ${url}
    • `)} -
    -
    ` - : nothing} + + ${originsWithRemainder(data.origins)} - ${this.localize.relativeDate(modifiedByAnyDate)} + ${this.localize.relativeDate(modifiedByAnyDate, { capitalize: true })} ${this.renderActions(data)} diff --git a/frontend/src/pages/org/browser-profiles/profile.ts b/frontend/src/pages/org/browser-profiles/profile.ts index dc33472519..434e2e0c12 100644 --- a/frontend/src/pages/org/browser-profiles/profile.ts +++ b/frontend/src/pages/org/browser-profiles/profile.ts @@ -309,7 +309,7 @@ export class BrowserProfilesProfilePage extends BtrixElement { const archivingDisabled = isArchivingDisabled(this.org); return panel({ - heading: msg("Configured Sites"), + heading: msg("Saved Sites"), actions: this.appState.isCrawler ? html` @@ -467,7 +468,9 @@ export class BrowserProfilesProfilePage extends BtrixElement { ${this.renderDetail((profile) => - this.localize.relativeDate(modifiedByAnyDate || profile.created), + this.localize.relativeDate(modifiedByAnyDate || profile.created, { + capitalize: true, + }), )} diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index 261855e125..b3530c830f 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -493,7 +493,7 @@ export class Org extends BtrixElement { .proxyServers=${proxies.servers} .crawlerChannels=${crawlerChannels} defaultProxyId=${ifDefined( - org.crawlingDefaults?.profileid || + org.crawlingDefaults?.proxyId || proxies.default_proxy_id || undefined, )} diff --git a/frontend/src/pages/org/settings/components/crawling-defaults.ts b/frontend/src/pages/org/settings/components/crawling-defaults.ts index 38284065d1..14ce869f75 100644 --- a/frontend/src/pages/org/settings/components/crawling-defaults.ts +++ b/frontend/src/pages/org/settings/components/crawling-defaults.ts @@ -20,6 +20,7 @@ import { orgProxiesContext, type OrgProxiesContext, } from "@/context/org-proxies"; +import type { SelectBrowserProfile } from "@/features/browser-profiles/select-browser-profile"; import type { CustomBehaviorsTable } from "@/features/crawl-workflows/custom-behaviors-table"; import type { QueueExclusionTable } from "@/features/crawl-workflows/queue-exclusion-table"; import { columns, type Cols } from "@/layouts/columns"; @@ -79,6 +80,9 @@ export class OrgSettingsCrawlWorkflows extends BtrixElement { @query("btrix-language-select") languageSelect?: LanguageSelect | null; + @query("btrix-select-browser-profile") + browserProfileSelect?: SelectBrowserProfile | null; + @query("btrix-select-crawler-proxy") proxySelect?: SelectCrawlerProxy | null; @@ -362,7 +366,7 @@ export class OrgSettingsCrawlWorkflows extends BtrixElement { behaviorTimeout: parseNumber(values.behaviorTimeoutSeconds), pageExtraDelay: parseNumber(values.pageExtraDelaySeconds), blockAds: values.blockAds === "on", - profileid: values.profileid, + profileid: this.browserProfileSelect?.value || undefined, crawlerChannel: values.crawlerChannel, proxyId: this.proxySelect?.value || undefined, userAgent: values.userAgent, diff --git a/frontend/src/strings/crawl-workflows/infoText.ts b/frontend/src/strings/crawl-workflows/infoText.ts index 042aa12c84..5a04269bb6 100644 --- a/frontend/src/strings/crawl-workflows/infoText.ts +++ b/frontend/src/strings/crawl-workflows/infoText.ts @@ -38,7 +38,7 @@ export const infoTextFor = { msg(`Choose a custom profile to make use of saved cookies and logged-in accounts. Note that websites may log profiles out after a period of time.`), crawlerChannel: msg( - `Choose a Browsertrix Crawler Release Channel. If available, other versions may provide new/experimental crawling features.`, + `Choose a Browsertrix Crawler release channel. If available, other versions may provide new or experimental crawling features.`, ), blockAds: msg( html`Blocks advertising content from being loaded. Uses diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index 59842237dd..191cd90e5a 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -240,7 +240,7 @@ sl-option:not([aria-selected="true"]):not(:disabled), sl-menu-item:not([disabled]), btrix-menu-item-link { - @apply part-[base]:text-neutral-700 part-[base]:hover:bg-cyan-50/50 part-[base]:hover:text-cyan-700 part-[base]:focus-visible:bg-cyan-50/50; + @apply part-[base]:bg-white part-[base]:text-neutral-700 part-[base]:hover:bg-cyan-50/50 part-[base]:hover:text-cyan-700 part-[base]:focus:text-cyan-700 part-[base]:focus-visible:bg-cyan-50/50; } sl-option[aria-selected="true"] { @@ -411,12 +411,14 @@ .font-monostyle { @apply font-mono; font-variation-settings: var(--font-monostyle-variation); + font-size: 95%; } /* Actually monospaced font */ .font-monospace { @apply font-mono; font-variation-settings: var(--font-monospace-variation); + font-size: 95%; } .truncate { @@ -528,6 +530,11 @@ top: auto; } +html { + /* Fixes sl-input components resizing when sl-scroll-lock is removed */ + scrollbar-gutter: stable; +} + /* Ensure buttons in shadow dom inherit hover color */ [class^="hover\:text-"]::part(base):hover, [class*=" hover\:text-"]::part(base):hover { diff --git a/frontend/src/types/crawler.ts b/frontend/src/types/crawler.ts index b68c0535d1..1b7eedfad3 100644 --- a/frontend/src/types/crawler.ts +++ b/frontend/src/types/crawler.ts @@ -66,6 +66,7 @@ export type WorkflowParams = { schedule: string; browserWindows: number; profileid: string | null; + profileName?: string | null; config: SeedConfig; tags: string[]; crawlTimeout: number | null; diff --git a/frontend/src/utils/workflow.ts b/frontend/src/utils/workflow.ts index 14621dbbef..40f3449716 100644 --- a/frontend/src/utils/workflow.ts +++ b/frontend/src/utils/workflow.ts @@ -383,7 +383,10 @@ export function getInitialFormState(params: { jobName: params.initialWorkflow.name || defaultFormState.jobName, description: params.initialWorkflow.description, browserProfile: params.initialWorkflow.profileid - ? ({ id: params.initialWorkflow.profileid } as Profile) + ? ({ + id: params.initialWorkflow.profileid, + name: params.initialWorkflow.profileName, + } as Profile) : defaultFormState.browserProfile, scopeType: primarySeedConfig.scopeType as FormState["scopeType"], exclusions: seedsConfig.exclude?.length === 0 ? [""] : seedsConfig.exclude, From 937a563797c9c9eca31c8af63e73667ae2890706 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Wed, 26 Nov 2025 18:04:14 -0800 Subject: [PATCH 10/12] fix: Handle no origins or invalid origin (#3025) - Fixes edge case where profile without valid origin can be saved - Handles edge case of no saved sites, since backend does not currently prevent saving profiles without any origins --- .../profile-browser-dialog.ts | 50 ++++++++++++++----- .../browser-profiles/profile-browser.ts | 24 ++++++++- .../browser-profiles/start-browser-dialog.ts | 12 +++-- .../src/pages/org/browser-profiles-list.ts | 5 +- .../src/pages/org/browser-profiles/profile.ts | 22 +++++--- 5 files changed, 88 insertions(+), 25 deletions(-) diff --git a/frontend/src/features/browser-profiles/profile-browser-dialog.ts b/frontend/src/features/browser-profiles/profile-browser-dialog.ts index 036c2343f8..83b4dd7068 100644 --- a/frontend/src/features/browser-profiles/profile-browser-dialog.ts +++ b/frontend/src/features/browser-profiles/profile-browser-dialog.ts @@ -13,6 +13,7 @@ import type { Dialog } from "@/components/ui/dialog"; import { bgClass, type BrowserConnectionChange, + type BrowserOriginsChange, type ProfileBrowser, } from "@/features/browser-profiles/profile-browser"; import type { @@ -24,6 +25,14 @@ import type { Profile } from "@/types/crawler"; import { isApiError } from "@/utils/api"; import { tw } from "@/utils/tailwind"; +enum BrowserStatus { + Initial, + Pending, + Ready, + Complete, + Error, +} + /** * @fires btrix-updated */ @@ -43,7 +52,7 @@ export class ProfileBrowserDialog extends BtrixElement { open = false; @state() - private isBrowserLoaded = false; + private browserStatus = BrowserStatus.Initial; @state() private showConfirmation = false; @@ -77,7 +86,7 @@ export class ProfileBrowserDialog extends BtrixElement { if (changedProperties.has("open")) { if (!this.open) { this.showConfirmation = false; - this.isBrowserLoaded = false; + this.browserStatus = BrowserStatus.Initial; this.#savedBrowserId = undefined; this.browserIdTask.abort(); } @@ -142,6 +151,7 @@ export class ProfileBrowserDialog extends BtrixElement { render() { const isCrawler = this.appState.isCrawler; const creatingNew = this.duplicating || !this.profile; + const incomplete = this.browserStatus !== BrowserStatus.Complete; const saving = this.saveProfileTask.status === TaskStatus.PENDING; return html` void this.dialog?.hide() : () => (this.showConfirmation = true)} > @@ -254,13 +266,13 @@ export class ProfileBrowserDialog extends BtrixElement { void this.submit()} > @@ -289,12 +301,16 @@ export class ProfileBrowserDialog extends BtrixElement { ? html` { - this.isBrowserLoaded = false; + this.browserStatus = BrowserStatus.Initial; }; private readonly onBrowserLoad = () => { - this.isBrowserLoaded = true; + this.browserStatus = BrowserStatus.Ready; }; private readonly onBrowserReload = () => { - this.isBrowserLoaded = false; + this.browserStatus = BrowserStatus.Pending; }; private readonly onBrowserError = () => { - this.isBrowserLoaded = false; + this.browserStatus = BrowserStatus.Error; + }; + + private readonly onBrowserOriginsChange = ( + e: CustomEvent, + ) => { + if (e.detail.origins.length) { + this.browserStatus = BrowserStatus.Complete; + } }; private readonly onBrowserConnectionChange = ( e: CustomEvent, ) => { - this.isBrowserLoaded = e.detail.connected; + this.browserStatus = e.detail.connected + ? BrowserStatus.Pending + : BrowserStatus.Initial; }; private async submit() { diff --git a/frontend/src/features/browser-profiles/profile-browser.ts b/frontend/src/features/browser-profiles/profile-browser.ts index 61023782e8..9a48753f9e 100644 --- a/frontend/src/features/browser-profiles/profile-browser.ts +++ b/frontend/src/features/browser-profiles/profile-browser.ts @@ -11,6 +11,7 @@ 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 @@ -31,6 +32,9 @@ export type BrowserNotAvailableError = { export type BrowserConnectionChange = { connected: boolean; }; +export type BrowserOriginsChange = { + origins: string[]; +}; const isPolling = (value: unknown): value is number => { return typeof value === "number"; @@ -52,6 +56,7 @@ const isPolling = (value: unknown): value is number => { * @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 @@ -166,9 +171,24 @@ export class ProfileBrowser extends BtrixElement { if (!browser || isPolling(browser)) return; try { - const data = await this.pingBrowser(browser.id, signal); + const { origins } = await this.pingBrowser(browser.id, signal); + + if ( + this.originsTask.value && + origins && + isNotEqual(this.originsTask.value, origins) + ) { + this.dispatchEvent( + new CustomEvent( + "btrix-browser-origins-change", + { + detail: { origins }, + }, + ), + ); + } - return data.origins; + return origins || []; } catch (err) { if (isApiError(err) && err.details === "no_such_browser") { void this.onBrowserError(); diff --git a/frontend/src/features/browser-profiles/start-browser-dialog.ts b/frontend/src/features/browser-profiles/start-browser-dialog.ts index a9b9fa2a11..22ce2c18cd 100644 --- a/frontend/src/features/browser-profiles/start-browser-dialog.ts +++ b/frontend/src/features/browser-profiles/start-browser-dialog.ts @@ -300,9 +300,15 @@ export class StartBrowserDialog extends BtrixElement { }} > ${msg("New Site")} - - ${msg("Saved Sites")} - ${profile.origins.map(option)} + + ${when( + profile.origins.length, + () => html` + + ${msg("Saved Sites")} + ${profile.origins.map(option)} + `, + )} ${when(this.workflowOrigins.value, (seeds) => seeds.length ? html` diff --git a/frontend/src/pages/org/browser-profiles-list.ts b/frontend/src/pages/org/browser-profiles-list.ts index 4ace718c76..7917de49a2 100644 --- a/frontend/src/pages/org/browser-profiles-list.ts +++ b/frontend/src/pages/org/browser-profiles-list.ts @@ -521,6 +521,9 @@ export class BrowserProfilesList extends BtrixElement { (a, b) => (b && a && b > a ? b : a), data.created, ) || data.created; + const none = html` + + `; return html` - ${originsWithRemainder(data.origins)} + ${data.origins.length ? originsWithRemainder(data.origins) : none} ${this.localize.relativeDate(modifiedByAnyDate, { capitalize: true })} diff --git a/frontend/src/pages/org/browser-profiles/profile.ts b/frontend/src/pages/org/browser-profiles/profile.ts index 434e2e0c12..d28e373968 100644 --- a/frontend/src/pages/org/browser-profiles/profile.ts +++ b/frontend/src/pages/org/browser-profiles/profile.ts @@ -394,13 +394,21 @@ export class BrowserProfilesProfilePage extends BtrixElement { return when( this.profile, - (profile) => html` -
    -
      - ${origins(profile)} -
    -
    - `, + (profile) => + profile.origins.length + ? html` +
    +
      + ${origins(profile)} +
    +
    + ` + : panelBody({ + content: emptyMessage({ + message: msg("No saved sites yet"), + detail: msg("Load a new URL to configure this profile."), + }), + }), originsSkeleton, ); } From 8ada243999d8926d80b180bc629bf46eb97779bb Mon Sep 17 00:00:00 2001 From: sua yoo Date: Thu, 27 Nov 2025 11:57:07 -0800 Subject: [PATCH 11/12] feat: Filter browser profile list by name (#3017) - Allows users to filter browser profile list by name - Fixes layout inconsistencies with browser profiles list --------- Co-authored-by: Emma Segal-Grossman --- frontend/src/layouts/pageHeader.ts | 4 +- .../src/pages/org/browser-profiles-list.ts | 228 +++++++++++------- frontend/src/pages/org/collections-list.ts | 4 +- frontend/src/utils/searchValues.ts | 14 ++ 4 files changed, 158 insertions(+), 92 deletions(-) create mode 100644 frontend/src/utils/searchValues.ts diff --git a/frontend/src/layouts/pageHeader.ts b/frontend/src/layouts/pageHeader.ts index 3b6f7478e2..5bd908cb2d 100644 --- a/frontend/src/layouts/pageHeader.ts +++ b/frontend/src/layouts/pageHeader.ts @@ -123,8 +123,8 @@ export function pageHeader({ return html`
    diff --git a/frontend/src/pages/org/browser-profiles-list.ts b/frontend/src/pages/org/browser-profiles-list.ts index 7917de49a2..94e064f33b 100644 --- a/frontend/src/pages/org/browser-profiles-list.ts +++ b/frontend/src/pages/org/browser-profiles-list.ts @@ -3,6 +3,7 @@ import { Task } from "@lit/task"; import { html, type PropertyValues } from "lit"; import { customElement, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; +import omit from "lodash/fp/omit"; import queryString from "query-string"; import type { Profile } from "./types"; @@ -20,7 +21,7 @@ import { ClipboardController } from "@/controllers/clipboard"; import { SearchParamsValue } from "@/controllers/searchParamsValue"; import { originsWithRemainder } from "@/features/browser-profiles/templates/origins-with-remainder"; import { emptyMessage } from "@/layouts/emptyMessage"; -import { page } from "@/layouts/page"; +import { pageHeader } from "@/layouts/pageHeader"; import { OrgTab } from "@/routes"; import type { APIPaginatedList, @@ -30,6 +31,7 @@ import type { import { SortDirection as SortDirectionEnum } from "@/types/utils"; import { isApiError } from "@/utils/api"; import { isArchivingDisabled } from "@/utils/orgs"; +import { toSearchItem, type SearchValues } from "@/utils/searchValues"; const SORT_DIRECTIONS = ["asc", "desc"] as const; type SortDirection = (typeof SORT_DIRECTIONS)[number]; @@ -45,7 +47,7 @@ const sortableFields: Record< > = { name: { label: msg("Name"), - defaultDirection: "desc", + defaultDirection: "asc", }, url: { label: msg("Primary Site"), @@ -66,7 +68,17 @@ const DEFAULT_SORT_BY = { direction: sortableFields.modified.defaultDirection || "desc", } as const satisfies SortBy; const INITIAL_PAGE_SIZE = 20; -const FILTER_BY_CURRENT_USER_STORAGE_KEY = "btrix.filterByCurrentUser.crawls"; +const FILTER_BY_CURRENT_USER_STORAGE_KEY = + "btrix.filterByCurrentUser.browserProfiles"; +const SEARCH_KEYS = ["name"] as const; +const DEFAULT_TAGS_TYPE = "or"; + +type FilterBy = { + name?: string; + tags?: string[]; + tagsType?: "and" | "or"; + mine?: boolean; +}; const columnsCss = [ "min-content", // Status @@ -124,56 +136,65 @@ export class BrowserProfilesList extends BtrixElement { }, ); - private readonly filterByTags = new SearchParamsValue( - this, - (value, params) => { - params.delete("tags"); - value?.forEach((v) => { - params.append("tags", v); - }); - return params; - }, - (params) => params.getAll("tags"), - ); - - private readonly filterByTagsType = new SearchParamsValue<"and" | "or">( + private readonly filterBy = new SearchParamsValue( this, (value, params) => { - if (value === "and") { - params.set("tagsType", value); + if ("name" in value && value["name"]) { + params.set("name", value["name"]); + } else { + params.delete("name"); + } + if ("tags" in value) { + params.delete("tags"); + value["tags"]?.forEach((v) => { + params.append("tags", v); + }); + } else { + params.delete("tags"); + } + if ("tagsType" in value && value["tagsType"] === "and") { + params.set("tagsType", value["tagsType"]); } else { params.delete("tagsType"); } - return params; - }, - (params) => (params.get("tagsType") === "and" ? "and" : "or"), - ); - - private readonly filterByCurrentUser = new SearchParamsValue( - this, - (value, params) => { - if (value) { + if ("mine" in value && value["mine"]) { params.set("mine", "true"); + window.sessionStorage.setItem( + FILTER_BY_CURRENT_USER_STORAGE_KEY, + "true", + ); } else { params.delete("mine"); + window.sessionStorage.removeItem(FILTER_BY_CURRENT_USER_STORAGE_KEY); } return params; }, - (params) => params.get("mine") === "true", + (params) => ({ + name: params.get("name") || undefined, + tags: params.getAll("tags"), + tagsType: params.get("tagsType") === "and" ? "and" : "or", + mine: params.get("mine") === "true", + }), { - initial: (initialValue) => - window.sessionStorage.getItem(FILTER_BY_CURRENT_USER_STORAGE_KEY) === - "true" || - initialValue || - false, + initial: (initialValue) => ({ + ...initialValue, + mine: + window.sessionStorage.getItem(FILTER_BY_CURRENT_USER_STORAGE_KEY) === + "true" || + initialValue?.["mine"] || + false, + }), }, ); private get hasFiltersSet() { - return [ - this.filterByCurrentUser.value || undefined, - this.filterByTags.value?.length || undefined, - ].some((v) => v !== undefined); + const filterBy = this.filterBy.value; + return ( + filterBy.name || + filterBy.tags?.length || + (filterBy.tagsType ?? DEFAULT_TAGS_TYPE) !== DEFAULT_TAGS_TYPE || + filterBy.mine + ); } get isCrawler() { @@ -181,27 +202,18 @@ export class BrowserProfilesList extends BtrixElement { } private clearFilters() { - this.filterByCurrentUser.setValue(false); - this.filterByTags.setValue([]); + this.filterBy.setValue({}); } private readonly profilesTask = new Task(this, { - task: async ( - [ - pagination, - orderBy, - filterByCurrentUser, - filterByTags, - filterByTagsType, - ], - { signal }, - ) => { + task: async ([pagination, orderBy, filterBy], { signal }) => { return this.getProfiles( { ...pagination, - userid: filterByCurrentUser ? this.userInfo?.id : undefined, - tags: filterByTags, - tagMatch: filterByTagsType, + userid: filterBy.mine ? this.userInfo?.id : undefined, + name: filterBy.name || undefined, + tags: filterBy.tags, + tagMatch: filterBy.tagsType, sortBy: orderBy.field, sortDirection: orderBy.direction === "desc" @@ -212,39 +224,33 @@ export class BrowserProfilesList extends BtrixElement { ); }, args: () => - [ - this.pagination, - this.orderBy.value, - this.filterByCurrentUser.value, - this.filterByTags.value, - this.filterByTagsType.value, - ] as const, + [this.pagination, this.orderBy.value, this.filterBy.value] as const, + }); + + private readonly searchOptionsTask = new Task(this, { + task: async (_args, { signal }) => { + const data = await this.getSearchValues(signal); + + return [...data.names.map(toSearchItem("name"))]; + }, + args: () => [] as const, }); protected willUpdate(changedProperties: PropertyValues): void { if ( changedProperties.has("orderBy.internalValue") || - changedProperties.has("filterByCurrentUser.internalValue") || - changedProperties.has("filterByTags.internalValue") || - changedProperties.has("filterByTagsType.internalValue") + changedProperties.has("filterBy.internalValue") ) { this.pagination = { ...this.pagination, page: 1, }; } - - if (changedProperties.has("filterByCurrentUser.internalValue")) { - window.sessionStorage.setItem( - FILTER_BY_CURRENT_USER_STORAGE_KEY, - this.filterByCurrentUser.value.toString(), - ); - } } render() { - return page( - { + return html` + ${pageHeader({ title: msg("Browser Profiles"), border: false, actions: this.isCrawler @@ -266,9 +272,9 @@ export class BrowserProfilesList extends BtrixElement { ` : undefined, - }, - this.renderPage, - ); + })} + ${this.renderPage()} + `; } private readonly renderPage = () => { @@ -377,41 +383,77 @@ export class BrowserProfilesList extends BtrixElement { private renderControls() { return html` -
    +
    +
    ${this.renderSearch()}
    + +
    + + ${this.renderSortControl()} +
    +
    ${msg("Filter by:")} ${this.renderFilterControls()}
    - -
    - - ${this.renderSortControl()} -
    `; } + private renderSearch() { + return html` + { + const { key, value } = e.detail; + this.filterBy.setValue({ + ...this.filterBy.value, + [key]: value, + }); + }} + @btrix-clear=${() => { + const otherFilters = omit(SEARCH_KEYS, this.filterBy.value); + this.filterBy.setValue(otherFilters); + }} + > + + `; + } + private renderFilterControls() { + const filterBy = this.filterBy.value; + return html` { - this.filterByTags.setValue(e.detail.value?.tags || []); - this.filterByTagsType.setValue(e.detail.value?.type || "or"); + this.filterBy.setValue({ + ...this.filterBy.value, + tags: e.detail.value?.tags || [], + tagsType: e.detail.value?.type || DEFAULT_TAGS_TYPE, + }); }} > { const { checked } = e.target as FilterChip; - this.filterByCurrentUser.setValue(Boolean(checked)); + this.filterBy.setValue({ + ...this.filterBy.value, + mine: checked, + }); }} > ${msg("Mine")} @@ -652,6 +694,7 @@ export class BrowserProfilesList extends BtrixElement { private async getProfiles( params: { userid?: string; + name?: string; tags?: string[]; tagMatch?: string; } & APIPaginationQuery & @@ -674,4 +717,13 @@ export class BrowserProfilesList extends BtrixElement { return data; } + + private async getSearchValues(signal: AbortSignal) { + return this.api.fetch( + `/orgs/${this.orgId}/profiles/search-values`, + { + signal, + }, + ); + } } diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index 336eeeb88f..bedc7b7430 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -150,6 +150,7 @@ export class CollectionsList extends WithSearchOrgContext(BtrixElement) {
    ${pageHeader({ title: msg("Collections"), + border: false, actions: this.isCrawler ? html` ` : nothing, - classNames: tw`border-b-transparent`, })}
    @@ -384,7 +384,7 @@ export class CollectionsList extends WithSearchOrgContext(BtrixElement) { > { this.searchResultsOpen = false; diff --git a/frontend/src/utils/searchValues.ts b/frontend/src/utils/searchValues.ts new file mode 100644 index 0000000000..267eb5c80a --- /dev/null +++ b/frontend/src/utils/searchValues.ts @@ -0,0 +1,14 @@ +export type SearchValues = { + names: string[]; +}; + +export type SearchField = "name"; + +/** + * Convert API search values to a format compatible with Fuse collections + */ +export function toSearchItem(key: T) { + return (value: string) => ({ + [key as string]: value, + }); +} From 6caea40773beb622989c90bbcf809ba55a910c9d Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 27 Nov 2025 11:57:40 -0800 Subject: [PATCH 12/12] Frontend browser profile UI enhancements origin check fix (#3030) - Fix for 'Create Profile' button sometimes not being enabled due to event not firing, see: https://github.com/webrecorder/browsertrix/pull/3024#issuecomment-3587067342 - Ensure that if origins are loaded before browser is ready, both are checked separately. --- .../features/browser-profiles/profile-browser-dialog.ts | 9 ++++++--- .../src/features/browser-profiles/profile-browser.ts | 6 +----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/frontend/src/features/browser-profiles/profile-browser-dialog.ts b/frontend/src/features/browser-profiles/profile-browser-dialog.ts index 83b4dd7068..9166155afc 100644 --- a/frontend/src/features/browser-profiles/profile-browser-dialog.ts +++ b/frontend/src/features/browser-profiles/profile-browser-dialog.ts @@ -29,7 +29,6 @@ enum BrowserStatus { Initial, Pending, Ready, - Complete, Error, } @@ -54,6 +53,9 @@ export class ProfileBrowserDialog extends BtrixElement { @state() private browserStatus = BrowserStatus.Initial; + @state() + private originsLoaded = false; + @state() private showConfirmation = false; @@ -151,7 +153,8 @@ export class ProfileBrowserDialog extends BtrixElement { render() { const isCrawler = this.appState.isCrawler; const creatingNew = this.duplicating || !this.profile; - const incomplete = this.browserStatus !== BrowserStatus.Complete; + const incomplete = + !this.originsLoaded || this.browserStatus !== BrowserStatus.Ready; const saving = this.saveProfileTask.status === TaskStatus.PENDING; return html`, ) => { if (e.detail.origins.length) { - this.browserStatus = BrowserStatus.Complete; + this.originsLoaded = true; } }; diff --git a/frontend/src/features/browser-profiles/profile-browser.ts b/frontend/src/features/browser-profiles/profile-browser.ts index 9a48753f9e..61e4263fa4 100644 --- a/frontend/src/features/browser-profiles/profile-browser.ts +++ b/frontend/src/features/browser-profiles/profile-browser.ts @@ -173,11 +173,7 @@ export class ProfileBrowser extends BtrixElement { try { const { origins } = await this.pingBrowser(browser.id, signal); - if ( - this.originsTask.value && - origins && - isNotEqual(this.originsTask.value, origins) - ) { + if (origins && isNotEqual(this.originsTask.value, origins)) { this.dispatchEvent( new CustomEvent( "btrix-browser-origins-change",