Skip to content

Commit ee3b412

Browse files
added dynamic check for project name and slug
Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com>
1 parent 0dddbb4 commit ee3b412

File tree

8 files changed

+177
-20
lines changed

8 files changed

+177
-20
lines changed

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,14 @@
4949
"mode-watcher": "^0.3.0",
5050
"pino": "^9.0.0",
5151
"pino-pretty": "^11.0.0",
52+
"slugify": "^1.6.6",
5253
"svelte-sonner": "^0.3.24",
5354
"sveltekit-superforms": "^2.13.0",
5455
"tailwind-merge": "^2.3.0",
5556
"tailwind-variants": "^0.2.1",
57+
"throttle-debounce": "^5.0.2",
5658
"typesafe-utils": "^1.16.2",
57-
"zod": "^3.23.5",
58-
"slugify": "^1.6.6"
59+
"zod": "^3.23.5"
5960
},
6061
"devDependencies": {
6162
"@eslint/js": "^9.2.0",
@@ -71,6 +72,7 @@
7172
"@types/jsonwebtoken": "^9.0.6",
7273
"@types/minimist": "^1.2.5",
7374
"@types/node": "^20.12.7",
75+
"@types/throttle-debounce": "^5.0.2",
7476
"@typescript-eslint/eslint-plugin": "^7.6.0",
7577
"@typescript-eslint/parser": "^7.6.0",
7678
"autoprefixer": "^10.4.19",

pnpm-lock.yaml

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

services/src/project/project-repository.integration.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { beforeEach, describe, expect, it } from 'vitest'
2-
import { createProject, getAllProjects } from './project-repository'
2+
import {
3+
checkProjectNameExists,
4+
checkProjectSlugExists,
5+
createProject,
6+
getAllProjects
7+
} from './project-repository'
38
import { runMigration } from '../db/database-migration-util'
49
import { db } from '../db/database'
510
import type { ProjectCreationParams, SelectableProject } from './project'
@@ -147,4 +152,36 @@ describe('Project Repository', () => {
147152
expect(project.id).toBeTypeOf('number')
148153
})
149154
})
155+
156+
describe('checkProjectNameExists', () => {
157+
it('should return true if a project with the given name exists', async () => {
158+
await createProject(projectCreationObject)
159+
160+
const nameExists = await checkProjectNameExists(projectCreationObject.name)
161+
expect(nameExists).toBe(true)
162+
})
163+
164+
it('should return false if no project with the given name exists', async () => {
165+
await createProject(projectCreationObject)
166+
167+
const nameExists = await checkProjectNameExists('Nonexistent Project')
168+
expect(nameExists).toBe(false)
169+
})
170+
})
171+
172+
describe('checkProjectSlugExists', () => {
173+
it('should return true if a project with the given slug exists', async () => {
174+
await createProject(projectCreationObject)
175+
176+
const slugExists = await checkProjectSlugExists(projectCreationObject.slug)
177+
expect(slugExists).toBe(true)
178+
})
179+
180+
it('should return false if no project with the given slug exists', async () => {
181+
await createProject(projectCreationObject)
182+
183+
const slugExists = await checkProjectSlugExists('nonexistent-slug')
184+
expect(slugExists).toBe(false)
185+
})
186+
})
150187
})

services/src/project/project-repository.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,15 @@ export function createProject(project: ProjectCreationParams): Promise<Selectabl
2929
export function getAllProjects(): Promise<SelectableProject[]> {
3030
return db.selectFrom('projects').selectAll().execute()
3131
}
32+
33+
export async function checkProjectNameExists(name: string) {
34+
const result = await db.selectFrom('projects').selectAll().where('name', '==', name).execute()
35+
36+
return result.length > 0
37+
}
38+
39+
export async function checkProjectSlugExists(slug: string) {
40+
const result = await db.selectFrom('projects').selectAll().where('slug', '==', slug).execute()
41+
42+
return result.length > 0
43+
}

services/src/project/project-service.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,20 @@ export async function getAllProjects() {
2525
throw new Error('Error Getting Projects')
2626
}
2727
}
28+
29+
export async function checkProjectNameExists(name: string) {
30+
try {
31+
return await repository.checkProjectNameExists(name)
32+
} catch (e) {
33+
console.error(e)
34+
throw new Error('Error Checking Project Name')
35+
}
36+
}
37+
38+
export async function checkProjectSlugExists(name: string) {
39+
try {
40+
return await repository.checkProjectSlugExists(createSlug(name))
41+
} catch (e) {
42+
throw new Error('Error Checking Project Slug')
43+
}
44+
}
Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import { createSlug } from 'services/util/slug/slug-service'
22
import { z } from 'zod'
33

4-
export const createProjectSchema = z
5-
.object({
6-
name: z
7-
.string({ required_error: 'Project name is required' })
8-
.min(1, 'Project name must have at least one character'),
9-
base_language_code: z
10-
.string({ required_error: 'Base language is required' })
11-
.min(1, 'Base language must have at least one character')
12-
})
13-
.refine((data) => createSlug(data.name).length > 0, {
4+
export const baseCreateProjectSchema = z.object({
5+
name: z
6+
.string({ required_error: 'Project name is required' })
7+
.min(1, 'Project name must have at least one character'),
8+
base_language_code: z
9+
.string({ required_error: 'Base language is required' })
10+
.min(1, 'Base language must have at least one character')
11+
})
12+
13+
export const createProjectSchema = baseCreateProjectSchema.refine(
14+
(data) => createSlug(data.name).length > 0,
15+
{
1416
message: 'URL must have at least one character',
1517
path: ['name']
16-
})
18+
}
19+
)
1720

1821
export type CreateProjectFormSchema = z.infer<typeof createProjectSchema>

src/components/container/projects/create-project.svelte

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
import { page } from '$app/stores'
1010
import { toast } from 'svelte-sonner'
1111
import SlugDisplay from './slug-display.svelte'
12+
import { debounce } from 'throttle-debounce'
1213
1314
export let data: SuperValidated<Infer<typeof createProjectSchema>>
1415
1516
let open = false
1617
1718
const form = superForm(data, {
1819
validators: zodClient(createProjectSchema),
20+
validationMethod: 'oninput',
1921
async onUpdated({ form }) {
2022
if (form.message) {
2123
if ($page.status >= 400) {
@@ -25,10 +27,42 @@
2527
open = false
2628
}
2729
}
30+
},
31+
async onChange(event) {
32+
// if name changed the server error is no longer relevant
33+
if (event.paths.includes('name')) {
34+
nameServerError = []
35+
}
36+
37+
$errors.name = [...nameServerError, ...($errors.name || [])]
2838
}
2939
})
3040
31-
const { form: formData, enhance } = form
41+
const { form: formData, enhance, errors, allErrors } = form
42+
43+
// name validation hidden form
44+
let nameServerError: string[] = []
45+
46+
const { submit } = superForm(
47+
{ name: '' },
48+
{
49+
invalidateAll: false,
50+
applyAction: false,
51+
SPA: '?/check',
52+
onSubmit(event) {
53+
if (!$formData.name) {
54+
event.cancel()
55+
}
56+
57+
event.formData.set('name', $formData.name)
58+
},
59+
onUpdated({ form }) {
60+
nameServerError = form.errors.name || []
61+
$errors.name = [...nameServerError, ...($errors.name || [])]
62+
}
63+
}
64+
)
65+
const checkProjectName = debounce(300, submit)
3266
</script>
3367

3468
<Dialog.Root bind:open>
@@ -39,7 +73,7 @@
3973
+ Create Project
4074
</Dialog.Trigger>
4175
<Dialog.Content class="sm:max-w-[425px]">
42-
<form method="POST" use:enhance>
76+
<form method="POST" action="?/post" use:enhance>
4377
<Dialog.Header>
4478
<Dialog.Title>New Project</Dialog.Title>
4579
<Dialog.Description>
@@ -55,6 +89,7 @@
5589
data-testid="create-project-name-input"
5690
placeholder="Enter Name"
5791
bind:value={$formData.name}
92+
on:input={checkProjectName}
5893
/>
5994
</Form.Control>
6095
<SlugDisplay name={$formData.name} />
@@ -74,7 +109,9 @@
74109
</Form.Field>
75110
</div>
76111
<Dialog.Footer>
77-
<Form.Button data-testid="create-project-submit-button">Create Project</Form.Button>
112+
<Form.Button disabled={$allErrors.length !== 0} data-testid="create-project-submit-button">
113+
Create Project
114+
</Form.Button>
78115
</Dialog.Footer>
79116
</form>
80117
</Dialog.Content>

src/routes/(authenticated)/projects/+page.server.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import type { Actions, PageServerLoad } from './$types'
22
import { message, setError, superValidate } from 'sveltekit-superforms'
33
import { zod } from 'sveltekit-superforms/adapters'
4-
import { createProjectSchema } from '$components/container/projects/create-project-schema'
5-
import { createProject } from 'services/project/project-service'
4+
import {
5+
baseCreateProjectSchema,
6+
createProjectSchema
7+
} from '$components/container/projects/create-project-schema'
8+
import {
9+
checkProjectNameExists,
10+
checkProjectSlugExists,
11+
createProject
12+
} from 'services/project/project-service'
613
import { getAllProjects } from 'services/project/project-repository'
714
import { CreateProjectNameNotUniqueError } from 'services/error'
815

@@ -13,8 +20,10 @@ export const load: PageServerLoad = async () => {
1320
}
1421
}
1522

23+
const nameSchema = baseCreateProjectSchema.pick({ name: true })
24+
1625
export const actions: Actions = {
17-
default: async ({ request }) => {
26+
post: async ({ request }) => {
1827
const form = await superValidate(request, zod(createProjectSchema))
1928

2029
if (!form.valid) {
@@ -38,5 +47,24 @@ export const actions: Actions = {
3847
}
3948

4049
return message(form, { message: 'Project Created', project })
50+
},
51+
check: async ({ request }) => {
52+
const form = await superValidate(request, zod(nameSchema))
53+
54+
if (!form.valid) {
55+
return message(form, 'Invalid form', {
56+
status: 500
57+
})
58+
}
59+
60+
if (await checkProjectNameExists(form.data.name)) {
61+
setError(form, 'name', 'Name already in use.')
62+
}
63+
64+
if (await checkProjectSlugExists(form.data.name)) {
65+
setError(form, 'name', 'URL already in use.')
66+
}
67+
68+
return { form }
4169
}
4270
}

0 commit comments

Comments
 (0)