Update org settings & org invite UI (#528)

This commit is contained in:
sua yoo 2023-01-29 11:38:22 -08:00 committed by GitHub
parent 3c199419a2
commit 05ce32d898
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 306 additions and 277 deletions

View File

@ -0,0 +1,38 @@
import { css } from "lit";
import SLDialog from "@shoelace-style/shoelace/dist/components/dialog/dialog.js";
import dialogStyles from "@shoelace-style/shoelace/dist/components/dialog/dialog.styles.js";
/**
* Customized <sl-dialog>
*
* Usage: see https://shoelace.style/components/dialog
*/
export class Dialog extends SLDialog {
static styles = css`
${dialogStyles} .dialog__panel {
overflow: hidden;
}
.dialog__header {
background-color: var(--sl-color-neutral-50);
border-bottom: 1px solid var(--sl-color-neutral-100);
}
.dialog__title {
padding-top: var(--sl-spacing-small);
padding-bottom: var(--sl-spacing-small);
font-size: var(--sl-font-size-medium);
font-weight: var(--sl-font-weight-medium);
}
.dialog__close {
--header-spacing: var(--sl-spacing-2x-small);
}
.dialog__footer {
padding-top: var(--sl-spacing-small);
padding-bottom: var(--sl-spacing-small);
border-top: 1px solid var(--sl-color-neutral-100);
}
`;
}

View File

@ -10,9 +10,6 @@ import("./locale-picker").then(({ LocalePicker }) => {
import("./account-settings").then(({ AccountSettings }) => {
customElements.define("btrix-account-settings", AccountSettings);
});
import("./org-invite-form").then(({ OrgInviteForm }) => {
customElements.define("btrix-org-invite-form", OrgInviteForm);
});
import("./config-editor").then(({ ConfigEditor }) => {
customElements.define("btrix-config-editor", ConfigEditor);
});
@ -101,6 +98,9 @@ import("./tag-input").then(({ TagInput }) => {
import("./tag").then(({ Tag }) => {
customElements.define("btrix-tag", Tag);
});
import("./dialog").then(({ Dialog }) => {
customElements.define("btrix-dialog", Dialog);
});
customElements.define("btrix-alert", Alert);
customElements.define("btrix-input", Input);

View File

@ -1,132 +0,0 @@
import { state, property } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize";
import type { AuthState } from "../utils/AuthService";
import LiteElement, { html } from "../utils/LiteElement";
import { AccessCode } from "../utils/orgs";
@localized()
export class OrgInviteForm extends LiteElement {
@property({ type: String })
orgId?: string;
@property({ type: Object })
authState?: AuthState;
@state()
private isSubmitting: boolean = false;
@state()
private serverError?: string;
render() {
let formError;
if (this.serverError) {
formError = html`
<div class="mb-5">
<btrix-alert id="formError" variant="danger"
>${this.serverError}</btrix-alert
>
</div>
`;
}
return html`
<form
class="max-w-md"
@submit=${this.onSubmit}
aria-describedby="formError"
>
<div class="mb-5">
<sl-input
id="inviteEmail"
name="inviteEmail"
type="email"
label=${msg("Email")}
placeholder=${msg("org-member@email.com", {
desc: "Placeholder text for email to invite",
})}
required
>
</sl-input>
</div>
<div class="mb-5">
<sl-radio-group
name="role"
label="Select an option"
value=${AccessCode.viewer}
>
<sl-radio value=${AccessCode.owner}>
${msg("Admin")} - ${msg("Can manage crawls and invite others")}
</sl-radio>
<sl-radio value=${AccessCode.crawler}>
${msg("Crawler")} - ${msg("Can manage crawls")}
</sl-radio>
<sl-radio value=${AccessCode.viewer}>
${msg("Viewer")} - ${msg("Can view crawls")}
</sl-radio>
</sl-radio-group>
</div>
${formError}
<div>
<sl-button
variant="primary"
type="submit"
?loading=${this.isSubmitting}
?disabled=${this.isSubmitting}
>${msg("Invite")}</sl-button
>
<sl-button
variant="text"
@click=${() => this.dispatchEvent(new CustomEvent("cancel"))}
>${msg("Cancel")}</sl-button
>
</div>
</form>
`;
}
async onSubmit(event: SubmitEvent) {
event.preventDefault();
if (!this.authState) return;
this.isSubmitting = true;
const formData = new FormData(event.target as HTMLFormElement);
const inviteEmail = formData.get("inviteEmail") as string;
try {
const data = await this.apiFetch(
`/orgs/${this.orgId}/invite`,
this.authState,
{
method: "POST",
body: JSON.stringify({
email: inviteEmail,
role: Number(formData.get("role")),
}),
}
);
this.dispatchEvent(
new CustomEvent("success", {
detail: {
inviteEmail,
isExistingUser: data.invited === "existing_user",
},
})
);
} catch (e: any) {
if (e?.isApiError) {
this.serverError = e?.message;
} else {
this.serverError = msg("Something unexpected went wrong");
}
}
this.isSubmitting = false;
}
}

View File

@ -168,15 +168,19 @@ export class TabList extends LitElement {
top: var(--sl-spacing-medium);
}
ul {
.tablist {
display: flex;
margin: 0 0 0 var(--track-width);
margin: 0;
list-style: none;
padding: 0;
}
.show-indicator .tablist {
margin-left: var(--track-width);
}
@media only screen and (min-width: ${SCREEN_LG}px) {
ul {
.tablist {
display: block;
}
}
@ -201,9 +205,9 @@ export class TabList extends LitElement {
}
@media only screen and (min-width: ${SCREEN_LG}px) {
ul,
.track,
.indicator {
.tablist,
.show-indicator .track,
.show-indicator .indicator {
display: block;
}
}
@ -217,6 +221,9 @@ export class TabList extends LitElement {
@property({ type: String })
progressPanel?: string;
@property({ type: Boolean })
hideIndicator = false;
@queryAsync(".track")
private trackElem!: HTMLElement;
@ -233,7 +240,7 @@ export class TabList extends LitElement {
}
private async repositionIndicator(activeTab?: TabElement, animate = true) {
if (!activeTab) return;
if (!activeTab || this.hideIndicator) return;
const trackElem = await this.trackElem;
const indicatorElem = await this.indicatorElem;
@ -274,12 +281,17 @@ export class TabList extends LitElement {
@sl-resize=${() =>
this.repositionIndicator(this.getTab(this.progressPanel))}
>
<div class="nav ${this.progressPanel ? "linear" : "nonlinear"}">
<div
class="nav ${this.progressPanel ? "linear" : "nonlinear"} ${this
.hideIndicator
? "hide-indicator"
: "show-indicator"}"
>
<div class="track" role="presentation">
<div class="indicator" role="presentation"></div>
</div>
<ul role="tablist">
<ul class="tablist" role="tablist">
<slot name="nav"></slot>
</ul>
</div>

View File

@ -572,6 +572,9 @@ export class App extends LiteElement {
.viewStateData=${this.viewState.data}
.params=${this.viewState.params}
orgId=${this.viewState.params.orgId}
orgPath=${this.viewState.pathname.split(
this.viewState.params.orgId
)[1]}
orgTab=${this.viewState.params.orgTab as OrgTab}
></btrix-org>`;

View File

@ -46,6 +46,10 @@ export class Org extends LiteElement {
@property({ type: Object })
viewStateData?: ViewState["data"];
// Path after `/orgs/:orgId/`
@property({ type: String })
orgPath!: string;
@property({ type: Object })
params!: Params;
@ -259,6 +263,10 @@ export class Org extends LiteElement {
}
private renderOrgSettings() {
// const activePanel = this.
const activePanel = this.orgPath.includes("/members")
? "members"
: "information";
const isAddingMember = this.params.hasOwnProperty("invite");
return html`<btrix-org-settings
@ -266,6 +274,7 @@ export class Org extends LiteElement {
.userInfo=${this.userInfo}
.org=${this.org}
.orgId=${this.orgId}
activePanel=${activePanel}
?isAddingMember=${isAddingMember}
></btrix-org-settings>`;
}

View File

@ -3,7 +3,6 @@ import { ifDefined } from "lit/directives/if-defined.js";
import { msg, localized, str } from "@lit/localize";
import { when } from "lit/directives/when.js";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import type { SlButton } from "@shoelace-style/shoelace";
import type { AuthState } from "../../utils/AuthService";
import LiteElement, { html } from "../../utils/LiteElement";
@ -11,6 +10,8 @@ import { isOwner, AccessCode } from "../../utils/orgs";
import type { OrgData } from "../../utils/orgs";
import type { CurrentUser } from "../../types/user";
type Tab = "information" | "members";
/**
* Usage:
* ```ts
@ -37,131 +38,142 @@ export class OrgSettings extends LiteElement {
@property({ type: Object })
org!: OrgData;
@property({ type: String })
activePanel: Tab = "information";
@property({ type: Boolean })
isAddingMember = false;
@state()
private successfullyInvitedEmail?: string;
@state()
private isEditingOrgName = false;
private isAddMemberFormVisible = false;
@state()
private isSavingOrgName = false;
@state()
private isSubmittingInvite = false;
private get tabLabels() {
return {
information: msg("Org Information"),
members: msg("Members"),
};
}
async willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("isAddingMember") && this.isAddingMember) {
this.successfullyInvitedEmail = undefined;
this.isAddMemberFormVisible = true;
}
}
render() {
if (this.isAddingMember) {
return this.renderAddMember();
}
return html`<header class="mb-5">
<h2 class="text-xl leading-10">${msg("Org Settings")}</h2>
</header>
return html`<btrix-section-heading
>${msg("Org Information")}</btrix-section-heading
>
<section class="mt-5 mb-10">${this.renderOrgName()}</section>
<btrix-section-heading>${msg("Org Members")}</btrix-section-heading>
<section class="mt-5">${this.renderMembers()}</section>`;
}
private renderOrgName() {
return html`<form
@submit=${this.onOrgNameSubmit}
@reset=${() => (this.isEditingOrgName = false)}
>
<div class="flex items-end">
<div class="flex-1 mr-3">
<sl-input
name="orgName"
label=${msg("Org Name")}
autocomplete="off"
value=${this.org.name}
?readonly=${!this.isEditingOrgName}
?required=${this.isEditingOrgName}
></sl-input>
</div>
<div class="flex-0">
<btrix-tab-list activePanel=${this.activePanel} ?hideIndicator=${true}>
<header slot="header" class="flex items-end justify-between h-5">
<h3>${this.tabLabels[this.activePanel]}</h3>
${when(
this.isEditingOrgName,
this.activePanel === "members",
() => html`
<sl-button type="reset" class="mr-1">${msg("Cancel")}</sl-button>
<sl-button
type="submit"
href=${`/orgs/${this.orgId}/settings/members?invite`}
variant="primary"
?disabled=${this.isSavingOrgName}
?loading=${this.isSavingOrgName}
>${msg("Save Changes")}</sl-button
>
`,
() => html`
<sl-button
@click=${(e: MouseEvent) => {
this.isEditingOrgName = true;
(e.target as SlButton)
.closest("form")
?.querySelector("sl-input")
?.focus();
}}
>${msg("Edit")}</sl-button
size="small"
@click=${this.navLink}
>${msg("Invite New Member")}</sl-button
>
`
)}
</header>
${this.renderTab("information", "settings")}
${this.renderTab("members", "settings/members")}
<btrix-tab-panel name="information"
>${this.renderInformation()}</btrix-tab-panel
>
<btrix-tab-panel name="members"
>${this.renderMembers()}</btrix-tab-panel
>
</btrix-tab-list>`;
}
private renderTab(name: Tab, path: string) {
const isActive = name === this.activePanel;
return html`
<a
slot="nav"
href=${`/orgs/${this.orgId}/${path}`}
class="block font-medium rounded-sm mb-2 mr-2 p-2 transition-all ${isActive
? "text-blue-600 bg-blue-50 shadow-sm"
: "text-neutral-600 hover:bg-neutral-50"}"
@click=${this.navLink}
aria-selected=${isActive}
>
${this.tabLabels[name]}
</a>
`;
}
private renderInformation() {
return html`<div class="rounded border p-5">
<form @submit=${this.onOrgNameSubmit}>
<div class="flex items-end">
<div class="flex-1 mr-3">
<sl-input
name="orgName"
size="small"
label=${msg("Org Name")}
autocomplete="off"
value=${this.org.name}
required
></sl-input>
</div>
<div class="flex-0">
<sl-button
type="submit"
size="small"
variant="primary"
?disabled=${this.isSavingOrgName}
?loading=${this.isSavingOrgName}
>${msg("Save Changes")}</sl-button
>
</div>
</div>
</div>
</form>`;
</form>
</div>`;
}
private renderMembers() {
let successMessage;
if (this.successfullyInvitedEmail) {
successMessage = html`
<div class="my-3">
<btrix-alert variant="success"
>${msg(
str`Successfully invited ${this.successfullyInvitedEmail}`
)}</btrix-alert
>
</div>
`;
}
return html`${successMessage}
<div class="text-right">
<sl-button
href=${`/orgs/${this.orgId}/settings/members?invite`}
@click=${this.navLink}
>${msg("Add Member")}</sl-button
>
</div>
<div role="table">
<div class="border-b" role="rowgroup">
return html`
<div role="table" class="rounded border">
<div class="border-b bg-neutral-50" role="rowgroup">
<div class="flex font-medium" role="row">
<div class="w-1/2 px-3 py-2" role="columnheader" aria-sort="none">
<div class="flex-1 px-3 py-1" role="columnheader" aria-sort="none">
${msg("Name")}
</div>
<div class="px-3 py-2" role="columnheader" aria-sort="none">
<div
class="flex-0 w-52 px-3 py-1"
role="columnheader"
aria-sort="none"
>
${msg("Role", { desc: "Organization member's role" })}
</div>
</div>
</div>
<div role="rowgroup">
${Object.entries(this.org.users!).map(
([id, { name, role }]) => html`
<div class="border-b flex" role="row">
<div class="w-1/2 p-3" role="cell">
${name ||
html`<span class="text-gray-400">${msg("Member")}</span>`}
</div>
<div class="p-3" role="cell">
${isOwner(role)
([id, user]) => html`
<div
class="border-b last:border-none flex items-center"
role="row"
>
<div class="flex-1 p-3" role="cell">${user.name}</div>
<div class="flex-0 w-52 p-3" role="cell">
${isOwner(user.role)
? msg("Admin")
: role === AccessCode.crawler
: user.role === AccessCode.crawler
? msg("Crawler")
: msg("Viewer")}
</div>
@ -169,43 +181,99 @@ export class OrgSettings extends LiteElement {
`
)}
</div>
</div>`;
}
private renderAddMember() {
return html`
<div class="mb-5">
<a
class="text-neutral-500 hover:text-neutral-600 text-sm font-medium"
href=${`/orgs/${this.orgId}/settings/members`}
@click=${this.navLink}
>
<sl-icon
name="arrow-left"
class="inline-block align-middle"
></sl-icon>
<span class="inline-block align-middle"
>${msg("Back to Settings")}</span
>
</a>
</div>
<div class="border rounded-lg p-4 md:p-8 md:pt-6">
<h2 class="text-lg font-medium mb-4">${msg("Add New Member")}</h2>
<btrix-org-invite-form
@success=${this.onInviteSuccess}
@cancel=${() => this.navTo(`/orgs/${this.orgId}/settings/members`)}
.authState=${this.authState}
.orgId=${this.orgId}
></btrix-org-invite-form>
<btrix-dialog
label=${msg("Invite New Member")}
?open=${this.isAddingMember}
@sl-request-close=${this.hideInviteDialog}
@sl-show=${() => (this.isAddMemberFormVisible = true)}
@sl-after-hide=${() => (this.isAddMemberFormVisible = false)}
>
${this.isAddMemberFormVisible ? this.renderInviteForm() : ""}
</btrix-dialog>
`;
}
private renderUserRole(user: { name: string; role: typeof AccessCode }) {
return html`<sl-select value=${user.role} size="small">
<sl-menu-item value=${AccessCode.owner}> ${"Admin"} </sl-menu-item>
<sl-menu-item value=${AccessCode.crawler}> ${"Crawler"} </sl-menu-item>
<sl-menu-item value=${AccessCode.viewer}> ${"Viewer"} </sl-menu-item>
</sl-select>`;
}
private hideInviteDialog() {
this.navTo(`/orgs/${this.orgId}/settings/members`);
}
private renderInviteForm() {
return html`
<form
id="orgInviteForm"
@submit=${this.onOrgInviteSubmit}
@reset=${this.hideInviteDialog}
>
<div class="mb-5">
<sl-input
id="inviteEmail"
name="inviteEmail"
type="email"
label=${msg("Email")}
placeholder=${msg("org-member@email.com", {
desc: "Placeholder text for email to invite",
})}
required
>
</sl-input>
</div>
<div class="mb-5">
<sl-radio-group
name="role"
label="Permission"
value=${AccessCode.viewer}
>
<sl-radio value=${AccessCode.owner}>
${msg("Admin — Can create crawls and manage org members")}
</sl-radio>
<sl-radio value=${AccessCode.crawler}>
${msg("Crawler — Can create crawls")}
</sl-radio>
<sl-radio value=${AccessCode.viewer}>
${msg("Viewer — Can view crawls")}
</sl-radio>
</sl-radio-group>
</div>
</form>
<div slot="footer" class="flex justify-between">
<sl-button form="orgInviteForm" type="reset" size="small"
>${msg("Cancel")}</sl-button
>
<sl-button
form="orgInviteForm"
variant="primary"
type="submit"
size="small"
?loading=${this.isSubmittingInvite}
?disabled=${this.isSubmittingInvite}
>${msg("Invite")}</sl-button
>
</div>
`;
}
async checkFormValidity(formEl: HTMLFormElement) {
await this.updateComplete;
return !formEl.querySelector("[data-invalid]");
}
private async onOrgNameSubmit(e: SubmitEvent) {
e.preventDefault();
if (!this.org) return;
const { orgName } = serialize(e.target as HTMLFormElement);
const formEl = e.target as HTMLFormElement;
if (!(await this.checkFormValidity(formEl))) return;
const { orgName } = serialize(formEl);
this.isSavingOrgName = true;
@ -222,19 +290,14 @@ export class OrgSettings extends LiteElement {
duration: 8000,
});
this.org = {
...this.org,
name: orgName as string,
};
this.isEditingOrgName = false;
this.dispatchEvent(
new CustomEvent("update-user-info", { bubbles: true })
);
} catch (e) {
console.debug(e);
} catch (e: any) {
this.notify({
message: msg("Sorry, couldn't update organization name at this time."),
message: e.isApiError
? e.message
: msg("Sorry, couldn't update organization name at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
@ -243,12 +306,48 @@ export class OrgSettings extends LiteElement {
this.isSavingOrgName = false;
}
private onInviteSuccess(
event: CustomEvent<{ inviteEmail: string; isExistingUser: boolean }>
) {
this.successfullyInvitedEmail = event.detail.inviteEmail;
async onOrgInviteSubmit(e: SubmitEvent) {
e.preventDefault();
this.navTo(`/orgs/${this.orgId}/settings/members`);
const formEl = e.target as HTMLFormElement;
if (!(await this.checkFormValidity(formEl))) return;
const { inviteEmail, role } = serialize(formEl);
this.isSubmittingInvite = true;
try {
const data = await this.apiFetch(
`/orgs/${this.orgId}/invite`,
this.authState!,
{
method: "POST",
body: JSON.stringify({
email: inviteEmail,
role: Number(role),
}),
}
);
this.notify({
message: msg(str`Successfully invited ${inviteEmail}.`),
variant: "success",
icon: "check2-circle",
duration: 8000,
});
this.hideInviteDialog();
} catch (e: any) {
this.notify({
message: e.isApiError
? e.message
: msg("Sorry, couldn't invite user at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
this.isSubmittingInvite = false;
}
}