0
Fork 0
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:
IceHe 2022-09-30 10:30:32 +08:00 committed by GitHub
parent 1271cd162e
commit 3eb44e1e56
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 172 additions and 138 deletions

View file

@ -31,7 +31,7 @@ const ManageLanguageModal = ({ isOpen, onClose }: ManageLanguageModalProps) => {
[
...new Set([
...builtInUiLanguages,
...(customPhraseResponses?.map(({ languageKey }) => languageKey) ?? []),
...(customPhraseResponses?.map(({ languageTag }) => languageTag) ?? []),
]),
]
.slice()

View file

@ -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;
};

View file

@ -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',

View file

@ -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);
});

View file

@ -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))
);
};

View file

@ -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,

View file

@ -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),

View file

@ -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);
}
};

View file

@ -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);
});
});
});

View file

@ -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();

View file

@ -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);
});
});

View file

@ -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 () => {

View file

@ -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);

View file

@ -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', () => {

View file

@ -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
},

View file

@ -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
},

View file

@ -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
},

View file

@ -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
},

View file

@ -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
},

View file

@ -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: {

View file

@ -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;

View file

@ -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)
);