feat(frontend): auto-derive slugs from names on create forms (#48)

The org/project/bucket/collection create forms required typing a slug
by hand. While the slug field is untouched (empty or still equal to the
derived value), naming the entity fills its slug; a manual slug edit
stops the derivation and is sanitized to slug form as you type.

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-09-28 21:53:27 +02:00
1 parent 40c189c431
commit 5eec80bbca
4 files changed
+82 -5

No files matched your search

+6 -5
View File
@@ -28,6 +28,7 @@ import { Show, createEffect, createMemo, createResource, createSignal, onCleanup
import { authClient, fetchApiToken } from "./lib/auth-client";
import { configureApiToken, errorMessage } from "./lib/api";
import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode";
import { applySlugField } from "./lib/slug";
import {
AgentSetupModal,
AppShell,
@@ -1670,14 +1671,14 @@ export default function App() {
label="Organization name"
placeholder="Acme Corporation"
value={organizationInput().name}
onInput={(e) => setOrganizationInput((c) => ({ ...c, name: e.currentTarget.value }))}
onInput={(e) => setOrganizationInput((c) => applySlugField(c, "name", e.currentTarget.value))}
required
/>
<Input
label="Slug"
placeholder="acme"
value={organizationInput().slug}
onInput={(e) => setOrganizationInput((c) => ({ ...c, slug: e.currentTarget.value }))}
onInput={(e) => setOrganizationInput((c) => applySlugField(c, "slug", e.currentTarget.value))}
required
/>
<button type="submit" class="btn btn-primary w-full" disabled={workspacePending()}>
@@ -1723,7 +1724,7 @@ export default function App() {
projectMessage={projectMessage()}
projectPending={projectPending()}
canUpdateProject={canUpdateProject()}
onProjectInputChange={(field, value) => setProjectInput((c) => ({ ...c, [field]: value }))}
onProjectInputChange={(field, value) => setProjectInput((c) => applySlugField(c, field, value))}
onProjectEditInputChange={(field, value) => setProjectEditInput((c) => ({ ...c, [field]: value }))}
onCreateProject={createProject}
onUpdateProject={updateActiveProject}
@@ -1822,7 +1823,7 @@ export default function App() {
previewLoading={objectPreview.loading}
renameObjectKey={renameObjectKeyInput()}
moveDestinationBucketID={moveDestinationBucketID()}
onBucketInputChange={(field, value) => setBucketInput((c) => ({ ...c, [field]: value }))}
onBucketInputChange={(field, value) => setBucketInput((c) => applySlugField(c, field, value))}
onBucketEditInputChange={(field, value) => setBucketEditInput((c) => ({ ...c, [field]: value }))}
onCreateBucket={createBucket}
onUpdateBucket={updateSelectedBucket}
@@ -1859,7 +1860,7 @@ export default function App() {
collectionPending={collectionPending()}
documentPending={documentPending()}
canUpdate={canUpdateProject()}
onCollectionInputChange={(field, value) => setCollectionInput((c) => ({ ...c, [field]: value }))}
onCollectionInputChange={(field, value) => setCollectionInput((c) => applySlugField(c, field, value))}
onCreateCollection={createCollection}
onDeleteCollection={deleteCollection}
onSelectCollection={setSelectedCollectionID}
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { applySlugField, slugify } from "../slug";
describe("slugify", () => {
it("derives lowercase hyphenated slugs", () => {
expect(slugify("TDvorak")).toBe("tdvorak");
expect(slugify("Acme Corporation")).toBe("acme-corporation");
expect(slugify(" My_App!! v2 ")).toBe("my-app-v2");
});
it("strips accents and non-ascii characters", () => {
expect(slugify("Zürich — Héllo")).toBe("zurich-hello");
expect(slugify("日本語")).toBe("");
});
});
describe("applySlugField", () => {
const form = { name: "", slug: "", description: "" };
it("fills the slug while it is untouched", () => {
const next = applySlugField(form, "name", "My Org");
expect(next.slug).toBe("my-org");
// keeps following name edits
expect(applySlugField(next, "name", "Renamed Org").slug).toBe("renamed-org");
});
it("stops deriving once the slug was hand-edited", () => {
const custom = applySlugField(applySlugField(form, "name", "My Org"), "slug", "custom-slug");
expect(applySlugField(custom, "name", "New Name").slug).toBe("custom-slug");
});
it("resumes deriving if the slug is cleared", () => {
const cleared = applySlugField(applySlugField(form, "name", "My Org"), "slug", "");
expect(applySlugField(cleared, "name", "Other").slug).toBe("other");
});
it("sanitizes manual slug edits", () => {
expect(applySlugField(form, "slug", "Not A Slug!").slug).toBe("not-a-slug");
});
it("passes through unrelated fields", () => {
expect(applySlugField(form, "description", "hi").description).toBe("hi");
});
});
+27
View File
@@ -0,0 +1,27 @@
export function slugify(value: string): string {
return value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 63);
}
/**
* Applies a field edit to a {name, slug, ...} form. While the slug is
* untouched — empty or still equal to the derived value — renaming the
* entity re-derives its slug. Explicit slug edits are sanitized in place.
*/
export function applySlugField<T extends { name: string; slug: string }>(
current: T,
field: string,
value: string,
): T {
if (field === "slug") return { ...current, slug: slugify(value) };
if (field === "name" && (current.slug === "" || current.slug === slugify(current.name))) {
return { ...current, name: value, slug: slugify(value) };
}
return { ...current, [field]: value };
}