browsertrix/frontend/src/pages/home.ts
Emma Segal-Grossman 512698d747
Fix attribute casing & lit-analyzer issues (#1429)
## Changes
- Reverts changes introduced in #1407 that incorrectly changed attribute
casing
- Patches `@shoelace-style/shoelace` using
[`patch-package`](https://www.npmjs.com/package/patch-package) to add
JSDoc comments to component typedefs so that `lit-analyzer` can properly
pick up attributes
- Adds component typedef for `<replay-web-page>` component

## Testing
Tested by hand, it looks like missing help text/date formatting
changes/etc are back!

Before | After
-|-
![dev browsertrix
cloud_orgs_default-org_browser-profiles_profile_dea43f41-8777-4a42-b2ad-b8d43f6599b8](https://github.com/webrecorder/browsertrix-cloud/assets/5727389/1c6be749-ee8f-4b07-84c7-b05c5df376a7)
|
![localhost_9870_orgs_default-org_browser-profiles_profile_dea43f41-8777-4a42-b2ad-b8d43f6599b8](https://github.com/webrecorder/browsertrix-cloud/assets/5727389/4a305d3f-7947-4e13-b379-a82dc01620ea)
![dev browsertrix
cloud_orgs_default-org_browser-profiles_profile_dea43f41-8777-4a42-b2ad-b8d43f6599b8
(2)](https://github.com/webrecorder/browsertrix-cloud/assets/5727389/a5e6bba6-ce03-4622-8f39-194ce08481b7)
|
![localhost_9870_orgs_default-org_browser-profiles_profile_dea43f41-8777-4a42-b2ad-b8d43f6599b8
(2)](https://github.com/webrecorder/browsertrix-cloud/assets/5727389/33f076d8-aa20-4d25-9d1f-e6927d32819d)
![dev browsertrix
cloud_orgs_default-org_browser-profiles_profile_dea43f41-8777-4a42-b2ad-b8d43f6599b8
(1)](https://github.com/webrecorder/browsertrix-cloud/assets/5727389/34761f6b-32a9-4eb5-a129-0df67bb90f65)
|
![localhost_9870_orgs_default-org_browser-profiles_profile_dea43f41-8777-4a42-b2ad-b8d43f6599b8
(1)](https://github.com/webrecorder/browsertrix-cloud/assets/5727389/d8144b10-fc9b-49a4-9641-604ad8fa4e5a)

---------

Co-authored-by: Ilya Kreymer <ikreymer@users.noreply.github.com>
2023-12-11 12:34:03 -05:00

334 lines
9.4 KiB
TypeScript

import { state, property, customElement } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import type { AuthState } from "@/utils/AuthService";
import type { CurrentUser } from "@/types/user";
import type { OrgData } from "@/utils/orgs";
import LiteElement, { html } from "@/utils/LiteElement";
import type { APIPaginatedList } from "@/types/api";
import { maxLengthValidator } from "@/utils/form";
@localized()
@customElement("btrix-home")
export class Home extends LiteElement {
@property({ type: Object })
authState?: AuthState;
@property({ type: Object })
userInfo?: CurrentUser;
@property({ type: String })
slug?: string;
@state()
private isInviteComplete?: boolean;
@state()
private orgList?: OrgData[];
@state()
private isAddingOrg = false;
@state()
private isAddOrgFormVisible = false;
@state()
private isSubmittingNewOrg = false;
private validateOrgNameMax = maxLengthValidator(50);
connectedCallback() {
if (this.authState) {
super.connectedCallback();
} else {
this.navTo("/log-in");
}
}
willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("slug") && this.slug) {
this.navTo(`/orgs/${this.slug}`);
} else if (changedProperties.has("authState") && this.authState) {
this.fetchOrgs();
}
}
async updated(changedProperties: Map<string, any>) {
const orgListUpdated = changedProperties.has("orgList") && this.orgList;
const userInfoUpdated = changedProperties.has("userInfo") && this.userInfo;
if (orgListUpdated || userInfoUpdated) {
if (this.userInfo?.isAdmin && this.orgList && !this.orgList.length) {
this.isAddingOrg = true;
}
}
}
render() {
if (!this.userInfo || !this.orgList) {
return html`
<div class="flex items-center justify-center my-24 text-3xl">
<sl-spinner></sl-spinner>
</div>
`;
}
let title: any;
let content: any;
if (this.userInfo.isAdmin === true) {
title = msg("Welcome");
content = this.renderAdminOrgs();
}
if (this.userInfo.isAdmin === false) {
title = msg("Organizations");
content = this.renderLoggedInNonAdmin();
}
return html`
<div class="bg-white">
<header
class="w-full max-w-screen-lg mx-auto px-3 py-4 box-border md:py-8"
>
<h1 class="text-xl font-medium">${title}</h1>
</header>
<hr />
</div>
<main class="w-full max-w-screen-lg mx-auto px-3 py-4 box-border">
${content}
</main>
`;
}
private renderAdminOrgs() {
return html`
<section class="border rounded-lg bg-white p-4 md:p-6 mb-5">
<form
@submit=${(e: SubmitEvent) => {
const formData = new FormData(e.target as HTMLFormElement);
const id = formData.get("crawlId");
this.navTo(`/crawls/crawl/${id}`);
}}
>
<div class="flex flex-wrap items-center">
<div
class="w-full md:w-min grow-0 mr-8 text-lg font-medium whitespace-nowrap"
>
${msg("Go to Crawl")}
</div>
<div class="grow mt-2 md:mt-0 md:mr-2">
<sl-input
name="crawlId"
placeholder=${msg("Enter Crawl ID")}
required
></sl-input>
</div>
<div class="grow-0 mt-2 md:mt-0 text-right">
<sl-button variant="neutral" type="submit">
<sl-icon slot="suffix" name="arrow-right"></sl-icon>
${msg("Go")}</sl-button
>
</div>
</div>
</form>
</section>
<div class="grid grid-cols-5 gap-8">
<div class="col-span-5 md:col-span-3">
<section>
<header class="flex items-start justify-between items-center">
<h2 class="text-lg font-medium mb-3 mt-2">
${msg("All Organizations")}
</h2>
<sl-button
variant="primary"
size="small"
@click=${() => (this.isAddingOrg = true)}
>
<sl-icon slot="prefix" name="plus-lg"></sl-icon>
${msg("New Organization")}
</sl-button>
</header>
<btrix-orgs-list
.userInfo=${this.userInfo}
.orgList=${this.orgList}
.defaultOrg=${this.userInfo?.orgs.find(
(org) => org.default === true
)}
@update-quotas=${this.onUpdateOrgQuotas}
></btrix-orgs-list>
</section>
</div>
<div class="col-span-5 md:col-span-2">
<section class="md:border md:rounded-lg md:bg-white p-3 md:p-8">
<h2 class="text-lg font-medium mb-3">
${msg("Invite User to Org")}
</h2>
${this.renderInvite()}
</section>
</div>
</div>
<btrix-dialog
.label=${msg("New Organization")}
.open=${this.isAddingOrg}
@sl-request-close=${(e: CustomEvent) => {
// Disable closing if there are no orgs
if (this.orgList?.length) {
this.isAddingOrg = false;
} else {
e.preventDefault();
}
}}
@sl-show=${() => (this.isAddOrgFormVisible = true)}
@sl-after-hide=${() => (this.isAddOrgFormVisible = false)}
>
${this.isAddOrgFormVisible
? html`
<form
id="newOrgForm"
@reset=${() => (this.isAddingOrg = false)}
@submit=${this.onSubmitNewOrg}
>
<div class="mb-5">
<sl-input
class="with-max-help-text"
name="name"
label=${msg("Org Name")}
placeholder=${msg("My Organization")}
autocomplete="off"
required
help-text=${this.validateOrgNameMax.helpText}
@sl-input=${this.validateOrgNameMax.validate}
>
</sl-input>
</div>
</form>
<div slot="footer" class="flex justify-between">
${this.orgList?.length
? html`<sl-button form="newOrgForm" type="reset" size="small">
${msg("Cancel")}
</sl-button>`
: ""}
<sl-button
form="newOrgForm"
variant="primary"
type="submit"
size="small"
?loading=${this.isSubmittingNewOrg}
?disabled=${this.isSubmittingNewOrg}
>${msg("Create Org")}</sl-button
>
</div>
`
: ""}
</btrix-dialog>
`;
}
private renderLoggedInNonAdmin() {
if (this.orgList && !this.orgList.length) {
return html`<div class="border rounded-lg bg-white p-4 md:p-8">
<p class="text-neutral-400 text-center">
${msg("You don't have any organizations.")}
</p>
</div>`;
}
return html`
<btrix-orgs-list
.userInfo=${this.userInfo}
.orgList=${this.orgList}
?skeleton=${!this.orgList}
></btrix-orgs-list>
`;
}
private renderInvite() {
if (this.isInviteComplete) {
return html`
<sl-button @click=${() => (this.isInviteComplete = false)}
>${msg("Send another invite")}</sl-button
>
`;
}
const defaultOrg = this.userInfo?.orgs.find(
(org) => org.default === true
) || { name: "" };
return html`
<btrix-invite-form
.authState=${this.authState}
.orgs=${this.orgList}
.defaultOrg=${defaultOrg || null}
@success=${() => (this.isInviteComplete = true)}
></btrix-invite-form>
`;
}
private async fetchOrgs() {
this.orgList = await this.getOrgs();
}
private async getOrgs() {
const data = await this.apiFetch<APIPaginatedList<OrgData>>(
"/orgs",
this.authState!
);
return data.items;
}
private async onSubmitNewOrg(e: SubmitEvent) {
e.preventDefault();
const formEl = e.target as HTMLFormElement;
if (!(await this.checkFormValidity(formEl))) return;
const params = serialize(formEl);
this.isSubmittingNewOrg = true;
try {
await this.apiFetch(`/orgs/create`, this.authState!, {
method: "POST",
body: JSON.stringify(params),
});
this.fetchOrgs();
this.notify({
message: msg(str`Created new org named "${params.name}".`),
variant: "success",
icon: "check2-circle",
duration: 8000,
});
this.isAddingOrg = false;
} catch (e: any) {
this.notify({
message: e.isApiError
? e.message
: msg("Sorry, couldn't create organization at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
this.isSubmittingNewOrg = false;
}
async onUpdateOrgQuotas(e: CustomEvent) {
const org = e.detail as OrgData;
await this.apiFetch(`/orgs/${org.id}/quotas`, this.authState!, {
method: "POST",
body: JSON.stringify(org.quotas),
});
}
async checkFormValidity(formEl: HTMLFormElement) {
await this.updateComplete;
return !formEl.querySelector("[data-invalid]");
}
}