browsertrix/frontend/src/pages/org/index.ts
sua yoo eb2dab8ae0
fix: Update browser title (#2054)
Updates browser title when visiting the following pages:

- Superadmin dashboard
- Org top-level pages
- Account settings
2024-08-29 16:50:14 -07:00

731 lines
21 KiB
TypeScript

import { localized, msg, str } from "@lit/localize";
import { nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { choose } from "lit/directives/choose.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { when } from "lit/directives/when.js";
import isEqual from "lodash/fp/isEqual";
import type { QATab } from "./archived-item-qa/types";
import type { Tab as CollectionTab } from "./collection-detail";
import type {
Member,
OrgRemoveMemberEvent,
UserRoleChangeEvent,
} from "./settings/settings";
import type { QuotaUpdateDetail } from "@/controllers/api";
import needLogin from "@/decorators/needLogin";
import type { CollectionSavedEvent } from "@/features/collections/collection-metadata-dialog";
import type { SelectJobTypeEvent } from "@/features/crawl-workflows/new-workflow-dialog";
import type { Crawl, JobType } from "@/types/crawler";
import type { UserOrg } from "@/types/user";
import { isApiError } from "@/utils/api";
import type { ViewState } from "@/utils/APIRouter";
import { DEFAULT_MAX_SCALE } from "@/utils/crawler";
import LiteElement, { html } from "@/utils/LiteElement";
import { type OrgData } from "@/utils/orgs";
import { AppStateService } from "@/utils/state";
import "./workflow-detail";
import "./workflows-list";
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(/* 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];
export type SelectNewDialogEvent = CustomEvent<ResourceName>;
export type OrgParams = {
home: Record<string, never>;
workflows: {
workflowId?: string;
jobType?: JobType;
new?: ResourceName;
};
items: {
itemType?: Crawl["type"];
itemId?: string;
itemPageId?: string;
qaTab?: QATab;
qaRunId?: string;
workflowId?: string;
collectionId?: string;
};
"browser-profiles": {
browserProfileId?: string;
browserId?: string;
new?: ResourceName;
name?: string;
url?: string;
description?: string;
crawlerChannel?: string;
profileId?: string;
navigateUrl?: string;
};
collections: {
collectionId?: string;
collectionTab?: string;
};
settings: {
settingsTab?: "information" | "members";
};
};
export type OrgTab = keyof OrgParams;
const defaultTab = "home";
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
@localized()
@customElement("btrix-org")
@needLogin
export class Org extends LiteElement {
@property({ type: Object })
viewStateData?: ViewState["data"];
// Path after `/orgs/:orgId/`
@property({ type: String })
orgPath!: string;
@property({ type: Object })
params: OrgParams[OrgTab] = {};
@property({ type: String })
orgTab: OrgTab = defaultTab;
@property({ type: Number })
maxScale: number = DEFAULT_MAX_SCALE;
@state()
private openDialogName?: ResourceName;
@state()
private isCreateDialogVisible = false;
connectedCallback() {
super.connectedCallback();
this.addEventListener(
"btrix-execution-minutes-quota-update",
this.onExecutionMinutesQuotaUpdate,
);
this.addEventListener(
"btrix-storage-quota-update",
this.onStorageQuotaUpdate,
);
}
disconnectedCallback() {
this.removeEventListener(
"btrix-execution-minutes-quota-update",
this.onExecutionMinutesQuotaUpdate,
);
this.removeEventListener(
"btrix-storage-quota-update",
this.onStorageQuotaUpdate,
);
super.disconnectedCallback();
}
async willUpdate(changedProperties: Map<string, unknown>) {
if (
changedProperties.has("appState.orgSlug") &&
this.userInfo &&
this.orgSlug
) {
if (this.userOrg) {
void this.updateOrg();
} else {
// Couldn't find org with slug, redirect to first org
const org = this.userInfo.orgs[0] as UserOrg | undefined;
if (org) {
this.navTo(`/orgs/${org.slug}`);
} else {
this.navTo(`/account/settings`);
}
return;
}
} else if (changedProperties.has("orgTab") && this.orgId) {
// Get most up to date org data
void this.updateOrg();
}
if (changedProperties.has("openDialogName")) {
// Sync URL to create dialog
const url = new URL(window.location.href);
if (this.openDialogName) {
if (url.searchParams.get("new") !== this.openDialogName) {
url.searchParams.set("new", this.openDialogName);
this.navTo(`${url.pathname}${url.search}`);
}
} else {
const prevOpenDialogName = changedProperties.get("openDialogName");
if (
prevOpenDialogName &&
prevOpenDialogName === url.searchParams.get("new")
) {
url.searchParams.delete("new");
this.navTo(`${url.pathname}${url.search}`);
}
}
}
}
private async updateOrg(e?: CustomEvent) {
if (e) {
e.stopPropagation();
}
if (!this.userInfo || !this.orgId) return;
try {
const org = await this.getOrg(this.orgId);
if (!isEqual(this.org, org)) {
AppStateService.updateOrg(org);
}
} catch (e) {
console.debug(e);
this.notify({
message: msg("Sorry, couldn't retrieve organization at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
async firstUpdated() {
// if slug is actually an orgId (UUID), attempt to lookup the slug
// and redirect to the slug url
if (this.orgSlug && UUID_REGEX.test(this.orgSlug)) {
const org = await this.getOrg(this.orgSlug);
const actualSlug = org?.slug;
if (actualSlug) {
this.navTo(
window.location.href
.slice(window.location.origin.length)
.replace(this.orgSlug, actualSlug),
);
return;
}
}
// Sync URL to create dialog
const url = new URL(window.location.href);
const dialogName = url.searchParams.get("new");
if (dialogName && RESOURCE_NAMES.includes(dialogName)) {
this.openDialogName = dialogName;
this.isCreateDialogVisible = true;
}
}
render() {
const noMaxWidth =
this.orgTab === "items" && (this.params as OrgParams["items"]).qaTab;
return html`
<btrix-document-title
title=${ifDefined(this.userOrg?.name)}
></btrix-document-title>
<div class="flex min-h-full flex-col">
<btrix-org-status-banner></btrix-org-status-banner>
${this.renderOrgNavBar()}
<main
class="${noMaxWidth
? "w-full"
: "w-full max-w-screen-desktop pt-7"} mx-auto box-border flex flex-1 flex-col p-3"
aria-labelledby="${this.orgTab}-tab"
>
${when(this.userOrg, (userOrg) =>
choose(
this.orgTab,
[
["home", this.renderDashboard],
[
"items",
() => html`
<btrix-document-title
title=${`${msg("Archived Items")} - ${userOrg.name}`}
></btrix-document-title>
${this.renderArchivedItem()}
`,
],
[
"workflows",
() => html`
<btrix-document-title
title=${`${msg("Crawl Workflows")} - ${userOrg.name}`}
></btrix-document-title>
${this.renderWorkflows()}
`,
],
[
"browser-profiles",
() => html`
<btrix-document-title
title=${`${msg("Browser Profiles")} - ${userOrg.name}`}
></btrix-document-title>
${this.renderBrowserProfiles()}
`,
],
[
"collections",
() => html`
<btrix-document-title
title=${`${msg("Collections")} - ${userOrg.name}`}
></btrix-document-title>
${this.renderCollections()}
`,
],
[
"settings",
() =>
this.appState.isAdmin
? html`
<btrix-document-title
title=${`${msg("Org Settings")} - ${userOrg.name}`}
></btrix-document-title>
${this.renderOrgSettings()}
`
: nothing,
],
],
() =>
html`<btrix-not-found
class="flex items-center justify-center"
></btrix-not-found>`,
),
)}
</main>
${this.renderNewResourceDialogs()}
</div>
`;
}
private renderOrgNavBar() {
return html`
<div
class="mx-auto box-border w-full overflow-x-hidden overscroll-contain"
>
<nav class="-mx-3 flex items-end overflow-x-auto px-3 xl:px-6">
${this.renderNavTab({
tabName: "home",
label: msg("Overview"),
path: "",
})}
${this.renderNavTab({
tabName: "workflows",
label: msg("Crawling"),
path: "workflows/crawls",
})}
${this.renderNavTab({
tabName: "items",
label: msg("Archived Items"),
path: "items",
})}
${this.renderNavTab({
tabName: "collections",
label: msg("Collections"),
path: "collections",
})}
${when(this.appState.isCrawler, () =>
this.renderNavTab({
tabName: "browser-profiles",
label: msg("Browser Profiles"),
path: "browser-profiles",
}),
)}
${when(this.appState.isAdmin || this.userInfo?.isSuperAdmin, () =>
this.renderNavTab({
tabName: "settings",
label: msg("Settings"),
path: "settings",
}),
)}
</nav>
</div>
<hr />
`;
}
private renderNavTab({
tabName,
label,
path,
}: {
tabName: OrgTab;
label: string;
path: string;
}) {
const isActive = this.orgTab === tabName;
return html`
<a
id="${tabName}-tab"
class="block flex-shrink-0 rounded-t px-3 transition-colors hover:bg-neutral-50"
href=${`${this.orgBasePath}${path ? `/${path}` : ""}`}
aria-selected=${isActive}
@click=${this.navLink}
>
<div
class="${isActive
? "border-primary text-primary"
: "border-transparent text-neutral-500 hover:border-neutral-100 hover:text-neutral-900"} border-b-2 py-3 text-sm font-medium transition-colors"
>
${label}
</div>
</a>
`;
}
private renderNewResourceDialogs() {
if (!this.orgId || !this.appState.isCrawler) {
return;
}
if (!this.isCreateDialogVisible) {
return;
}
return html`
<div
@sl-hide=${(e: CustomEvent) => {
e.stopPropagation();
this.openDialogName = undefined;
}}
@sl-after-hide=${(e: CustomEvent) => {
e.stopPropagation();
this.isCreateDialogVisible = false;
}}
>
<btrix-file-uploader
?open=${this.openDialogName === "upload"}
@request-close=${() => (this.openDialogName = undefined)}
@uploaded=${() => {
if (this.orgTab === "home") {
this.navTo(`${this.orgBasePath}/items/upload`);
}
}}
></btrix-file-uploader>
<btrix-new-browser-profile-dialog
?open=${this.openDialogName === "browser-profile"}
@sl-hide=${() => (this.openDialogName = undefined)}
>
</btrix-new-browser-profile-dialog>
<btrix-new-workflow-dialog
?open=${this.openDialogName === "workflow"}
@sl-hide=${() => (this.openDialogName = undefined)}
@select-job-type=${(e: SelectJobTypeEvent) => {
this.openDialogName = undefined;
this.navTo(`${this.orgBasePath}/workflows?new&jobType=${e.detail}`);
}}
>
</btrix-new-workflow-dialog>
<btrix-collection-metadata-dialog
?open=${this.openDialogName === "collection"}
@sl-hide=${() => (this.openDialogName = undefined)}
@btrix-collection-saved=${(e: CollectionSavedEvent) => {
this.navTo(
`${this.orgBasePath}/collections/view/${e.detail.id}/items`,
);
}}
>
</btrix-collection-metadata-dialog>
</div>
`;
}
private readonly renderDashboard = () => {
return html`
<btrix-dashboard
?isCrawler=${this.appState.isCrawler}
?isAdmin=${this.appState.isAdmin}
@select-new-dialog=${this.onSelectNewDialog}
></btrix-dashboard>
`;
};
private readonly renderArchivedItem = () => {
const params = this.params as OrgParams["items"];
if (params.itemId) {
if (params.qaTab) {
if (!this.appState.isCrawler) {
return html`<btrix-not-found
class="flex items-center justify-center"
></btrix-not-found>`;
}
return html`<btrix-archived-item-qa
class="flex-1"
itemId=${params.itemId}
itemPageId=${ifDefined(params.itemPageId)}
qaRunId=${ifDefined(params.qaRunId)}
tab=${params.qaTab}
></btrix-archived-item-qa>`;
}
return html`<btrix-archived-item-detail
crawlId=${params.itemId}
collectionId=${params.collectionId || ""}
workflowId=${params.workflowId || ""}
itemType=${params.itemType || "crawl"}
?isCrawler=${this.appState.isCrawler}
></btrix-archived-item-detail>`;
}
return html`<btrix-archived-items
?isCrawler=${this.appState.isCrawler}
itemType=${ifDefined(params.itemType || undefined)}
@select-new-dialog=${this.onSelectNewDialog}
></btrix-archived-items>`;
};
private readonly renderWorkflows = () => {
const params = this.params as OrgParams["workflows"];
const isEditing = Object.prototype.hasOwnProperty.call(params, "edit");
const isNewResourceTab =
Object.prototype.hasOwnProperty.call(params, "new") && params.jobType;
const workflowId = params.workflowId;
if (workflowId) {
return html`
<btrix-workflow-detail
class="col-span-5 mt-6"
workflowId=${workflowId}
openDialogName=${this.viewStateData?.dialog}
?isEditing=${isEditing}
?isCrawler=${this.appState.isCrawler}
.maxScale=${this.maxScale}
></btrix-workflow-detail>
`;
}
if (isNewResourceTab) {
const { workflow, seeds } = this.viewStateData || {};
return html` <btrix-workflows-new
class="col-span-5 mt-6"
?isCrawler=${this.appState.isCrawler}
.initialWorkflow=${workflow}
.initialSeeds=${seeds}
jobType=${ifDefined(params.jobType)}
@select-new-dialog=${this.onSelectNewDialog}
></btrix-workflows-new>`;
}
return html`<btrix-workflows-list
@select-new-dialog=${this.onSelectNewDialog}
></btrix-workflows-list>`;
};
private readonly renderBrowserProfiles = () => {
const params = this.params as OrgParams["browser-profiles"];
if (params.browserProfileId) {
return html`<btrix-browser-profiles-detail
profileId=${params.browserProfileId}
?isCrawler=${this.appState.isCrawler}
></btrix-browser-profiles-detail>`;
}
if (params.browserId) {
return html`<btrix-browser-profiles-new
.browserId=${params.browserId}
.browserParams=${{
name: params.name || "",
url: params.url || "",
description: params.description,
crawlerChannel: params.crawlerChannel,
profileId: params.profileId,
navigateUrl: params.navigateUrl,
}}
></btrix-browser-profiles-new>`;
}
return html`<btrix-browser-profiles-list
?isCrawler=${this.appState.isCrawler}
@select-new-dialog=${this.onSelectNewDialog}
></btrix-browser-profiles-list>`;
};
private readonly renderCollections = () => {
const params = this.params as OrgParams["collections"];
if (params.collectionId) {
return html`<btrix-collection-detail
collectionId=${params.collectionId}
collectionTab=${(params.collectionTab as CollectionTab | undefined) ||
"replay"}
?isCrawler=${this.appState.isCrawler}
></btrix-collection-detail>`;
}
return html`<btrix-collections-list
?isCrawler=${this.appState.isCrawler}
@select-new-dialog=${this.onSelectNewDialog}
></btrix-collections-list>`;
};
private readonly renderOrgSettings = () => {
const params = this.params as OrgParams["settings"];
const activePanel = params.settingsTab || "information";
const isAddingMember = Object.prototype.hasOwnProperty.call(
this.params,
"invite",
);
return html`<btrix-org-settings
activePanel=${activePanel}
?isAddingMember=${isAddingMember}
@org-user-role-change=${this.onUserRoleChange}
@org-remove-member=${this.onOrgRemoveMember}
></btrix-org-settings>`;
};
private async onSelectNewDialog(e: SelectNewDialogEvent) {
e.stopPropagation();
this.isCreateDialogVisible = true;
await this.updateComplete;
this.openDialogName = e.detail;
}
private async getOrg(orgId: string): Promise<OrgData | undefined> {
const data = await this.apiFetch<OrgData>(`/orgs/${orgId}`);
return data;
}
private async onOrgRemoveMember(e: OrgRemoveMemberEvent) {
void this.removeMember(e.detail.member);
}
private async onStorageQuotaUpdate(e: CustomEvent<QuotaUpdateDetail>) {
e.stopPropagation();
const { reached } = e.detail;
AppStateService.partialUpdateOrg({
id: this.orgId,
storageQuotaReached: reached,
});
}
private async onExecutionMinutesQuotaUpdate(
e: CustomEvent<QuotaUpdateDetail>,
) {
e.stopPropagation();
const { reached } = e.detail;
AppStateService.partialUpdateOrg({
id: this.orgId,
execMinutesQuotaReached: reached,
});
}
private async onUserRoleChange(e: UserRoleChangeEvent) {
const { user, newRole } = e.detail;
try {
await this.apiFetch(`/orgs/${this.orgId}/user-role`, {
method: "PATCH",
body: JSON.stringify({
email: user.email,
role: newRole,
}),
});
this.notify({
message: msg(
str`Successfully updated role for ${user.name || user.email}.`,
),
variant: "success",
icon: "check2-circle",
});
const org = await this.getOrg(this.orgId);
AppStateService.updateOrg(org);
} catch (e) {
console.debug(e);
this.notify({
message: isApiError(e)
? e.message
: msg(
str`Sorry, couldn't update role for ${
user.name || user.email
} at this time.`,
),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async removeMember(member: Member) {
if (!this.userOrg) return;
const isSelf = member.email === this.userInfo!.email;
if (
isSelf &&
!window.confirm(
msg(
str`Are you sure you want to remove yourself from ${this.userOrg.name}?`,
),
)
) {
return;
}
try {
await this.apiFetch(`/orgs/${this.orgId}/remove`, {
method: "POST",
body: JSON.stringify({
email: member.email,
}),
});
this.notify({
message: msg(
str`Successfully removed ${member.name || member.email} from ${
this.userOrg.name
}.`,
),
variant: "success",
icon: "check2-circle",
});
if (isSelf) {
// FIXME better UX, this is the only page currently that doesn't require org...
this.navTo("/account/settings");
} else {
const org = await this.getOrg(this.orgId);
AppStateService.updateOrg(org);
}
} catch (e) {
console.debug(e);
this.notify({
message: isApiError(e)
? e.message
: msg(
str`Sorry, couldn't remove ${
member.name || member.email
} at this time.`,
),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
}