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