Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8f4366e
Added header to projects
benjaminstrasser Jun 4, 2024
8fb54c3
added basic card layout and design
benjaminstrasser Jun 5, 2024
29251b3
added basic navigation
benjaminstrasser Jun 5, 2024
547cb8a
added popover to project card
benjaminstrasser Jun 5, 2024
383e43d
added shadcn dialog
benjaminstrasser Jun 5, 2024
6bd620b
fixed type errors in autogenerated dialog component
benjaminstrasser Jun 5, 2024
6c552fa
removed unnecesarry comment
benjaminstrasser Jun 5, 2024
e2a9a57
added description to create project dialog
benjaminstrasser Jun 7, 2024
dc7729e
added project backend functionality
benjaminstrasser Jun 12, 2024
37f7c7b
added getAllProjects to repository
benjaminstrasser Jun 16, 2024
7c041d3
added getAllProject to porject service
benjaminstrasser Jun 16, 2024
c12ceee
added getAllProjects to frontend
benjaminstrasser Jun 16, 2024
0472b7a
fixed timezone issue
benjaminstrasser Jun 16, 2024
e009a35
rearranged route components into components/container folder
benjaminstrasser Jun 16, 2024
c4da4da
added empty projects view
benjaminstrasser Jun 16, 2024
f0e547e
added e2e test to create project
benjaminstrasser Jun 30, 2024
b0a86a8
moved project card into seperate component
benjaminstrasser Jul 1, 2024
959733c
fixed a bug with language codes and added user form error if name is …
benjaminstrasser Jul 1, 2024
55f86cd
imporved grid layout
benjaminstrasser Jul 1, 2024
6e0899c
feedback
benjaminstrasser Jul 2, 2024
0f7315f
added domain model for Project
benjaminstrasser Jul 3, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions e2e/specs/create-project-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { expect } from '@playwright/test'
import { testWithUser as test } from './fixtures'
import { waitForHydration } from './util'

test.describe('create project', { tag: ['@foo-bar'] }, () => {
test.describe('create project', () => {
test('projects', async ({ page }) => {
const projectName = 'My new project'

await page.goto('/projects')
// TODO add test
await waitForHydration(page)

await page.getByTestId('create-project-modal-trigger').click()

await page.getByTestId('create-project-name-input').fill(projectName)
await page.getByTestId('create-project-base-language-input').fill('en')

await page.getByTestId('create-project-submit-button').click()

await expect(page.getByTestId('project-card-name')).toHaveText(projectName)
})
})
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"better-sqlite3": "^10.0.0",
"bits-ui": "^0.21.7",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"formsnap": "^1.0.0",
"jsonwebtoken": "^9.0.2",
"kysely": "^0.27.3",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions services/src/error/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class CreateProjectNameNotUniqueError extends Error {
constructor() {
super('Project name must be unique')
}
}
13 changes: 9 additions & 4 deletions services/src/kysely/migrations/2024-04-28T09_init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,21 @@ export async function up(db: Kysely<unknown>): Promise<void> {
await createTableMigration(tx, 'projects')
.addColumn('name', 'text', (col) => col.unique().notNull())
.addColumn('base_language', 'integer', (col) =>
col.references('languages.id').onDelete('restrict').notNull()
col
.references('languages.id')
.onDelete('restrict')
.notNull()
.modifyEnd(sql`DEFERRABLE INITIALLY DEFERRED`)
)
.execute()

await createTableMigration(tx, 'languages')
.addColumn('code', 'text', (col) => col.unique().notNull())
.addColumn('code', 'text', (col) => col.notNull())
.addColumn('fallback_language', 'integer', (col) => col.references('languages.id'))
.addColumn('project_id', 'integer', (col) =>
col.references('project.id').onDelete('cascade').notNull()
col.references('projects.id').onDelete('cascade').notNull()
)
.addUniqueConstraint('languages_code_project_id_unique', ['code', 'project_id'])
.execute()

await createTableMigration(tx, 'keys')
Expand Down Expand Up @@ -60,7 +65,7 @@ export async function down(db: Kysely<unknown>): Promise<void> {
await tx.schema.dropTable('projects_users').execute()
await tx.schema.dropTable('translations').execute()
await tx.schema.dropTable('keys').execute()
await tx.schema.dropTable('languages').execute()
await tx.schema.dropTable('projects').execute()
await tx.schema.dropTable('languages').execute()
})
}
129 changes: 129 additions & 0 deletions services/src/project/project-repository.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createProject, getAllProjects } from './project-repository'
import { runMigration } from '../db/database-migration-util'
import { db } from '../db/database'
import type { CreateProjectFormSchema, SelectableProject } from './project'
import type { Languages } from 'kysely-codegen'
import type { Selectable } from 'kysely'

const projectCreationObject: CreateProjectFormSchema = {
name: 'Test Project',
base_language: 'en'
}

beforeEach(async () => {
db.reset()
await runMigration()
})

describe('Project Repository', () => {
describe('createProject', () => {
it('should create a project with the correct attributes', async () => {
const createdProject = await createProject(projectCreationObject)

const projects = await db.selectFrom('projects').selectAll().execute()
expect(projects).toHaveLength(1)

const project = projects[0] as SelectableProject

expect(project).toMatchObject({
id: createdProject.id,
name: projectCreationObject.name,
base_language: createdProject.base_language
})

expect(project.id).toBeTypeOf('number')
})

it('should not allow creation of projects with duplicate names', async () => {
await createProject(projectCreationObject)

await expect(createProject(projectCreationObject)).rejects.toThrow()

const projects = await db.selectFrom('projects').selectAll().execute()
expect(projects).toHaveLength(1)
})

it('should create a base language for the project', async () => {
const createdProject = await createProject(projectCreationObject)

const languages = await db.selectFrom('languages').selectAll().execute()
expect(languages).toHaveLength(1)

const language = languages[0] as Selectable<Languages>

expect(language.project_id).toBe(createdProject.id)
expect(language.code).toBe(projectCreationObject.base_language)
})

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

expect(createdProject.base_language).not.toBe(0)

const language = await db
.selectFrom('languages')
.where('id', '==', createdProject.base_language)
.selectAll()
.executeTakeFirstOrThrow()

expect(language.project_id).toBe(createdProject.id)
})

it('should allow creation of multiple projects with the same base language code', async () => {
const project1 = { name: 'Project 1', base_language: 'en' }
const project2 = { name: 'Project 2', base_language: 'en' }

await createProject(project1)
await createProject(project2)

const projects = await db.selectFrom('projects').selectAll().execute()
expect(projects).toHaveLength(2)

const languages = await db.selectFrom('languages').selectAll().execute()
expect(languages).toHaveLength(2)

const languageCodes = languages.map((language: Selectable<Languages>) => language.code)
expect(languageCodes.filter((code) => code === 'en')).toHaveLength(2)
})
})

describe('getAllProjects', () => {
it('should return an empty array when there are no projects', async () => {
const projects = await getAllProjects()
expect(projects).toHaveLength(0)
})

it('should return all created projects', async () => {
const project1 = { name: 'Project 1', base_language: 'en' }
const project2 = { name: 'Project 2', base_language: 'fr' }

await createProject(project1)
await createProject(project2)

const projects = await getAllProjects()
expect(projects).toHaveLength(2)

const projectNames = projects.map((project: SelectableProject) => project.name)
expect(projectNames).toContain('Project 1')
expect(projectNames).toContain('Project 2')
})

it('should return projects with correct attributes', async () => {
const createdProject = await createProject(projectCreationObject)

const projects = await getAllProjects()
expect(projects).toHaveLength(1)

const project = projects[0] as SelectableProject

expect(project).toMatchObject({
id: createdProject.id,
name: projectCreationObject.name,
base_language: createdProject.base_language
})

expect(project.id).toBeTypeOf('number')
})
})
})
31 changes: 31 additions & 0 deletions services/src/project/project-repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { CreateProjectFormSchema, SelectableProject } from './project'
import { db } from '../db/database'

export function createProject(project: CreateProjectFormSchema): Promise<SelectableProject> {
return db.transaction().execute(async (tx) => {
const tempProject = await tx
.insertInto('projects')
.values({ name: project.name, base_language: 0 })
.returning('id')
.executeTakeFirstOrThrow(() => new Error('Error Creating Project'))

const baseLanguage = await tx
.insertInto('languages')
.values({ code: project.base_language, project_id: tempProject.id })
.returning('id')
.executeTakeFirstOrThrow(() => new Error('Error Creating Base Language'))

const createdProject = await tx
.updateTable('projects')
.set({ base_language: baseLanguage.id })
.where('id', '==', tempProject.id)
.returningAll()
.executeTakeFirstOrThrow(() => new Error('Error Updating Project'))

return createdProject
})
}

export function getAllProjects(): Promise<SelectableProject[]> {
return db.selectFrom('projects').selectAll().execute()
}
24 changes: 24 additions & 0 deletions services/src/project/project-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { CreateProjectNameNotUniqueError } from '../error'
import { type CreateProjectFormSchema } from './project'
import * as repository from './project-repository'
import { SqliteError } from 'better-sqlite3'

export async function createProject(project: CreateProjectFormSchema) {
try {
return await repository.createProject(project)
} catch (e: unknown) {
if (e instanceof SqliteError && e.code === 'SQLITE_CONSTRAINT_UNIQUE') {
throw new CreateProjectNameNotUniqueError()
}

throw new Error('Error Creating Project')
}
}

export async function getAllProjects() {
try {
return await repository.getAllProjects()
} catch (e) {
throw new Error('Error Getting Projects')
}
}
86 changes: 86 additions & 0 deletions services/src/project/project-service.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createProject, getAllProjects } from './project-service'
import * as repository from './project-repository'
import type { CreateProjectFormSchema } from './project'
import { CreateProjectNameNotUniqueError } from '../error'
import { SqliteError } from 'better-sqlite3'

vi.mock('./project-repository', () => ({
createProject: vi.fn(),
getAllProjects: vi.fn()
}))

const projectCreationObject: CreateProjectFormSchema = {
name: 'Test Project',
base_language: 'en'
}

const mockSelectableProject = {
id: 1,
name: 'Test Project',
base_language: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}

beforeEach(() => {
vi.resetAllMocks()
})

describe('Project Service', () => {
describe('createProject', () => {
it('should call the repository to create a project', async () => {
vi.mocked(repository.createProject).mockResolvedValue(mockSelectableProject)

const project = await createProject(projectCreationObject)

expect(repository.createProject).toHaveBeenCalledWith(projectCreationObject)
expect(project).toEqual(mockSelectableProject)
})

it('should throw an error if the repository throws an error', async () => {
vi.mocked(repository.createProject).mockRejectedValue(new Error('Repository error'))

await expect(createProject(projectCreationObject)).rejects.toThrow('Error Creating Project')
})

it('should throw a CreateProjectNameNotUniqueError if the repository throws a SQLITE_CONSTRAINT_UNIQUE error', async () => {
const sqliteError = new SqliteError(
'SQLITE_CONSTRAINT_UNIQUE: UNIQUE constraint failed: projects.name',
'SQLITE_CONSTRAINT_UNIQUE'
)
vi.mocked(repository.createProject).mockRejectedValue(sqliteError)

await expect(createProject(projectCreationObject)).rejects.toThrow(
new CreateProjectNameNotUniqueError()
)
})
})

describe('getAllProjects', () => {
it('should call the repository to get all projects', async () => {
const mockProjects = [mockSelectableProject]
vi.mocked(repository.getAllProjects).mockResolvedValue(mockProjects)

const projects = await getAllProjects()

expect(repository.getAllProjects).toHaveBeenCalled()
expect(projects).toEqual(mockProjects)
})

it('should return an empty array when there are no projects', async () => {
vi.mocked(repository.getAllProjects).mockResolvedValue([])

const projects = await getAllProjects()

expect(repository.getAllProjects).toHaveBeenCalled()
expect(projects).toEqual([])
})

it('should throw an error if the repository throws an error', async () => {
vi.mocked(repository.getAllProjects).mockRejectedValue(new Error('Repository error'))

await expect(getAllProjects()).rejects.toThrow('Error Getting Projects')
})
})
})
20 changes: 20 additions & 0 deletions services/src/project/project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Insertable, Selectable } from 'kysely'
import type { Projects } from 'kysely-codegen'
import { z } from 'zod'

export type ProjectCreationParams = Insertable<Omit<Projects, 'id' | 'created_at' | 'updated_at'>>
export type Project = SelectableProject

export type SelectableProject = Selectable<Projects>
export type InsertableProject = Insertable<Projects>

export const createProjectSchema = z.object({
name: z
.string({ required_error: 'Project name is required' })
.min(1, 'Project name must have at least one character'),
base_language: z
.string({ required_error: 'Base language is required' })
.min(1, 'Base language must have at least one character')
})

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