Skip to content

Commit bfd71df

Browse files
(feat) projects overview page (#84)
* Added header to projects Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added basic card layout and design Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added basic navigation Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added popover to project card Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added shadcn dialog added create project dialog and form Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * fixed type errors in autogenerated dialog component Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * removed unnecesarry comment Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added description to create project dialog Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added project backend functionality Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added getAllProjects to repository Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added getAllProject to porject service Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added getAllProjects to frontend Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * fixed timezone issue Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * rearranged route components into components/container folder Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added empty projects view Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added e2e test to create project Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * moved project card into seperate component Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * fixed a bug with language codes and added user form error if name is already in use Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * imporved grid layout Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * feedback Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added domain model for Project Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> --------- Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com>
1 parent 7e0d91d commit bfd71df

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+778
-28
lines changed
Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
1+
import { expect } from '@playwright/test'
12
import { testWithUser as test } from './fixtures'
3+
import { waitForHydration } from './util'
24

3-
test.describe('create project', { tag: ['@foo-bar'] }, () => {
5+
test.describe('create project', () => {
46
test('projects', async ({ page }) => {
7+
const projectName = 'My new project'
8+
59
await page.goto('/projects')
6-
// TODO add test
10+
await waitForHydration(page)
11+
12+
await page.getByTestId('create-project-modal-trigger').click()
13+
14+
await page.getByTestId('create-project-name-input').fill(projectName)
15+
await page.getByTestId('create-project-base-language-input').fill('en')
16+
17+
await page.getByTestId('create-project-submit-button').click()
18+
19+
await expect(page.getByTestId('project-card-name')).toHaveText(projectName)
720
})
821
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"better-sqlite3": "^11.0.0",
4040
"bits-ui": "^0.21.7",
4141
"clsx": "^2.1.1",
42+
"date-fns": "^3.6.0",
4243
"formsnap": "^1.0.0",
4344
"jsonwebtoken": "^9.0.2",
4445
"kysely": "^0.27.3",

pnpm-lock.yaml

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

services/src/error/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export class CreateProjectNameNotUniqueError extends Error {
2+
constructor() {
3+
super('Project name must be unique')
4+
}
5+
}

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,21 @@ export async function up(db: Kysely<unknown>): Promise<void> {
1414
await createTableMigration(tx, 'projects')
1515
.addColumn('name', 'text', (col) => col.unique().notNull())
1616
.addColumn('base_language', 'integer', (col) =>
17-
col.references('languages.id').onDelete('restrict').notNull()
17+
col
18+
.references('languages.id')
19+
.onDelete('restrict')
20+
.notNull()
21+
.modifyEnd(sql`DEFERRABLE INITIALLY DEFERRED`)
1822
)
1923
.execute()
2024

2125
await createTableMigration(tx, 'languages')
22-
.addColumn('code', 'text', (col) => col.unique().notNull())
26+
.addColumn('code', 'text', (col) => col.notNull())
2327
.addColumn('fallback_language', 'integer', (col) => col.references('languages.id'))
2428
.addColumn('project_id', 'integer', (col) =>
25-
col.references('project.id').onDelete('cascade').notNull()
29+
col.references('projects.id').onDelete('cascade').notNull()
2630
)
31+
.addUniqueConstraint('languages_code_project_id_unique', ['code', 'project_id'])
2732
.execute()
2833

2934
await createTableMigration(tx, 'keys')
@@ -60,7 +65,7 @@ export async function down(db: Kysely<unknown>): Promise<void> {
6065
await tx.schema.dropTable('projects_users').execute()
6166
await tx.schema.dropTable('translations').execute()
6267
await tx.schema.dropTable('keys').execute()
63-
await tx.schema.dropTable('languages').execute()
6468
await tx.schema.dropTable('projects').execute()
69+
await tx.schema.dropTable('languages').execute()
6570
})
6671
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { beforeEach, describe, expect, it } from 'vitest'
2+
import { createProject, getAllProjects } from './project-repository'
3+
import { runMigration } from '../db/database-migration-util'
4+
import { db } from '../db/database'
5+
import type { CreateProjectFormSchema, SelectableProject } from './project'
6+
import type { Languages } from 'kysely-codegen'
7+
import type { Selectable } from 'kysely'
8+
9+
const projectCreationObject: CreateProjectFormSchema = {
10+
name: 'Test Project',
11+
base_language: 'en'
12+
}
13+
14+
beforeEach(async () => {
15+
db.reset()
16+
await runMigration()
17+
})
18+
19+
describe('Project Repository', () => {
20+
describe('createProject', () => {
21+
it('should create a project with the correct attributes', async () => {
22+
const createdProject = await createProject(projectCreationObject)
23+
24+
const projects = await db.selectFrom('projects').selectAll().execute()
25+
expect(projects).toHaveLength(1)
26+
27+
const project = projects[0] as SelectableProject
28+
29+
expect(project).toMatchObject({
30+
id: createdProject.id,
31+
name: projectCreationObject.name,
32+
base_language: createdProject.base_language
33+
})
34+
35+
expect(project.id).toBeTypeOf('number')
36+
})
37+
38+
it('should not allow creation of projects with duplicate names', async () => {
39+
await createProject(projectCreationObject)
40+
41+
await expect(createProject(projectCreationObject)).rejects.toThrow()
42+
43+
const projects = await db.selectFrom('projects').selectAll().execute()
44+
expect(projects).toHaveLength(1)
45+
})
46+
47+
it('should create a base language for the project', async () => {
48+
const createdProject = await createProject(projectCreationObject)
49+
50+
const languages = await db.selectFrom('languages').selectAll().execute()
51+
expect(languages).toHaveLength(1)
52+
53+
const language = languages[0] as Selectable<Languages>
54+
55+
expect(language.project_id).toBe(createdProject.id)
56+
expect(language.code).toBe(projectCreationObject.base_language)
57+
})
58+
59+
it('should link the base language to the project', async () => {
60+
const createdProject = await createProject(projectCreationObject)
61+
62+
expect(createdProject.base_language).not.toBe(0)
63+
64+
const language = await db
65+
.selectFrom('languages')
66+
.where('id', '==', createdProject.base_language)
67+
.selectAll()
68+
.executeTakeFirstOrThrow()
69+
70+
expect(language.project_id).toBe(createdProject.id)
71+
})
72+
73+
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' }
76+
77+
await createProject(project1)
78+
await createProject(project2)
79+
80+
const projects = await db.selectFrom('projects').selectAll().execute()
81+
expect(projects).toHaveLength(2)
82+
83+
const languages = await db.selectFrom('languages').selectAll().execute()
84+
expect(languages).toHaveLength(2)
85+
86+
const languageCodes = languages.map((language: Selectable<Languages>) => language.code)
87+
expect(languageCodes.filter((code) => code === 'en')).toHaveLength(2)
88+
})
89+
})
90+
91+
describe('getAllProjects', () => {
92+
it('should return an empty array when there are no projects', async () => {
93+
const projects = await getAllProjects()
94+
expect(projects).toHaveLength(0)
95+
})
96+
97+
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' }
100+
101+
await createProject(project1)
102+
await createProject(project2)
103+
104+
const projects = await getAllProjects()
105+
expect(projects).toHaveLength(2)
106+
107+
const projectNames = projects.map((project: SelectableProject) => project.name)
108+
expect(projectNames).toContain('Project 1')
109+
expect(projectNames).toContain('Project 2')
110+
})
111+
112+
it('should return projects with correct attributes', async () => {
113+
const createdProject = await createProject(projectCreationObject)
114+
115+
const projects = await getAllProjects()
116+
expect(projects).toHaveLength(1)
117+
118+
const project = projects[0] as SelectableProject
119+
120+
expect(project).toMatchObject({
121+
id: createdProject.id,
122+
name: projectCreationObject.name,
123+
base_language: createdProject.base_language
124+
})
125+
126+
expect(project.id).toBeTypeOf('number')
127+
})
128+
})
129+
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { CreateProjectFormSchema, SelectableProject } from './project'
2+
import { db } from '../db/database'
3+
4+
export function createProject(project: CreateProjectFormSchema): Promise<SelectableProject> {
5+
return db.transaction().execute(async (tx) => {
6+
const tempProject = await tx
7+
.insertInto('projects')
8+
.values({ name: project.name, base_language: 0 })
9+
.returning('id')
10+
.executeTakeFirstOrThrow(() => new Error('Error Creating Project'))
11+
12+
const baseLanguage = await tx
13+
.insertInto('languages')
14+
.values({ code: project.base_language, project_id: tempProject.id })
15+
.returning('id')
16+
.executeTakeFirstOrThrow(() => new Error('Error Creating Base Language'))
17+
18+
const createdProject = await tx
19+
.updateTable('projects')
20+
.set({ base_language: baseLanguage.id })
21+
.where('id', '==', tempProject.id)
22+
.returningAll()
23+
.executeTakeFirstOrThrow(() => new Error('Error Updating Project'))
24+
25+
return createdProject
26+
})
27+
}
28+
29+
export function getAllProjects(): Promise<SelectableProject[]> {
30+
return db.selectFrom('projects').selectAll().execute()
31+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { CreateProjectNameNotUniqueError } from '../error'
2+
import { type CreateProjectFormSchema } from './project'
3+
import * as repository from './project-repository'
4+
import { SqliteError } from 'better-sqlite3'
5+
6+
export async function createProject(project: CreateProjectFormSchema) {
7+
try {
8+
return await repository.createProject(project)
9+
} catch (e: unknown) {
10+
if (e instanceof SqliteError && e.code === 'SQLITE_CONSTRAINT_UNIQUE') {
11+
throw new CreateProjectNameNotUniqueError()
12+
}
13+
14+
throw new Error('Error Creating Project')
15+
}
16+
}
17+
18+
export async function getAllProjects() {
19+
try {
20+
return await repository.getAllProjects()
21+
} catch (e) {
22+
throw new Error('Error Getting Projects')
23+
}
24+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { createProject, getAllProjects } from './project-service'
3+
import * as repository from './project-repository'
4+
import type { CreateProjectFormSchema } from './project'
5+
import { CreateProjectNameNotUniqueError } from '../error'
6+
import { SqliteError } from 'better-sqlite3'
7+
8+
vi.mock('./project-repository', () => ({
9+
createProject: vi.fn(),
10+
getAllProjects: vi.fn()
11+
}))
12+
13+
const projectCreationObject: CreateProjectFormSchema = {
14+
name: 'Test Project',
15+
base_language: 'en'
16+
}
17+
18+
const mockSelectableProject = {
19+
id: 1,
20+
name: 'Test Project',
21+
base_language: 1,
22+
created_at: new Date().toISOString(),
23+
updated_at: new Date().toISOString()
24+
}
25+
26+
beforeEach(() => {
27+
vi.resetAllMocks()
28+
})
29+
30+
describe('Project Service', () => {
31+
describe('createProject', () => {
32+
it('should call the repository to create a project', async () => {
33+
vi.mocked(repository.createProject).mockResolvedValue(mockSelectableProject)
34+
35+
const project = await createProject(projectCreationObject)
36+
37+
expect(repository.createProject).toHaveBeenCalledWith(projectCreationObject)
38+
expect(project).toEqual(mockSelectableProject)
39+
})
40+
41+
it('should throw an error if the repository throws an error', async () => {
42+
vi.mocked(repository.createProject).mockRejectedValue(new Error('Repository error'))
43+
44+
await expect(createProject(projectCreationObject)).rejects.toThrow('Error Creating Project')
45+
})
46+
47+
it('should throw a CreateProjectNameNotUniqueError if the repository throws a SQLITE_CONSTRAINT_UNIQUE error', async () => {
48+
const sqliteError = new SqliteError(
49+
'SQLITE_CONSTRAINT_UNIQUE: UNIQUE constraint failed: projects.name',
50+
'SQLITE_CONSTRAINT_UNIQUE'
51+
)
52+
vi.mocked(repository.createProject).mockRejectedValue(sqliteError)
53+
54+
await expect(createProject(projectCreationObject)).rejects.toThrow(
55+
new CreateProjectNameNotUniqueError()
56+
)
57+
})
58+
})
59+
60+
describe('getAllProjects', () => {
61+
it('should call the repository to get all projects', async () => {
62+
const mockProjects = [mockSelectableProject]
63+
vi.mocked(repository.getAllProjects).mockResolvedValue(mockProjects)
64+
65+
const projects = await getAllProjects()
66+
67+
expect(repository.getAllProjects).toHaveBeenCalled()
68+
expect(projects).toEqual(mockProjects)
69+
})
70+
71+
it('should return an empty array when there are no projects', async () => {
72+
vi.mocked(repository.getAllProjects).mockResolvedValue([])
73+
74+
const projects = await getAllProjects()
75+
76+
expect(repository.getAllProjects).toHaveBeenCalled()
77+
expect(projects).toEqual([])
78+
})
79+
80+
it('should throw an error if the repository throws an error', async () => {
81+
vi.mocked(repository.getAllProjects).mockRejectedValue(new Error('Repository error'))
82+
83+
await expect(getAllProjects()).rejects.toThrow('Error Getting Projects')
84+
})
85+
})
86+
})

services/src/project/project.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Insertable, Selectable } from 'kysely'
2+
import type { Projects } from 'kysely-codegen'
3+
import { z } from 'zod'
4+
5+
export type ProjectCreationParams = Insertable<Omit<Projects, 'id' | 'created_at' | 'updated_at'>>
6+
export type Project = SelectableProject
7+
8+
export type SelectableProject = Selectable<Projects>
9+
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>

0 commit comments

Comments
 (0)