Skip to content

Commit 4c1ff75

Browse files
(feat) added slugs to projects (#114)
* added slugs to projects * renamed slug service and moved dependency * added slug display to create project * added dynamic check for project name and slug * added debounce utility function * pr improvement * updated pnpm-lock.json --------- Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com>
1 parent ad2c580 commit 4c1ff75

File tree

23 files changed

+560
-50
lines changed

23 files changed

+560
-50
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
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",

pnpm-lock.yaml

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

services/src/kysely/migrations/2024-04-28T09_init.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@ export async function up(db: Kysely<unknown>): Promise<void> {
1313

1414
await createTableMigration(tx, 'projects')
1515
.addColumn('name', 'text', (col) => col.unique().notNull())
16-
.addColumn('base_language', 'integer', (col) =>
16+
.addColumn('base_language_id', 'integer', (col) =>
1717
col
1818
.references('languages.id')
1919
.onDelete('restrict')
2020
.notNull()
2121
.modifyEnd(sql`DEFERRABLE INITIALLY DEFERRED`)
2222
)
23+
.addColumn('slug', 'text', (col) => col.unique().notNull())
2324
.execute()
2425

2526
await createTableMigration(tx, 'languages')

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

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
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'
5-
import type { CreateProjectFormSchema, SelectableProject } from './project'
10+
import type { ProjectCreationParams, SelectableProject } from './project'
611
import type { Languages } from 'kysely-codegen'
712
import type { Selectable } from 'kysely'
813

9-
const projectCreationObject: CreateProjectFormSchema = {
14+
const projectCreationObject: ProjectCreationParams = {
1015
name: 'Test Project',
11-
base_language: 'en'
16+
base_language_code: 'en',
17+
slug: 'test-project'
1218
}
1319

1420
beforeEach(async () => {
@@ -29,7 +35,7 @@ describe('Project Repository', () => {
2935
expect(project).toMatchObject({
3036
id: createdProject.id,
3137
name: projectCreationObject.name,
32-
base_language: createdProject.base_language
38+
base_language_id: createdProject.base_language_id
3339
})
3440

3541
expect(project.id).toBeTypeOf('number')
@@ -44,6 +50,26 @@ describe('Project Repository', () => {
4450
expect(projects).toHaveLength(1)
4551
})
4652

53+
it('should not allow creation of projects with duplicate slugs', async () => {
54+
const projectCreationObject1 = {
55+
name: 'Test Project',
56+
base_language_code: 'en',
57+
slug: 'test-project'
58+
}
59+
const projectCreationObject2 = {
60+
name: 'test-project',
61+
base_language_code: 'en',
62+
slug: 'test-project'
63+
}
64+
65+
await createProject(projectCreationObject1)
66+
67+
await expect(createProject(projectCreationObject2)).rejects.toThrow()
68+
69+
const projects = await db.selectFrom('projects').selectAll().execute()
70+
expect(projects).toHaveLength(1)
71+
})
72+
4773
it('should create a base language for the project', async () => {
4874
const createdProject = await createProject(projectCreationObject)
4975

@@ -53,26 +79,26 @@ describe('Project Repository', () => {
5379
const language = languages[0] as Selectable<Languages>
5480

5581
expect(language.project_id).toBe(createdProject.id)
56-
expect(language.code).toBe(projectCreationObject.base_language)
82+
expect(language.code).toBe(projectCreationObject.base_language_code)
5783
})
5884

5985
it('should link the base language to the project', async () => {
6086
const createdProject = await createProject(projectCreationObject)
6187

62-
expect(createdProject.base_language).not.toBe(0)
88+
expect(createdProject.base_language_id).not.toBe(0)
6389

6490
const language = await db
6591
.selectFrom('languages')
66-
.where('id', '==', createdProject.base_language)
92+
.where('id', '==', createdProject.base_language_id)
6793
.selectAll()
6894
.executeTakeFirstOrThrow()
6995

7096
expect(language.project_id).toBe(createdProject.id)
7197
})
7298

7399
it('should allow creation of multiple projects with the same base language code', async () => {
74-
const project1 = { name: 'Project 1', base_language: 'en' }
75-
const project2 = { name: 'Project 2', base_language: 'en' }
100+
const project1 = { name: 'Project 1', base_language_code: 'en', slug: 'project-1' }
101+
const project2 = { name: 'Project 2', base_language_code: 'en', slug: 'project-2' }
76102

77103
await createProject(project1)
78104
await createProject(project2)
@@ -95,8 +121,8 @@ describe('Project Repository', () => {
95121
})
96122

97123
it('should return all created projects', async () => {
98-
const project1 = { name: 'Project 1', base_language: 'en' }
99-
const project2 = { name: 'Project 2', base_language: 'fr' }
124+
const project1 = { name: 'Project 1', base_language_code: 'en', slug: 'project-1' }
125+
const project2 = { name: 'Project 2', base_language_code: 'fr', slug: 'project-2' }
100126

101127
await createProject(project1)
102128
await createProject(project2)
@@ -120,10 +146,42 @@ describe('Project Repository', () => {
120146
expect(project).toMatchObject({
121147
id: createdProject.id,
122148
name: projectCreationObject.name,
123-
base_language: createdProject.base_language
149+
base_language_id: createdProject.base_language_id
124150
})
125151

126152
expect(project.id).toBeTypeOf('number')
127153
})
128154
})
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+
})
129187
})
Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
import type { CreateProjectFormSchema, SelectableProject } from './project'
1+
import type { ProjectCreationParams, SelectableProject } from './project'
22
import { db } from '../db/database'
33

4-
export function createProject(project: CreateProjectFormSchema): Promise<SelectableProject> {
4+
export function createProject(project: ProjectCreationParams): Promise<SelectableProject> {
55
return db.transaction().execute(async (tx) => {
66
const tempProject = await tx
77
.insertInto('projects')
8-
.values({ name: project.name, base_language: 0 })
8+
.values({ name: project.name, base_language_id: 0, slug: project.slug })
99
.returning('id')
1010
.executeTakeFirstOrThrow(() => new Error('Error Creating Project'))
1111

1212
const baseLanguage = await tx
1313
.insertInto('languages')
14-
.values({ code: project.base_language, project_id: tempProject.id })
14+
.values({ code: project.base_language_code, project_id: tempProject.id })
1515
.returning('id')
1616
.executeTakeFirstOrThrow(() => new Error('Error Creating Base Language'))
1717

1818
const createdProject = await tx
1919
.updateTable('projects')
20-
.set({ base_language: baseLanguage.id })
20+
.set({ base_language_id: baseLanguage.id })
2121
.where('id', '==', tempProject.id)
2222
.returningAll()
2323
.executeTakeFirstOrThrow(() => new Error('Error Updating Project'))
@@ -29,3 +29,15 @@ export function createProject(project: CreateProjectFormSchema): Promise<Selecta
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: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import type { CreateProjectFormSchema } from '$components/container/projects/create-project-schema'
2+
import { createSlug } from '../util/slug/slug-service'
13
import { CreateProjectNameNotUniqueError } from '../error'
2-
import { type CreateProjectFormSchema } from './project'
34
import * as repository from './project-repository'
45
import { SqliteError } from 'better-sqlite3'
56

67
export async function createProject(project: CreateProjectFormSchema) {
78
try {
8-
return await repository.createProject(project)
9+
const slug = createSlug(project.name)
10+
11+
return await repository.createProject({ ...project, slug })
912
} catch (e: unknown) {
1013
if (e instanceof SqliteError && e.code === 'SQLITE_CONSTRAINT_UNIQUE') {
1114
throw new CreateProjectNameNotUniqueError()
@@ -22,3 +25,20 @@ export async function getAllProjects() {
2225
throw new Error('Error Getting Projects')
2326
}
2427
}
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+
}

services/src/project/project-service.unit.test.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,37 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
22
import { createProject, getAllProjects } from './project-service'
33
import * as repository from './project-repository'
4-
import type { CreateProjectFormSchema } from './project'
54
import { CreateProjectNameNotUniqueError } from '../error'
65
import { SqliteError } from 'better-sqlite3'
6+
import type { CreateProjectFormSchema } from '$components/container/projects/create-project-schema'
7+
import { createSlug } from '../util/slug/slug-service'
78

89
vi.mock('./project-repository', () => ({
910
createProject: vi.fn(),
1011
getAllProjects: vi.fn()
1112
}))
1213

14+
vi.mock('../util/slug/slug-service', () => ({
15+
createSlug: vi.fn()
16+
}))
17+
1318
const projectCreationObject: CreateProjectFormSchema = {
1419
name: 'Test Project',
15-
base_language: 'en'
20+
base_language_code: 'en'
1621
}
1722

1823
const mockSelectableProject = {
1924
id: 1,
2025
name: 'Test Project',
21-
base_language: 1,
26+
slug: 'test-project',
27+
base_language_id: 1,
2228
created_at: new Date().toISOString(),
2329
updated_at: new Date().toISOString()
2430
}
2531

2632
beforeEach(() => {
2733
vi.resetAllMocks()
34+
vi.mocked(createSlug).mockReturnValue('test-project')
2835
})
2936

3037
describe('Project Service', () => {
@@ -34,7 +41,11 @@ describe('Project Service', () => {
3441

3542
const project = await createProject(projectCreationObject)
3643

37-
expect(repository.createProject).toHaveBeenCalledWith(projectCreationObject)
44+
expect(repository.createProject).toHaveBeenCalledWith({
45+
...projectCreationObject,
46+
slug: 'test-project'
47+
})
48+
3849
expect(project).toEqual(mockSelectableProject)
3950
})
4051

@@ -55,6 +66,22 @@ describe('Project Service', () => {
5566
new CreateProjectNameNotUniqueError()
5667
)
5768
})
69+
70+
it('should call the slug service to create a slug and use it to call repository', async () => {
71+
const mockedSlug = 'ABCD'
72+
vi.mocked(createSlug).mockReturnValue(mockedSlug)
73+
vi.mocked(repository.createProject).mockResolvedValue(mockSelectableProject)
74+
75+
const project = await createProject(projectCreationObject)
76+
77+
expect(createSlug).toHaveBeenCalledWith(projectCreationObject.name)
78+
expect(repository.createProject).toHaveBeenCalledWith({
79+
...projectCreationObject,
80+
slug: mockedSlug
81+
})
82+
83+
expect(project).toEqual(mockSelectableProject)
84+
})
5885
})
5986

6087
describe('getAllProjects', () => {

services/src/project/project.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,12 @@
11
import type { Insertable, Selectable } from 'kysely'
22
import type { Projects } from 'kysely-codegen'
3-
import { z } from 'zod'
43

5-
export type ProjectCreationParams = Insertable<Omit<Projects, 'id' | 'created_at' | 'updated_at'>>
4+
export type ProjectCreationParams = {
5+
name: string
6+
slug: string
7+
base_language_code: string
8+
}
69
export type Project = SelectableProject
710

811
export type SelectableProject = Selectable<Projects>
912
export type InsertableProject = Insertable<Projects>
10-
11-
export const createProjectSchema = z.object({
12-
name: z
13-
.string({ required_error: 'Project name is required' })
14-
.min(1, 'Project name must have at least one character'),
15-
base_language: z
16-
.string({ required_error: 'Base language is required' })
17-
.min(1, 'Base language must have at least one character')
18-
})
19-
20-
export type CreateProjectFormSchema = z.infer<typeof createProjectSchema>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import slugify from 'slugify/slugify'
2+
3+
export function createSlug(text: string): string {
4+
return slugify(text, {
5+
lower: true,
6+
strict: true
7+
})
8+
}

0 commit comments

Comments
 (0)