diff --git a/frontend/src/pages/home.ts b/frontend/src/pages/home.ts index 37d13a70..d00765ae 100644 --- a/frontend/src/pages/home.ts +++ b/frontend/src/pages/home.ts @@ -1,4 +1,5 @@ import { localized, msg, str } from "@lit/localize"; +import type { SlInput, SlInputEvent } from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; import { type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; @@ -11,6 +12,7 @@ import type { AuthState } from "@/utils/AuthService"; import { maxLengthValidator } from "@/utils/form"; import LiteElement, { html } from "@/utils/LiteElement"; import type { OrgData } from "@/utils/orgs"; +import slugifyStrict from "@/utils/slugify"; /** * @fires btrix-update-user-info @@ -30,6 +32,9 @@ export class Home extends LiteElement { @state() private orgList?: OrgData[]; + @state() + private orgSlugs: string[] = []; + @state() private isAddingOrg = false; @@ -39,7 +44,10 @@ export class Home extends LiteElement { @state() private isSubmittingNewOrg = false; - private readonly validateOrgNameMax = maxLengthValidator(50); + @state() + private isOrgNameValid: boolean | null = null; + + private readonly validateOrgNameMax = maxLengthValidator(40); connectedCallback() { if (this.authState) { @@ -171,6 +179,29 @@ export class Home extends LiteElement { + ${this.renderAddOrgDialog()} + `; + } + + private renderAddOrgDialog() { + let orgNameStatusLabel = msg("Start typing to see availability"); + let orgNameStatusIcon = html` + + `; + + if (this.isOrgNameValid) { + orgNameStatusLabel = msg("This org name is available"); + orgNameStatusIcon = html` + + `; + } else if (this.isOrgNameValid === false) { + orgNameStatusLabel = msg("This org name is taken"); + orgNameStatusIcon = html` + + `; + } + + return html` (this.isAddOrgFormVisible = true)} - @sl-after-hide=${() => (this.isAddOrgFormVisible = false)} + @sl-after-hide=${() => { + this.isAddOrgFormVisible = false; + this.isOrgNameValid = null; + }} > ${this.isAddOrgFormVisible ? html` @@ -201,8 +235,17 @@ export class Home extends LiteElement { autocomplete="off" required help-text=${this.validateOrgNameMax.helpText} - @sl-input=${this.validateOrgNameMax.validate} + @sl-input=${this.onOrgNameInput} > + e.stopPropagation()} + @sl-after-hide=${(e: CustomEvent) => e.stopPropagation()} + hoist + > + ${orgNameStatusIcon} + @@ -277,7 +320,12 @@ export class Home extends LiteElement { } private async fetchOrgs() { - this.orgList = await this.getOrgs(); + try { + this.orgList = await this.getOrgs(); + this.orgSlugs = await this.getOrgSlugs(); + } catch (e) { + console.debug(e); + } } private async getOrgs() { @@ -289,6 +337,31 @@ export class Home extends LiteElement { return data.items; } + private async getOrgSlugs() { + const data = await this.apiFetch<{ slugs: string[] }>( + "/orgs/slugs", + this.authState!, + ); + + return data.slugs; + } + + private async onOrgNameInput(e: SlInputEvent) { + this.validateOrgNameMax.validate(e); + + const input = e.target as SlInput; + const slug = slugifyStrict(input.value); + const isInvalid = this.orgSlugs.includes(slug); + + if (isInvalid) { + input.setCustomValidity(msg("This org name is already taken.")); + } else { + input.setCustomValidity(""); + } + + this.isOrgNameValid = !isInvalid; + } + private async onSubmitNewOrg(e: SubmitEvent) { e.preventDefault(); @@ -318,10 +391,24 @@ export class Home extends LiteElement { }); this.isAddingOrg = false; } catch (e) { + let message = msg("Sorry, couldn't create organization at this time."); + + if (isApiError(e)) { + if (e.details === "duplicate_org_name") { + message = msg("This org name is already taken, try another one."); + } else if (e.details === "duplicate_org_slug") { + message = msg( + "This org URL identifier is already taken, try another one.", + ); + } else if (e.details === "invalid_slug") { + message = msg( + "This org URL identifier is invalid. Please use alphanumeric characters and dashes (-) only.", + ); + } + } + this.notify({ - message: isApiError(e) - ? e.message - : msg("Sorry, couldn't create organization at this time."), + message, variant: "danger", icon: "exclamation-octagon", }); diff --git a/frontend/src/pages/invite/ui/org-form.ts b/frontend/src/pages/invite/ui/org-form.ts index c08ad2ae..f24f2a94 100644 --- a/frontend/src/pages/invite/ui/org-form.ts +++ b/frontend/src/pages/invite/ui/org-form.ts @@ -3,8 +3,7 @@ import { Task, TaskStatus } from "@lit/task"; import type { SlInput } from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; import { html } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import slugify from "slugify"; +import { customElement, property, query } from "lit/decorators.js"; import { TailwindElement } from "@/classes/TailwindElement"; import { APIController } from "@/controllers/api"; @@ -12,6 +11,8 @@ import { NotifyController } from "@/controllers/notify"; import { type APIUser } from "@/index"; import { isApiError } from "@/utils/api"; import type { AuthState } from "@/utils/AuthService"; +import { maxLengthValidator } from "@/utils/form"; +import slugifyStrict from "@/utils/slugify"; import { AppStateService } from "@/utils/state"; import { formatAPIUser } from "@/utils/user"; @@ -42,9 +43,14 @@ export class OrgForm extends TailwindElement { @property({ type: String }) slug = ""; + @query("#orgForm") + private readonly form?: HTMLFormElement | null; + readonly _api = new APIController(this); readonly _notify = new NotifyController(this); + private readonly validateOrgNameMax = maxLengthValidator(40); + readonly _renameOrgTask = new Task(this, { autoRun: false, task: async ([id, name, slug]) => { @@ -67,20 +73,20 @@ export class OrgForm extends TailwindElement {
{ const input = e.target as SlInput; - input.helpText = helpText(slugify(input.value, { strict: true })); + input.helpText = helpText(slugifyStrict(input.value)); }} > @@ -123,7 +129,7 @@ export class OrgForm extends TailwindElement { const params = serialize(form) as FormValues; const orgName = params.orgName; - const orgSlug = slugify(params.orgSlug, { strict: true }); + const orgSlug = slugifyStrict(params.orgSlug); void this._renameOrgTask.run([this.orgId, orgName, orgSlug]); } @@ -148,20 +154,35 @@ export class OrgForm extends TailwindElement { } catch (e) { console.debug(e); if (isApiError(e)) { + let error: Error | null = null; + let fieldName = ""; + if (e.details === "duplicate_org_name") { - throw new Error( - msg("This org name is already taken, try another one."), + fieldName = "orgName"; + error = new Error( + msg(str`The org name "${name}" is already taken, try another one.`), ); } else if (e.details === "duplicate_org_slug") { - throw new Error( - msg("This org URL is already taken, try another one."), - ); - } else if (e.details === "invalid_slug") { - throw new Error( + fieldName = "orgSlug"; + error = new Error( msg( - "This org URL is invalid. Please use alphanumeric characters and dashes (-) only.", + str`The org URL identifier "${slug}" is already taken, try another one.`, ), ); + } else if (e.details === "invalid_slug") { + fieldName = "orgSlug"; + error = new Error( + msg( + str`The org URL identifier "${slug}" is not a valid URL. Please use alphanumeric characters and dashes (-) only`, + ), + ); + } + + if (error) { + if (fieldName) { + this.highlightErrorField(fieldName, error); + } + throw error; } } @@ -175,6 +196,20 @@ export class OrgForm extends TailwindElement { } } + private highlightErrorField(fieldName: string, error: Error) { + const input = this.form?.querySelector(`[name="${fieldName}"]`); + + if (input) { + input.setCustomValidity(error.message); + + const onOneInput = () => { + input.setCustomValidity(""); + input.removeEventListener("sl-input", onOneInput); + }; + input.addEventListener("sl-input", onOneInput); + } + } + private async checkFormValidity(formEl: HTMLFormElement) { await this.updateComplete; return !formEl.querySelector("[data-invalid]"); diff --git a/frontend/src/pages/org/settings/settings.ts b/frontend/src/pages/org/settings/settings.ts index 02432c6b..24e0d499 100644 --- a/frontend/src/pages/org/settings/settings.ts +++ b/frontend/src/pages/org/settings/settings.ts @@ -5,7 +5,6 @@ import { html, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; -import slugify from "slugify"; import { columns } from "./ui/columns"; @@ -20,6 +19,7 @@ import { isApiError } from "@/utils/api"; import type { AuthState } from "@/utils/AuthService"; import { maxLengthValidator } from "@/utils/form"; import { AccessCode, isAdmin, isCrawler, type OrgData } from "@/utils/orgs"; +import slugifyStrict from "@/utils/slugify"; import appState, { AppStateService, use } from "@/utils/state"; import { formatAPIUser } from "@/utils/user"; @@ -233,7 +233,7 @@ export class OrgSettings extends TailwindElement { window.location.hostname }/orgs/${ this.slugValue - ? this.slugify(this.slugValue) + ? slugifyStrict(this.slugValue) : this.org.slug }`, )} @@ -454,12 +454,6 @@ export class OrgSettings extends TailwindElement { `; } - private slugify(value: string) { - return slugify(value, { - strict: true, - }); - } - private async checkFormValidity(formEl: HTMLFormElement) { await this.updateComplete; return !formEl.querySelector("[data-invalid]"); @@ -502,7 +496,7 @@ export class OrgSettings extends TailwindElement { }; if (this.slugValue) { - params.slug = this.slugify(this.slugValue); + params.slug = slugifyStrict(this.slugValue); } this.isSavingOrgName = true; @@ -634,10 +628,12 @@ export class OrgSettings extends TailwindElement { if (e.details === "duplicate_org_name") { message = msg("This org name is already taken, try another one."); } else if (e.details === "duplicate_org_slug") { - message = msg("This org URL is already taken, try another one."); + message = msg( + "This org URL identifier is already taken, try another one.", + ); } else if (e.details === "invalid_slug") { message = msg( - "This org URL is invalid. Please use alphanumeric characters and dashes (-) only.", + "This org URL identifier is invalid. Please use alphanumeric characters and dashes (-) only.", ); } } diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index b421f37d..5875dd84 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -166,14 +166,15 @@ box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-100); } - [data-user-invalid]:not([disabled])::part(form-control-label):after { - /* Required asterisk color */ - color: var(--sl-color-danger-500); + [data-user-invalid]:not([disabled])::part(form-control-label), + /* Required asterisk color */ + [data-user-invalid]:not([disabled])::part(form-control-label)::after { + color: var(--sl-color-danger-700); } [data-user-invalid]:not([disabled])::part(form-control-help-text), [data-user-invalid]:not([disabled]) .form-help-text { - color: var(--sl-color-danger-500); + color: var(--sl-color-danger-700); } /* TODO tailwind sets border-width: 0, see if this can be fixed in tw */ diff --git a/frontend/src/utils/form.ts b/frontend/src/utils/form.ts index d28cbee5..56788c16 100644 --- a/frontend/src/utils/form.ts +++ b/frontend/src/utils/form.ts @@ -39,17 +39,27 @@ export function getHelpText(maxLength: number, currentLength: number) { * ``` */ export function maxLengthValidator(maxLength: number): MaxLengthValidator { - const helpText = msg(str`Maximum ${maxLength} characters`); + const validityHelpText = msg(str`Maximum ${maxLength} characters`); + let origHelpText: null | string = null; + const validate = (e: CustomEvent) => { const el = e.target as SlTextarea | SlInput; - const helpText = getHelpText(maxLength, el.value.length); + + if (origHelpText === null && el.helpText) { + origHelpText = el.helpText; + } + + const validityText = getHelpText(maxLength, el.value.length); + const isInvalid = el.value.length > maxLength; + el.setCustomValidity( - el.value.length > maxLength - ? msg(str`Please shorten this text to ${maxLength} or less characters.`) + isInvalid + ? msg(str`Please shorten this text to ${maxLength} or fewer characters.`) : "", ); - el.helpText = helpText; + + el.helpText = isInvalid ? validityText : origHelpText || validityHelpText; }; - return { helpText, validate }; + return { helpText: validityHelpText, validate }; } diff --git a/frontend/src/utils/slugify.ts b/frontend/src/utils/slugify.ts new file mode 100644 index 00000000..da3c0feb --- /dev/null +++ b/frontend/src/utils/slugify.ts @@ -0,0 +1,7 @@ +import slugify from "slugify"; + +import { getLocale } from "./localization"; + +export default function slugifyStrict(value: string) { + return slugify(value, { strict: true, lower: true, locale: getLocale() }); +}