browsertrix/frontend/src/pages/org/browser-profiles-detail.ts
sua yoo 99ed08656a
feat: Localization workflow improvements (#2069)
- Extracts translatable text strings in pre-commit hook
- Updates ternary pluralization to use `pluralOf` instead
- Generates XLIFF for Spanish
2024-09-10 14:15:26 -07:00

801 lines
24 KiB
TypeScript

import { localized, msg, str } from "@lit/localize";
import { html, nothing, type TemplateResult } 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, ProfileWorkflow } from "./types";
import { BtrixElement } from "@/classes/BtrixElement";
import type { Dialog } from "@/components/ui/dialog";
import type { BrowserConnectionChange } from "@/features/browser-profiles/profile-browser";
import { pageBreadcrumbs } from "@/layouts/pageHeader";
import { isApiError } from "@/utils/api";
import { maxLengthValidator } from "@/utils/form";
import { formatNumber, getLocale } from "@/utils/localization";
import { isArchivingDisabled } from "@/utils/orgs";
import { pluralOf } from "@/utils/pluralize";
const DESCRIPTION_MAXLENGTH = 500;
/**
* Usage:
* ```ts
* <btrix-browser-profiles-detail
* authState=${authState}
* profileId=${profileId}
* ></btrix-browser-profiles-detail>
* ```
*/
@localized()
@customElement("btrix-browser-profiles-detail")
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);
disconnectedCallback() {
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`<span class="text-neutral-400">${msg("None")}</span>`;
return html`<div class="mb-7">${this.renderBreadcrumbs()}</div>
<header class="mb-3 items-center justify-between md:flex">
<h1 class="min-w-0 flex-1 truncate text-xl font-medium leading-7">
${this.profile?.name
? html`${this.profile.name}`
: html`<sl-skeleton class="w-80 md:h-7"></sl-skeleton>`}
</h1>
<div>
${this.profile
? this.renderMenu()
: html`<sl-skeleton
style="width: 6em; height: 2em;"
></sl-skeleton>`}
</div>
</header>
<section class="mb-5 rounded-lg border px-4 py-2">
<btrix-desc-list horizontal>
<btrix-desc-list-item label=${msg("Crawler Release Channel")}>
${this.profile
? this.profile.crawlerChannel
? capitalize(this.profile.crawlerChannel)
: none
: nothing}
</btrix-desc-list-item>
<btrix-desc-list-item label=${msg("Created On")}>
${this.profile
? html`
<sl-format-date
lang=${getLocale()}
date=${`${this.profile.created}Z` /** Z for UTC */}
month="2-digit"
day="2-digit"
year="2-digit"
hour="numeric"
minute="numeric"
time-zone-name="short"
></sl-format-date>
`
: nothing}
</btrix-desc-list-item>
<btrix-desc-list-item label=${msg("Last Updated")}>
${this.profile
? html` <sl-format-date
lang=${getLocale()}
date=${
`${
// NOTE older profiles may not have "modified" data
this.profile.modified || this.profile.created
}Z` /** Z for UTC */
}
month="2-digit"
day="2-digit"
year="2-digit"
hour="numeric"
minute="numeric"
time-zone-name="short"
></sl-format-date>`
: nothing}
</btrix-desc-list-item>
${
// NOTE older profiles may not have "modified/created by" data
this.profile?.modifiedByName || this.profile?.createdByName
? html`
<btrix-desc-list-item label=${msg("Updated By")}>
${this.profile.modifiedByName || this.profile.createdByName}
</btrix-desc-list-item>
`
: nothing
}
</btrix-desc-list>
</section>
<div class="mb-7 flex flex-col gap-5 lg:flex-row">
<section class="flex-1">
<header class="flex items-center gap-2">
<sl-tooltip
content=${isBackedUp ? msg("Backed Up") : msg("Not Backed Up")}
?disabled=${!this.profile}
>
<sl-icon
class="${isBackedUp
? "text-success"
: "text-neutral-500"} text-base"
name=${this.profile
? isBackedUp
? "clouds-fill"
: "cloud-slash-fill"
: "clouds"}
></sl-icon>
</sl-tooltip>
<h2 class="text-lg font-medium leading-none">
${msg("Browser Profile")}
</h2>
</header>
${when(this.isCrawler, () =>
this.browserId || this.isBrowserLoading
? html`
<div id="profileBrowserContainer" class="flex h-screen py-3">
<div class="flex flex-1 flex-col gap-2">
<btrix-profile-browser
class="flex-1 overflow-hidden rounded border"
browserId=${ifDefined(this.browserId)}
.origins=${this.profile?.origins}
@btrix-browser-load=${() =>
(this.isBrowserLoaded = true)}
@btrix-browser-error=${this.onBrowserError}
@btrix-browser-reload=${this.startBrowserPreview}
@btrix-browser-connection-change=${this
.onBrowserConnectionChange}
></btrix-profile-browser>
<div
class="flex-0 sticky bottom-2 rounded-lg border bg-neutral-0 shadow"
>
${this.renderBrowserProfileControls()}
</div>
</div>
</div>
`
: html`<div
class="mt-3 flex aspect-video flex-col items-center justify-center rounded-lg border bg-neutral-50"
>
<p
class="mx-auto mb-4 max-w-prose text-center text-neutral-600"
>
${msg(
"View or edit the current browser profile configuration.",
)}
</p>
<sl-button
?disabled=${isArchivingDisabled(this.org)}
@click=${this.startBrowserPreview}
>
<sl-icon slot="prefix" name="gear"></sl-icon>
${msg("Configure Browser Profile")}
</sl-button>
</div>`,
)}
</section>
${when(
!(this.browserId || this.isBrowserLoading),
this.renderVisitedSites,
)}
</div>
<section class="mb-7">
<header class="flex items-center justify-between">
<h2 class="mb-1 text-lg font-medium leading-none">
${msg("Description")}
</h2>
${when(
this.isCrawler,
() => html`
<sl-icon-button
class="text-base"
name="pencil"
@click=${() => (this.isEditDialogOpen = true)}
label=${msg("Edit description")}
></sl-icon-button>
`,
)}
</header>
<!-- display: inline -->
<div
class="leading whitespace-pre-line rounded border p-5 leading-relaxed first-line:leading-[0]"
>${this.profile
? this.profile.description ||
html`
<div class="text-center text-neutral-400">
&nbsp;${msg("No description added.")}
</div>
`
: nothing}</div
>
</section>
<section class="mb-7">
<h2 class="mb-2 text-lg font-medium leading-none">
${msg("Crawl Workflows")}${this.profile?.crawlconfigs?.length
? html`<span class="font-normal text-neutral-500">
(${formatNumber(this.profile.crawlconfigs.length)})
</span>`
: nothing}
</h2>
${this.renderCrawlWorkflows()}
</section>
<btrix-dialog id="discardChangesDialog" .label=${msg("Cancel Editing?")}>
${msg(
"Are you sure you want to discard changes to this browser profile?",
)}
<div slot="footer" class="flex justify-between">
<sl-button
size="small"
.autofocus=${true}
@click=${() => void this.discardChangesDialog?.hide()}
>
${msg("No, Continue Editing")}
</sl-button>
<sl-button
size="small"
variant="danger"
@click=${() => {
void this.cancelEditBrowser();
void this.discardChangesDialog?.hide();
}}
>${msg("Yes, Discard Changes")}
</sl-button>
</div>
</btrix-dialog>
<btrix-dialog
.label=${msg(str`Edit Metadata`)}
.open=${this.isEditDialogOpen}
@sl-request-close=${() => (this.isEditDialogOpen = false)}
@sl-show=${() => (this.isEditDialogContentVisible = true)}
@sl-after-hide=${() => (this.isEditDialogContentVisible = false)}
>
${this.isEditDialogContentVisible ? this.renderEditProfile() : nothing}
</btrix-dialog> `;
}
private renderBreadcrumbs() {
const breadcrumbs = [
{
href: `${this.navigate.orgBasePath}/browser-profiles`,
content: msg("Browser Profiles"),
},
{
content: this.profile?.name,
},
];
return pageBreadcrumbs(breadcrumbs);
}
private renderCrawlWorkflows() {
if (this.profile?.crawlconfigs?.length) {
return html`<ul>
${this.profile.crawlconfigs.map(
(workflow) => html`
<li
class="border-x border-b first:rounded-t first:border-t last:rounded-b"
>
<a
class="block p-2 transition-colors focus-within:bg-neutral-50 hover:bg-neutral-50"
href=${`${this.navigate.orgBasePath}/workflows/crawl/${workflow.id}`}
@click=${this.navigate.link}
>
${this.renderWorkflowName(workflow)}
</a>
</li>
`,
)}
</ul>`;
}
return html`<div class="rounded border p-5 text-center text-neutral-400">
${msg("Not used in any crawl workflows.")}
</div>`;
}
private renderWorkflowName(workflow: ProfileWorkflow) {
if (workflow.name)
return html`<span class="truncate">${workflow.name}</span>`;
if (!workflow.firstSeed)
return html`<span class="truncate font-mono">${workflow.id}</span>
<span class="text-neutral-400">${msg("(no name)")}</span>`;
const remainder = workflow.seedCount - 1;
let nameSuffix: string | TemplateResult<1> = "";
if (remainder) {
nameSuffix = html`<span class="ml-2 text-neutral-500"
>+${formatNumber(remainder, { notation: "compact" })}
${pluralOf("URLs", remainder)}</span
>`;
}
return html`
<span class="primaryUrl truncate">${workflow.firstSeed}</span
>${nameSuffix}
`;
}
private readonly renderVisitedSites = () => {
return html`
<section class="flex-grow-1 flex flex-col lg:w-[60ch]">
<header class="flex-0 mb-3">
<h2 class="text-lg font-medium leading-none">
${msg("Visited Sites")}
</h2>
</header>
<div class="flex-1 overflow-auto rounded-lg border p-4">
<ul class="font-monostyle text-neutral-800">
${this.profile?.origins.map((origin) => html`<li>${origin}</li>`)}
</ul>
</div>
</section>
`;
};
private renderBrowserProfileControls() {
return html`
<div class="flex justify-between p-4">
<sl-button size="small" @click=${this.onCancel}>
${msg("Cancel")}
</sl-button>
<div>
<sl-button
variant="primary"
size="small"
?loading=${this.isSubmittingBrowserChange}
?disabled=${this.isSubmittingBrowserChange || !this.isBrowserLoaded}
@click=${this.saveBrowser}
>
${msg("Save Browser Profile")}
</sl-button>
</div>
</div>
`;
}
private renderMenu() {
const archivingDisabled = isArchivingDisabled(this.org);
return html`
<sl-dropdown distance="4" placement="bottom-end">
<sl-button size="small" slot="trigger" caret>
${msg("Actions")}
</sl-button>
<sl-menu>
<sl-menu-item @click=${() => (this.isEditDialogOpen = true)}>
<sl-icon slot="prefix" name="pencil"></sl-icon>
${msg("Edit Metadata")}
</sl-menu-item>
<sl-menu-item
?disabled=${archivingDisabled}
@click=${this.startBrowserPreview}
>
<sl-icon slot="prefix" name="gear"></sl-icon>
${msg("Configure Browser Profile")}
</sl-menu-item>
<sl-menu-item
?disabled=${archivingDisabled}
@click=${() => void this.duplicateProfile()}
>
<sl-icon slot="prefix" name="files"></sl-icon>
${msg("Duplicate Profile")}
</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item
style="--sl-color-neutral-700: var(--danger)"
@click=${() => void this.deleteProfile()}
>
<sl-icon slot="prefix" name="trash3"></sl-icon>
${msg("Delete")}
</sl-menu-item>
</sl-menu>
</sl-dropdown>
`;
}
private renderEditProfile() {
if (!this.profile) return;
return html`
<form @submit=${this.onSubmitEdit}>
<div>
<sl-input
name="name"
class="with-max-help-text"
label=${msg("Name")}
autocomplete="off"
value=${this.profile.name}
help-text=${this.validateNameMax.helpText}
@sl-input=${this.validateNameMax.validate}
required
></sl-input>
</div>
<div class="mb-5">
<sl-textarea
name="description"
class="with-max-help-text"
label=${msg("Description")}
value=${this.profile.description || ""}
rows="3"
autocomplete="off"
resize="auto"
help-text=${this.validateDescriptionMax.helpText}
@sl-input=${this.validateDescriptionMax.validate}
></sl-textarea>
</div>
<div class="flex justify-between">
<sl-button
variant="default"
size="small"
@click=${() => (this.isEditDialogOpen = false)}
>${msg("Cancel")}</sl-button
>
<sl-button
variant="primary"
size="small"
type="submit"
?disabled=${this.isSubmittingProfileChange}
?loading=${this.isSubmittingProfileChange}
>${msg("Save Changes")}</sl-button
>
</div>
</form>
`;
}
private async onBrowserError() {
this.isBrowserLoaded = false;
}
private async onBrowserConnectionChange(
e: CustomEvent<BrowserConnectionChange>,
) {
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",
});
}
}
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,
})}`,
);
} catch (e) {
this.isBrowserLoading = false;
this.notify.toast({
message: msg("Sorry, couldn't create browser profile at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async deleteProfile() {
const profileName = this.profile!.name;
try {
const data = await this.api.fetch<Profile & { error: boolean }>(
`/orgs/${this.orgId}/profiles/${this.profile!.id}`,
{
method: "DELETE",
},
);
if (data.error && data.crawlconfigs) {
this.notify.toast({
message: msg(
html`Could not delete <strong>${profileName}</strong>, in use by
<strong
>${data.crawlconfigs.map(({ name }) => name).join(", ")}</strong
>. Please remove browser profile from Workflow to continue.`,
),
variant: "warning",
icon: "exclamation-triangle",
duration: 15000,
});
} else {
this.navigate.to(`${this.navigate.orgBasePath}/browser-profiles`);
this.notify.toast({
message: msg(html`Deleted <strong>${profileName}</strong>.`),
variant: "success",
icon: "check2-circle",
});
}
} catch (e) {
this.notify.toast({
message: msg("Sorry, couldn't delete browser profile at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
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<void> {
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",
});
}
}
private async getProfile() {
const data = await this.api.fetch<Profile>(
`/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 {
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",
});
this.browserId = undefined;
} else {
throw data;
}
} catch (e) {
this.notify.toast({
message: msg("Sorry, couldn't save browser profile at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
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",
});
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",
});
}
this.isSubmittingProfileChange = false;
}
async checkFormValidity(formEl: HTMLFormElement) {
await this.updateComplete;
return !formEl.querySelector("[data-invalid]");
}
}