From bdd279c4f8ccf0e4f28db3f61f1ddcc4a70d5183 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 15 Jul 2024 10:36:16 -0700 Subject: [PATCH 1/8] show validation message --- frontend/src/pages/invite/ui/org-form.ts | 5 ++++- frontend/src/utils/form.ts | 20 +++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/invite/ui/org-form.ts b/frontend/src/pages/invite/ui/org-form.ts index c08ad2ae..cb9821f8 100644 --- a/frontend/src/pages/invite/ui/org-form.ts +++ b/frontend/src/pages/invite/ui/org-form.ts @@ -12,6 +12,7 @@ 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 { AppStateService } from "@/utils/state"; import { formatAPIUser } from "@/utils/user"; @@ -45,6 +46,8 @@ export class OrgForm extends TailwindElement { 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]) => { @@ -72,9 +75,9 @@ export class OrgForm extends TailwindElement { autocomplete="off" value=${this.name === this.orgId ? "" : this.name} minlength="2" - maxlength="40" help-text=${msg("You can change this in your org settings later.")} required + @sl-input=${this.validateOrgNameMax.validate} >
diff --git a/frontend/src/utils/form.ts b/frontend/src/utils/form.ts index d28cbee5..d0aab22c 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 + isInvalid ? msg(str`Please shorten this text to ${maxLength} or less characters.`) : "", ); - el.helpText = helpText; + + el.helpText = isInvalid ? validityText : origHelpText || validityHelpText; }; - return { helpText, validate }; + return { helpText: validityHelpText, validate }; } From 6f031f105910974ace9220dcf409f4786ceb16a9 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 15 Jul 2024 11:02:10 -0700 Subject: [PATCH 2/8] show correct field when validating --- frontend/src/pages/invite/ui/org-form.ts | 44 ++++++++++++++++++++---- frontend/src/theme.stylesheet.css | 9 ++--- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/invite/ui/org-form.ts b/frontend/src/pages/invite/ui/org-form.ts index cb9821f8..def73be0 100644 --- a/frontend/src/pages/invite/ui/org-form.ts +++ b/frontend/src/pages/invite/ui/org-form.ts @@ -3,7 +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 { customElement, property, query } from "lit/decorators.js"; import slugify from "slugify"; import { TailwindElement } from "@/classes/TailwindElement"; @@ -43,6 +43,9 @@ 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); @@ -151,21 +154,34 @@ 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."), + fieldName = "orgSlug"; + error = new Error( + msg(str`The org URL "${slug}" 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 "${slug}" is not a valid URL. Please use alphanumeric characters and dashes (-) only`, ), ); } + + if (error) { + if (fieldName) { + this.highlightErrorField(fieldName, error); + } + throw error; + } } this._notify.toast({ @@ -178,6 +194,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/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 */ From adea46640e9a74d98b4b5d753478f64b9afb5bd1 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 15 Jul 2024 11:40:15 -0700 Subject: [PATCH 3/8] standardize max length --- frontend/src/pages/home.ts | 2 +- frontend/src/pages/invite/ui/org-form.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/home.ts b/frontend/src/pages/home.ts index 37d13a70..27f0e497 100644 --- a/frontend/src/pages/home.ts +++ b/frontend/src/pages/home.ts @@ -39,7 +39,7 @@ export class Home extends LiteElement { @state() private isSubmittingNewOrg = false; - private readonly validateOrgNameMax = maxLengthValidator(50); + private readonly validateOrgNameMax = maxLengthValidator(40); connectedCallback() { if (this.authState) { diff --git a/frontend/src/pages/invite/ui/org-form.ts b/frontend/src/pages/invite/ui/org-form.ts index def73be0..4bce596a 100644 --- a/frontend/src/pages/invite/ui/org-form.ts +++ b/frontend/src/pages/invite/ui/org-form.ts @@ -73,7 +73,7 @@ export class OrgForm extends TailwindElement {
Date: Mon, 15 Jul 2024 12:05:19 -0700 Subject: [PATCH 4/8] check org slug --- frontend/src/pages/home.ts | 79 +++++++++++++++++++++++++++++++++-- frontend/src/utils/slugify.ts | 7 ++++ 2 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 frontend/src/utils/slugify.ts diff --git a/frontend/src/pages/home.ts b/frontend/src/pages/home.ts index 27f0e497..fc751e7a 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,6 +44,9 @@ export class Home extends LiteElement { @state() private isSubmittingNewOrg = false; + @state() + private isOrgNameValid: boolean | null = null; + private readonly validateOrgNameMax = maxLengthValidator(40); connectedCallback() { @@ -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(); 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() }); +} From a234a36057e2ee4b9d9ee61a7954f1f36e48e7de Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 15 Jul 2024 12:06:43 -0700 Subject: [PATCH 5/8] standarize slugify --- frontend/src/pages/invite/ui/org-form.ts | 6 +++--- frontend/src/pages/org/settings/settings.ts | 12 +++--------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/invite/ui/org-form.ts b/frontend/src/pages/invite/ui/org-form.ts index 4bce596a..3011d229 100644 --- a/frontend/src/pages/invite/ui/org-form.ts +++ b/frontend/src/pages/invite/ui/org-form.ts @@ -4,7 +4,6 @@ import type { SlInput } from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; import { html } from "lit"; import { customElement, property, query } from "lit/decorators.js"; -import slugify from "slugify"; import { TailwindElement } from "@/classes/TailwindElement"; import { APIController } from "@/controllers/api"; @@ -13,6 +12,7 @@ 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"; @@ -96,7 +96,7 @@ export class OrgForm extends TailwindElement { required @sl-input=${(e: InputEvent) => { const input = e.target as SlInput; - input.helpText = helpText(slugify(input.value, { strict: true })); + input.helpText = helpText(slugifyStrict(input.value)); }} > @@ -129,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]); } diff --git a/frontend/src/pages/org/settings/settings.ts b/frontend/src/pages/org/settings/settings.ts index 02432c6b..30a2a642 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; From 38a877fa8d116517d1d2be46b07f748fa912b15f Mon Sep 17 00:00:00 2001 From: sua yoo Date: Tue, 16 Jul 2024 12:57:12 -0700 Subject: [PATCH 6/8] Update frontend/src/utils/form.ts Co-authored-by: Emma Segal-Grossman --- frontend/src/utils/form.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/utils/form.ts b/frontend/src/utils/form.ts index d0aab22c..56788c16 100644 --- a/frontend/src/utils/form.ts +++ b/frontend/src/utils/form.ts @@ -54,7 +54,7 @@ export function maxLengthValidator(maxLength: number): MaxLengthValidator { el.setCustomValidity( isInvalid - ? msg(str`Please shorten this text to ${maxLength} or less characters.`) + ? msg(str`Please shorten this text to ${maxLength} or fewer characters.`) : "", ); From 8577b5bd9313adfe750651bb800ae78a54ed97a7 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Mon, 15 Jul 2024 12:11:38 -0700 Subject: [PATCH 7/8] update superadmin error --- frontend/src/pages/home.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/home.ts b/frontend/src/pages/home.ts index fc751e7a..34c8c232 100644 --- a/frontend/src/pages/home.ts +++ b/frontend/src/pages/home.ts @@ -391,10 +391,22 @@ 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 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.notify({ - message: isApiError(e) - ? e.message - : msg("Sorry, couldn't create organization at this time."), + message, variant: "danger", icon: "exclamation-octagon", }); From 79ff806352a2242ec0daf7b124825204375b5aaa Mon Sep 17 00:00:00 2001 From: sua yoo Date: Tue, 16 Jul 2024 12:59:12 -0700 Subject: [PATCH 8/8] update org url errors --- frontend/src/pages/home.ts | 6 ++++-- frontend/src/pages/invite/ui/org-form.ts | 6 ++++-- frontend/src/pages/org/settings/settings.ts | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/home.ts b/frontend/src/pages/home.ts index 34c8c232..d00765ae 100644 --- a/frontend/src/pages/home.ts +++ b/frontend/src/pages/home.ts @@ -397,10 +397,12 @@ export class Home extends LiteElement { 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/pages/invite/ui/org-form.ts b/frontend/src/pages/invite/ui/org-form.ts index 3011d229..f24f2a94 100644 --- a/frontend/src/pages/invite/ui/org-form.ts +++ b/frontend/src/pages/invite/ui/org-form.ts @@ -165,13 +165,15 @@ export class OrgForm extends TailwindElement { } else if (e.details === "duplicate_org_slug") { fieldName = "orgSlug"; error = new Error( - msg(str`The org URL "${slug}" is already taken, try another one.`), + msg( + 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 "${slug}" is not a valid URL. Please use alphanumeric characters and dashes (-) only`, + str`The org URL identifier "${slug}" is not a valid URL. Please use alphanumeric characters and dashes (-) only`, ), ); } diff --git a/frontend/src/pages/org/settings/settings.ts b/frontend/src/pages/org/settings/settings.ts index 30a2a642..24e0d499 100644 --- a/frontend/src/pages/org/settings/settings.ts +++ b/frontend/src/pages/org/settings/settings.ts @@ -628,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.", ); } }