## Changes Implements suggestions from https://typescript-eslint.io/blog/consistent-type-imports-and-exports-why-and-how/ and https://www.totaltypescript.com/method-shorthand-syntax-considered-harmful, along with a couple more auto-fixable consistency rules. Of note: - Functions that return a promise are marked as async - Suggestions now appear for where to simplify boolean checks, non-nullish assertions, and optional chaining
671 lines
18 KiB
TypeScript
671 lines
18 KiB
TypeScript
import { state, property, customElement } from "lit/decorators.js";
|
|
import { ifDefined } from "lit/directives/if-defined.js";
|
|
import { when } from "lit/directives/when.js";
|
|
import { msg, localized, str } from "@lit/localize";
|
|
|
|
import type { AuthState } from "@/utils/AuthService";
|
|
import LiteElement, { html } from "@/utils/LiteElement";
|
|
import type { Profile } from "./types";
|
|
import { isApiError } from "@/utils/api";
|
|
import { type SlDropdown } from "@shoelace-style/shoelace";
|
|
|
|
/**
|
|
* Usage:
|
|
* ```ts
|
|
* <btrix-browser-profiles-detail
|
|
* authState=${authState}
|
|
* orgId=${orgId}
|
|
* profileId=${profileId}
|
|
* ></btrix-browser-profiles-detail>
|
|
* ```
|
|
*/
|
|
@localized()
|
|
@customElement("btrix-browser-profiles-detail")
|
|
export class BrowserProfilesDetail extends LiteElement {
|
|
@property({ type: Object })
|
|
authState!: AuthState;
|
|
|
|
@property({ type: String })
|
|
orgId!: string;
|
|
|
|
@property({ type: String })
|
|
profileId!: string;
|
|
|
|
@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;
|
|
|
|
disconnectedCallback() {
|
|
if (this.browserId) {
|
|
void this.deleteBrowser(this.browserId);
|
|
}
|
|
}
|
|
|
|
firstUpdated() {
|
|
void this.fetchProfile();
|
|
}
|
|
|
|
render() {
|
|
return html`<div class="mb-7">
|
|
<a
|
|
class="text-sm font-medium text-neutral-500 hover:text-neutral-600"
|
|
href=${`${this.orgBasePath}/browser-profiles`}
|
|
@click=${this.navLink}
|
|
>
|
|
<sl-icon
|
|
name="arrow-left"
|
|
class="inline-block align-middle"
|
|
></sl-icon>
|
|
<span class="inline-block align-middle"
|
|
>${msg("Back to Browser Profiles")}</span
|
|
>
|
|
</a>
|
|
</div>
|
|
|
|
<header class="mb-3 items-center justify-between md:flex">
|
|
<h2 class="mb-1 text-xl font-semibold md:h-9">
|
|
${this.profile?.name
|
|
? html`${this.profile.name}
|
|
<sl-button
|
|
size="small"
|
|
variant="text"
|
|
@click=${() => (this.isEditDialogOpen = true)}
|
|
>
|
|
${msg("Edit")}
|
|
</sl-button>`
|
|
: html`<sl-skeleton class="w-80 md:h-9"></sl-skeleton>`}
|
|
</h2>
|
|
<div>
|
|
${this.profile
|
|
? this.renderMenu()
|
|
: html`<sl-skeleton
|
|
style="width: 6em; height: 2em;"
|
|
></sl-skeleton>`}
|
|
</div>
|
|
</header>
|
|
|
|
<section class="mb-5 rounded border p-4">
|
|
<dl class="grid grid-cols-3 gap-5">
|
|
<div class="col-span-3 md:col-span-1">
|
|
<dt class="text-sm text-0-600">${msg("Description")}</dt>
|
|
<dd>
|
|
${this.profile
|
|
? this.profile.description ||
|
|
html`<span class="text-neutral-400">${msg("None")}</span>`
|
|
: ""}
|
|
</dd>
|
|
</div>
|
|
<div class="col-span-3 md:col-span-1">
|
|
<dt class="text-sm text-0-600">
|
|
<span class="inline-block align-middle"
|
|
>${msg("Created at")}</span
|
|
>
|
|
</dt>
|
|
<dd>
|
|
${this.profile
|
|
? html`
|
|
<sl-format-date
|
|
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>
|
|
`
|
|
: ""}
|
|
</dd>
|
|
</div>
|
|
<div class="col-span-3 md:col-span-1">
|
|
<dt class="text-sm text-0-600">
|
|
<span class="inline-block align-middle"
|
|
>${msg("Crawl Workflows")}</span
|
|
>
|
|
<sl-tooltip content=${msg("Crawl workflows using this profile")}>
|
|
<sl-icon
|
|
class="inline-block align-middle"
|
|
name="info-circle"
|
|
></sl-icon>
|
|
</sl-tooltip>
|
|
</dt>
|
|
<dd>
|
|
<ul class="text-sm font-medium">
|
|
${this.profile?.crawlconfigs?.map(
|
|
({ id, name }) => html`
|
|
<li>
|
|
<a
|
|
class="text-neutral-600 hover:underline"
|
|
href=${`/orgs/${
|
|
this.profile!.oid
|
|
}/workflows/crawl/${id}`}
|
|
>
|
|
${name}
|
|
</a>
|
|
</li>
|
|
`,
|
|
)}
|
|
</ul>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
|
|
<div class="flex">
|
|
<section class="flex-1">
|
|
<header class="mb-2">
|
|
<h3 class="text-lg font-medium">${msg("Browser Profile")}</h3>
|
|
</header>
|
|
${when(
|
|
this.browserId || this.isBrowserLoading,
|
|
() => html`
|
|
<div
|
|
class="flex h-screen flex-col overflow-hidden rounded-lg border"
|
|
>
|
|
<btrix-profile-browser
|
|
class="flex-1"
|
|
.authState=${this.authState}
|
|
orgId=${this.orgId}
|
|
browserId=${ifDefined(this.browserId)}
|
|
.origins=${this.profile?.origins}
|
|
@load=${() => (this.isBrowserLoaded = true)}
|
|
></btrix-profile-browser>
|
|
<div class="flex-0 border-t">
|
|
${this.renderBrowserProfileControls()}
|
|
</div>
|
|
</div>
|
|
`,
|
|
() =>
|
|
html`<div
|
|
class="flex aspect-4/3 flex-col items-center justify-center rounded-lg border bg-neutral-50"
|
|
>
|
|
<p class="mb-4 max-w-prose text-neutral-600">
|
|
${msg(
|
|
"Edit the profile to make changes or view its present configuration",
|
|
)}
|
|
</p>
|
|
<sl-button @click=${this.startBrowserPreview}
|
|
><sl-icon slot="prefix" name="pencil-square"></sl-icon> ${msg(
|
|
"Edit Browser Profile",
|
|
)}</sl-button
|
|
>
|
|
</div>`,
|
|
)}
|
|
</section>
|
|
${when(
|
|
!(this.browserId || this.isBrowserLoading),
|
|
this.renderVisitedSites,
|
|
)}
|
|
</div>
|
|
|
|
<btrix-dialog
|
|
.label=${msg(str`Edit Profile`)}
|
|
.open=${this.isEditDialogOpen}
|
|
@sl-request-close=${() => (this.isEditDialogOpen = false)}
|
|
@sl-show=${() => (this.isEditDialogContentVisible = true)}
|
|
@sl-after-hide=${() => (this.isEditDialogContentVisible = false)}
|
|
>
|
|
${this.isEditDialogContentVisible ? this.renderEditProfile() : ""}
|
|
</btrix-dialog> `;
|
|
}
|
|
|
|
private readonly renderVisitedSites = () => {
|
|
return html`
|
|
<section class="flex-grow-1 flex flex-col lg:w-80 lg:pl-6">
|
|
<header class="flex-0 mb-2">
|
|
<h3 class="text-lg font-medium">${msg("Visited Sites")}</h3>
|
|
</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">
|
|
<div class="max-w-prose">
|
|
<p class="mx-1 text-xs text-neutral-500">
|
|
${msg(
|
|
"Interact with the browsing tool to set up the browser profile. Workflows that use this browser profile will behave as if they have logged into the same websites and have the same cookies that have been set here.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<sl-button size="small" @click=${this.cancelEditBrowser}
|
|
>${msg("Cancel")}</sl-button
|
|
>
|
|
<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() {
|
|
return html`
|
|
<sl-dropdown placement="bottom-end" distance="4">
|
|
<sl-button size="small" slot="trigger" caret
|
|
>${msg("Actions")}</sl-button
|
|
>
|
|
|
|
<ul
|
|
class="whitespace-nowrap bg-white text-left text-sm text-neutral-800"
|
|
role="menu"
|
|
>
|
|
<li
|
|
class="cursor-pointer p-2 hover:bg-zinc-100"
|
|
role="menuitem"
|
|
@click=${(e: Event) => {
|
|
void (e.target as HTMLElement)
|
|
.closest<SlDropdown>("sl-dropdown")!
|
|
.hide();
|
|
this.isEditDialogOpen = true;
|
|
}}
|
|
>
|
|
<sl-icon
|
|
class="inline-block px-1 align-middle"
|
|
name="pencil"
|
|
></sl-icon>
|
|
<span class="inline-block pr-2 align-middle"
|
|
>${msg("Edit name & description")}</span
|
|
>
|
|
</li>
|
|
<li
|
|
class="cursor-pointer p-2 hover:bg-zinc-100"
|
|
role="menuitem"
|
|
@click=${() => void this.duplicateProfile()}
|
|
>
|
|
<sl-icon
|
|
class="inline-block px-1 align-middle"
|
|
name="files"
|
|
></sl-icon>
|
|
<span class="inline-block pr-2 align-middle"
|
|
>${msg("Duplicate profile")}</span
|
|
>
|
|
</li>
|
|
<hr />
|
|
<li
|
|
class="cursor-pointer p-2 text-danger hover:bg-danger hover:text-white"
|
|
role="menuitem"
|
|
@click=${() => {
|
|
void this.deleteProfile();
|
|
}}
|
|
>
|
|
<sl-icon
|
|
class="inline-block px-1 align-middle"
|
|
name="trash3"
|
|
></sl-icon>
|
|
<span class="inline-block pr-2 align-middle">${msg("Delete")}</span>
|
|
</li>
|
|
</ul>
|
|
</sl-dropdown>
|
|
`;
|
|
}
|
|
|
|
private renderEditProfile() {
|
|
if (!this.profile) return;
|
|
|
|
return html`
|
|
<form @submit=${this.onSubmitEdit}>
|
|
<div class="mb-5">
|
|
<sl-input
|
|
name="name"
|
|
label=${msg("Name")}
|
|
autocomplete="off"
|
|
value=${this.profile.name}
|
|
required
|
|
></sl-input>
|
|
</div>
|
|
|
|
<div class="mb-5">
|
|
<sl-textarea
|
|
name="description"
|
|
label=${msg("Description")}
|
|
rows="2"
|
|
autocomplete="off"
|
|
value=${this.profile.description || ""}
|
|
></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 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;
|
|
} catch (e) {
|
|
this.isBrowserLoading = false;
|
|
|
|
this.notify({
|
|
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({
|
|
message: msg("Starting up browser with current profile..."),
|
|
variant: "success",
|
|
icon: "check2-circle",
|
|
});
|
|
|
|
this.navTo(
|
|
`${this.orgBasePath}/browser-profiles/profile/browser/${
|
|
data.browserid
|
|
}?name=${window.encodeURIComponent(
|
|
this.profile.name,
|
|
)}&description=${window.encodeURIComponent(
|
|
this.profile.description || "",
|
|
)}&profileId=${window.encodeURIComponent(this.profile.id)}&navigateUrl=`,
|
|
);
|
|
} catch (e) {
|
|
this.isBrowserLoading = false;
|
|
|
|
this.notify({
|
|
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.apiFetch<Profile & { error: boolean }>(
|
|
`/orgs/${this.orgId}/profiles/${this.profile!.id}`,
|
|
this.authState!,
|
|
{
|
|
method: "DELETE",
|
|
},
|
|
);
|
|
|
|
if (data.error && data.crawlconfigs) {
|
|
this.notify({
|
|
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.navTo(`${this.orgBasePath}/browser-profiles`);
|
|
|
|
this.notify({
|
|
message: msg(html`Deleted <strong>${profileName}</strong>.`),
|
|
variant: "success",
|
|
icon: "check2-circle",
|
|
});
|
|
}
|
|
} catch (e) {
|
|
this.notify({
|
|
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.apiFetch<{ browserid: string }>(
|
|
`/orgs/${this.orgId}/profiles/browser`,
|
|
this.authState!,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(params),
|
|
},
|
|
);
|
|
}
|
|
|
|
private async deleteBrowser(id: string) {
|
|
return this.apiFetch(
|
|
`/orgs/${this.orgId}/profiles/browser/${id}`,
|
|
this.authState!,
|
|
{
|
|
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({
|
|
message: msg("Sorry, couldn't retrieve browser profiles at this time."),
|
|
variant: "danger",
|
|
icon: "exclamation-octagon",
|
|
});
|
|
}
|
|
}
|
|
|
|
private async getProfile() {
|
|
const data = await this.apiFetch<Profile>(
|
|
`/orgs/${this.orgId}/profiles/${this.profileId}`,
|
|
this.authState!,
|
|
);
|
|
|
|
return data;
|
|
}
|
|
|
|
private async saveBrowser() {
|
|
if (!this.browserId) return;
|
|
|
|
if (
|
|
!window.confirm(
|
|
msg(
|
|
"Save browser changes to profile? You will need to re-load the browsing tool to make additional changes.",
|
|
),
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.isSubmittingBrowserChange = true;
|
|
|
|
const params = {
|
|
name: this.profile!.name,
|
|
browserid: this.browserId,
|
|
};
|
|
|
|
try {
|
|
const data = await this.apiFetch<{ updated: boolean }>(
|
|
`/orgs/${this.orgId}/profiles/${this.profileId}`,
|
|
this.authState!,
|
|
{
|
|
method: "PATCH",
|
|
body: JSON.stringify(params),
|
|
},
|
|
);
|
|
|
|
if (data.updated) {
|
|
this.notify({
|
|
message: msg("Successfully saved browser profile."),
|
|
variant: "success",
|
|
icon: "check2-circle",
|
|
});
|
|
|
|
this.browserId = undefined;
|
|
} else {
|
|
throw data;
|
|
}
|
|
} catch (e) {
|
|
this.notify({
|
|
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;
|
|
this.isSubmittingProfileChange = true;
|
|
|
|
const formData = new FormData(e.target as HTMLFormElement);
|
|
const name = formData.get("name") as string;
|
|
const description = formData.get("description") as string;
|
|
|
|
const params = {
|
|
name,
|
|
description,
|
|
};
|
|
|
|
try {
|
|
const data = await this.apiFetch<{ updated: boolean }>(
|
|
`/orgs/${this.orgId}/profiles/${this.profileId}`,
|
|
this.authState!,
|
|
{
|
|
method: "PATCH",
|
|
body: JSON.stringify(params),
|
|
},
|
|
);
|
|
|
|
if (data.updated) {
|
|
this.notify({
|
|
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({
|
|
message: message,
|
|
variant: "danger",
|
|
icon: "exclamation-octagon",
|
|
});
|
|
}
|
|
|
|
this.isSubmittingProfileChange = false;
|
|
}
|
|
|
|
/**
|
|
* Stop propgation of sl-select events.
|
|
* Prevents bug where sl-dialog closes when dropdown closes
|
|
* https://github.com/shoelace-style/shoelace/issues/170
|
|
*/
|
|
private stopProp(e: CustomEvent) {
|
|
e.stopPropagation();
|
|
}
|
|
}
|