Allow org admins to update slug (#1276)

- Allows editing of org slugs (actual URL updates will be handled in
https://github.com/webrecorder/browsertrix-cloud/issues/1258.)
- Converts user input to slug using slugify
- Adds help text to org name and slug
- Renames tab from "information" to "general" settings
This commit is contained in:
sua yoo 2023-10-13 17:00:43 -07:00 committed by GitHub
parent 0bd8748e68
commit 8466caf1d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 119 additions and 42 deletions

View File

@ -50,6 +50,7 @@
"pretty-ms": "^7.0.1", "pretty-ms": "^7.0.1",
"query-string": "^8.1.0", "query-string": "^8.1.0",
"regex-colorize": "^0.0.3", "regex-colorize": "^0.0.3",
"slugify": "^1.6.6",
"style-loader": "^3.3.0", "style-loader": "^3.3.0",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"ts-loader": "^9.2.6", "ts-loader": "^9.2.6",

View File

@ -31,7 +31,7 @@ import "./components/new-collection-dialog";
import "./components/new-workflow-dialog"; import "./components/new-workflow-dialog";
import type { import type {
Member, Member,
OrgNameChangeEvent, OrgInfoChangeEvent,
UserRoleChangeEvent, UserRoleChangeEvent,
OrgRemoveMemberEvent, OrgRemoveMemberEvent,
} from "./settings"; } from "./settings";
@ -104,7 +104,7 @@ export class Org extends LiteElement {
private org?: OrgData | null; private org?: OrgData | null;
@state() @state()
private isSavingOrgName = false; private isSavingOrgInfo = false;
get userOrg() { get userOrg() {
if (!this.userInfo) return null; if (!this.userInfo) return null;
@ -535,8 +535,8 @@ export class Org extends LiteElement {
.orgId=${this.orgId} .orgId=${this.orgId}
activePanel=${activePanel} activePanel=${activePanel}
?isAddingMember=${isAddingMember} ?isAddingMember=${isAddingMember}
?isSavingOrgName=${this.isSavingOrgName} ?isSavingOrgName=${this.isSavingOrgInfo}
@org-name-change=${this.onOrgNameChange} @org-info-change=${this.onOrgInfoChange}
@org-user-role-change=${this.onUserRoleChange} @org-user-role-change=${this.onUserRoleChange}
@org-remove-member=${this.onOrgRemoveMember} @org-remove-member=${this.onOrgRemoveMember}
></btrix-org-settings>`; ></btrix-org-settings>`;
@ -554,18 +554,17 @@ export class Org extends LiteElement {
return data; return data;
} }
private async onOrgInfoChange(e: OrgInfoChangeEvent) {
private async onOrgNameChange(e: OrgNameChangeEvent) { this.isSavingOrgInfo = true;
this.isSavingOrgName = true;
try { try {
await this.apiFetch(`/orgs/${this.org!.id}/rename`, this.authState!, { await this.apiFetch(`/orgs/${this.org!.id}/rename`, this.authState!, {
method: "POST", method: "POST",
body: JSON.stringify({ name: e.detail.value }), body: JSON.stringify(e.detail),
}); });
this.notify({ this.notify({
message: msg("Updated organization name."), message: msg("Updated organization."),
variant: "success", variant: "success",
icon: "check2-circle", icon: "check2-circle",
}); });
@ -577,13 +576,13 @@ export class Org extends LiteElement {
this.notify({ this.notify({
message: e.isApiError message: e.isApiError
? e.message ? e.message
: msg("Sorry, couldn't update organization name at this time."), : msg("Sorry, couldn't update organization at this time."),
variant: "danger", variant: "danger",
icon: "exclamation-octagon", icon: "exclamation-octagon",
}); });
} }
this.isSavingOrgName = false; this.isSavingOrgInfo = false;
} }
private async onOrgRemoveMember(e: OrgRemoveMemberEvent) { private async onOrgRemoveMember(e: OrgRemoveMemberEvent) {

View File

@ -3,6 +3,8 @@ import { ifDefined } from "lit/directives/if-defined.js";
import { msg, localized, str } from "@lit/localize"; import { msg, localized, str } from "@lit/localize";
import { when } from "lit/directives/when.js"; import { when } from "lit/directives/when.js";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import slugify from "slugify";
import type { SlInput } from "@shoelace-style/shoelace";
import type { AuthState } from "../../utils/AuthService"; import type { AuthState } from "../../utils/AuthService";
import LiteElement, { html } from "../../utils/LiteElement"; import LiteElement, { html } from "../../utils/LiteElement";
@ -24,8 +26,9 @@ type Invite = User & {
export type Member = User & { export type Member = User & {
name: string; name: string;
}; };
export type OrgNameChangeEvent = CustomEvent<{ export type OrgInfoChangeEvent = CustomEvent<{
value: string; name: string;
slug?: string;
}>; }>;
export type UserRoleChangeEvent = CustomEvent<{ export type UserRoleChangeEvent = CustomEvent<{
user: Member; user: Member;
@ -48,7 +51,7 @@ export type OrgRemoveMemberEvent = CustomEvent<{
* ``` * ```
* *
* @events * @events
* org-name-change * org-info-change
* org-user-role-change * org-user-role-change
* org-remove-member * org-remove-member
*/ */
@ -84,14 +87,17 @@ export class OrgSettings extends LiteElement {
@state() @state()
private isSubmittingInvite = false; private isSubmittingInvite = false;
private get tabLabels() { @state()
private slugValue = "";
private get tabLabels(): Record<Tab, string> {
return { return {
information: msg("Org Information"), information: msg("General"),
members: msg("Members"), members: msg("Members"),
}; };
} }
private validateOrgNameMax = maxLengthValidator(50); private validateOrgNameMax = maxLengthValidator(40);
async willUpdate(changedProperties: Map<string, any>) { async willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("isAddingMember") && this.isAddingMember) { if (changedProperties.has("isAddingMember") && this.isAddingMember) {
@ -164,28 +170,80 @@ export class OrgSettings extends LiteElement {
} }
private renderInformation() { private renderInformation() {
return html`<div class="rounded border p-5"> return html`<div class="rounded border">
<form class="inline-control-form" @submit=${this.onOrgNameSubmit}> <form @submit=${this.onOrgInfoSubmit}>
<sl-input <div class="grid grid-cols-5 gap-x-4 p-4">
class="inline-control-input with-max-help-text" <div class="col-span-5 md:col-span-3">
name="orgName" <sl-input
size="small" class="with-max-help-text"
label=${msg("Org Name")} name="orgName"
autocomplete="off" size="small"
value=${this.org.name} label=${msg("Org Name")}
required placeholder=${msg("My Organization")}
help-text=${this.validateOrgNameMax.helpText} autocomplete="off"
@sl-input=${this.validateOrgNameMax.validate} value=${this.org.name}
></sl-input> minlength="2"
<sl-button required
class="inline-control-button" help-text=${this.validateOrgNameMax.helpText}
type="submit" @sl-input=${this.validateOrgNameMax.validate}
size="small" ></sl-input>
variant="primary" </div>
?disabled=${this.isSavingOrgName} <div class="col-span-5 md:col-span-2 flex gap-2 pt-6">
?loading=${this.isSavingOrgName} <div class="text-base">
>${msg("Save Changes")}</sl-button <sl-icon name="info-circle"></sl-icon>
> </div>
<div class="mt-0.5 text-xs text-neutral-500">
${msg(
"Name of your organization that is visible to all org members."
)}
</div>
</div>
<div class="col-span-5 md:col-span-3">
<sl-input
name="orgSlug"
size="small"
label=${msg("Org ID")}
placeholder="my-organization"
autocomplete="off"
value=${this.org.slug}
minlength="2"
maxlength="30"
required
help-text=${msg(
str`Org URL will be ${window.location.protocol}//${
window.location.hostname
}/${
this.slugValue ? this.slugify(this.slugValue) : this.org.slug
}`
)}
@sl-input=${(e: InputEvent) => {
const input = e.target as SlInput;
this.slugValue = input.value;
}}
>
</sl-input>
</div>
<div class="col-span-5 md:col-span-2 flex gap-2 pt-6">
<div class="text-base">
<sl-icon name="info-circle"></sl-icon>
</div>
<div class="mt-0.5 text-xs text-neutral-500">
${msg("Unique URL for this organization.")}
</div>
</div>
</div>
<footer class="border-t flex justify-end px-4 py-3">
<sl-button
class="inline-control-button"
type="submit"
size="small"
variant="primary"
?disabled=${this.isSavingOrgName}
?loading=${this.isSavingOrgName}
>${msg("Save Changes")}</sl-button
>
</footer>
</form> </form>
</div>`; </div>`;
} }
@ -362,6 +420,12 @@ export class OrgSettings extends LiteElement {
`; `;
} }
private slugify(value: string) {
return slugify(value, {
strict: true,
});
}
private async checkFormValidity(formEl: HTMLFormElement) { private async checkFormValidity(formEl: HTMLFormElement) {
await this.updateComplete; await this.updateComplete;
return !formEl.querySelector("[data-invalid]"); return !formEl.querySelector("[data-invalid]");
@ -390,16 +454,23 @@ export class OrgSettings extends LiteElement {
} }
} }
private async onOrgNameSubmit(e: SubmitEvent) { private async onOrgInfoSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
const formEl = e.target as HTMLFormElement; const formEl = e.target as HTMLFormElement;
if (!(await this.checkFormValidity(formEl))) return; if (!(await this.checkFormValidity(formEl))) return;
const { orgName } = serialize(formEl); const { orgName } = serialize(formEl);
const orgSlug = this.slugify(this.slugValue);
const detail: any = { name: orgName };
if (orgSlug !== this.org.slug) {
detail.slug = orgSlug;
}
this.dispatchEvent( this.dispatchEvent(
<OrgNameChangeEvent>new CustomEvent("org-name-change", { <OrgInfoChangeEvent>new CustomEvent("org-info-change", {
detail: { value: orgName }, detail,
}) })
); );
} }

View File

@ -11,6 +11,7 @@ export const AccessCode: Record<UserRole, number> = {
export type OrgData = { export type OrgData = {
id: string; id: string;
name: string; name: string;
slug: string;
quotas: Record<string, number>; quotas: Record<string, number>;
bytesStored: number; bytesStored: number;
users?: { users?: {

View File

@ -6191,6 +6191,11 @@ slice-ansi@^5.0.0:
ansi-styles "^6.0.0" ansi-styles "^6.0.0"
is-fullwidth-code-point "^4.0.0" is-fullwidth-code-point "^4.0.0"
slugify@^1.6.6:
version "1.6.6"
resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b"
integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==
sockjs@^0.3.24: sockjs@^0.3.24:
version "0.3.24" version "0.3.24"
resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"