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 { when } from "lit/directives/when.js"; import queryString from "query-string"; import type { Profile } from "./types"; import type { SelectNewDialogEvent } from "."; import { BtrixElement } from "@/classes/BtrixElement"; 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 type { APIPaginatedList, APIPaginationQuery, APISortQuery, } from "@/types/api"; import type { Browser } from "@/types/browser"; import { html } from "@/utils/LiteElement"; import { isArchivingDisabled } from "@/utils/orgs"; import { tw } from "@/utils/tailwind"; const INITIAL_PAGE_SIZE = 20; /** * 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, }; @state() private isLoading = true; static styles = css` btrix-table { grid-template-columns: [clickable-start] minmax(30ch, 50ch) minmax(30ch, 40ch) repeat(2, 1fr) [clickable-end] min-content; --btrix-cell-gap: var(--sl-spacing-x-small); --btrix-cell-padding-left: var(--sl-spacing-small); --btrix-cell-padding-right: 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(); } } render() { return html`${pageHeader({ title: msg("Browser Profiles"), actions: this.isCrawler ? html` { this.dispatchEvent( new CustomEvent("select-new-dialog", { detail: "browser-profile", }) as SelectNewDialogEvent, ); }} > ${msg("New Browser Profile")} ` : undefined, classNames: tw`mb-3`, })}
${this.renderTable()}
`; } 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`, }, }; const getSortIcon = (sortValue: SortValues) => { const { name, label, className } = sortProps[sortValue]; return html` `; }; 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.")}

`, )} `; } private readonly renderLoading = () => html`
`; private readonly renderItem = (data: Profile) => { return html` ${data.name}
${data.origins[0]}
${data.origins.length > 1 ? html` ${data.origins.slice(1).join(", ")} ${msg(str`+${data.origins.length - 1}`)} ` : nothing}
${this.renderActions(data)}
`; }; private renderActions(data: Profile) { return html` e.preventDefault()}> { void this.duplicateProfile(data); }} > ${msg("Duplicate Profile")} ClipboardController.copyToClipboard(data.id)} > ${msg("Copy Profile ID")} { void this.deleteProfile(data); }} > ${msg("Delete Profile")} `; } 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", }); } } private async deleteProfile(profile: Profile) { try { const data = await this.api.fetch( `/orgs/${this.orgId}/profiles/${profile.id}`, { method: "DELETE", }, ); if (data.error && data.crawlconfigs) { this.notify.toast({ message: msg( html`Could not delete ${profile.name}, in use by ${data.crawlconfigs.map(({ name }) => name).join(", ")}. Please remove browser profile from Workflow to continue.`, ), variant: "warning", icon: "exclamation-triangle", duration: 15000, }); } else { this.notify.toast({ message: msg(html`Deleted ${profile.name}.`), variant: "success", icon: "check2-circle", id: "browser-profile-deleted-status", }); void this.fetchBrowserProfiles(); } } catch (e) { this.notify.toast({ message: msg("Sorry, couldn't delete browser profile at this time."), variant: "danger", icon: "exclamation-octagon", id: "browser-profile-deleted-status", }); } } private async createBrowser({ url }: { url: string }) { const params = { url, }; return this.api.fetch(`/orgs/${this.orgId}/profiles/browser`, { method: "POST", body: JSON.stringify(params), }); } /** * 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) { const query = queryString.stringify( { ...params, ...this.sort, }, { arrayFormat: "comma", }, ); const data = await this.api.fetch>( `/orgs/${this.orgId}/profiles?${query}`, ); return data; } }