diff --git a/src/backend/configure.ts b/src/backend/configure.ts index 55d74ca0..503f2c4d 100644 --- a/src/backend/configure.ts +++ b/src/backend/configure.ts @@ -164,6 +164,7 @@ export async function configure(command: Configure) { await codemods.makeUsingStub(stubsRoot, 'tests/unit/stream_service.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/unit/user_service.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/unit/progress_service.stub', {}); + await codemods.makeUsingStub(stubsRoot, 'tests/unit/language_service.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/unit/model.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/helpers/cms_mock.stub', {}); await codemods.makeUsingStub(stubsRoot, 'tests/helpers/story_test_helper.stub', {}); diff --git a/src/backend/index.ts b/src/backend/index.ts index 270a6026..dd26af89 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -40,6 +40,7 @@ export * from './services/draft_service.js'; export * from './services/index_service.js'; export * from './services/page_service.js'; export * from './services/progress_service.js'; +export * from './services/language_service.js'; export * from './services/ui_service.js'; export * from './services/user_service.js'; export * from './services/stream_service.js'; diff --git a/src/backend/services/language_service.ts b/src/backend/services/language_service.ts new file mode 100644 index 00000000..e3a81b33 --- /dev/null +++ b/src/backend/services/language_service.ts @@ -0,0 +1,218 @@ +import { inject } from '@adonisjs/core'; +import { + type LanguageSpecification, + type LanguageTableItem, + type LanguagesEditProps, + type SettingsPageProps, + type SupportCode, + type UserInterface, +} from '../../types.js'; +import User from '../models/user.js'; +import { CmsService } from './cms_service.js'; +import { ProgressService } from './progress_service.js'; + +export interface SupportRequestLanguageSpec { + name: string; + nativeName: string; + locale: string; +} + +export interface SupportRequestDetails { + subject: string; + details: string; + language?: SupportRequestLanguageSpec; +} + +interface SupportCodeDefinition { + code: SupportCode; + description: string; + subject: string; +} + +const SUPPORT_CODES = { + REMOVE_LANGUAGE: { + code: 'REMOVE_LANGUAGE', + subject: 'Remove language', + description: 'Language requested to be removed', + }, + UPDATE_LANGUAGE: { + code: 'UPDATE_LANGUAGE', + subject: 'App update - new language added.', + description: 'Language requested to be added', + }, + UPDATE_CONTENT: { + code: 'UPDATE_CONTENT', + subject: 'App update - content added.', + description: 'Content requested to be updated', + }, + UPDATE_APP: { + code: 'UPDATE_APP', + subject: 'App update - new language and content.', + description: 'App update requested for new language and new content', + }, +} as const satisfies Record; + +const defaultTranslationProgress = [ + { name: 'Interface', done: 0, draft: 0, total: 0 }, + { name: 'Content', done: 0, draft: 0, total: 0 }, +]; + +@inject() +export class LanguageService { + protected sourceLocale: string; + + constructor(protected cms: CmsService) { + this.sourceLocale = cms.sourceLocale; + } + + public find(locale: string): LanguageSpecification | undefined { + return this.cms.config.languages.find( + (lang: LanguageSpecification) => lang.locale === locale, + ); + } + + public languagesEdit(): LanguagesEditProps { + return { + addedLanguages: this.cms.config.languages, + }; + } + + public async settingsIndex( + user: UserInterface, + ): Promise> { + const sourceLanguageSpec = + this.cms.config.languages.find( + (lang: LanguageSpecification) => lang.locale === this.sourceLocale, + ) ?? this.cms.config.languages[0]; + + const progressService = new ProgressService(this.cms); + const progressItems = await progressService.progress(user); + const translationProgressByLocale = Object.fromEntries( + (progressItems ?? []).map((item) => [item.locale, item.progress]), + ); + + const users = await User.query().where('name', '!=', 'redacted'); + + const sourceLanguage = this.toLanguageTableItem( + sourceLanguageSpec, + translationProgressByLocale, + users, + ); + + const languageItems = this.cms.config.languages + .filter( + (language: LanguageSpecification) => language.locale !== sourceLanguage.locale, + ) + .map((language: LanguageSpecification) => + this.toLanguageTableItem(language, translationProgressByLocale, users), + ); + + return { sourceLanguage, languageItems }; + } + + public async addLanguages(languages: LanguageSpecification[]): Promise { + const existingLocales = new Set( + this.cms.config.languages.map((lang: LanguageSpecification) => lang.locale), + ); + const toAdd = languages.filter((lang) => !existingLocales.has(lang.locale)); + + if (toAdd.length > 0) { + await this.save([...this.cms.config.languages, ...toAdd]); + } + } + + public async updateBibleTranslation( + locale: string, + bibleVersion: string, + bibleLabel: string, + ): Promise { + if (!this.find(locale)) { + throw new Error('Language not found'); + } + + const languages = this.cms.config.languages.map((lang: LanguageSpecification) => + lang.locale === locale ? { ...lang, bibleVersion, bibleLabel } : lang, + ); + + await this.save(languages); + } + + public async removeLanguage(locale: string): Promise { + if (locale === this.sourceLocale) { + throw new Error('Cannot remove the source language'); + } + + if (!this.find(locale)) { + throw new Error('Language not found'); + } + + const languages = this.cms.config.languages.filter( + (lang: LanguageSpecification) => lang.locale !== locale, + ); + await this.save(languages); + } + + public getSupportRequestDetails( + supportCode: SupportCode, + removeLanguageCode?: string, + ): SupportRequestDetails { + const definition = this.supportCodeDefinition(supportCode); + if (!definition) { + throw new Error('Invalid support code'); + } + + let language: SupportRequestLanguageSpec | undefined; + if (supportCode === 'REMOVE_LANGUAGE' && removeLanguageCode) { + const languageSpec = this.find(removeLanguageCode); + if (languageSpec) { + language = this.parseLanguageForSupport(languageSpec); + } + } + + return { + subject: `Support request: ${definition.subject}`, + details: definition.description, + language, + }; + } + + private toLanguageTableItem( + spec: LanguageSpecification, + translationProgressByLocale: Record, + users: InstanceType[], + ): LanguageTableItem { + return { + language: spec.language, + languageDirection: spec.languageDirection, + locale: spec.locale, + bibleLabel: spec.bibleLabel, + bibleVersion: spec.bibleVersion, + translationProgress: + translationProgressByLocale[spec.locale] ?? defaultTranslationProgress, + teamMembers: users + .filter((user) => user.language === spec.locale) + .map((user) => user.meta), + }; + } + + private parseLanguageForSupport( + spec: LanguageSpecification, + ): SupportRequestLanguageSpec { + const { language, locale } = spec; + const name = language.split('|')[0].trim(); + + if (language.includes('|')) { + return { name, nativeName: language.split('|')[1].trim(), locale }; + } + + return { name, nativeName: language, locale }; + } + + private supportCodeDefinition(code: string): SupportCodeDefinition | undefined { + return Object.values(SUPPORT_CODES).find((definition) => definition.code === code); + } + + private async save(languages: LanguageSpecification[]) { + await this.cms.patchConfig({ languages }); + } +} diff --git a/src/backend/stubs/controllers/settings_controller.stub b/src/backend/stubs/controllers/settings_controller.stub index e223e0a4..1319a5cf 100644 --- a/src/backend/stubs/controllers/settings_controller.stub +++ b/src/backend/stubs/controllers/settings_controller.stub @@ -4,11 +4,8 @@ import type { HttpContext } from '@adonisjs/core/http'; import mail from '@adonisjs/mail/services/main'; import { - User, - ProgressService, + LanguageService, type LanguageSpecification, - type LanguageTableItem, - type LanguagesEditProps, type SettingsPageProps, type UserInterface, } from '@story-cms/kit'; @@ -16,101 +13,15 @@ import { import cms from '#services/cms'; import providers from '#config/providers'; -interface SupportRequestLanguageSpec { - name: string; - nativeName: string; - locale: string; -} - -interface SupportCodeDefinition { - code: string; - description: string; - subject: string; -} - -const SUPPORT_CODES = { - REMOVE_LANGUAGE: { - code: 'REMOVE_LANGUAGE', - subject: 'Remove language', - description: 'Language requested to be removed', - }, - UPDATE_LANGUAGE: { - code: 'UPDATE_LANGUAGE', - subject: 'App update - new language added.', - description: 'Language requested to be added', - }, - UPDATE_CONTENT: { - code: 'UPDATE_CONTENT', - subject: 'App update - content added.', - description: 'Content requested to be updated', - }, - UPDATE_APP: { - code: 'UPDATE_APP', - subject: 'App update - new language and content.', - description: 'App update requested for new language and new content', - }, -} as const satisfies Record; - -type SupportCode = (typeof SUPPORT_CODES)[keyof typeof SUPPORT_CODES]['code']; - -function languageDisplayName(language: string): string { - return language.split('|')[0].trim(); -} - -function parseLanguageForSupport( - spec: LanguageSpecification, -): SupportRequestLanguageSpec { - const { language, locale } = spec; - const name = languageDisplayName(language); - - if (language.includes('|')) { - const nativeName = language.split('|')[1].trim(); - return { name, nativeName, locale }; - } - - return { name, nativeName: language, locale }; -} - -function supportCodeDefinition(code: string): SupportCodeDefinition | undefined { - return Object.values(SUPPORT_CODES).find((definition) => definition.code === code); -} - export default class SettingsController { public async index(ctx: HttpContext) { cms.localeFromPath(ctx); - const sourceLanguage = - cms.config.languages.find( - (lang: LanguageSpecification) => lang.locale === cms.sourceLocale, - ) ?? cms.config.languages[0]; - - const progressService = new ProgressService(cms); - const progressItems = await progressService.progress(ctx.auth.user! as UserInterface); - const translationProgressByLocale = Object.fromEntries( - (progressItems ?? []).map((item) => [item.locale, item.progress]), + const languageService = new LanguageService(cms); + const { sourceLanguage, languageItems } = await languageService.settingsIndex( + ctx.auth.user! as UserInterface, ); - const users = await User.query().where('name', '!=', 'redacted'); - - const languageItems: LanguageTableItem[] = cms.config.languages - .filter( - (language: LanguageSpecification) => language.locale !== sourceLanguage.locale, - ) - .map((language: LanguageSpecification) => ({ - language: language.language, - languageDirection: language.languageDirection, - locale: language.locale, - bibleLabel: language.bibleLabel, - bibleVersion: language.bibleVersion, - translationProgress: translationProgressByLocale[language.locale] ?? [ - { name: 'Interface', done: 0, draft: 0, total: 0 }, - { name: 'Content', done: 0, draft: 0, total: 0 }, - ], - teamMembers: users - .filter((user) => user.language === language.locale) - .map((user) => user.meta), - })); - const props: SettingsPageProps = { sourceLanguage, languageItems, @@ -125,9 +36,7 @@ export default class SettingsController { public async editLanguage(ctx: HttpContext) { cms.localeFromPath(ctx); - const props: LanguagesEditProps = { - addedLanguages: cms.config.languages, - }; + const props = new LanguageService(cms).languagesEdit(); // @ts-expect-error Inertia page name return ctx.inertia.render('LanguagesEdit', props); @@ -137,16 +46,7 @@ export default class SettingsController { cms.localeFromPath(ctx); const payload = ctx.request.all() as { languages?: LanguageSpecification[] }; - const existingLocales = new Set( - cms.config.languages.map((lang: LanguageSpecification) => lang.locale), - ); - const toAdd = (payload.languages ?? []).filter( - (lang: LanguageSpecification) => !existingLocales.has(lang.locale), - ); - - if (toAdd.length > 0) { - await cms.patchConfig({ languages: [...cms.config.languages, ...toAdd] }); - } + await new LanguageService(cms).addLanguages(payload.languages ?? []); return ctx.response.redirect().back(); } @@ -154,52 +54,43 @@ export default class SettingsController { public async updateBibleTranslation(ctx: HttpContext) { cms.localeFromPath(ctx); - const languageLocale = ctx.params.languageLocale; const { bibleVersion, bibleVersionName } = ctx.request.only([ 'bibleVersion', 'bibleVersionName', ]); - const language = cms.config.languages.find( - (lang: LanguageSpecification) => lang.locale === languageLocale, - ); - if (!language) { - return ctx.response.notFound({ error: 'Language not found' }); + try { + await new LanguageService(cms).updateBibleTranslation( + ctx.params.languageLocale, + bibleVersion, + bibleVersionName, + ); + } catch (error) { + if ((error as Error).message === 'Language not found') { + return ctx.response.notFound({ error: 'Language not found' }); + } + throw error; } - const languages = cms.config.languages.map((lang: LanguageSpecification) => - lang.locale === languageLocale - ? { ...lang, bibleVersion, bibleLabel: bibleVersionName } - : lang, - ); - - await cms.patchConfig({ languages }); - return ctx.response.redirect().back(); } public async removeLanguage(ctx: HttpContext) { cms.localeFromPath(ctx); - const languageLocale = ctx.params.languageLocale; - - if (languageLocale === cms.sourceLocale) { - return ctx.response.badRequest({ error: 'Cannot remove the source language' }); - } - - const language = cms.config.languages.find( - (lang: LanguageSpecification) => lang.locale === languageLocale, - ); - if (!language) { - return ctx.response.notFound({ error: 'Language not found' }); + try { + await new LanguageService(cms).removeLanguage(ctx.params.languageLocale); + } catch (error) { + const message = (error as Error).message; + if (message === 'Cannot remove the source language') { + return ctx.response.badRequest({ error: message }); + } + if (message === 'Language not found') { + return ctx.response.notFound({ error: message }); + } + throw error; } - const languages = cms.config.languages.filter( - (lang: LanguageSpecification) => lang.locale !== languageLocale, - ); - - await cms.patchConfig({ languages }); - return ctx.response.redirect().back(); } @@ -209,43 +100,38 @@ export default class SettingsController { const { supportCode, removeLanguageCode } = ctx.request.only([ 'supportCode', 'removeLanguageCode', - ]) as { - supportCode?: SupportCode; - removeLanguageCode?: string; - }; + ]); - const definition = supportCode ? supportCodeDefinition(supportCode) : undefined; - if (!definition) { + if (!supportCode) { return ctx.response.badRequest({ error: 'Invalid support code' }); } - let language: SupportRequestLanguageSpec | undefined; - if (supportCode === 'REMOVE_LANGUAGE' && removeLanguageCode) { - const languageSpec = cms.config.languages.find( - (lang: LanguageSpecification) => lang.locale === removeLanguageCode, - ); - if (languageSpec) { - language = parseLanguageForSupport(languageSpec); + try { + const { subject, details, language } = new LanguageService( + cms, + ).getSupportRequestDetails(supportCode, removeLanguageCode); + + await mail.send((message) => { + message + .to(cms.config.supportEmail, 'Journey Studio Support') + .subject(subject) + .htmlView('emails/support_request', { + appName: cms.config.name, + requester: ctx.auth.user?.name ?? 'Unknown', + subject, + supportCode, + details, + language, + logo: cms.config.logo, + }); + }); + } catch (error) { + if ((error as Error).message === 'Invalid support code') { + return ctx.response.badRequest({ error: 'Invalid support code' }); } + throw error; } - const subject = {{ '`Support request: ${definition.subject}`' }}; - - await mail.send((message) => { - message - .to(cms.config.supportEmail, 'Journey Studio Support') - .subject(subject) - .htmlView('emails/support_request', { - appName: cms.config.name, - requester: ctx.auth.user?.name ?? 'Unknown', - subject, - supportCode, - details: definition.description, - language, - logo: cms.config.logo, - }); - }); - return ctx.response.redirect().back(); } } diff --git a/src/backend/stubs/tests/unit/language_service.stub b/src/backend/stubs/tests/unit/language_service.stub new file mode 100644 index 00000000..32376855 --- /dev/null +++ b/src/backend/stubs/tests/unit/language_service.stub @@ -0,0 +1,168 @@ +{{{ + exports({ to: app.makePath('tests/unit/language_service.spec.ts') }) +}}} +import testUtils from '@adonisjs/core/services/test_utils'; +import { test } from '@japa/runner'; +import { + Config, + User, + LanguageService, + type CmsConfig, + type LanguageSpecification, +} from '@story-cms/kit'; +import cms from '#services/cms'; +import { setupMockCms, testCmsConfig } from '#tests/helpers/cms_mock'; + +test.group('LanguageService', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()); + + const createConfigRecord = async () => { + return Config.create({ + key: 'cms', + version: 1, + data: structuredClone(testCmsConfig) as unknown as Record, + }); + }; + + test('addLanguages skips duplicate locales', async ({ assert }) => { + await createConfigRecord(); + setupMockCms(testCmsConfig); + + const service = new LanguageService(cms); + await service.addLanguages([ + { locale: 'es', language: 'Spanish', languageDirection: 'ltr' }, + { locale: 'fr', language: 'French', languageDirection: 'ltr' }, + ]); + + assert.lengthOf(cms.config.languages, 4); + assert.isTrue( + cms.config.languages.some((lang: LanguageSpecification) => lang.locale === 'fr'), + ); + assert.equal( + cms.config.languages.filter((lang: LanguageSpecification) => lang.locale === 'es') + .length, + 1, + ); + }); + + test('updateBibleTranslation updates one language and leaves others untouched', async ({ + assert, + }) => { + const config = await createConfigRecord(); + setupMockCms(testCmsConfig); + + const service = new LanguageService(cms); + await service.updateBibleTranslation('es', 'new-es-version', 'Nueva Biblia'); + + await config.refresh(); + const data = config.data as CmsConfig; + + const spanish = data.languages.find((lang) => lang.locale === 'es'); + assert.equal(spanish?.bibleVersion, 'new-es-version'); + assert.equal(spanish?.bibleLabel, 'Nueva Biblia'); + + const english = data.languages.find((lang) => lang.locale === 'en'); + assert.equal(english?.bibleVersion, 'test-bible-version'); + }); + + test('updateBibleTranslation throws for unknown locale', async ({ assert }) => { + await createConfigRecord(); + setupMockCms(testCmsConfig); + + const service = new LanguageService(cms); + + await assert.rejects( + () => service.updateBibleTranslation('xx', 'some-version', 'Some Version'), + /Language not found/, + ); + }); + + test('removeLanguage rejects source locale', async ({ assert }) => { + await createConfigRecord(); + setupMockCms(testCmsConfig); + + const service = new LanguageService(cms); + + await assert.rejects( + () => service.removeLanguage('en'), + /Cannot remove the source language/, + ); + assert.lengthOf(cms.config.languages, 3); + }); + + test('removeLanguage throws for unknown locale', async ({ assert }) => { + await createConfigRecord(); + setupMockCms(testCmsConfig); + + const service = new LanguageService(cms); + + await assert.rejects(() => service.removeLanguage('xx'), /Language not found/); + assert.lengthOf(cms.config.languages, 3); + }); + + test('removeLanguage deletes a non-source language', async ({ assert }) => { + const config = await createConfigRecord(); + setupMockCms(testCmsConfig); + + const service = new LanguageService(cms); + await service.removeLanguage('es'); + + await config.refresh(); + const data = config.data as CmsConfig; + assert.isUndefined(data.languages.find((lang) => lang.locale === 'es')); + }); + + test('settingsIndex returns source language and non-source items', async ({ + assert, + }) => { + await createConfigRecord(); + setupMockCms(testCmsConfig); + + await User.create({ + name: 'Spanish Editor', + email: 'es@test.com', + password: 'password', + language: 'es', + }); + + const service = new LanguageService(cms); + const user = await User.create({ + name: 'Admin', + email: 'admin@test.com', + password: 'password', + language: '*', + }); + + const { sourceLanguage, languageItems } = await service.settingsIndex(user); + + assert.equal(sourceLanguage.locale, 'en'); + assert.lengthOf(languageItems, 2); + assert.isTrue(languageItems.every((item) => item.locale !== 'en')); + + const spanish = languageItems.find((item) => item.locale === 'es'); + assert.isDefined(spanish); + assert.lengthOf(spanish!.teamMembers ?? [], 1); + assert.equal(spanish!.teamMembers![0].name, 'Spanish Editor'); + }); + + test('languagesEdit returns configured languages', async ({ assert }) => { + setupMockCms(testCmsConfig); + + const service = new LanguageService(cms); + const props = service.languagesEdit(); + + assert.lengthOf(props.addedLanguages, 3); + assert.equal(props.addedLanguages[0].locale, 'en'); + }); + + test('getSupportRequestDetails throws for unknown support code', async ({ assert }) => { + setupMockCms(testCmsConfig); + + const service = new LanguageService(cms); + + await assert.rejects( + () => service.getSupportRequestDetails('UNKNOWN_CODE' as 'REMOVE_LANGUAGE'), + /Invalid support code/, + ); + }); +}); diff --git a/src/frontend/settings/languages/components/language-table-row.vue b/src/frontend/settings/languages/components/language-table-row.vue new file mode 100644 index 00000000..2b2b8b0d --- /dev/null +++ b/src/frontend/settings/languages/components/language-table-row.vue @@ -0,0 +1,115 @@ + + + diff --git a/src/frontend/settings/languages/components/language-table.story.vue b/src/frontend/settings/languages/components/language-table.story.vue index 83d576c3..577668eb 100644 --- a/src/frontend/settings/languages/components/language-table.story.vue +++ b/src/frontend/settings/languages/components/language-table.story.vue @@ -3,16 +3,18 @@ - + import LangTable from './language-table.vue'; -import { languageTableItems, manyLanguageTableItems } from '../../../test/mocks'; +import { + languageTableItems, + manyLanguageTableItems, + sourceLanguage, +} from '../../../test/mocks'; import type { LanguageTableItem } from '../../../../types'; const onRemove = (item: LanguageTableItem) => { diff --git a/src/frontend/settings/languages/components/language-table.vue b/src/frontend/settings/languages/components/language-table.vue index 601699b8..4c0733e6 100644 --- a/src/frontend/settings/languages/components/language-table.vue +++ b/src/frontend/settings/languages/components/language-table.vue @@ -6,123 +6,57 @@

- - +
+ + + + + + + + - - - - - - - - - + +
Active translations Translation progress Team members Bible translation + Actions
- - - - -
- -
-
-

No team members yet

-

- Press the three dots to
- assign team members. -

-
-
- {{ truncate(item.bibleLabel, 30) }} - - - - - Actions for {{ item.language }} - - - - - - Assign team members - - - - - - - - - - - -
@@ -146,9 +80,9 @@ /> @@ -157,21 +91,18 @@ diff --git a/src/frontend/settings/languages/components/source-language.story.vue b/src/frontend/settings/languages/components/source-language.story.vue deleted file mode 100644 index e90cf2ed..00000000 --- a/src/frontend/settings/languages/components/source-language.story.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/src/frontend/settings/languages/components/source-language.vue b/src/frontend/settings/languages/components/source-language.vue deleted file mode 100644 index 7a69858a..00000000 --- a/src/frontend/settings/languages/components/source-language.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - diff --git a/src/frontend/settings/languages/languages-edit.story.vue b/src/frontend/settings/languages/languages-edit.story.vue index 67a6a9e8..f465fe0f 100644 --- a/src/frontend/settings/languages/languages-edit.story.vue +++ b/src/frontend/settings/languages/languages-edit.story.vue @@ -2,15 +2,11 @@ @@ -19,7 +15,7 @@ diff --git a/src/frontend/shared/helpers.ts b/src/frontend/shared/helpers.ts index c737599c..a6eca5ca 100644 --- a/src/frontend/shared/helpers.ts +++ b/src/frontend/shared/helpers.ts @@ -1,8 +1,11 @@ import type { App, PropType } from 'vue'; -import type { FieldSpec, LanguageSpecification } from '../../types'; +import { router } from '@inertiajs/vue3'; +import type { RequestPayload } from '@inertiajs/core'; +import { ResponseStatus, type FieldSpec, type LanguageSpecification } from '../../types'; import { BibleBooksMap } from './bibleBooks'; import type { Variant, Story } from 'histoire'; import { DateTime } from 'luxon'; +import { useSharedStore } from '../store'; export const commonProps = { field: { @@ -305,9 +308,7 @@ export function compareLanguagesByDisplayName( a: LanguageSortable, b: LanguageSortable, ): number { - return languageDisplayName(a.language).localeCompare( - languageDisplayName(b.language), - ); + return languageDisplayName(a.language).localeCompare(languageDisplayName(b.language)); } export function sortLanguagesByDisplayName( @@ -349,3 +350,40 @@ export function replaceLocaleInPath( segments[0] = targetLocale; return `/${segments.join('/')}`; } + +type PostOptions = { + onSuccess?: () => void; + onError?: () => void; + successMessage?: string; + successDetail?: string; + failureMessage?: string; +}; + +export const postWithPayload = ( + url: string, + payload: Record | object = {}, + options: PostOptions = {}, +) => { + const shared = useSharedStore(); + + router.post(url, payload as RequestPayload, { + preserveScroll: true, + onSuccess: () => { + if (options.successMessage) { + shared.addMessage( + ResponseStatus.Confirmation, + options.successMessage, + options.successDetail, + ); + } + options.onSuccess?.(); + }, + onError: (errors) => { + shared.setErrors(errors); + if (options.failureMessage) { + shared.addMessage(ResponseStatus.Failure, options.failureMessage); + } + options.onError?.(); + }, + }); +}; diff --git a/src/frontend/test/mocks.ts b/src/frontend/test/mocks.ts index eedce499..5b9c200e 100644 --- a/src/frontend/test/mocks.ts +++ b/src/frontend/test/mocks.ts @@ -1059,12 +1059,42 @@ export const languageTableItems: LanguageTableItem[] = [ }, ]; -export const sourceLanguage: LanguageSpecification = { +export const sourceLanguage: LanguageTableItem = { language: 'English | American', locale: 'en', languageDirection: 'ltr', bibleVersion: 'de4e12af7f28f599-01', bibleLabel: '(KJV) King James Version', + translationProgress: [ + { name: 'Interface', done: 100, draft: 0, total: 100 }, + { name: 'Content', done: 250, draft: 25, total: 300 }, + ], + teamMembers: [ + { + id: 1, + name: 'John Doe', + initials: 'JD', + email: 'john@example.com', + isManager: true, + isAdmin: true, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + { + id: 2, + name: 'Jane Smith', + initials: 'JS', + email: 'jane.smith@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], }; export const manyLanguageTableItems: LanguageTableItem[] = [ @@ -1105,6 +1135,681 @@ export const manyLanguageTableItems: LanguageTableItem[] = [ }, ], }, + { + language: 'Afrikaans', + languageDirection: 'ltr', + locale: 'af', + bibleLabel: 'Die Bybel', + translationProgress: [ + { name: 'Interface', done: 42, draft: 8, total: 100 }, + { name: 'Content', done: 120, draft: 15, total: 300 }, + ], + teamMembers: [ + { + id: 11, + name: 'Pieter van Wyk', + initials: 'PW', + email: 'pieter.vanwyk@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Amharic - አማርኛ', + languageDirection: 'ltr', + locale: 'am', + bibleLabel: 'Amharic Bible', + translationProgress: [ + { name: 'Interface', done: 15, draft: 5, total: 100 }, + { name: 'Content', done: 45, draft: 10, total: 200 }, + ], + teamMembers: [], + }, + { + language: 'Assamese - অসমীয়া', + languageDirection: 'ltr', + locale: 'as', + bibleLabel: 'Assamese Bible', + translationProgress: [ + { name: 'Interface', done: 60, draft: 0, total: 100 }, + { name: 'Content', done: 180, draft: 20, total: 300 }, + ], + teamMembers: [ + { + id: 12, + name: 'Ananya Das', + initials: 'AD', + email: 'ananya.das@example.com', + isManager: false, + isAdmin: false, + role: 'translator', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + { + id: 13, + name: 'Rohan Baruah', + initials: 'RB', + email: 'rohan.baruah@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: true, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Azerbaijani - Azərbaycan', + languageDirection: 'ltr', + locale: 'az', + bibleLabel: 'Azərbaycan Müqəddəs Kitabı', + translationProgress: [ + { name: 'Interface', done: 90, draft: 0, total: 100 }, + { name: 'Content', done: 270, draft: 0, total: 300 }, + ], + teamMembers: [ + { + id: 14, + name: 'Elvin Mammadov', + initials: 'EM', + email: 'elvin.mammadov@example.com', + isManager: true, + isAdmin: false, + role: 'admin', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Belarusian - Беларуская', + languageDirection: 'ltr', + locale: 'be', + bibleLabel: 'Біблія', + translationProgress: [ + { name: 'Interface', done: 30, draft: 12, total: 100 }, + { name: 'Content', done: 90, draft: 30, total: 250 }, + ], + teamMembers: [], + }, + { + language: 'Bulgarian - Български', + languageDirection: 'ltr', + locale: 'bg', + bibleLabel: 'Библия', + translationProgress: [ + { name: 'Interface', done: 75, draft: 5, total: 100 }, + { name: 'Content', done: 210, draft: 0, total: 300 }, + ], + teamMembers: [ + { + id: 15, + name: 'Ivana Petrova', + initials: 'IP', + email: 'ivana.petrova@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Tibetan - བོད་ཡིག', + languageDirection: 'ltr', + locale: 'bo', + bibleLabel: 'Tibetan Bible', + translationProgress: [ + { name: 'Interface', done: 5, draft: 2, total: 100 }, + { name: 'Content', done: 12, draft: 3, total: 150 }, + ], + teamMembers: [], + }, + { + language: 'Bosnian - Bosanski', + languageDirection: 'ltr', + locale: 'bs', + bibleLabel: 'Biblija', + translationProgress: [ + { name: 'Interface', done: 55, draft: 10, total: 100 }, + { name: 'Content', done: 165, draft: 15, total: 300 }, + ], + teamMembers: [ + { + id: 16, + name: 'Amira Hodžić', + initials: 'AH', + email: 'amira.hodzic@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Catalan Valencian - Català', + languageDirection: 'ltr', + locale: 'ca', + bibleLabel: 'Bíblia Catalana', + translationProgress: [ + { name: 'Interface', done: 100, draft: 0, total: 100 }, + { name: 'Content', done: 300, draft: 0, total: 300 }, + ], + teamMembers: [ + { + id: 17, + name: 'Marc Soler', + initials: 'MS', + email: 'marc.soler@example.com', + isManager: false, + isAdmin: true, + role: 'admin', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + { + id: 18, + name: 'Laia Ferrer', + initials: 'LF', + email: 'laia.ferrer@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Czech - Čeština', + languageDirection: 'ltr', + locale: 'cs', + bibleLabel: 'Bible kralická', + translationProgress: [ + { name: 'Interface', done: 68, draft: 7, total: 100 }, + { name: 'Content', done: 204, draft: 6, total: 300 }, + ], + teamMembers: [ + { + id: 19, + name: 'Tomáš Novák', + initials: 'TN', + email: 'tomas.novak@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Welsh - Cymraeg', + languageDirection: 'ltr', + locale: 'cy', + bibleLabel: 'Y Beibl Cymraeg', + translationProgress: [ + { name: 'Interface', done: 22, draft: 8, total: 100 }, + { name: 'Content', done: 66, draft: 12, total: 200 }, + ], + teamMembers: [], + }, + { + language: 'Danish - Dansk', + languageDirection: 'ltr', + locale: 'da', + bibleLabel: 'Bibelen på dansk', + translationProgress: [ + { name: 'Interface', done: 95, draft: 0, total: 100 }, + { name: 'Content', done: 285, draft: 5, total: 300 }, + ], + teamMembers: [ + { + id: 20, + name: 'Freja Andersen', + initials: 'FA', + email: 'freja.andersen@example.com', + isManager: false, + isAdmin: false, + role: 'translator', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Modern Greek - Ελληνικά', + languageDirection: 'ltr', + locale: 'el', + bibleLabel: 'Βίβλος', + translationProgress: [ + { name: 'Interface', done: 80, draft: 0, total: 100 }, + { name: 'Content', done: 240, draft: 0, total: 300 }, + ], + teamMembers: [ + { + id: 21, + name: 'Elena Papadopoulos', + initials: 'EP', + email: 'elena.papadopoulos@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + { + id: 22, + name: 'Nikos Georgiou', + initials: 'NG', + email: 'nikos.georgiou@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Estonian - Eesti', + languageDirection: 'ltr', + locale: 'et', + bibleLabel: 'Piibel', + translationProgress: [ + { name: 'Interface', done: 50, draft: 15, total: 100 }, + { name: 'Content', done: 150, draft: 25, total: 300 }, + ], + teamMembers: [], + }, + { + language: 'Basque - Euskara', + languageDirection: 'ltr', + locale: 'eu', + bibleLabel: 'Euskal Biblia', + translationProgress: [ + { name: 'Interface', done: 35, draft: 5, total: 100 }, + { name: 'Content', done: 105, draft: 10, total: 300 }, + ], + teamMembers: [ + { + id: 23, + name: 'Ane Iturrioz', + initials: 'AI', + email: 'ane.iturrioz@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Persian - فارسی', + languageDirection: 'rtl', + locale: 'fa', + bibleLabel: 'ترجمه فارسی', + translationProgress: [ + { name: 'Interface', done: 72, draft: 3, total: 100 }, + { name: 'Content', done: 216, draft: 9, total: 300 }, + ], + teamMembers: [ + { + id: 24, + name: 'Sara Rahmani', + initials: 'SR', + email: 'sara.rahmani@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Finnish - Suomi', + languageDirection: 'ltr', + locale: 'fi', + bibleLabel: 'Raamattu', + translationProgress: [ + { name: 'Interface', done: 100, draft: 0, total: 100 }, + { name: 'Content', done: 298, draft: 2, total: 300 }, + ], + teamMembers: [ + { + id: 25, + name: 'Mikko Virtanen', + initials: 'MV', + email: 'mikko.virtanen@example.com', + isManager: true, + isAdmin: false, + role: 'admin', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Filipino Pilipino - Pilipino', + languageDirection: 'ltr', + locale: 'fil', + bibleLabel: 'Ang Biblia', + translationProgress: [ + { name: 'Interface', done: 88, draft: 2, total: 100 }, + { name: 'Content', done: 264, draft: 6, total: 300 }, + ], + teamMembers: [ + { + id: 26, + name: 'Maria Santos', + initials: 'MS', + email: 'maria.santos@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + { + id: 27, + name: 'Jose Reyes', + initials: 'JR', + email: 'jose.reyes@example.com', + isManager: false, + isAdmin: false, + role: 'translator', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Swiss French - Français suisse', + languageDirection: 'ltr', + locale: 'fr-ch', + bibleLabel: 'Bible Segond 21', + translationProgress: [ + { name: 'Interface', done: 62, draft: 8, total: 100 }, + { name: 'Content', done: 186, draft: 14, total: 300 }, + ], + teamMembers: [], + }, + { + language: 'Irish - Gaeilge', + languageDirection: 'ltr', + locale: 'ga', + bibleLabel: 'An Bíobla', + translationProgress: [ + { name: 'Interface', done: 18, draft: 6, total: 100 }, + { name: 'Content', done: 54, draft: 8, total: 200 }, + ], + teamMembers: [ + { + id: 28, + name: 'Siobhán Murphy', + initials: 'SM', + email: 'siobhan.murphy@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Galician - Galego', + languageDirection: 'ltr', + locale: 'gl', + bibleLabel: 'Biblia Galega', + translationProgress: [ + { name: 'Interface', done: 45, draft: 10, total: 100 }, + { name: 'Content', done: 135, draft: 20, total: 300 }, + ], + teamMembers: [], + }, + { + language: 'Swiss German Alemannic Alsatian - Alemannisch', + languageDirection: 'ltr', + locale: 'gsw', + bibleLabel: 'Schwyzerdütsch Bibel', + translationProgress: [ + { name: 'Interface', done: 28, draft: 4, total: 100 }, + { name: 'Content', done: 84, draft: 6, total: 300 }, + ], + teamMembers: [ + { + id: 29, + name: 'Hans Müller', + initials: 'HM', + email: 'hans.muller@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Gujarati - ગુજરાતી', + languageDirection: 'ltr', + locale: 'gu', + bibleLabel: 'Gujarati Bible', + translationProgress: [ + { name: 'Interface', done: 58, draft: 12, total: 100 }, + { name: 'Content', done: 174, draft: 18, total: 300 }, + ], + teamMembers: [ + { + id: 30, + name: 'Priya Shah', + initials: 'PS', + email: 'priya.shah@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + { + id: 31, + name: 'Raj Patel', + initials: 'RP', + email: 'raj.patel@example.com', + isManager: false, + isAdmin: false, + role: 'translator', + language: null, + hasPendingInvite: true, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Hebrew - עברית', + languageDirection: 'rtl', + locale: 'he', + bibleLabel: 'תנ״ך', + translationProgress: [ + { name: 'Interface', done: 92, draft: 0, total: 100 }, + { name: 'Content', done: 276, draft: 0, total: 300 }, + ], + teamMembers: [ + { + id: 32, + name: 'David Cohen', + initials: 'DC', + email: 'david.cohen@example.com', + isManager: false, + isAdmin: false, + role: 'admin', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Croatian - Hrvatski', + languageDirection: 'ltr', + locale: 'hr', + bibleLabel: 'Biblija', + translationProgress: [ + { name: 'Interface', done: 70, draft: 5, total: 100 }, + { name: 'Content', done: 210, draft: 10, total: 300 }, + ], + teamMembers: [], + }, + { + language: 'Hungarian - Magyar', + languageDirection: 'ltr', + locale: 'hu', + bibleLabel: 'Károli Biblia', + translationProgress: [ + { name: 'Interface', done: 85, draft: 0, total: 100 }, + { name: 'Content', done: 255, draft: 5, total: 300 }, + ], + teamMembers: [ + { + id: 33, + name: 'Zsófia Nagy', + initials: 'ZN', + email: 'zsofia.nagy@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Armenian - Հայերեն', + languageDirection: 'ltr', + locale: 'hy', + bibleLabel: 'Աստվածաշունչ', + translationProgress: [ + { name: 'Interface', done: 40, draft: 15, total: 100 }, + { name: 'Content', done: 120, draft: 30, total: 300 }, + ], + teamMembers: [ + { + id: 34, + name: 'Armen Petrosyan', + initials: 'AP', + email: 'armen.petrosyan@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Indonesian - Bahasa Indonesia', + languageDirection: 'ltr', + locale: 'id', + bibleLabel: 'Alkitab Terjemahan Baru', + translationProgress: [ + { name: 'Interface', done: 100, draft: 0, total: 100 }, + { name: 'Content', done: 300, draft: 0, total: 300 }, + ], + teamMembers: [ + { + id: 35, + name: 'Budi Santoso', + initials: 'BS', + email: 'budi.santoso@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + { + id: 36, + name: 'Siti Wijaya', + initials: 'SW', + email: 'siti.wijaya@example.com', + isManager: false, + isAdmin: false, + role: 'translator', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, + { + language: 'Icelandic - Íslenska', + languageDirection: 'ltr', + locale: 'is', + bibleLabel: 'Biblían', + translationProgress: [ + { name: 'Interface', done: 33, draft: 7, total: 100 }, + { name: 'Content', done: 99, draft: 11, total: 300 }, + ], + teamMembers: [], + }, + { + language: 'Georgian - ქართული', + languageDirection: 'ltr', + locale: 'ka', + bibleLabel: 'ბიბლია', + translationProgress: [ + { name: 'Interface', done: 52, draft: 8, total: 100 }, + { name: 'Content', done: 156, draft: 12, total: 300 }, + ], + teamMembers: [ + { + id: 37, + name: 'Nino Beridze', + initials: 'NB', + email: 'nino.beridze@example.com', + isManager: false, + isAdmin: false, + role: 'editor', + language: null, + hasPendingInvite: false, + isAllowed: isAllowedMock(true), + }, + ], + }, ]; export const config: UiConfig = { diff --git a/src/types.ts b/src/types.ts index d458f410..1afb1741 100644 --- a/src/types.ts +++ b/src/types.ts @@ -635,7 +635,7 @@ export interface LanguageSpecification { export interface LanguageTableItem extends LanguageSpecification { translationProgress?: Omit[]; - teamMembers?: UserInterface[]; + teamMembers?: UserMeta[]; } export interface Providers { @@ -673,7 +673,7 @@ export interface Providers { /// ---------------------------------------------------- export interface SettingsPageProps { - sourceLanguage: LanguageSpecification; + sourceLanguage: LanguageTableItem; languageItems: LanguageTableItem[]; providers: Providers; requireAppUpdate: boolean;