feat: Improve org name and slug validation (#1924)
- Verifies org slug (name) availability when creating new org - Show org max length error when signing up - Highlights org error field when signing up - Fixes org name max length discrepancy - Standardizes org slug to lowercase
This commit is contained in:
		
						commit
						5e9e897713
					
				| @ -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 { | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       ${this.renderAddOrgDialog()} | ||||
|     `;
 | ||||
|   } | ||||
| 
 | ||||
|   private renderAddOrgDialog() { | ||||
|     let orgNameStatusLabel = msg("Start typing to see availability"); | ||||
|     let orgNameStatusIcon = html` | ||||
|       <sl-icon class="mr-3 text-neutral-300" name="check-lg"></sl-icon> | ||||
|     `;
 | ||||
| 
 | ||||
|     if (this.isOrgNameValid) { | ||||
|       orgNameStatusLabel = msg("This org name is available"); | ||||
|       orgNameStatusIcon = html` | ||||
|         <sl-icon class="mr-3 text-success" name="check-lg"></sl-icon> | ||||
|       `;
 | ||||
|     } else if (this.isOrgNameValid === false) { | ||||
|       orgNameStatusLabel = msg("This org name is taken"); | ||||
|       orgNameStatusIcon = html` | ||||
|         <sl-icon class="mr-3 text-danger" name="x-lg"></sl-icon> | ||||
|       `;
 | ||||
|     } | ||||
| 
 | ||||
|     return html` | ||||
|       <btrix-dialog | ||||
|         .label=${msg("New Organization")} | ||||
|         .open=${this.isAddingOrg} | ||||
| @ -183,7 +214,10 @@ export class Home extends LiteElement { | ||||
|           } | ||||
|         }} | ||||
|         @sl-show=${() => (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} | ||||
|                   > | ||||
|                     <sl-tooltip | ||||
|                       slot="suffix" | ||||
|                       content=${orgNameStatusLabel} | ||||
|                       @sl-hide=${(e: CustomEvent) => e.stopPropagation()} | ||||
|                       @sl-after-hide=${(e: CustomEvent) => e.stopPropagation()} | ||||
|                       hoist | ||||
|                     > | ||||
|                       ${orgNameStatusIcon} | ||||
|                     </sl-tooltip> | ||||
|                   </sl-input> | ||||
|                 </div> | ||||
|               </form> | ||||
| @ -277,7 +320,12 @@ export class Home extends LiteElement { | ||||
|   } | ||||
| 
 | ||||
|   private async fetchOrgs() { | ||||
|     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", | ||||
|       }); | ||||
|  | ||||
| @ -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 { | ||||
|         <div class="mb-5"> | ||||
|           <sl-input | ||||
|             name="orgName" | ||||
|             label=${msg("Org name")} | ||||
|             label=${msg("Org Name")} | ||||
|             placeholder=${msg("My Organization")} | ||||
|             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} | ||||
|           ></sl-input> | ||||
|         </div> | ||||
|         <div class="mb-5"> | ||||
|           <sl-input | ||||
|             name="orgSlug" | ||||
|             label=${msg("Custom URL identifier")} | ||||
|             label=${msg("Custom URL Identifier")} | ||||
|             placeholder="my-organization" | ||||
|             autocomplete="off" | ||||
|             value=${this.slug} | ||||
| @ -90,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)); | ||||
|             }} | ||||
|           > | ||||
|           </sl-input> | ||||
| @ -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<SlInput>(`[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]"); | ||||
|  | ||||
| @ -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.", | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|  | ||||
| @ -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 { | ||||
|   [data-user-invalid]:not([disabled])::part(form-control-label), | ||||
|   /* Required asterisk color */ | ||||
|     color: var(--sl-color-danger-500); | ||||
|   [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 */ | ||||
|  | ||||
| @ -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 }; | ||||
| } | ||||
|  | ||||
							
								
								
									
										7
									
								
								frontend/src/utils/slugify.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/src/utils/slugify.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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() }); | ||||
| } | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user