diff --git a/.gitignore b/.gitignore index e209d47e..c02bd93a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ vite.config.ts.timestamp-* docs/.vitepress/cache docs/.vitepress/dist - +aider +.aider* diff --git a/e2e/specs/create-project-flow.spec.ts b/e2e/specs/create-project-flow.spec.ts index 7025ab3b..35f047c3 100644 --- a/e2e/specs/create-project-flow.spec.ts +++ b/e2e/specs/create-project-flow.spec.ts @@ -12,8 +12,8 @@ test.describe('create project', () => { 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-base-language-select').click() + await page.getByRole('option', { name: 'en - English' }).click() await page.getByTestId('create-project-submit-button').click() await expect(page.getByTestId('project-card-name')).toHaveText(projectName) diff --git a/services/src/kysely/migrations/2024-04-28T09_init.ts b/services/src/kysely/migrations/2024-04-28T09_init.ts index 951a3597..4450874c 100644 --- a/services/src/kysely/migrations/2024-04-28T09_init.ts +++ b/services/src/kysely/migrations/2024-04-28T09_init.ts @@ -25,6 +25,7 @@ export async function up(db: Kysely): Promise { await createTableMigration(tx, 'languages') .addColumn('code', 'text', (col) => col.notNull()) + .addColumn('label', 'text', (col) => col.notNull()) .addColumn('fallback_language', 'integer', (col) => col.references('languages.id')) .addColumn('project_id', 'integer', (col) => col.references('projects.id').onDelete('cascade').notNull() @@ -51,7 +52,7 @@ export async function up(db: Kysely): Promise { await createTableMigration(tx, 'projects_users', false, false) .addColumn('project_id', 'integer', (col) => col.references('projects.id').notNull()) - .addColumn('user_id', 'integer', (col) => col.references('user.id').notNull()) + .addColumn('user_id', 'integer', (col) => col.references('users.id').notNull()) .addColumn('permission', 'text', (col) => col.check(sql`permission in ('READONLY', 'WRITE', 'ADMIN')`) ) diff --git a/services/src/language/language-repository.integration.test.ts b/services/src/language/language-repository.integration.test.ts new file mode 100644 index 00000000..3a2d7b28 --- /dev/null +++ b/services/src/language/language-repository.integration.test.ts @@ -0,0 +1,383 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + deleteLanguage, + getBaseLanguageForProject, + getLanguagesForProject, + updateLanguage, + upsertLanguages +} from './language-repository' +import { createProject } from '../project/project-repository' +import { runMigration } from '../db/database-migration-util' +import { db } from '../db/database' +import type { CreateProjectFormSchema } from '../project/project' +import type { LanguageSchema } from '$components/container/language/schema' +import type { LanguageCode } from '$components/container/language/languages' + +const projectCreationObject: CreateProjectFormSchema = { + name: 'Test Project', + base_language: 'en', + base_language_label: 'English', + slug: 'test-project' +} + +beforeEach(async () => { + db.reset() + await runMigration() +}) + +describe('Language Repository', () => { + describe('getLanguagesForProject', () => { + it('should return an empty array when there are no languages for the project', async () => { + const project = await createProject(projectCreationObject) + const languages = await getLanguagesForProject(project.slug) + expect(languages).toHaveLength(1) // Base language is always created + expect(languages[0]?.code).toBe('en') + }) + + it('should return all languages for a project', async () => { + const project = await createProject(projectCreationObject) + + // Add another language + await db + .insertInto('languages') + .values({ + code: 'fr', + label: 'French', + project_id: project.id, + fallback_language: project.base_language_id + }) + .execute() + + const languages = await getLanguagesForProject(project.slug) + expect(languages).toHaveLength(2) + + const languageCodes = languages.map((lang) => lang.code) + expect(languageCodes).toContain('en') + expect(languageCodes).toContain('fr') + }) + + it('should return languages with correct attributes', async () => { + const project = await createProject(projectCreationObject) + const languages = await getLanguagesForProject(project.slug) + + expect(languages[0]).toMatchObject({ + id: expect.any(Number), + code: 'en', + label: 'English', + fallback_language: null + }) + }) + + it('should return languages with correct fallback language', async () => { + const project = await createProject(projectCreationObject) + + // Add another language with fallback + await db + .insertInto('languages') + .values({ + code: 'fr', + label: 'French', + project_id: project.id, + fallback_language: project.base_language_id + }) + .execute() + + const languages = await getLanguagesForProject(project.slug) + const frenchLanguage = languages.find((lang) => lang.code === 'fr') + + expect(frenchLanguage).toBeDefined() + expect(frenchLanguage?.fallback_language).toBe('en') + }) + }) + + describe('updateLanguage', () => { + it('should update a language with the correct attributes', async () => { + const project = await createProject(projectCreationObject) + const languages = await getLanguagesForProject(project.slug) + const languageToUpdate = languages[0]! + + const updatedLanguageData: LanguageSchema = { + id: languageToUpdate.id, + code: 'fr' as LanguageCode, + label: 'French', + fallback: 'en' + } + + const updatedLanguage = await updateLanguage(updatedLanguageData) + + expect(updatedLanguage).toMatchObject({ + id: languageToUpdate.id, + code: 'fr', + label: 'French', + fallback_language: 'en' + }) + + // Verify the update in the database + const dbLanguage = await db + .selectFrom('languages') + .where('id', '=', languageToUpdate.id) + .selectAll() + .executeTakeFirst() + + expect(dbLanguage).toMatchObject({ + code: 'fr', + label: 'French' + }) + }) + + it('should update a language without changing the fallback if not provided', async () => { + const project = await createProject(projectCreationObject) + const languages = await getLanguagesForProject(project.slug) + const languageToUpdate = languages[0]! + + const updatedLanguageData: LanguageSchema = { + id: languageToUpdate.id, + code: 'es' as LanguageCode, + label: 'Spanish' + } + + const updatedLanguage = await updateLanguage(updatedLanguageData) + + expect(updatedLanguage.fallback_language).toBeNull() + }) + + it('should throw an error when updating a non-existent language', async () => { + const nonExistentLanguage: LanguageSchema = { + id: 9999, + code: 'xx' as LanguageCode, + label: 'Non-existent', + fallback: 'en' + } + + await expect(updateLanguage(nonExistentLanguage)).rejects.toThrow() + }) + + it('should update fallback language to null when fallback is undefined', async () => { + const project = await createProject(projectCreationObject) + const languages = await getLanguagesForProject(project.slug) + const languageToUpdate = languages[0]! + + // First, set a fallback language + await db + .updateTable('languages') + .set({ fallback_language: project.base_language_id }) + .where('id', '=', languageToUpdate.id) + .execute() + + const updatedLanguageData: LanguageSchema = { + id: languageToUpdate.id, + code: 'fr' as LanguageCode, + label: 'French' + // fallback is intentionally omitted + } + + const updatedLanguage = await updateLanguage(updatedLanguageData) + + expect(updatedLanguage.fallback_language).toBeNull() + + // Verify the update in the database + const dbLanguage = await db + .selectFrom('languages') + .where('id', '=', languageToUpdate.id) + .selectAll() + .executeTakeFirst() + + expect(dbLanguage?.fallback_language).toBeNull() + }) + }) + + describe('upsertLanguages', () => { + it('should insert new languages for a project', async () => { + const project = await createProject(projectCreationObject) + const newLanguages: LanguageSchema[] = [ + { code: 'fr' as LanguageCode, label: 'French', fallback: 'en' }, + { code: 'es' as LanguageCode, label: 'Spanish', fallback: 'en' } + ] + + const upsertedLanguages = await upsertLanguages(project.slug, newLanguages) + + expect(upsertedLanguages).toHaveLength(2) + expect(upsertedLanguages[0]).toMatchObject({ + code: 'fr', + label: 'French', + fallback_language: 'en' + }) + + expect(upsertedLanguages[1]).toMatchObject({ + code: 'es', + label: 'Spanish', + fallback_language: 'en' + }) + + // Verify in the database + const dbLanguages = await getLanguagesForProject(project.slug) + expect(dbLanguages).toHaveLength(3) // Including the base language + expect(dbLanguages.map((l) => l.code)).toContain('fr') + expect(dbLanguages.map((l) => l.code)).toContain('es') + }) + + it('should update existing languages for a project', async () => { + const project = await createProject(projectCreationObject) + const initialLanguages: LanguageSchema[] = [ + { code: 'fr' as LanguageCode, label: 'French', fallback: 'en' } + ] + const [insertedLanguage] = await upsertLanguages(project.slug, initialLanguages) + + const updatedLanguages: LanguageSchema[] = [ + { + id: insertedLanguage?.id, + code: 'fr' as LanguageCode, + label: 'Français', + fallback: undefined + } + ] + const upsertedLanguages = await upsertLanguages(project.slug, updatedLanguages) + + expect(upsertedLanguages).toHaveLength(1) + expect(upsertedLanguages[0]).toMatchObject({ + id: insertedLanguage?.id, + code: 'fr', + label: 'Français', + fallback_language: null + }) + + // Verify in the database + const dbLanguages = await getLanguagesForProject(project.slug) + const frenchLanguage = dbLanguages.find((l) => l.code === 'fr') + expect(frenchLanguage?.label).toBe('Français') + expect(frenchLanguage?.fallback_language).toBeNull() + }) + + it('should handle a mix of insert and update operations', async () => { + const project = await createProject(projectCreationObject) + const initialLanguages: LanguageSchema[] = [ + { code: 'fr' as LanguageCode, label: 'French', fallback: 'en' } + ] + const [initialLanguage] = await upsertLanguages(project.slug, initialLanguages) + + const mixedLanguages: LanguageSchema[] = [ + { + id: initialLanguage?.id, + code: 'fr' as LanguageCode, + label: 'Français', + fallback: undefined + }, + { code: 'es' as LanguageCode, label: 'Spanish', fallback: 'en' } + ] + const upsertedLanguages = await upsertLanguages(project.slug, mixedLanguages) + + expect(upsertedLanguages).toHaveLength(2) + expect(upsertedLanguages.find((l) => l.code === 'fr')).toMatchObject({ + id: initialLanguage?.id, + code: 'fr', + label: 'Français', + fallback_language: null + }) + + expect(upsertedLanguages.find((l) => l.code === 'es')).toMatchObject({ + code: 'es', + label: 'Spanish', + fallback_language: 'en' + }) + + // Verify in the database + const dbLanguages = await getLanguagesForProject(project.slug) + expect(dbLanguages).toHaveLength(3) // Including the base language + }) + + it('should throw an error when upserting languages for a non-existent project', async () => { + const nonExistentProjectSlug = 'no-existing-slug' + const languages: LanguageSchema[] = [ + { code: 'fr' as LanguageCode, label: 'French', fallback: 'en' } + ] + + await expect(upsertLanguages(nonExistentProjectSlug, languages)).rejects.toThrow() + }) + }) + + describe('deleteLanguage', () => { + it('should delete an existing language', async () => { + const project = await createProject(projectCreationObject) + const newLanguage: LanguageSchema = { + code: 'fr' as LanguageCode, + label: 'French', + fallback: 'en' + } + const [upsertedLanguage] = await upsertLanguages(project.slug, [newLanguage]) + + await deleteLanguage(upsertedLanguage?.id as number) + + // Verify the language is deleted + const dbLanguages = await getLanguagesForProject(project.slug) + expect(dbLanguages).toHaveLength(1) // Only the base language remains + expect(dbLanguages[0]?.code).not.toBe('fr') + }) + + it('should throw an error when deleting a non-existent language', async () => { + const nonExistentLanguageId = 9999 + + await expect(deleteLanguage(nonExistentLanguageId)).rejects.toThrow() + }) + + it('should not delete the base language of a project', async () => { + const project = await createProject(projectCreationObject) + const languages = await getLanguagesForProject(project.slug) + const baseLanguage = languages.find((l) => l.id === project.base_language_id) + + if (!baseLanguage) { + throw new Error('Base language not found') + } + + await expect(deleteLanguage(baseLanguage.id)).rejects.toThrow() + + // Verify the base language still exists + const dbLanguages = await getLanguagesForProject(project.slug) + expect(dbLanguages).toHaveLength(1) + expect(dbLanguages[0]?.id).toBe(project.base_language_id) + }) + }) + + describe('getBaseLanguageForProject', () => { + it('should return the base language for a project', async () => { + const project = await createProject(projectCreationObject) + const baseLanguage = await getBaseLanguageForProject(project.slug) + + expect(baseLanguage).toMatchObject({ + id: expect.any(Number), + code: 'en', + label: 'English', + fallback_language: null + }) + }) + + it('should return the correct base language when multiple languages exist', async () => { + const project = await createProject(projectCreationObject) + + // Add another language + await db + .insertInto('languages') + .values({ + code: 'fr', + label: 'French', + project_id: project.id, + fallback_language: project.base_language_id + }) + .execute() + + const baseLanguage = await getBaseLanguageForProject(project.slug) + + expect(baseLanguage).toMatchObject({ + id: project.base_language_id, + code: 'en', + label: 'English', + fallback_language: null + }) + }) + + it('should throw an error when the project does not exist', async () => { + const nonExistentSlug = 'non-existent-project' + + await expect(getBaseLanguageForProject(nonExistentSlug)).rejects.toThrow() + }) + }) +}) diff --git a/services/src/language/language-repository.ts b/services/src/language/language-repository.ts new file mode 100644 index 00000000..44a29134 --- /dev/null +++ b/services/src/language/language-repository.ts @@ -0,0 +1,115 @@ +import type { SelectableLanguage } from './language.model' +import { db } from '../db/database' +import type { LanguageSchema } from '$components/container/language/schema' +import type { LanguageCode } from '$components/container/language/languages' +import { getProjectBySlug } from 'services/project/project-repository' +import type { Transaction } from 'kysely' +import type { DB } from 'kysely-codegen' + +async function getFallbackLanguageId(fallback: string | undefined, tx?: Transaction) { + if (!fallback) return null + + return ( + await (tx ?? db) + .selectFrom('languages') + .where('code', '=', fallback) + .select('id') + .executeTakeFirstOrThrow() + ).id +} + +export function getLanguagesForProject(slug: string): Promise { + return db + .selectFrom('languages as l1') + .leftJoin('languages as l2', 'l1.fallback_language', 'l2.id') + .leftJoin('projects', 'projects.id', 'l1.project_id') + .where('projects.slug', '=', slug) + .select(['l1.id', 'l1.code', 'l1.label', 'l2.code as fallback_language']) + .execute() +} + +export async function getBaseLanguageForProject(slug: string): Promise { + const baseLanguage = await db + .selectFrom('languages') + .leftJoin('projects', 'languages.id', 'projects.base_language_id') + .where('projects.slug', '=', slug) + .select(['languages.id', 'languages.code', 'languages.label']) + .executeTakeFirstOrThrow() + + return { ...baseLanguage, fallback_language: null } +} + +export async function updateLanguage(language: LanguageSchema): Promise { + const updatedLanguages = await db + .updateTable('languages') + .set({ + code: language.code, + label: language.label, + fallback_language: await getFallbackLanguageId(language.fallback) + }) + .where('id', '=', language.id!) + .returning(['id', 'code', 'label', 'fallback_language']) + .execute() + + const updatedLanguage = updatedLanguages[0] + + if (!updatedLanguage) throw new Error(`Failed to update language "${language.code}"`) + + return { + ...updatedLanguage, + fallback_language: language.fallback ? (language.fallback as LanguageCode) : null + } +} + +export async function upsertLanguages( + projectSlug: string, + languages: LanguageSchema[], + tx?: Transaction +): Promise { + const project = await getProjectBySlug(projectSlug, tx) + + const languagesToUpsert = await Promise.all( + languages.map(async (language) => ({ + id: language.id, + project_id: project.id, + code: language.code, + label: language.label, + fallback_language: await getFallbackLanguageId(language.fallback, tx) + })) + ) + + const upsertedLanguages = await (tx ?? db) + .insertInto('languages') + .values(languagesToUpsert) + .onConflict((oc) => + oc.column('id').doUpdateSet({ + code: (eb) => eb.ref('excluded.code'), + label: (eb) => eb.ref('excluded.label'), + fallback_language: (eb) => eb.ref('excluded.fallback_language') + }) + ) + .returning(['id', 'code', 'label', 'fallback_language']) + .execute() + + return upsertedLanguages.map((upsertedLanguage) => ({ + ...upsertedLanguage, + fallback_language: + languages.find(({ code }) => upsertedLanguage.code === code)?.fallback ?? null + })) +} + +/** + * @throws {Error} if language id does not exist or language could not be deleted + */ +export async function deleteLanguage(id: number): Promise { + // remove from fallback languages first + await db + .updateTable('languages') + .set({ fallback_language: null }) + .where('languages.fallback_language', '=', id) + .execute() + + const result = await db.deleteFrom('languages').where('id', '=', id).executeTakeFirstOrThrow() + + if (result.numDeletedRows === 0n) throw new Error(`Failed to delete language with id ${id}`) +} diff --git a/services/src/language/language-service.ts b/services/src/language/language-service.ts new file mode 100644 index 00000000..79d2283c --- /dev/null +++ b/services/src/language/language-service.ts @@ -0,0 +1,73 @@ +import type { LanguageCode } from '$components/container/language/languages' +import type { LanguageId, LanguageSchema } from '$components/container/language/schema' +import type { Logger } from 'pino' +import * as repository from './language-repository' +import type { SelectableLanguage } from './language.model' +import { db } from '../db/database' + +function mapToLanguage(language: SelectableLanguage): LanguageSchema { + return { + id: language.id as LanguageId, + code: language.code as LanguageCode, + label: language.label, + fallback: language.fallback_language ? (language.fallback_language as LanguageCode) : undefined + } +} + +export async function getLanguagesForProject(slug: string): Promise { + const languages = await repository.getLanguagesForProject(slug) + + return languages.map(mapToLanguage) +} + +export async function getBaseLanguageForProject(slug: string): Promise { + const baseLanguage = await repository.getBaseLanguageForProject(slug) + if (!baseLanguage) throw new Error('No base language found for project') + + return mapToLanguage(baseLanguage) +} + +export async function updateLanguage(language: LanguageSchema): Promise { + if (!language.id) { + throw new Error('Language ID is required for updating') + } + + const updatedLanguage = await repository.updateLanguage(language) + + return mapToLanguage(updatedLanguage) +} + +export async function upsertLanguagesForProject( + projectSlug: string, + languages: LanguageSchema[] +): Promise { + return db.transaction().execute(async (tx) => { + // add new languages first to make them referencable as fallback + const addedLanguages = await repository.upsertLanguages( + projectSlug, + languages.filter((l) => l.id === undefined), + tx + ) + + const updatedLanguages = await repository.upsertLanguages( + projectSlug, + languages.filter((l) => l.id !== undefined), + tx + ) + + return [...updatedLanguages, ...addedLanguages].map(mapToLanguage) + }) +} + +export async function deleteLanguage(projectSlug: string, languageId: number, logger: Logger) { + try { + await repository.deleteLanguage(languageId) + } catch (err: unknown) { + let message = 'Failed to delete language.' + if (err instanceof Error) message = err.message + + logger.info(message) + } + + return getLanguagesForProject(projectSlug) +} diff --git a/services/src/language/language-service.unit.test.ts b/services/src/language/language-service.unit.test.ts new file mode 100644 index 00000000..f45d3c91 --- /dev/null +++ b/services/src/language/language-service.unit.test.ts @@ -0,0 +1,322 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + deleteLanguage, + getBaseLanguageForProject, + getLanguagesForProject, + updateLanguage, + upsertLanguagesForProject +} from './language-service' +import * as repository from './language-repository' +import type { SelectableLanguage } from './language.model' +import type { LanguageSchema } from '$components/container/language/schema' +import type { LanguageCode } from '$components/container/language/languages' +import { mockedLogger } from '../unit-test.utils' + +vi.mock('./language-repository', () => ({ + getLanguagesForProject: vi.fn(), + getBaseLanguageForProject: vi.fn(), + updateLanguage: vi.fn(), + upsertLanguages: vi.fn(), + deleteLanguage: vi.fn() +})) + +const mockSelectableLanguages: SelectableLanguage[] = [ + { + id: 1, + code: 'en', + label: 'English', + fallback_language: null + }, + { + id: 2, + code: 'es', + label: 'Spanish', + fallback_language: 'en' + } +] + +const expectedLanguageSchemas: LanguageSchema[] = [ + { + id: 1, + code: 'en', + label: 'English', + fallback: undefined + }, + { + id: 2, + code: 'es' as LanguageCode, + label: 'Spanish', + fallback: 'en' + } +] + +beforeEach(() => { + vi.resetAllMocks() +}) + +describe('Language Service', () => { + describe('getLanguagesForProject', () => { + const mockedProjectSlug = 'test-slug' + + it('should call the repository to get languages for a project', async () => { + vi.mocked(repository.getLanguagesForProject).mockResolvedValue(mockSelectableLanguages) + + const languages = await getLanguagesForProject(mockedProjectSlug) + + expect(repository.getLanguagesForProject).toHaveBeenCalledWith(mockedProjectSlug) + expect(languages).toEqual(expectedLanguageSchemas) + }) + + it('should return an empty array when there are no languages for the project', async () => { + vi.mocked(repository.getLanguagesForProject).mockResolvedValue([]) + + const languages = await getLanguagesForProject(mockedProjectSlug) + + expect(repository.getLanguagesForProject).toHaveBeenCalledWith(mockedProjectSlug) + expect(languages).toEqual([]) + }) + + it('should throw an error if the repository throws an error', async () => { + vi.mocked(repository.getLanguagesForProject).mockRejectedValue(new Error('Repository error')) + + await expect(getLanguagesForProject(mockedProjectSlug)).rejects.toThrow('Repository error') + }) + }) + + describe('updateLanguage', () => { + const mockLanguageToUpdate: LanguageSchema = { + id: 1, + code: 'fr' as LanguageCode, + label: 'French', + fallback: 'en' + } + + const mockUpdatedSelectableLanguage: SelectableLanguage = { + id: 1, + code: 'fr', + label: 'French', + fallback_language: 'en' + } + + it('should call the repository to update a language and return the updated language', async () => { + vi.mocked(repository.updateLanguage).mockResolvedValue(mockUpdatedSelectableLanguage) + + const updatedLanguage = await updateLanguage(mockLanguageToUpdate) + + expect(repository.updateLanguage).toHaveBeenCalledWith(mockLanguageToUpdate) + expect(updatedLanguage).toEqual(mockLanguageToUpdate) + }) + + it('should throw an error if the language ID is missing', async () => { + const invalidLanguage = { ...mockLanguageToUpdate, id: undefined } + + await expect(updateLanguage(invalidLanguage as LanguageSchema)).rejects.toThrow( + 'Language ID is required for updating' + ) + }) + + it('should handle null fallback_language correctly', async () => { + const languageWithNullFallback: SelectableLanguage = { + ...mockUpdatedSelectableLanguage, + fallback_language: null + } + vi.mocked(repository.updateLanguage).mockResolvedValue(languageWithNullFallback) + + const updatedLanguage = await updateLanguage(mockLanguageToUpdate) + + expect(updatedLanguage.fallback).toBeUndefined() + }) + + it('should throw an error if the repository throws an error', async () => { + vi.mocked(repository.updateLanguage).mockRejectedValue(new Error('Update failed')) + + await expect(updateLanguage(mockLanguageToUpdate)).rejects.toThrow('Update failed') + }) + }) + + describe('upsertLanguagesForProject', () => { + const mockProjectSlug = 'test-slug' + const mockLanguagesToUpsert: LanguageSchema[] = [ + { + id: 1, + code: 'en' as LanguageCode, + label: 'English', + fallback: undefined + }, + { + id: 2, + code: 'fr' as LanguageCode, + label: 'French', + fallback: 'en' + } + ] + + const mockUpsertedSelectableLanguages: SelectableLanguage[] = [ + { + id: 1, + code: 'en', + label: 'English', + fallback_language: null + }, + { + id: 2, + code: 'fr', + label: 'French', + fallback_language: 'en' + } + ] + + it('should call the repository to upsert languages and return the upserted languages', async () => { + vi.mocked(repository.upsertLanguages).mockResolvedValueOnce([]) + vi.mocked(repository.upsertLanguages).mockResolvedValue(mockUpsertedSelectableLanguages) + + const upsertedLanguages = await upsertLanguagesForProject( + mockProjectSlug, + mockLanguagesToUpsert + ) + + expect(repository.upsertLanguages).toHaveBeenNthCalledWith(1, mockProjectSlug, [], {}) + + expect(repository.upsertLanguages).toHaveBeenNthCalledWith( + 2, + mockProjectSlug, + mockLanguagesToUpsert, + {} + ) + + expect(upsertedLanguages).toEqual(mockLanguagesToUpsert) + }) + + it('should handle an empty array of languages', async () => { + vi.mocked(repository.upsertLanguages).mockResolvedValue([]) + + const upsertedLanguages = await upsertLanguagesForProject(mockProjectSlug, []) + + expect(repository.upsertLanguages).toHaveBeenCalledWith(mockProjectSlug, [], {}) + expect(upsertedLanguages).toEqual([]) + }) + + it('should throw an error if the repository throws an error', async () => { + vi.mocked(repository.upsertLanguages).mockRejectedValue(new Error('Upsert failed')) + + await expect( + upsertLanguagesForProject(mockProjectSlug, mockLanguagesToUpsert) + ).rejects.toThrow('Upsert failed') + }) + }) + + describe('deleteLanguage', () => { + const mockProjectSlug = 'test-slug' + const mockLanguageId = 2 + + it('should call the repository to delete a language and return updated languages for the project', async () => { + vi.mocked(repository.deleteLanguage).mockResolvedValue() + vi.mocked(repository.getLanguagesForProject).mockResolvedValue([ + mockSelectableLanguages[0] as SelectableLanguage + ]) + + const updatedLanguages = await deleteLanguage(mockProjectSlug, mockLanguageId, mockedLogger) + + expect(repository.deleteLanguage).toHaveBeenCalledWith(mockLanguageId) + expect(repository.getLanguagesForProject).toHaveBeenCalledWith(mockProjectSlug) + expect(updatedLanguages).toEqual([expectedLanguageSchemas[0]]) + }) + + it('should return an empty array if no languages remain after deletion', async () => { + vi.mocked(repository.deleteLanguage).mockResolvedValue() + vi.mocked(repository.getLanguagesForProject).mockResolvedValue([]) + + const updatedLanguages = await deleteLanguage(mockProjectSlug, mockLanguageId, mockedLogger) + + expect(repository.deleteLanguage).toHaveBeenCalledWith(mockLanguageId) + expect(repository.getLanguagesForProject).toHaveBeenCalledWith(mockProjectSlug) + expect(updatedLanguages).toEqual([]) + }) + + it('should return all languages if the repository throws an error during deletion', async () => { + const language = { + id: 1, + code: 'en', + label: 'English', + fallback_language: null + } + + vi.mocked(repository.deleteLanguage).mockRejectedValue(new Error('Delete failed')) + vi.mocked(repository.getLanguagesForProject).mockResolvedValue([language]) + + const result = await deleteLanguage(mockProjectSlug, mockLanguageId, mockedLogger) + + expect(result).toHaveLength(1) + expect(result).toEqual([{ ...language, fallback_language: undefined }]) + }) + + it('should throw an error if getting updated languages fails', async () => { + vi.mocked(repository.deleteLanguage).mockResolvedValue() + vi.mocked(repository.getLanguagesForProject).mockRejectedValue( + new Error('Get languages failed') + ) + + await expect(deleteLanguage(mockProjectSlug, mockLanguageId, mockedLogger)).rejects.toThrow( + 'Get languages failed' + ) + }) + }) + + describe('getBaseLanguageForProject', () => { + const mockedProjectSlug = 'test-slug' + const mockBaseLanguage: SelectableLanguage = { + id: 1, + code: 'en', + label: 'English', + fallback_language: null + } + + const expectedBaseLanguageSchema: LanguageSchema = { + id: 1, + code: 'en' as LanguageCode, + label: 'English', + fallback: undefined + } + + it('should call the repository to get the base language for a project', async () => { + vi.mocked(repository.getBaseLanguageForProject).mockResolvedValue(mockBaseLanguage) + + const baseLanguage = await getBaseLanguageForProject(mockedProjectSlug) + + expect(repository.getBaseLanguageForProject).toHaveBeenCalledWith(mockedProjectSlug) + expect(baseLanguage).toEqual(expectedBaseLanguageSchema) + }) + + it('should handle a base language with a fallback correctly', async () => { + const mockBaseLanguageWithFallback: SelectableLanguage = { + ...mockBaseLanguage, + fallback_language: 'fr' + } + vi.mocked(repository.getBaseLanguageForProject).mockResolvedValue( + mockBaseLanguageWithFallback + ) + + const baseLanguage = await getBaseLanguageForProject(mockedProjectSlug) + + expect(baseLanguage.fallback).toBe('fr') + }) + + it('should throw an error if the repository throws an error', async () => { + vi.mocked(repository.getBaseLanguageForProject).mockRejectedValue( + new Error('Repository error') + ) + + await expect(getBaseLanguageForProject(mockedProjectSlug)).rejects.toThrow('Repository error') + }) + + it('should throw an error if no base language is found', async () => { + vi.mocked(repository.getBaseLanguageForProject).mockResolvedValue( + null as unknown as SelectableLanguage + ) + + await expect(getBaseLanguageForProject(mockedProjectSlug)).rejects.toThrow( + 'No base language found for project' + ) + }) + }) +}) diff --git a/services/src/language/language.model.ts b/services/src/language/language.model.ts new file mode 100644 index 00000000..d9074de6 --- /dev/null +++ b/services/src/language/language.model.ts @@ -0,0 +1,6 @@ +import type { Selectable } from 'kysely' +import type { Languages } from 'kysely-codegen' + +export type SelectableLanguage = Selectable< + Pick & { fallback_language: string | null } +> diff --git a/services/src/project/project-repository.integration.test.ts b/services/src/project/project-repository.integration.test.ts index 64ef36ed..0add43d1 100644 --- a/services/src/project/project-repository.integration.test.ts +++ b/services/src/project/project-repository.integration.test.ts @@ -7,13 +7,14 @@ import { } from './project-repository' import { runMigration } from '../db/database-migration-util' import { db } from '../db/database' -import type { ProjectCreationParams, SelectableProject } from './project' +import type { CreateProjectFormSchema, SelectableProject } from './project' import type { Languages } from 'kysely-codegen' import type { Selectable } from 'kysely' -const projectCreationObject: ProjectCreationParams = { +const projectCreationObject: CreateProjectFormSchema = { name: 'Test Project', - base_language_code: 'en', + base_language: 'en', + base_language_label: 'English', slug: 'test-project' } @@ -51,14 +52,17 @@ describe('Project Repository', () => { }) it('should not allow creation of projects with duplicate slugs', async () => { - const projectCreationObject1 = { + const projectCreationObject1: CreateProjectFormSchema = { name: 'Test Project', - base_language_code: 'en', + base_language: 'en', + base_language_label: 'English', slug: 'test-project' } - const projectCreationObject2 = { + + const projectCreationObject2: CreateProjectFormSchema = { name: 'test-project', - base_language_code: 'en', + base_language: 'en', + base_language_label: 'English', slug: 'test-project' } @@ -79,7 +83,7 @@ describe('Project Repository', () => { const language = languages[0] as Selectable expect(language.project_id).toBe(createdProject.id) - expect(language.code).toBe(projectCreationObject.base_language_code) + expect(language.code).toBe(projectCreationObject.base_language) }) it('should link the base language to the project', async () => { @@ -97,8 +101,19 @@ describe('Project Repository', () => { }) it('should allow creation of multiple projects with the same base language code', async () => { - const project1 = { name: 'Project 1', base_language_code: 'en', slug: 'project-1' } - const project2 = { name: 'Project 2', base_language_code: 'en', slug: 'project-2' } + const project1: CreateProjectFormSchema = { + name: 'Project 1', + base_language: 'en', + base_language_label: 'English', + slug: 'project-1' + } + + const project2: CreateProjectFormSchema = { + name: 'Project 2', + base_language: 'en', + base_language_label: 'English', + slug: 'project-2' + } await createProject(project1) await createProject(project2) @@ -121,8 +136,19 @@ describe('Project Repository', () => { }) it('should return all created projects', async () => { - const project1 = { name: 'Project 1', base_language_code: 'en', slug: 'project-1' } - const project2 = { name: 'Project 2', base_language_code: 'fr', slug: 'project-2' } + const project1: CreateProjectFormSchema = { + name: 'Project 1', + base_language: 'en', + base_language_label: 'English', + slug: 'project-1' + } + + const project2: CreateProjectFormSchema = { + name: 'Project 2', + base_language: 'fr', + base_language_label: 'French', + slug: 'project-2' + } await createProject(project1) await createProject(project2) diff --git a/services/src/project/project-repository.ts b/services/src/project/project-repository.ts index a16d9f44..beaec7aa 100644 --- a/services/src/project/project-repository.ts +++ b/services/src/project/project-repository.ts @@ -1,7 +1,9 @@ -import type { ProjectCreationParams, SelectableProject } from './project' +import type { CreateProjectFormSchema, SelectableProject } from './project' import { db } from '../db/database' +import type { Transaction } from 'kysely' +import type { DB } from 'kysely-codegen' -export function createProject(project: ProjectCreationParams): Promise { +export function createProject(project: CreateProjectFormSchema): Promise { return db.transaction().execute(async (tx) => { const tempProject = await tx .insertInto('projects') @@ -11,7 +13,11 @@ export function createProject(project: ProjectCreationParams): Promise new Error('Error Creating Base Language')) @@ -30,6 +36,14 @@ export function getAllProjects(): Promise { return db.selectFrom('projects').selectAll().execute() } +export function getProjectBySlug(slug: string, tx?: Transaction): Promise { + return (tx ?? db) + .selectFrom('projects') + .selectAll() + .where('slug', '=', slug) + .executeTakeFirstOrThrow(() => new Error(`Could not find project with slug "${slug}".`)) +} + export async function checkProjectNameExists(name: string) { const result = await db.selectFrom('projects').selectAll().where('name', '==', name).execute() diff --git a/services/src/project/project-service.ts b/services/src/project/project-service.ts index 2efd6ad3..3f7b6449 100644 --- a/services/src/project/project-service.ts +++ b/services/src/project/project-service.ts @@ -21,11 +21,15 @@ export async function createProject(project: CreateProjectFormSchema) { export async function getAllProjects() { try { return await repository.getAllProjects() - } catch (e) { + } catch (e: unknown) { throw new Error('Error Getting Projects') } } +export async function getProjectBySlug(slug: string) { + return await repository.getProjectBySlug(slug) +} + export async function checkProjectNameExists(name: string) { try { return await repository.checkProjectNameExists(name) diff --git a/services/src/project/project-service.unit.test.ts b/services/src/project/project-service.unit.test.ts index 29503747..4db65798 100644 --- a/services/src/project/project-service.unit.test.ts +++ b/services/src/project/project-service.unit.test.ts @@ -17,7 +17,8 @@ vi.mock('../util/slug/slug-service', () => ({ const projectCreationObject: CreateProjectFormSchema = { name: 'Test Project', - base_language_code: 'en' + base_language: 'en', + base_language_label: 'English' } const mockSelectableProject = { diff --git a/services/src/project/project.ts b/services/src/project/project.ts index f48f63ba..772a9106 100644 --- a/services/src/project/project.ts +++ b/services/src/project/project.ts @@ -1,12 +1,26 @@ import type { Insertable, Selectable } from 'kysely' import type { Projects } from 'kysely-codegen' +import { z } from 'zod' -export type ProjectCreationParams = { - name: string - slug: string - base_language_code: string -} export type Project = SelectableProject export type SelectableProject = Selectable export type InsertableProject = Insertable + +// TODO: add better slug validation +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'), + base_language_label: z + .string({ required_error: 'Base language label is required' }) + .min(1, 'Base language label must have at least one character'), + slug: z + .string({ required_error: 'Slug is required' }) + .min(1, 'Slug must have at least 1 character') +}) + +export type CreateProjectFormSchema = z.infer diff --git a/tests/unit-test.utils.ts b/services/src/unit-test.utils.ts similarity index 100% rename from tests/unit-test.utils.ts rename to services/src/unit-test.utils.ts diff --git a/src/components/container/auth/signup-form.svelte b/src/components/container/auth/signup-form.svelte index 900b6417..8503b47a 100644 --- a/src/components/container/auth/signup-form.svelte +++ b/src/components/container/auth/signup-form.svelte @@ -33,7 +33,7 @@

Register

- Enter you information to create an user account for this Tiny-TMS instance + Enter your information to create an user account for this Tiny-TMS instance

or sign in to an existing account diff --git a/src/components/container/language/LanguageSelect.svelte b/src/components/container/language/LanguageSelect.svelte new file mode 100644 index 00000000..99ec1a50 --- /dev/null +++ b/src/components/container/language/LanguageSelect.svelte @@ -0,0 +1,36 @@ + + + + + + + + + {#each items as language} + + {/each} + + diff --git a/src/components/container/language/LanguageTable.svelte b/src/components/container/language/LanguageTable.svelte new file mode 100644 index 00000000..348a975f --- /dev/null +++ b/src/components/container/language/LanguageTable.svelte @@ -0,0 +1,162 @@ + + + + + + Code + Label + Fallback + Action + + + + {#each $formData.languages as language, i (language.id)} + {#if $formData.languages[i]} + + + + + {#if $formData.languages[i]} + + {/if} + + + + + + + + {#if $formData.languages[i]} + + {/if} + + + + + + {#if $formData.languages[i].code !== baseLanguage.code} + + + {#if $formData.languages[i]} + {#key $formData.languages.length} + + {/key} + {/if} + + + + {/if} + + + {#if $formData.languages[i].code !== baseLanguage.code} + + {/if} + + + {/if} + {/each} + + + + + + diff --git a/src/components/container/language/languages.ts b/src/components/container/language/languages.ts new file mode 100644 index 00000000..8606e4c2 --- /dev/null +++ b/src/components/container/language/languages.ts @@ -0,0 +1,186 @@ +export const availableLanguages = { + af: 'Afrikaans', + sq: 'Albanian', + ar: 'Arabic', + 'ar-dz': 'Arabic (Algeria)', + 'ar-bh': 'Arabic (Bahrain)', + 'ar-eg': 'Arabic (Egypt)', + 'ar-iq': 'Arabic (Iraq)', + 'ar-jo': 'Arabic (Jordan)', + 'ar-kw': 'Arabic (Kuwait)', + 'ar-lb': 'Arabic (Lebanon)', + 'ar-ly': 'Arabic (Libya)', + 'ar-ma': 'Arabic (Morocco)', + 'ar-om': 'Arabic (Oman)', + 'ar-qa': 'Arabic (Qatar)', + 'ar-sa': 'Arabic (Saudi Arabia)', + 'ar-sy': 'Arabic (Syria)', + 'ar-tn': 'Arabic (Tunisia)', + 'ar-ae': 'Arabic (U.A.E.)', + 'ar-ye': 'Arabic (Yemen)', + an: 'Aragonese', + hy: 'Armenian', + as: 'Assamese', + ast: 'Asturian', + az: 'Azerbaijani', + eu: 'Basque', + be: 'Belarusian', + bn: 'Bengali', + bs: 'Bosnian', + br: 'Breton', + bg: 'Bulgarian', + my: 'Burmese', + ca: 'Catalan', + ch: 'Chamorro', + ce: 'Chechen', + zh: 'Chinese', + 'zh-hk': 'Chinese (Hong Kong)', + 'zh-sg': 'Chinese (Singapore)', + 'zh-tw': 'Chinese (Taiwan)', + cv: 'Chuvash', + co: 'Corsican', + cr: 'Cree', + hr: 'Croatian', + da: 'Danish', + nl: 'Dutch', + 'nl-be': 'Dutch (Belgian)', + en: 'English', + 'en-au': 'English (Australia)', + 'en-bz': 'English (Belize)', + 'en-ca': 'English (Canada)', + 'en-ie': 'English (Ireland)', + 'en-jm': 'English (Jamaica)', + 'en-nz': 'English (New Zealand)', + 'en-ph': 'English (Philippines)', + 'en-za': 'English (South Africa)', + 'en-tt': 'English (Trinidad & Tobago)', + 'en-gb': 'English (United Kingdom)', + 'en-us': 'English (United States)', + 'en-zw': 'English (Zimbabwe)', + eo: 'Esperanto', + et: 'Estonian', + fo: 'Faeroese', + fa: 'Farsi', + fj: 'Fijian', + fi: 'Finnish', + fr: 'French', + 'fr-be': 'French (Belgium)', + 'fr-ca': 'French (Canada)', + 'fr-fr': 'French (France)', + 'fr-lu': 'French (Luxembourg)', + 'fr-mc': 'French (Monaco)', + 'fr-ch': 'French (Switzerland)', + fy: 'Frisian', + fur: 'Friulian', + gd: 'Gaelic (Scots)', + 'gd-ie': 'Gaelic (Irish)', + gl: 'Galacian', + ka: 'Georgian', + de: 'German', + 'de-at': 'German (Austria)', + 'de-de': 'German (Germany)', + 'de-li': 'German (Liechtenstein)', + 'de-lu': 'German (Luxembourg)', + 'de-ch': 'German (Switzerland)', + gu: 'Gujurati', + ht: 'Haitian', + he: 'Hebrew', + hi: 'Hindi', + is: 'Icelandic', + id: 'Indonesian', + iu: 'Inuktitut', + ga: 'Irish', + 'it-ch': 'Italian (Switzerland)', + kn: 'Kannada', + ks: 'Kashmiri', + kk: 'Kazakh', + km: 'Khmer', + ky: 'Kirghiz', + tlh: 'Klingon', + 'ko-kp': 'Korean (North Korea)', + 'ko-kr': 'Korean (South Korea)', + la: 'Latin', + lv: 'Latvian', + lt: 'Lithuanian', + lb: 'Luxembourgish', + mk: 'FYRO Macedonian', + ms: 'Malay', + ml: 'Malayalam', + mt: 'Maltese', + mi: 'Maori', + mr: 'Marathi', + mo: 'Moldavian', + nv: 'Navajo', + ng: 'Ndonga', + ne: 'Nepali', + no: 'Norwegian', + nb: 'Norwegian (Bokmal)', + nn: 'Norwegian (Nynorsk)', + oc: 'Occitan', + or: 'Oriya', + om: 'Oromo', + 'fa-ir': 'Persian/Iran', + pa: 'Punjabi', + 'pa-in': 'Punjabi (India)', + 'pa-pk': 'Punjabi (Pakistan)', + qu: 'Quechua', + rm: 'Rhaeto-Romanic', + 'ro-mo': 'Romanian (Moldavia)', + 'ru-mo': 'Russian (Moldavia)', + sz: 'Sami (Lappish)', + sg: 'Sango', + sa: 'Sanskrit', + sc: 'Sardinian', + sd: 'Sindhi', + si: 'Singhalese', + sr: 'Serbian', + sk: 'Slovak', + sl: 'Slovenian', + so: 'Somani', + sb: 'Sorbian', + es: 'Spanish', + 'es-ar': 'Spanish (Argentina)', + 'es-bo': 'Spanish (Bolivia)', + 'es-cl': 'Spanish (Chile)', + 'es-co': 'Spanish (Colombia)', + 'es-cr': 'Spanish (Costa Rica)', + 'es-do': 'Spanish (Dominican Republic)', + 'es-ec': 'Spanish (Ecuador)', + 'es-sv': 'Spanish (El Salvador)', + 'es-gt': 'Spanish (Guatemala)', + 'es-hn': 'Spanish (Honduras)', + 'es-mx': 'Spanish (Mexico)', + 'es-ni': 'Spanish (Nicaragua)', + 'es-pa': 'Spanish (Panama)', + 'es-py': 'Spanish (Paraguay)', + 'es-pe': 'Spanish (Peru)', + 'es-pr': 'Spanish (Puerto Rico)', + 'es-uy': 'Spanish (Uruguay)', + 'es-ve': 'Spanish (Venezuela)', + sx: 'Sutu', + sw: 'Swahili', + sv: 'Swedish', + 'sv-fi': 'Swedish (Finland)', + 'sv-sv': 'Swedish (Sweden)', + ta: 'Tamil', + tt: 'Tatar', + te: 'Teluga', + th: 'Thai', + tig: 'Tigre', + ts: 'Tsonga', + tn: 'Tswana', + tk: 'Turkmen', + uk: 'Ukrainian', + hsb: 'Upper Sorbian', + ur: 'Urdu', + ve: 'Venda', + vi: 'Vietnamese', + vo: 'Volapuk', + wa: 'Walloon', + cy: 'Welsh', + xh: 'Xhosa', + ji: 'Yiddish', + zu: 'Zulu' +} as const + +export type LanguageCode = keyof typeof availableLanguages diff --git a/src/components/container/language/schema.ts b/src/components/container/language/schema.ts new file mode 100644 index 00000000..8132d1a2 --- /dev/null +++ b/src/components/container/language/schema.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' + +export const languageSchema = z.object({ + id: z.number().optional(), + code: z.string().min(1, { message: 'Language code is required' }), + label: z + .string({ + required_error: 'Language label is required' + }) + .min(1, 'Language label must at least consist of a single character'), + fallback: z.string().optional() +}) + +export const languagesSchema = z.object({ + languages: z.array(languageSchema).superRefine((languages, ctx) => { + const seenCodes = new Set() + + languages.forEach((language, index) => { + if (seenCodes.has(language.code)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Language codes must be unique', + path: [`${index}`, 'code'] + }) + } else { + seenCodes.add(language.code) + } + }) + }) +}) + +export type LanguageSchema = z.infer +export type LanguagesSchema = z.infer + +declare const actionPlanningId: unique symbol +export type LanguageId = number & { readonly [actionPlanningId]: never } diff --git a/src/components/container/projects/create-project-schema.ts b/src/components/container/projects/create-project-schema.ts index 21537dda..80a61f87 100644 --- a/src/components/container/projects/create-project-schema.ts +++ b/src/components/container/projects/create-project-schema.ts @@ -1,13 +1,22 @@ import { createSlug } from 'services/util/slug/slug-service' import { z } from 'zod' +import { type LanguageCode, availableLanguages } from '../language/languages' +// TODO: move to a shared and merge with the one in services project.ts export const baseCreateProjectSchema = z.object({ name: z .string({ required_error: 'Project name is required' }) .min(1, 'Project name must have at least one character'), - base_language_code: z - .string({ required_error: 'Base language is required' }) - .min(1, 'Base language must have at least one character') + base_language: z + .enum(Object.keys(availableLanguages) as [LanguageCode, ...LanguageCode[]], { + required_error: 'Base language is required' + }) + .default('en'), + base_language_label: z + .enum(Object.values(availableLanguages) as [string, ...string[]], { + required_error: 'Base language label is required' + }) + .default('English') }) export const createProjectSchema = baseCreateProjectSchema.refine( diff --git a/src/components/container/projects/create-project.svelte b/src/components/container/projects/create-project.svelte index 5eba181d..14643092 100644 --- a/src/components/container/projects/create-project.svelte +++ b/src/components/container/projects/create-project.svelte @@ -8,6 +8,7 @@ import * as Form from '$components/ui/form' import { page } from '$app/stores' import { toast } from 'svelte-sonner' + import LanguageSelect from '../language/LanguageSelect.svelte' import SlugDisplay from './slug-display.svelte' import { debounce } from '$lib/utils/debounce' @@ -95,14 +96,14 @@ - + Base Language - diff --git a/src/components/layout/dialog/ConfirmationDialog.svelte b/src/components/layout/dialog/ConfirmationDialog.svelte new file mode 100644 index 00000000..0111c312 --- /dev/null +++ b/src/components/layout/dialog/ConfirmationDialog.svelte @@ -0,0 +1,53 @@ + + + + + + + + {title} + + {description} + + + + + + + + + diff --git a/src/components/layout/main-content/header.svelte b/src/components/layout/main-content/header.svelte index 5f2bdf16..d70696c6 100644 --- a/src/components/layout/main-content/header.svelte +++ b/src/components/layout/main-content/header.svelte @@ -3,6 +3,9 @@

-

{title}

+
+

{title}

+ +
diff --git a/src/components/ui/button/index.ts b/src/components/ui/button/index.ts index e9a19720..23496c4e 100644 --- a/src/components/ui/button/index.ts +++ b/src/components/ui/button/index.ts @@ -44,5 +44,6 @@ export { Root as Button, type Props as ButtonProps, type Events as ButtonEvents, + type Variant as ButtonVariants, buttonVariants } diff --git a/src/components/ui/select/index.ts b/src/components/ui/select/index.ts new file mode 100644 index 00000000..fde69679 --- /dev/null +++ b/src/components/ui/select/index.ts @@ -0,0 +1,33 @@ +import { Select as SelectPrimitive } from 'bits-ui' +import Label from './select-label.svelte' +import Item from './select-item.svelte' +import Content from './select-content.svelte' +import Trigger from './select-trigger.svelte' +import Separator from './select-separator.svelte' + +const Root = SelectPrimitive.Root +const Group = SelectPrimitive.Group +const Input = SelectPrimitive.Input +const Value = SelectPrimitive.Value + +export { + Root, + Group, + Input, + Label, + Item, + Value, + Content, + Trigger, + Separator, + // + Root as Select, + Group as SelectGroup, + Input as SelectInput, + Label as SelectLabel, + Item as SelectItem, + Value as SelectValue, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator +} diff --git a/src/components/ui/select/select-content.svelte b/src/components/ui/select/select-content.svelte new file mode 100644 index 00000000..66fcbb9c --- /dev/null +++ b/src/components/ui/select/select-content.svelte @@ -0,0 +1,39 @@ + + + +
+ +
+
diff --git a/src/components/ui/select/select-item.svelte b/src/components/ui/select/select-item.svelte new file mode 100644 index 00000000..e2289585 --- /dev/null +++ b/src/components/ui/select/select-item.svelte @@ -0,0 +1,40 @@ + + + + + + + + + + {label || value} + + diff --git a/src/components/ui/select/select-label.svelte b/src/components/ui/select/select-label.svelte new file mode 100644 index 00000000..4d56d025 --- /dev/null +++ b/src/components/ui/select/select-label.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/src/components/ui/select/select-separator.svelte b/src/components/ui/select/select-separator.svelte new file mode 100644 index 00000000..15c2906b --- /dev/null +++ b/src/components/ui/select/select-separator.svelte @@ -0,0 +1,11 @@ + + + diff --git a/src/components/ui/select/select-trigger.svelte b/src/components/ui/select/select-trigger.svelte new file mode 100644 index 00000000..4d0e3ce1 --- /dev/null +++ b/src/components/ui/select/select-trigger.svelte @@ -0,0 +1,27 @@ + + +span]:line-clamp-1 data-[placeholder]:[&>span]:text-muted-foreground', + className + )} + {...$$restProps} + let:builder + on:click + on:keydown +> + +
+ +
+
diff --git a/src/components/ui/table/index.ts b/src/components/ui/table/index.ts new file mode 100644 index 00000000..66026d30 --- /dev/null +++ b/src/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Root from './table.svelte' +import Body from './table-body.svelte' +import Caption from './table-caption.svelte' +import Cell from './table-cell.svelte' +import Footer from './table-footer.svelte' +import Head from './table-head.svelte' +import Header from './table-header.svelte' +import Row from './table-row.svelte' + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow +} diff --git a/src/components/ui/table/table-body.svelte b/src/components/ui/table/table-body.svelte new file mode 100644 index 00000000..813f72bb --- /dev/null +++ b/src/components/ui/table/table-body.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/src/components/ui/table/table-caption.svelte b/src/components/ui/table/table-caption.svelte new file mode 100644 index 00000000..ded334d5 --- /dev/null +++ b/src/components/ui/table/table-caption.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/src/components/ui/table/table-cell.svelte b/src/components/ui/table/table-cell.svelte new file mode 100644 index 00000000..7a008df8 --- /dev/null +++ b/src/components/ui/table/table-cell.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/src/components/ui/table/table-footer.svelte b/src/components/ui/table/table-footer.svelte new file mode 100644 index 00000000..c5a43038 --- /dev/null +++ b/src/components/ui/table/table-footer.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/src/components/ui/table/table-head.svelte b/src/components/ui/table/table-head.svelte new file mode 100644 index 00000000..d0bc2160 --- /dev/null +++ b/src/components/ui/table/table-head.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/src/components/ui/table/table-header.svelte b/src/components/ui/table/table-header.svelte new file mode 100644 index 00000000..3aa50402 --- /dev/null +++ b/src/components/ui/table/table-header.svelte @@ -0,0 +1,14 @@ + + + + + + diff --git a/src/components/ui/table/table-row.svelte b/src/components/ui/table/table-row.svelte new file mode 100644 index 00000000..9f4d8b0c --- /dev/null +++ b/src/components/ui/table/table-row.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/src/components/ui/table/table.svelte b/src/components/ui/table/table.svelte new file mode 100644 index 00000000..08ef9175 --- /dev/null +++ b/src/components/ui/table/table.svelte @@ -0,0 +1,15 @@ + + +
+ + +
+
diff --git a/src/lib/models/language.model.ts b/src/lib/models/language.model.ts new file mode 100644 index 00000000..3a4bb6c7 --- /dev/null +++ b/src/lib/models/language.model.ts @@ -0,0 +1,8 @@ +// TODO pull into models +import type { LanguageCode } from '$components/container/language/languages' + +export type Language = { + code: LanguageCode + label: string + fallback?: LanguageCode +} diff --git a/src/routes/(authenticated)/projects/+page.server.ts b/src/routes/(authenticated)/projects/+page.server.ts index da4a4daf..745f93ec 100644 --- a/src/routes/(authenticated)/projects/+page.server.ts +++ b/src/routes/(authenticated)/projects/+page.server.ts @@ -3,6 +3,7 @@ import { message, setError, superValidate } from 'sveltekit-superforms' import { zod } from 'sveltekit-superforms/adapters' import { baseCreateProjectSchema, + //TODO: shared schema createProjectSchema } from '$components/container/projects/create-project-schema' import { diff --git a/src/routes/(authenticated)/projects/[slug]/+layout.server.ts b/src/routes/(authenticated)/projects/[slug]/+layout.server.ts new file mode 100644 index 00000000..d0d99832 --- /dev/null +++ b/src/routes/(authenticated)/projects/[slug]/+layout.server.ts @@ -0,0 +1,8 @@ +import type { LayoutServerLoad } from './$types' +import { getProjectBySlug } from 'services/project/project-service' + +export const load: LayoutServerLoad = async ({ params }) => { + return { + project: await getProjectBySlug(params.slug) + } +} diff --git a/src/routes/(authenticated)/projects/[slug]/+layout.ts b/src/routes/(authenticated)/projects/[slug]/+layout.ts index 8c2e6c5b..061e16d9 100644 --- a/src/routes/(authenticated)/projects/[slug]/+layout.ts +++ b/src/routes/(authenticated)/projects/[slug]/+layout.ts @@ -5,7 +5,7 @@ import Languages from 'lucide-svelte/icons/languages' import Settings from 'lucide-svelte/icons/settings' import type { LayoutLoad } from './$types' -export const load: LayoutLoad = () => { +export const load: LayoutLoad = ({ data }) => { const sidebarElements = [ { name: 'My Projects', @@ -35,6 +35,7 @@ export const load: LayoutLoad = () => { ] return { + ...data, sidebarElements } } diff --git a/src/routes/(authenticated)/projects/[slug]/languages/+page.server.ts b/src/routes/(authenticated)/projects/[slug]/languages/+page.server.ts new file mode 100644 index 00000000..23aed559 --- /dev/null +++ b/src/routes/(authenticated)/projects/[slug]/languages/+page.server.ts @@ -0,0 +1,49 @@ +import { + deleteLanguage, + getBaseLanguageForProject, + getLanguagesForProject, + upsertLanguagesForProject +} from 'services/language/language-service' +import type { Actions, PageServerLoad } from './$types' +import { superValidate } from 'sveltekit-superforms/server' +import { languagesSchema } from '$components/container/language/schema' +import { zod } from 'sveltekit-superforms/adapters' +import { fail } from '@sveltejs/kit' + +export const load: PageServerLoad = async ({ params }) => { + const languages = await getLanguagesForProject(params.slug) + const baseLanguage = await getBaseLanguageForProject(params.slug) + + return { + form: await superValidate({ languages }, zod(languagesSchema)), + baseLanguage + } +} + +export const actions: Actions = { + upsert: async ({ request, params }) => { + const form = await superValidate(request, zod(languagesSchema)) + + if (!form.valid) return fail(400, { form }) + + try { + form.data.languages = await upsertLanguagesForProject(params.slug, form.data.languages) + + return { form } + } catch (error) { + console.error('Failed to update languages:', error) + + return fail(500, { form, error: 'Failed to update languages' }) + } + }, + delete: async ({ request, params, locals: { logger } }) => { + const data = await request.formData() + + const languageId = Number(data.get('deleteLanguage')) + if (isNaN(languageId)) return fail(400, { message: 'Invalid language to delete.' }) + + const languages = await deleteLanguage(params.slug, languageId, logger) + + return { form: await superValidate({ languages }, zod(languagesSchema)) } + } +} diff --git a/src/routes/(authenticated)/projects/[slug]/languages/+page.svelte b/src/routes/(authenticated)/projects/[slug]/languages/+page.svelte index eab42fea..72cab76f 100644 --- a/src/routes/(authenticated)/projects/[slug]/languages/+page.svelte +++ b/src/routes/(authenticated)/projects/[slug]/languages/+page.svelte @@ -1 +1,88 @@ -languages + + + +
+ +
+ + + Save + +
+
+ +
+
+ +
+ +
+ + + +