From a04a2280c41a32aacf9c49d94255d5fcaef99117 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 10 Feb 2025 10:46:20 -0800 Subject: [PATCH] Merge org public gallery settings (#2356) - Merges public gallery settings into general org settings - Adds help text to "Org URL" to highlight impact of changing slug --- frontend/src/components/ui/copy-field.ts | 2 +- .../pages/org/settings/components/general.ts | 406 ++++++++++++++++++ .../org/settings/components/visibility.ts | 128 ------ frontend/src/pages/org/settings/settings.ts | 275 +----------- 4 files changed, 410 insertions(+), 401 deletions(-) create mode 100644 frontend/src/pages/org/settings/components/general.ts delete mode 100644 frontend/src/pages/org/settings/components/visibility.ts diff --git a/frontend/src/components/ui/copy-field.ts b/frontend/src/components/ui/copy-field.ts index bedb53d723..fbedc0152d 100644 --- a/frontend/src/components/ui/copy-field.ts +++ b/frontend/src/components/ui/copy-field.ts @@ -66,7 +66,7 @@ export class CopyField extends TailwindElement { role="group" class=${clsx( tw`rounded border`, - this.filled && tw`bg-slate-50`, + this.filled ? tw`bg-slate-50` : tw`border-neutral-150`, this.monostyle && tw`font-monostyle`, )} > diff --git a/frontend/src/pages/org/settings/components/general.ts b/frontend/src/pages/org/settings/components/general.ts new file mode 100644 index 0000000000..996843e709 --- /dev/null +++ b/frontend/src/pages/org/settings/components/general.ts @@ -0,0 +1,406 @@ +import { localized, msg } from "@lit/localize"; +import type { SlInput } from "@shoelace-style/shoelace"; +import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; +import { html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { when } from "lit/directives/when.js"; +import isEqual from "lodash/fp/isEqual"; + +import { UPDATED_STATUS_TOAST_ID, type UpdateOrgDetail } from "../settings"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import type { APIUser } from "@/index"; +import { columns } from "@/layouts/columns"; +import { RouteNamespace } from "@/routes"; +import { alerts } from "@/strings/orgs/alerts"; +import { isApiError } from "@/utils/api"; +import { formValidator, maxLengthValidator } from "@/utils/form"; +import slugifyStrict from "@/utils/slugify"; +import { AppStateService } from "@/utils/state"; +import { formatAPIUser } from "@/utils/user"; + +type InfoParams = { + orgName: string; + orgSlug: string; +}; + +type ProfileParams = { + enablePublicProfile: boolean; + publicDescription: string; + publicUrl: string; +}; + +/** + * @fires btrix-update-org + */ +@localized() +@customElement("btrix-org-settings-general") +export class OrgSettingsGeneral extends BtrixElement { + @state() + private isSubmitting = false; + + @state() + private slugValue = ""; + + private readonly checkFormValidity = formValidator(this); + private readonly validateOrgNameMax = maxLengthValidator(40); + private readonly validateDescriptionMax = maxLengthValidator(150); + + private get baseUrl() { + return `${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}`; + } + + private get slugPreview() { + return this.slugValue ? slugifyStrict(this.slugValue) : this.userOrg?.slug; + } + + render() { + if (!this.userOrg) return; + + const baseUrl = this.baseUrl; + const slugPreview = this.slugPreview; + + return html`
+
+
+ ${columns([ + [ + html` + + `, + msg( + "Choose a name that represents your organization, your team, or your personal web archive.", + ), + ], + [ + html` + +
+ ${baseUrl}/ +
+
+ ${msg("Examples of org URL in use")}: +
    +
  • + ${msg("Settings")} ${msg("(current page)")}: + + /${RouteNamespace.PrivateOrgs}/${slugPreview}/settings + +
  • + + ${this.org?.enablePublicProfile + ? html` +
  • + ${msg("Public gallery")}: + + /${RouteNamespace.PublicOrgs}/${slugPreview} + +
  • + ` + : html` +
  • + ${msg("Dashboard")}: + + /${RouteNamespace.PrivateOrgs}/${slugPreview}/dashboard + +
  • + `} +
+
+
+ `, + msg("Customize your org's Browsertrix URL."), + ], + ])} + +
${this.renderPublicGallerySettings()}
+
+
+ ${when( + this.org?.enablePublicProfile, + () => html` + + ${msg("View public gallery")} + + `, + )} + + ${msg("Save")} + +
+
+
`; + } + + private renderPublicGallerySettings() { + const baseUrl = this.baseUrl; + const slugPreview = this.slugPreview; + const publicGalleryUrl = `${window.location.protocol}//${baseUrl}/${RouteNamespace.PublicOrgs}/${slugPreview}`; + + return html` + + ${msg("Public Gallery")} + + ${columns([ + [ + html` +
+ + ${msg("Enable public collections gallery")} + +
+ + + + + + + `, + msg( + "If enabled, anyone on the Internet will be able to visit this URL to browse public collections and view general org information.", + ), + ], + [ + html` + + `, + msg( + "Write a short description that introduces your org and its public collections.", + ), + ], + [ + html` + + `, + msg( + "Link to your organization's (or your personal) website in the public gallery.", + ), + ], + ])} + `; + } + + private handleSlugInput(e: InputEvent) { + const input = e.target as SlInput; + // Ideally this would match against the full character map that slugify uses + // but this'll do for most use cases + const end = input.value.match(/[\s*_+~.,()'"!\-:@]$/g) ? "-" : ""; + input.value = slugifyStrict(input.value) + end; + this.slugValue = slugifyStrict(input.value); + + input.setCustomValidity( + this.slugValue.length < 2 ? msg("URL too short") : "", + ); + } + + private async onSubmit(e: SubmitEvent) { + e.preventDefault(); + + const formEl = e.target as HTMLFormElement; + if (!(await this.checkFormValidity(formEl)) || !this.org) return; + + const { + orgName, + orgSlug, + publicDescription, + publicUrl, + enablePublicProfile, + } = serialize(formEl) as InfoParams & + ProfileParams & { + enablePublicProfile: undefined | "on"; + }; + + // TODO See if backend can combine into one endpoint + const requests: Promise[] = []; + + const infoParams = { + name: orgName, + slug: this.slugValue ? slugifyStrict(this.slugValue) : orgSlug, + }; + const infoChanged = !isEqual(infoParams)({ + name: this.org.name, + slug: this.org.slug, + }); + + if (infoChanged) { + requests.push(this.renameOrg(infoParams)); + } + + const profileParams: ProfileParams = { + enablePublicProfile: enablePublicProfile === "on", + publicDescription, + publicUrl, + }; + const profileChanged = !isEqual(profileParams, { + enablePublicProfile: this.org.enablePublicProfile, + publicDescription: this.org.publicDescription, + publicUrl: this.org.publicUrl, + }); + + if (profileChanged) { + requests.push(this.updateProfile(profileParams)); + } + + this.isSubmitting = true; + + try { + await Promise.all(requests); + + this.notify.toast({ + message: alerts.settingsUpdateSuccess, + variant: "success", + icon: "check2-circle", + id: UPDATED_STATUS_TOAST_ID, + }); + } catch (err) { + console.debug(err); + + let message = alerts.settingsUpdateFailure; + + if (isApiError(err)) { + if (err.details === "duplicate_org_name") { + message = msg("This org name is already taken, try another one."); + } else if (err.details === "duplicate_org_slug") { + message = msg("This org URL is already taken, try another one."); + } else if (err.details === "invalid_slug") { + message = msg( + "This org URL is invalid. Please use alphanumeric characters and dashes (-) only.", + ); + } + } + + this.notify.toast({ + message, + variant: "danger", + icon: "exclamation-octagon", + id: UPDATED_STATUS_TOAST_ID, + }); + } + + this.isSubmitting = false; + } + + private async renameOrg({ name, slug }: { name: string; slug: string }) { + await this.api.fetch(`/orgs/${this.orgId}/rename`, { + method: "POST", + body: JSON.stringify({ name, slug }), + }); + + const user = await this.getCurrentUser(); + + AppStateService.updateUser(formatAPIUser(user), slug); + + await this.updateComplete; + + this.navigate.to(`${this.navigate.orgBasePath}/settings`); + } + + private async updateProfile({ + enablePublicProfile, + publicDescription, + publicUrl, + }: ProfileParams) { + const data = await this.api.fetch<{ updated: boolean }>( + `/orgs/${this.orgId}/public-profile`, + { + method: "POST", + body: JSON.stringify({ + enablePublicProfile, + publicDescription, + publicUrl, + }), + }, + ); + + if (!data.updated) { + throw new Error("`data.updated` is not true"); + } + + this.dispatchEvent( + new CustomEvent("btrix-update-org", { + detail: { + publicDescription, + publicUrl, + }, + bubbles: true, + composed: true, + }), + ); + } + + private async getCurrentUser(): Promise { + return this.api.fetch("/users/me"); + } +} diff --git a/frontend/src/pages/org/settings/components/visibility.ts b/frontend/src/pages/org/settings/components/visibility.ts deleted file mode 100644 index aadbbd0216..0000000000 --- a/frontend/src/pages/org/settings/components/visibility.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { localized, msg } from "@lit/localize"; -import type { SlChangeEvent, SlSwitch } from "@shoelace-style/shoelace"; -import { html } from "lit"; -import { customElement } from "lit/decorators.js"; - -import { UPDATED_STATUS_TOAST_ID, type UpdateOrgDetail } from "../settings"; - -import { BtrixElement } from "@/classes/BtrixElement"; -import { columns, type Cols } from "@/layouts/columns"; -import { RouteNamespace } from "@/routes"; -import { alerts } from "@/strings/orgs/alerts"; - -/** - * @fires btrix-update-org - */ -@localized() -@customElement("btrix-org-settings-visibility") -export class OrgSettingsVisibility extends BtrixElement { - render() { - const orgBaseUrl = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}`; - - const cols: Cols = [ - [ - html` - -
- - ${msg("Enable gallery of public collections")} - -
- `, - msg( - "If enabled, anyone on the Internet will be able to browse this org's public collections and view general org information.", - ), - ], - [ - html` -
- -
- `, - html` - ${msg( - html`To customize this URL, - ${msg("update your Org URL in General settings")}.`, - )} - `, - ], - ]; - - return html` -

- ${msg("Public Collections Gallery")} -

- -
-
${columns(cols)}
-
- `; - } - - private readonly onVisibilityChange = async (e: SlChangeEvent) => { - const checked = (e.target as SlSwitch).checked; - - if (checked === this.org?.enablePublicProfile) { - return; - } - - try { - const data = await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/public-profile`, - { - method: "POST", - body: JSON.stringify({ - enablePublicProfile: checked, - }), - }, - ); - - if (!data.updated) { - throw new Error("`data.updated` is not true"); - } - - this.dispatchEvent( - new CustomEvent("btrix-update-org", { - detail: { - enablePublicProfile: checked, - }, - bubbles: true, - composed: true, - }), - ); - - this.notify.toast({ - message: msg("Updated public collections gallery visibility."), - variant: "success", - icon: "check2-circle", - id: UPDATED_STATUS_TOAST_ID, - }); - } catch (err) { - console.debug(err); - - this.notify.toast({ - message: alerts.settingsUpdateFailure, - variant: "danger", - icon: "exclamation-octagon", - id: UPDATED_STATUS_TOAST_ID, - }); - } - }; -} diff --git a/frontend/src/pages/org/settings/settings.ts b/frontend/src/pages/org/settings/settings.ts index ea5c6ef625..6ea4bcea41 100644 --- a/frontend/src/pages/org/settings/settings.ts +++ b/frontend/src/pages/org/settings/settings.ts @@ -1,5 +1,4 @@ import { localized, msg, str } from "@lit/localize"; -import type { SlInput } from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; import { html, @@ -16,23 +15,17 @@ import { when } from "lit/directives/when.js"; import stylesheet from "./settings.stylesheet.css"; import { BtrixElement } from "@/classes/BtrixElement"; -import type { APIUser } from "@/index"; import { columns } from "@/layouts/columns"; import { pageHeader } from "@/layouts/pageHeader"; -import { RouteNamespace } from "@/routes"; -import { alerts } from "@/strings/orgs/alerts"; import type { APIPaginatedList } from "@/types/api"; import { isApiError } from "@/utils/api"; -import { formValidator, maxLengthValidator } from "@/utils/form"; +import { formValidator } from "@/utils/form"; import { AccessCode, isAdmin, isCrawler, type OrgData } from "@/utils/orgs"; -import slugifyStrict from "@/utils/slugify"; -import { AppStateService } from "@/utils/state"; import { tw } from "@/utils/tailwind"; -import { formatAPIUser } from "@/utils/user"; +import "./components/general"; import "./components/billing"; import "./components/crawling-defaults"; -import "./components/visibility"; const styles = unsafeCSS(stylesheet); @@ -84,9 +77,6 @@ export class OrgSettings extends BtrixElement { @property({ type: Boolean }) isAddingMember = false; - @state() - private isSavingOrgName = false; - @state() private pendingInvites: Invite[] = []; @@ -96,9 +86,6 @@ export class OrgSettings extends BtrixElement { @state() private isSubmittingInvite = false; - @state() - private slugValue = ""; - private get tabLabels(): Record { return { information: msg("General"), @@ -108,9 +95,6 @@ export class OrgSettings extends BtrixElement { }; } - private readonly validateOrgNameMax = maxLengthValidator(40); - private readonly validateDescriptionMax = maxLengthValidator(150); - async willUpdate(changedProperties: PropertyValues) { if (changedProperties.has("isAddingMember") && this.isAddingMember) { this.isAddMemberFormVisible = true; @@ -177,8 +161,7 @@ export class OrgSettings extends BtrixElement { ${this.renderPanelHeader({ title: msg("General") })} - ${this.renderInformation()} - + ${this.renderApi()} @@ -270,113 +253,6 @@ export class OrgSettings extends BtrixElement { `; } - private renderInformation() { - if (!this.userOrg) return; - - return html`
-
-
- ${columns([ - [ - html` - - `, - msg( - "Choose a name that represents your organization, your team, or your personal web archive.", - ), - ], - [ - html` - -
/
-
- `, - msg("Customize your org's Browsertrix URL."), - ], - [ - html` - - `, - msg("Write a short description to introduce your organization."), - ], - [ - html` - - `, - msg("Link to your organization's (or your personal) website."), - ], - ])} -
-
- - ${when( - this.org, - (org) => - org.enablePublicProfile - ? msg("View as public") - : msg("Preview how information appears to the public"), - () => html` `, - )} - - - ${msg("Save")} - -
-
-
`; - } - private renderApi() { if (!this.userOrg) return; @@ -402,19 +278,6 @@ export class OrgSettings extends BtrixElement { `; } - private handleSlugInput(e: InputEvent) { - const input = e.target as SlInput; - // Ideally this would match against the full character map that slugify uses - // but this'll do for most use cases - const end = input.value.match(/[\s*_+~.,()'"!\-:@]$/g) ? "-" : ""; - input.value = slugifyStrict(input.value) + end; - this.slugValue = slugifyStrict(input.value); - - input.setCustomValidity( - this.slugValue.length < 2 ? msg("URL Identifier too short") : "", - ); - } - private renderMembers() { if (!this.org?.users) return; @@ -707,87 +570,6 @@ export class OrgSettings extends BtrixElement { } } - private async onOrgInfoSubmit(e: SubmitEvent) { - e.preventDefault(); - - const formEl = e.target as HTMLFormElement; - if (!(await this.checkFormValidity(formEl)) || !this.org) return; - - const { orgName, publicDescription, publicUrl } = serialize(formEl) as { - orgName: string; - publicDescription: string; - publicUrl: string; - }; - - // TODO See if backend can combine into one endpoint - const requests: Promise[] = []; - - if (orgName !== this.org.name || this.slugValue) { - const params = { - name: orgName, - slug: this.orgSlugState!, - }; - - if (this.slugValue) { - params.slug = slugifyStrict(this.slugValue); - } - - requests.push(this.renameOrg(params)); - } - - if ( - publicDescription !== (this.org.publicDescription ?? "") || - publicUrl !== (this.org.publicUrl ?? "") - ) { - requests.push( - this.updateOrgProfile({ - publicDescription: publicDescription || this.org.publicDescription, - publicUrl: publicUrl || this.org.publicUrl, - }), - ); - } - - if (requests.length) { - this.isSavingOrgName = true; - - try { - await Promise.all(requests); - - this.notify.toast({ - message: alerts.settingsUpdateSuccess, - variant: "success", - icon: "check2-circle", - id: UPDATED_STATUS_TOAST_ID, - }); - } catch (err) { - console.debug(err); - - let message = alerts.settingsUpdateFailure; - - if (isApiError(err)) { - if (err.details === "duplicate_org_name") { - message = msg("This org name is already taken, try another one."); - } else if (err.details === "duplicate_org_slug") { - message = msg("This org URL is already taken, try another one."); - } else if (err.details === "invalid_slug") { - message = msg( - "This org URL is invalid. Please use alphanumeric characters and dashes (-) only.", - ); - } - } - - this.notify.toast({ - message, - variant: "danger", - icon: "exclamation-octagon", - id: UPDATED_STATUS_TOAST_ID, - }); - } - - this.isSavingOrgName = false; - } - } - private readonly selectUserRole = (user: User) => (e: Event) => { this.dispatchEvent( new CustomEvent("org-user-role-change", { @@ -876,57 +658,6 @@ export class OrgSettings extends BtrixElement { } } - private async renameOrg({ name, slug }: { name: string; slug: string }) { - await this.api.fetch(`/orgs/${this.orgId}/rename`, { - method: "POST", - body: JSON.stringify({ name, slug }), - }); - - const user = await this.getCurrentUser(); - - AppStateService.updateUser(formatAPIUser(user), slug); - - this.navigate.to(`${this.navigate.orgBasePath}/settings`); - } - - private async updateOrgProfile({ - publicDescription, - publicUrl, - }: { - publicDescription: string | null; - publicUrl: string | null; - }) { - const data = await this.api.fetch<{ updated: boolean }>( - `/orgs/${this.orgId}/public-profile`, - { - method: "POST", - body: JSON.stringify({ - publicDescription, - publicUrl, - }), - }, - ); - - if (!data.updated) { - throw new Error("`data.updated` is not true"); - } - - this.dispatchEvent( - new CustomEvent("btrix-update-org", { - detail: { - publicDescription, - publicUrl, - }, - bubbles: true, - composed: true, - }), - ); - } - - private async getCurrentUser(): Promise { - return this.api.fetch("/users/me"); - } - /** * Stop propgation of sl-tooltip events. * Prevents bug where sl-dialog closes when tooltip closes