import { localized, msg, str } from "@lit/localize"; 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; export type OrgParams = { home: Record; 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) { 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`
${this.renderOrgNavBar()}
${when(this.userOrg, () => choose( this.orgTab, [ ["home", this.renderDashboard], ["items", this.renderArchivedItem], ["workflows", this.renderWorkflows], ["browser-profiles", this.renderBrowserProfiles], ["collections", this.renderCollections], [ "settings", () => this.appState.isAdmin ? this.renderOrgSettings() : html``, ], ], () => html``, ), )}
${this.renderNewResourceDialogs()}
`; } private renderOrgNavBar() { return html`

`; } private renderNavTab({ tabName, label, path, }: { tabName: OrgTab; label: string; path: string; }) { const isActive = this.orgTab === tabName; return html`
${label}
`; } private renderNewResourceDialogs() { if (!this.orgId || !this.appState.isCrawler) { return; } if (!this.isCreateDialogVisible) { return; } return html`
{ e.stopPropagation(); this.openDialogName = undefined; }} @sl-after-hide=${(e: CustomEvent) => { e.stopPropagation(); this.isCreateDialogVisible = false; }} > (this.openDialogName = undefined)} @uploaded=${() => { if (this.orgTab === "home") { this.navTo(`${this.orgBasePath}/items/upload`); } }} > (this.openDialogName = undefined)} > (this.openDialogName = undefined)} @select-job-type=${(e: SelectJobTypeEvent) => { this.openDialogName = undefined; this.navTo(`${this.orgBasePath}/workflows?new&jobType=${e.detail}`); }} > (this.openDialogName = undefined)} @btrix-collection-saved=${(e: CollectionSavedEvent) => { this.navTo( `${this.orgBasePath}/collections/view/${e.detail.id}/items`, ); }} >
`; } private readonly renderDashboard = () => { return html` `; }; private readonly renderArchivedItem = () => { const params = this.params as OrgParams["items"]; if (params.itemId) { if (params.qaTab) { if (!this.appState.isCrawler) { return html``; } return html``; } return html` `; } return html``; }; 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` `; } if (isNewResourceTab) { const { workflow, seeds } = this.viewStateData || {}; return html` `; } return html``; }; private readonly renderBrowserProfiles = () => { const params = this.params as OrgParams["browser-profiles"]; if (params.browserProfileId) { return html``; } if (params.browserId) { return html``; } return html``; }; private readonly renderCollections = () => { const params = this.params as OrgParams["collections"]; if (params.collectionId) { return html``; } return html``; }; 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``; }; private async onSelectNewDialog(e: SelectNewDialogEvent) { e.stopPropagation(); this.isCreateDialogVisible = true; await this.updateComplete; this.openDialogName = e.detail; } private async getOrg(orgId: string): Promise { const data = await this.apiFetch(`/orgs/${orgId}`); return data; } private async onOrgRemoveMember(e: OrgRemoveMemberEvent) { void this.removeMember(e.detail.member); } private async onStorageQuotaUpdate(e: CustomEvent) { e.stopPropagation(); const { reached } = e.detail; AppStateService.partialUpdateOrg({ id: this.orgId, storageQuotaReached: reached, }); } private async onExecutionMinutesQuotaUpdate( e: CustomEvent, ) { 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", }); } } }