mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
refactor(core,console,phrases,schemas): replace language key with language tag (#2026)
This commit is contained in:
parent
1271cd162e
commit
3eb44e1e56
22 changed files with 172 additions and 138 deletions
|
@ -31,7 +31,7 @@ const ManageLanguageModal = ({ isOpen, onClose }: ManageLanguageModalProps) => {
|
|||
[
|
||||
...new Set([
|
||||
...builtInUiLanguages,
|
||||
...(customPhraseResponses?.map(({ languageKey }) => languageKey) ?? []),
|
||||
...(customPhraseResponses?.map(({ languageTag }) => languageTag) ?? []),
|
||||
]),
|
||||
]
|
||||
.slice()
|
||||
|
|
|
@ -2,6 +2,6 @@ import type { LanguageTag } from '@logto/language-kit';
|
|||
import type { Translation } from '@logto/schemas';
|
||||
|
||||
export type CustomPhraseResponse = {
|
||||
languageKey: LanguageTag;
|
||||
languageTag: LanguageTag;
|
||||
translation: Translation;
|
||||
};
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import en from '@logto/phrases-ui/lib/locales/en';
|
||||
|
||||
export const enKey = 'en';
|
||||
export const koKrKey = 'ko-KR';
|
||||
export const trTrKey = 'tr-TR';
|
||||
export const zhCnKey = 'zh-CN';
|
||||
export const zhHkKey = 'zh-HK';
|
||||
export const enTag = 'en';
|
||||
export const trTrTag = 'tr-TR';
|
||||
export const zhCnTag = 'zh-CN';
|
||||
export const zhHkTag = 'zh-HK';
|
||||
|
||||
export const mockEnCustomPhrase = {
|
||||
languageKey: enKey,
|
||||
languageTag: enTag,
|
||||
translation: {
|
||||
input: {
|
||||
username: 'Username 1',
|
||||
|
@ -20,7 +19,7 @@ export const mockEnCustomPhrase = {
|
|||
};
|
||||
|
||||
export const mockEnPhrase = {
|
||||
languageKey: enKey,
|
||||
languageTag: enTag,
|
||||
translation: {
|
||||
...en.translation,
|
||||
...mockEnCustomPhrase.translation,
|
||||
|
@ -28,7 +27,7 @@ export const mockEnPhrase = {
|
|||
};
|
||||
|
||||
export const mockTrTrCustomPhrase = {
|
||||
languageKey: trTrKey,
|
||||
languageTag: trTrTag,
|
||||
translation: {
|
||||
input: {
|
||||
username: 'Kullanıcı Adı 1',
|
||||
|
@ -41,7 +40,7 @@ export const mockTrTrCustomPhrase = {
|
|||
};
|
||||
|
||||
export const mockZhCnCustomPhrase = {
|
||||
languageKey: zhCnKey,
|
||||
languageTag: zhCnTag,
|
||||
translation: {
|
||||
input: {
|
||||
username: '用户名 1',
|
||||
|
@ -54,7 +53,7 @@ export const mockZhCnCustomPhrase = {
|
|||
};
|
||||
|
||||
export const mockZhHkCustomPhrase = {
|
||||
languageKey: zhHkKey,
|
||||
languageTag: zhHkTag,
|
||||
translation: {
|
||||
input: {
|
||||
email: '郵箱 1',
|
||||
|
|
|
@ -3,34 +3,34 @@ import { CustomPhrase } from '@logto/schemas';
|
|||
import deepmerge from 'deepmerge';
|
||||
|
||||
import {
|
||||
enKey,
|
||||
enTag,
|
||||
mockEnCustomPhrase,
|
||||
mockZhCnCustomPhrase,
|
||||
mockZhHkCustomPhrase,
|
||||
trTrKey,
|
||||
zhCnKey,
|
||||
zhHkKey,
|
||||
trTrTag,
|
||||
zhCnTag,
|
||||
zhHkTag,
|
||||
} from '@/__mocks__/custom-phrase';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { getPhrase } from '@/lib/phrase';
|
||||
|
||||
const englishBuiltInPhrase = resource[enKey];
|
||||
const englishBuiltInPhrase = resource[enTag];
|
||||
|
||||
const customOnlyLanguage = zhHkKey;
|
||||
const customOnlyLanguage = zhHkTag;
|
||||
const customOnlyCustomPhrase = mockZhHkCustomPhrase;
|
||||
|
||||
const customizedLanguage = zhCnKey;
|
||||
const customizedBuiltInPhrase = resource[zhCnKey];
|
||||
const customizedLanguage = zhCnTag;
|
||||
const customizedBuiltInPhrase = resource[zhCnTag];
|
||||
const customizedCustomPhrase = mockZhCnCustomPhrase;
|
||||
|
||||
const mockCustomPhrases: Record<string, CustomPhrase> = {
|
||||
[enKey]: mockEnCustomPhrase,
|
||||
[enTag]: mockEnCustomPhrase,
|
||||
[customOnlyLanguage]: customOnlyCustomPhrase,
|
||||
[customizedLanguage]: customizedCustomPhrase,
|
||||
};
|
||||
|
||||
const findCustomPhraseByLanguageKey = jest.fn(async (languageKey: string) => {
|
||||
const mockCustomPhrase = mockCustomPhrases[languageKey];
|
||||
const findCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => {
|
||||
const mockCustomPhrase = mockCustomPhrases[languageTag];
|
||||
|
||||
if (!mockCustomPhrase) {
|
||||
throw new RequestError({ code: 'entity.not_found', status: 404 });
|
||||
|
@ -40,7 +40,7 @@ const findCustomPhraseByLanguageKey = jest.fn(async (languageKey: string) => {
|
|||
});
|
||||
|
||||
jest.mock('@/queries/custom-phrase', () => ({
|
||||
findCustomPhraseByLanguageKey: async (key: string) => findCustomPhraseByLanguageKey(key),
|
||||
findCustomPhraseByLanguageTag: async (key: string) => findCustomPhraseByLanguageTag(key),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -54,7 +54,7 @@ it('should ignore empty string values from the custom phrase', async () => {
|
|||
confirm_password: 'Confirm password 5',
|
||||
};
|
||||
const mockEnCustomPhraseWithEmptyStringValues = {
|
||||
languageKey: enKey,
|
||||
languageTag: enTag,
|
||||
translation: {
|
||||
input: {
|
||||
...resource.en.translation.input,
|
||||
|
@ -65,10 +65,10 @@ it('should ignore empty string values from the custom phrase', async () => {
|
|||
},
|
||||
};
|
||||
|
||||
findCustomPhraseByLanguageKey.mockResolvedValueOnce(mockEnCustomPhraseWithEmptyStringValues);
|
||||
await expect(getPhrase(enKey, [enKey])).resolves.toEqual(
|
||||
findCustomPhraseByLanguageTag.mockResolvedValueOnce(mockEnCustomPhraseWithEmptyStringValues);
|
||||
await expect(getPhrase(enTag, [enTag])).resolves.toEqual(
|
||||
deepmerge(englishBuiltInPhrase, {
|
||||
languageKey: enKey,
|
||||
languageTag: enTag,
|
||||
translation: {
|
||||
input: {
|
||||
...resource.en.translation.input,
|
||||
|
@ -81,13 +81,13 @@ it('should ignore empty string values from the custom phrase', async () => {
|
|||
|
||||
describe('when the language is English', () => {
|
||||
it('should be English custom phrase merged with its built-in phrase when its custom phrase exists', async () => {
|
||||
await expect(getPhrase(enKey, [enKey])).resolves.toEqual(
|
||||
await expect(getPhrase(enTag, [enTag])).resolves.toEqual(
|
||||
deepmerge(englishBuiltInPhrase, mockEnCustomPhrase)
|
||||
);
|
||||
});
|
||||
|
||||
it('should be English built-in phrase when its custom phrase does not exist', async () => {
|
||||
await expect(getPhrase(enKey, [])).resolves.toEqual(englishBuiltInPhrase);
|
||||
await expect(getPhrase(enTag, [])).resolves.toEqual(englishBuiltInPhrase);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -99,8 +99,8 @@ describe('when the language is not English', () => {
|
|||
});
|
||||
|
||||
it('should be built-in phrase when there is built-in phrase and no custom phrase', async () => {
|
||||
const builtInOnlyLanguage = trTrKey;
|
||||
const builtInOnlyPhrase = resource[trTrKey];
|
||||
const builtInOnlyLanguage = trTrTag;
|
||||
const builtInOnlyPhrase = resource[trTrTag];
|
||||
await expect(getPhrase(builtInOnlyLanguage, [])).resolves.toEqual(builtInOnlyPhrase);
|
||||
});
|
||||
|
||||
|
|
|
@ -3,13 +3,13 @@ import { CustomPhrase } from '@logto/schemas';
|
|||
import cleanDeep from 'clean-deep';
|
||||
import deepmerge from 'deepmerge';
|
||||
|
||||
import { findCustomPhraseByLanguageKey } from '@/queries/custom-phrase';
|
||||
import { findCustomPhraseByLanguageTag } from '@/queries/custom-phrase';
|
||||
|
||||
export const getPhrase = async (supportedLanguage: string, customLanguages: string[]) => {
|
||||
if (!isBuiltInLanguageTag(supportedLanguage)) {
|
||||
return deepmerge<LocalePhrase, CustomPhrase>(
|
||||
resource.en,
|
||||
cleanDeep(await findCustomPhraseByLanguageKey(supportedLanguage))
|
||||
cleanDeep(await findCustomPhraseByLanguageTag(supportedLanguage))
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -19,6 +19,6 @@ export const getPhrase = async (supportedLanguage: string, customLanguages: stri
|
|||
|
||||
return deepmerge<LocalePhrase, CustomPhrase>(
|
||||
resource[supportedLanguage],
|
||||
cleanDeep(await findCustomPhraseByLanguageKey(supportedLanguage))
|
||||
cleanDeep(await findCustomPhraseByLanguageTag(supportedLanguage))
|
||||
);
|
||||
};
|
||||
|
|
|
@ -20,11 +20,11 @@ import {
|
|||
|
||||
const enabledConnectors = [mockFacebookConnector, mockGithubConnector];
|
||||
|
||||
const allCustomLanguageKeys: LanguageTag[] = [];
|
||||
const findAllCustomLanguageKeys = jest.fn(async () => allCustomLanguageKeys);
|
||||
const allCustomLanguageTags: LanguageTag[] = [];
|
||||
const findAllCustomLanguageTags = jest.fn(async () => allCustomLanguageTags);
|
||||
|
||||
jest.mock('@/queries/custom-phrase', () => ({
|
||||
findAllCustomLanguageKeys: async () => findAllCustomLanguageKeys(),
|
||||
findAllCustomLanguageTags: async () => findAllCustomLanguageTags(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -74,13 +74,13 @@ describe('validate branding', () => {
|
|||
});
|
||||
|
||||
describe('validate language info', () => {
|
||||
it('should call findAllCustomLanguageKeys', async () => {
|
||||
it('should call findAllCustomLanguageTags', async () => {
|
||||
await validateLanguageInfo({
|
||||
autoDetect: true,
|
||||
fallbackLanguage: 'zh-CN',
|
||||
fixedLanguage: 'en',
|
||||
});
|
||||
expect(findAllCustomLanguageKeys).toBeCalledTimes(1);
|
||||
expect(findAllCustomLanguageTags).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should pass when the language is built-in supported', async () => {
|
||||
|
@ -92,13 +92,13 @@ describe('validate language info', () => {
|
|||
fixedLanguage: 'en',
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
expect(findAllCustomLanguageKeys).toBeCalledTimes(1);
|
||||
expect(findAllCustomLanguageTags).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should pass when the language is custom supported', async () => {
|
||||
const customOnlySupportedLanguage = 'zh-HK';
|
||||
expect(customOnlySupportedLanguage in builtInLanguages).toBeFalsy();
|
||||
findAllCustomLanguageKeys.mockResolvedValueOnce([customOnlySupportedLanguage]);
|
||||
findAllCustomLanguageTags.mockResolvedValueOnce([customOnlySupportedLanguage]);
|
||||
await expect(
|
||||
validateLanguageInfo({
|
||||
autoDetect: true,
|
||||
|
@ -106,13 +106,13 @@ describe('validate language info', () => {
|
|||
fixedLanguage: 'en',
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
expect(findAllCustomLanguageKeys).toBeCalledTimes(1);
|
||||
expect(findAllCustomLanguageTags).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('unsupported fallback language should fail', async () => {
|
||||
const unsupportedLanguage = 'zh-MO';
|
||||
expect(unsupportedLanguage in builtInLanguages).toBeFalsy();
|
||||
expect(allCustomLanguageKeys.includes(unsupportedLanguage)).toBeFalsy();
|
||||
expect(allCustomLanguageTags.includes(unsupportedLanguage)).toBeFalsy();
|
||||
await expect(
|
||||
validateLanguageInfo({
|
||||
autoDetect: true,
|
||||
|
|
|
@ -11,7 +11,7 @@ import { Optional } from '@silverhand/essentials';
|
|||
|
||||
import { ConnectorType, LogtoConnector } from '@/connectors/types';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { findAllCustomLanguageKeys } from '@/queries/custom-phrase';
|
||||
import { findAllCustomLanguageTags } from '@/queries/custom-phrase';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
|
||||
export const validateBranding = (branding: Branding) => {
|
||||
|
@ -23,7 +23,7 @@ export const validateBranding = (branding: Branding) => {
|
|||
};
|
||||
|
||||
export const validateLanguageInfo = async (languageInfo: LanguageInfo) => {
|
||||
const supportedLanguages = [...builtInLanguages, ...(await findAllCustomLanguageKeys())];
|
||||
const supportedLanguages = [...builtInLanguages, ...(await findAllCustomLanguageTags())];
|
||||
|
||||
assertThat(
|
||||
supportedLanguages.includes(languageInfo.fallbackLanguage),
|
||||
|
|
|
@ -8,16 +8,16 @@ import { DeletionError } from '@/errors/SlonikError';
|
|||
|
||||
const { table, fields } = convertToIdentifiers(CustomPhrases);
|
||||
|
||||
export const findAllCustomLanguageKeys = async () => {
|
||||
const rows = await manyRows<{ languageKey: string }>(
|
||||
export const findAllCustomLanguageTags = async () => {
|
||||
const rows = await manyRows<{ languageTag: string }>(
|
||||
envSet.pool.query(sql`
|
||||
select ${fields.languageKey}
|
||||
select ${fields.languageTag}
|
||||
from ${table}
|
||||
order by ${fields.languageKey}
|
||||
order by ${fields.languageTag}
|
||||
`)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.languageKey);
|
||||
return rows.map((row) => row.languageTag);
|
||||
};
|
||||
|
||||
export const findAllCustomPhrases = async () =>
|
||||
|
@ -25,32 +25,32 @@ export const findAllCustomPhrases = async () =>
|
|||
envSet.pool.query<CustomPhrase>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`,`)}
|
||||
from ${table}
|
||||
order by ${fields.languageKey}
|
||||
order by ${fields.languageTag}
|
||||
`)
|
||||
);
|
||||
|
||||
export const findCustomPhraseByLanguageKey = async (languageKey: string): Promise<CustomPhrase> =>
|
||||
export const findCustomPhraseByLanguageTag = async (languageTag: string): Promise<CustomPhrase> =>
|
||||
envSet.pool.one<CustomPhrase>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.languageKey} = ${languageKey}
|
||||
where ${fields.languageTag} = ${languageTag}
|
||||
`);
|
||||
|
||||
export const upsertCustomPhrase = buildInsertInto<CreateCustomPhrase, CustomPhrase>(CustomPhrases, {
|
||||
returning: true,
|
||||
onConflict: {
|
||||
fields: [fields.languageKey],
|
||||
fields: [fields.languageTag],
|
||||
setExcludedFields: [fields.translation],
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCustomPhraseByLanguageKey = async (languageKey: string) => {
|
||||
export const deleteCustomPhraseByLanguageTag = async (languageTag: string) => {
|
||||
const { rowCount } = await envSet.pool.query(sql`
|
||||
delete from ${table}
|
||||
where ${fields.languageKey}=${languageKey}
|
||||
where ${fields.languageTag}=${languageTag}
|
||||
`);
|
||||
|
||||
if (rowCount < 1) {
|
||||
throw new DeletionError(CustomPhrases.table, languageKey);
|
||||
throw new DeletionError(CustomPhrases.table, languageTag);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -2,25 +2,25 @@ import en from '@logto/phrases-ui/lib/locales/en';
|
|||
import { CustomPhrase, SignInExperience, Translation } from '@logto/schemas';
|
||||
|
||||
import { mockSignInExperience } from '@/__mocks__';
|
||||
import { mockZhCnCustomPhrase, trTrKey, zhCnKey } from '@/__mocks__/custom-phrase';
|
||||
import { mockZhCnCustomPhrase, trTrTag, zhCnTag } from '@/__mocks__/custom-phrase';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import customPhraseRoutes from '@/routes/custom-phrase';
|
||||
import { createRequester } from '@/utils/test-utils';
|
||||
|
||||
const mockLanguageKey = zhCnKey;
|
||||
const mockLanguageTag = zhCnTag;
|
||||
const mockPhrase = mockZhCnCustomPhrase;
|
||||
const mockCustomPhrases: Record<string, CustomPhrase> = {
|
||||
[mockLanguageKey]: mockPhrase,
|
||||
[mockLanguageTag]: mockPhrase,
|
||||
};
|
||||
|
||||
const deleteCustomPhraseByLanguageKey = jest.fn(async (languageKey: string) => {
|
||||
if (!mockCustomPhrases[languageKey]) {
|
||||
const deleteCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => {
|
||||
if (!mockCustomPhrases[languageTag]) {
|
||||
throw new RequestError({ code: 'entity.not_found', status: 404 });
|
||||
}
|
||||
});
|
||||
|
||||
const findCustomPhraseByLanguageKey = jest.fn(async (languageKey: string) => {
|
||||
const mockCustomPhrase = mockCustomPhrases[languageKey];
|
||||
const findCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => {
|
||||
const mockCustomPhrase = mockCustomPhrases[languageTag];
|
||||
|
||||
if (!mockCustomPhrase) {
|
||||
throw new RequestError({ code: 'entity.not_found', status: 404 });
|
||||
|
@ -34,9 +34,9 @@ const findAllCustomPhrases = jest.fn(async (): Promise<CustomPhrase[]> => []);
|
|||
const upsertCustomPhrase = jest.fn(async (customPhrase: CustomPhrase) => mockPhrase);
|
||||
|
||||
jest.mock('@/queries/custom-phrase', () => ({
|
||||
deleteCustomPhraseByLanguageKey: async (key: string) => deleteCustomPhraseByLanguageKey(key),
|
||||
deleteCustomPhraseByLanguageTag: async (tag: string) => deleteCustomPhraseByLanguageTag(tag),
|
||||
findAllCustomPhrases: async () => findAllCustomPhrases(),
|
||||
findCustomPhraseByLanguageKey: async (key: string) => findCustomPhraseByLanguageKey(key),
|
||||
findCustomPhraseByLanguageTag: async (tag: string) => findCustomPhraseByLanguageTag(tag),
|
||||
upsertCustomPhrase: async (customPhrase: CustomPhrase) => upsertCustomPhrase(customPhrase),
|
||||
}));
|
||||
|
||||
|
@ -49,7 +49,7 @@ jest.mock('@/utils/translation', () => ({
|
|||
isValidStructure(fullTranslation, partialTranslation),
|
||||
}));
|
||||
|
||||
const mockFallbackLanguage = trTrKey;
|
||||
const mockFallbackLanguage = trTrTag;
|
||||
|
||||
const findDefaultSignInExperience = jest.fn(
|
||||
async (): Promise<SignInExperience> => ({
|
||||
|
@ -81,7 +81,7 @@ describe('customPhraseRoutes', () => {
|
|||
|
||||
it('should return all custom phrases', async () => {
|
||||
const mockCustomPhrase = {
|
||||
languageKey: 'zh-HK',
|
||||
languageTag: 'zh-HK',
|
||||
translation: {
|
||||
input: { username: '用戶名', password: '密碼' },
|
||||
},
|
||||
|
@ -93,73 +93,87 @@ describe('customPhraseRoutes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /custom-phrases/:languageKey', () => {
|
||||
it('should call findCustomPhraseByLanguageKey once', async () => {
|
||||
await customPhraseRequest.get(`/custom-phrases/${mockLanguageKey}`);
|
||||
expect(findCustomPhraseByLanguageKey).toBeCalledTimes(1);
|
||||
describe('GET /custom-phrases/:languageTag', () => {
|
||||
it('should call findCustomPhraseByLanguageTag once', async () => {
|
||||
await customPhraseRequest.get(`/custom-phrases/${mockLanguageTag}`);
|
||||
expect(findCustomPhraseByLanguageTag).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return the specified custom phrase existing in the database', async () => {
|
||||
const response = await customPhraseRequest.get(`/custom-phrases/${mockLanguageKey}`);
|
||||
const response = await customPhraseRequest.get(`/custom-phrases/${mockLanguageTag}`);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(mockCustomPhrases[mockLanguageKey]);
|
||||
expect(response.body).toEqual(mockCustomPhrases[mockLanguageTag]);
|
||||
});
|
||||
|
||||
it('should return 404 status code when there is no specified custom phrase in the database', async () => {
|
||||
const response = await customPhraseRequest.get('/custom-phrases/en-UK');
|
||||
const response = await customPhraseRequest.get('/custom-phrases/en-GB');
|
||||
expect(response.status).toEqual(404);
|
||||
});
|
||||
|
||||
it('should return 400 status code when the language tag is invalid', async () => {
|
||||
const invalidLanguageTag = 'xx-XX';
|
||||
const response = await customPhraseRequest.get(`/custom-phrases/${invalidLanguageTag}`);
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /custom-phrases/:languageKey', () => {
|
||||
const translation = mockCustomPhrases[mockLanguageKey]?.translation;
|
||||
describe('PUT /custom-phrases/:languageTag', () => {
|
||||
const translation = mockCustomPhrases[mockLanguageTag]?.translation;
|
||||
|
||||
it('should remove empty strings', async () => {
|
||||
const inputTranslation = { username: '用户名 1' };
|
||||
await customPhraseRequest.put(`/custom-phrases/${mockLanguageKey}`).send({
|
||||
await customPhraseRequest.put(`/custom-phrases/${mockLanguageTag}`).send({
|
||||
input: { ...inputTranslation, password: '' },
|
||||
});
|
||||
expect(upsertCustomPhrase).toBeCalledWith({
|
||||
languageKey: mockLanguageKey,
|
||||
languageTag: mockLanguageTag,
|
||||
translation: { input: inputTranslation },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call isValidStructure', async () => {
|
||||
await customPhraseRequest.put(`/custom-phrases/${mockLanguageKey}`).send(translation);
|
||||
await customPhraseRequest.put(`/custom-phrases/${mockLanguageTag}`).send(translation);
|
||||
expect(isValidStructure).toBeCalledWith(en.translation, translation);
|
||||
});
|
||||
|
||||
it('should fail when the input translation structure is invalid', async () => {
|
||||
isValidStructure.mockReturnValueOnce(false);
|
||||
const response = await customPhraseRequest
|
||||
.put(`/custom-phrases/${mockLanguageKey}`)
|
||||
.put(`/custom-phrases/${mockLanguageTag}`)
|
||||
.send(translation);
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
|
||||
it('should call upsertCustomPhrase with specified language key', async () => {
|
||||
await customPhraseRequest.put(`/custom-phrases/${mockLanguageKey}`).send(translation);
|
||||
expect(upsertCustomPhrase).toBeCalledWith(mockCustomPhrases[mockLanguageKey]);
|
||||
it('should call upsertCustomPhrase with specified language tag', async () => {
|
||||
await customPhraseRequest.put(`/custom-phrases/${mockLanguageTag}`).send(translation);
|
||||
expect(upsertCustomPhrase).toBeCalledWith(mockCustomPhrases[mockLanguageTag]);
|
||||
});
|
||||
|
||||
it('should return custom phrase after upserting', async () => {
|
||||
const response = await customPhraseRequest
|
||||
.put(`/custom-phrases/${mockLanguageKey}`)
|
||||
.put(`/custom-phrases/${mockLanguageTag}`)
|
||||
.send(translation);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(mockCustomPhrases[mockLanguageKey]);
|
||||
expect(response.body).toEqual(mockCustomPhrases[mockLanguageTag]);
|
||||
});
|
||||
|
||||
it('should return 400 status code when the language tag is invalid', async () => {
|
||||
const invalidLanguageTag = 'xx-XX';
|
||||
const response = await customPhraseRequest
|
||||
.put(`/custom-phrases/${invalidLanguageTag}`)
|
||||
.send(mockCustomPhrases[mockLanguageTag]?.translation);
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /custom-phrases/:languageKey', () => {
|
||||
it('should call deleteCustomPhraseByLanguageKey when custom phrase exists and is not fallback language in sign-in experience', async () => {
|
||||
await customPhraseRequest.delete(`/custom-phrases/${mockLanguageKey}`);
|
||||
expect(deleteCustomPhraseByLanguageKey).toBeCalledWith(mockLanguageKey);
|
||||
describe('DELETE /custom-phrases/:languageTag', () => {
|
||||
it('should call deleteCustomPhraseByLanguageTag when custom phrase exists and is not fallback language in sign-in experience', async () => {
|
||||
await customPhraseRequest.delete(`/custom-phrases/${mockLanguageTag}`);
|
||||
expect(deleteCustomPhraseByLanguageTag).toBeCalledWith(mockLanguageTag);
|
||||
});
|
||||
|
||||
it('should return 204 status code after deleting specified custom phrase', async () => {
|
||||
const response = await customPhraseRequest.delete(`/custom-phrases/${mockLanguageKey}`);
|
||||
const response = await customPhraseRequest.delete(`/custom-phrases/${mockLanguageTag}`);
|
||||
expect(response.status).toEqual(204);
|
||||
});
|
||||
|
||||
|
@ -172,5 +186,11 @@ describe('customPhraseRoutes', () => {
|
|||
const response = await customPhraseRequest.delete(`/custom-phrases/${mockFallbackLanguage}`);
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
|
||||
it('should return 400 status code when the language tag is invalid', async () => {
|
||||
const invalidLanguageTag = 'xx-XX';
|
||||
const response = await customPhraseRequest.delete(`/custom-phrases/${invalidLanguageTag}`);
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import { languageTagGuard } from '@logto/language-kit';
|
||||
import resource from '@logto/phrases-ui';
|
||||
import { CustomPhrases, Translation, translationGuard } from '@logto/schemas';
|
||||
import cleanDeep from 'clean-deep';
|
||||
import { object } from 'zod';
|
||||
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import koaGuard from '@/middleware/koa-guard';
|
||||
import {
|
||||
deleteCustomPhraseByLanguageKey,
|
||||
deleteCustomPhraseByLanguageTag,
|
||||
findAllCustomPhrases,
|
||||
findCustomPhraseByLanguageKey,
|
||||
findCustomPhraseByLanguageTag,
|
||||
upsertCustomPhrase,
|
||||
} from '@/queries/custom-phrase';
|
||||
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
|
||||
|
@ -35,33 +37,32 @@ export default function customPhraseRoutes<T extends AuthedRouter>(router: T) {
|
|||
);
|
||||
|
||||
router.get(
|
||||
'/custom-phrases/:languageKey',
|
||||
'/custom-phrases/:languageTag',
|
||||
koaGuard({
|
||||
// Next up: guard languageKey by enum LanguageKey (that will be provided by @sijie later.)
|
||||
params: CustomPhrases.createGuard.pick({ languageKey: true }),
|
||||
params: object({ languageTag: languageTagGuard }),
|
||||
response: CustomPhrases.guard,
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { languageKey },
|
||||
params: { languageTag },
|
||||
} = ctx.guard;
|
||||
|
||||
ctx.body = await findCustomPhraseByLanguageKey(languageKey);
|
||||
ctx.body = await findCustomPhraseByLanguageTag(languageTag);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/custom-phrases/:languageKey',
|
||||
'/custom-phrases/:languageTag',
|
||||
koaGuard({
|
||||
params: CustomPhrases.createGuard.pick({ languageKey: true }),
|
||||
params: object({ languageTag: languageTagGuard }),
|
||||
body: translationGuard,
|
||||
response: CustomPhrases.guard,
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { languageKey },
|
||||
params: { languageTag },
|
||||
body,
|
||||
} = ctx.guard;
|
||||
|
||||
|
@ -72,34 +73,34 @@ export default function customPhraseRoutes<T extends AuthedRouter>(router: T) {
|
|||
new RequestError('localization.invalid_translation_structure')
|
||||
);
|
||||
|
||||
ctx.body = await upsertCustomPhrase({ languageKey, translation });
|
||||
ctx.body = await upsertCustomPhrase({ languageTag, translation });
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/custom-phrases/:languageKey',
|
||||
'/custom-phrases/:languageTag',
|
||||
koaGuard({
|
||||
params: CustomPhrases.createGuard.pick({ languageKey: true }),
|
||||
params: object({ languageTag: languageTagGuard }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { languageKey },
|
||||
params: { languageTag },
|
||||
} = ctx.guard;
|
||||
|
||||
const {
|
||||
languageInfo: { fallbackLanguage },
|
||||
} = await findDefaultSignInExperience();
|
||||
|
||||
if (fallbackLanguage === languageKey) {
|
||||
if (fallbackLanguage === languageTag) {
|
||||
throw new RequestError({
|
||||
code: 'localization.cannot_delete_default_language',
|
||||
languageKey,
|
||||
languageTag,
|
||||
});
|
||||
}
|
||||
|
||||
await deleteCustomPhraseByLanguageKey(languageKey);
|
||||
await deleteCustomPhraseByLanguageTag(languageTag);
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
|
|
|
@ -2,7 +2,7 @@ import en from '@logto/phrases-ui/lib/locales/en';
|
|||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import { mockSignInExperience } from '@/__mocks__';
|
||||
import { trTrKey, zhCnKey, zhHkKey } from '@/__mocks__/custom-phrase';
|
||||
import { trTrTag, zhCnTag, zhHkTag } from '@/__mocks__/custom-phrase';
|
||||
import phraseRoutes from '@/routes/phrase';
|
||||
import { createRequester } from '@/utils/test-utils';
|
||||
|
||||
|
@ -18,7 +18,7 @@ jest.mock('oidc-provider', () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
const fallbackLanguage = trTrKey;
|
||||
const fallbackLanguage = trTrTag;
|
||||
const unsupportedLanguageX = 'xx-XX';
|
||||
const unsupportedLanguageY = 'yy-YY';
|
||||
|
||||
|
@ -36,7 +36,7 @@ jest.mock('@/queries/sign-in-experience', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('@/queries/custom-phrase', () => ({
|
||||
findAllCustomLanguageKeys: async () => [trTrKey, zhCnKey],
|
||||
findAllCustomLanguageTags: async () => [trTrTag, zhCnTag],
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/phrase', () => ({
|
||||
|
@ -65,7 +65,7 @@ describe('when auto-detect is not enabled', () => {
|
|||
});
|
||||
const response = await phraseRequest
|
||||
.get('/phrase')
|
||||
.set('Accept-Language', `${zhCnKey},${zhHkKey}`);
|
||||
.set('Accept-Language', `${zhCnTag},${zhHkTag}`);
|
||||
expect(response.headers['content-language']).toEqual('en');
|
||||
});
|
||||
|
||||
|
@ -89,7 +89,7 @@ describe('when auto-detect is not enabled', () => {
|
|||
it('when there are detected languages', async () => {
|
||||
const response = await phraseRequest
|
||||
.get('/phrase')
|
||||
.set('Accept-Language', `${zhCnKey},${zhHkKey}`);
|
||||
.set('Accept-Language', `${zhCnTag},${zhHkTag}`);
|
||||
expect(response.headers['content-language']).toEqual(fallbackLanguage);
|
||||
});
|
||||
});
|
||||
|
@ -141,10 +141,10 @@ describe('when auto-detect is enabled', () => {
|
|||
|
||||
describe('when there are detected languages but some of them is unsupported', () => {
|
||||
it('should be first supported detected language', async () => {
|
||||
const firstSupportedLanguage = zhCnKey;
|
||||
const firstSupportedLanguage = zhCnTag;
|
||||
const response = await phraseRequest
|
||||
.get('/phrase')
|
||||
.set('Accept-Language', `${unsupportedLanguageX},${firstSupportedLanguage},${zhHkKey}`);
|
||||
.set('Accept-Language', `${unsupportedLanguageX},${firstSupportedLanguage},${zhHkTag}`);
|
||||
expect(response.headers['content-language']).toEqual(firstSupportedLanguage);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@ import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/
|
|||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import { mockSignInExperience } from '@/__mocks__';
|
||||
import { zhCnKey } from '@/__mocks__/custom-phrase';
|
||||
import { zhCnTag } from '@/__mocks__/custom-phrase';
|
||||
import * as detectLanguage from '@/i18n/detect-language';
|
||||
import phraseRoutes from '@/routes/phrase';
|
||||
import { createRequester } from '@/utils/test-utils';
|
||||
|
@ -21,7 +21,7 @@ jest.mock('oidc-provider', () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
const customizedLanguage = zhCnKey;
|
||||
const customizedLanguage = zhCnTag;
|
||||
|
||||
const findDefaultSignInExperience = jest.fn(
|
||||
async (): Promise<SignInExperience> => ({
|
||||
|
@ -40,12 +40,12 @@ jest.mock('@/queries/sign-in-experience', () => ({
|
|||
|
||||
const detectLanguageSpy = jest.spyOn(detectLanguage, 'default');
|
||||
|
||||
const findAllCustomLanguageKeys = jest.fn(async () => [customizedLanguage]);
|
||||
const findCustomPhraseByLanguageKey = jest.fn(async (key: string) => ({}));
|
||||
const findAllCustomLanguageTags = jest.fn(async () => [customizedLanguage]);
|
||||
const findCustomPhraseByLanguageTag = jest.fn(async (tag: string) => ({}));
|
||||
|
||||
jest.mock('@/queries/custom-phrase', () => ({
|
||||
findAllCustomLanguageKeys: async () => findAllCustomLanguageKeys(),
|
||||
findCustomPhraseByLanguageKey: async (key: string) => findCustomPhraseByLanguageKey(key),
|
||||
findAllCustomLanguageTags: async () => findAllCustomLanguageTags(),
|
||||
findCustomPhraseByLanguageTag: async (tag: string) => findCustomPhraseByLanguageTag(tag),
|
||||
}));
|
||||
|
||||
const getPhrase = jest.fn(async (language: string, customLanguages: string[]) => zhCN);
|
||||
|
@ -87,9 +87,9 @@ describe('when the application is admin-console', () => {
|
|||
expect(detectLanguageSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call findAllCustomLanguageKeys', async () => {
|
||||
it('should call findAllCustomLanguageTags', async () => {
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(findAllCustomLanguageKeys).toBeCalledTimes(1);
|
||||
expect(findAllCustomLanguageTags).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call getPhrase with fallback language from Admin Console sign-in experience', async () => {
|
||||
|
@ -136,9 +136,9 @@ describe('when the application is not admin-console', () => {
|
|||
expect(detectLanguageSpy).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should call findAllCustomLanguageKeys', async () => {
|
||||
it('should call findAllCustomLanguageTags', async () => {
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(findAllCustomLanguageKeys).toBeCalledTimes(1);
|
||||
expect(findAllCustomLanguageTags).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call getPhrase with fallback language from default sign-in experience', async () => {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Provider } from 'oidc-provider';
|
|||
|
||||
import detectLanguage from '@/i18n/detect-language';
|
||||
import { getPhrase } from '@/lib/phrase';
|
||||
import { findAllCustomLanguageKeys } from '@/queries/custom-phrase';
|
||||
import { findAllCustomLanguageTags } from '@/queries/custom-phrase';
|
||||
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
|
||||
|
||||
import { AnonymousRouter } from './types';
|
||||
|
@ -31,10 +31,10 @@ export default function phraseRoutes<T extends AnonymousRouter>(router: T, provi
|
|||
|
||||
const detectedLanguages = autoDetect ? detectLanguage(ctx) : [];
|
||||
const acceptableLanguages = [...detectedLanguages, fallbackLanguage];
|
||||
const customLanguages = await findAllCustomLanguageKeys();
|
||||
const customLanguages = await findAllCustomLanguageTags();
|
||||
const language =
|
||||
acceptableLanguages.find(
|
||||
(key) => isBuiltInLanguageTag(key) || customLanguages.includes(key)
|
||||
(tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag)
|
||||
) ?? 'en';
|
||||
|
||||
ctx.set('Content-Language', language);
|
||||
|
|
|
@ -52,7 +52,7 @@ jest.mock('@/queries/sign-in-experience', () => ({
|
|||
const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes });
|
||||
|
||||
jest.mock('@/queries/custom-phrase', () => ({
|
||||
findAllCustomLanguageKeys: async () => [],
|
||||
findAllCustomLanguageTags: async () => [],
|
||||
}));
|
||||
|
||||
describe('GET /sign-in-exp', () => {
|
||||
|
|
|
@ -109,7 +109,7 @@ const errors = {
|
|||
},
|
||||
localization: {
|
||||
cannot_delete_default_language:
|
||||
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
'You cannot delete {{languageTag}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
invalid_translation_structure:
|
||||
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
|
||||
},
|
||||
|
|
|
@ -117,7 +117,7 @@ const errors = {
|
|||
},
|
||||
localization: {
|
||||
cannot_delete_default_language:
|
||||
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
'You cannot delete {{languageTag}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
invalid_translation_structure:
|
||||
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
|
||||
},
|
||||
|
|
|
@ -106,7 +106,7 @@ const errors = {
|
|||
},
|
||||
localization: {
|
||||
cannot_delete_default_language:
|
||||
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
'You cannot delete {{languageTag}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
invalid_translation_structure:
|
||||
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
|
||||
},
|
||||
|
|
|
@ -112,7 +112,7 @@ const errors = {
|
|||
},
|
||||
localization: {
|
||||
cannot_delete_default_language:
|
||||
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
'You cannot delete {{languageTag}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
invalid_translation_structure:
|
||||
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
|
||||
},
|
||||
|
|
|
@ -110,7 +110,7 @@ const errors = {
|
|||
},
|
||||
localization: {
|
||||
cannot_delete_default_language:
|
||||
'You cannot delete {{languageKey}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
'You cannot delete {{languageTag}} language since it is used as default language in sign-in experience.', // UNTRANSLATED
|
||||
invalid_translation_structure:
|
||||
'Invalid translation structure. Please check the input translation.', // UNTRANSLATED
|
||||
},
|
||||
|
|
|
@ -101,7 +101,7 @@ const errors = {
|
|||
unsupported_default_language: '不支持默认语言 {{language}}。', // UNTRANSLATED
|
||||
},
|
||||
localization: {
|
||||
cannot_delete_default_language: '不能删除「登录体验」正在使用的默认语言 {{languageKey}}。', // UNTRANSLATED
|
||||
cannot_delete_default_language: '不能删除「登录体验」正在使用的默认语言 {{languageTag}}。', // UNTRANSLATED
|
||||
invalid_translation_structure: '无效的 translation 结构。请检查输入的 translation。', // UNTRANSLATED
|
||||
},
|
||||
swagger: {
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
import { sql } from 'slonik';
|
||||
|
||||
import { AlterationScript } from '../lib/types/alteration';
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await pool.query(sql`alter table custom_phrases rename column language_key to language_tag;`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`alter table custom_phrases rename column language_tag to language_key;`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -1,5 +1,5 @@
|
|||
create table custom_phrases (
|
||||
language_key varchar(16) not null,
|
||||
language_tag varchar(16) not null,
|
||||
translation jsonb /* @use Translation */ not null,
|
||||
primary key(language_key)
|
||||
primary key(language_tag)
|
||||
);
|
||||
|
|
Loading…
Add table
Reference in a new issue