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() });
+}