mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(core): migrate phrase library to factory mode
This commit is contained in:
parent
836f3c101d
commit
1102e70226
7 changed files with 106 additions and 85 deletions
|
@ -13,6 +13,7 @@ import {
|
|||
zhHkTag,
|
||||
} from '#src/__mocks__/custom-phrase.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { MockQueries } from '#src/test-utils/tenant.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
|
@ -43,11 +44,10 @@ const findCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => {
|
|||
return mockCustomPhrase;
|
||||
});
|
||||
|
||||
mockEsm('#src/queries/custom-phrase.js', () => ({
|
||||
findCustomPhraseByLanguageTag,
|
||||
}));
|
||||
|
||||
const { getPhrase } = await import('#src/libraries/phrase.js');
|
||||
const { createPhraseLibrary } = await import('#src/libraries/phrase.js');
|
||||
const { getPhrases } = createPhraseLibrary(
|
||||
new MockQueries({ customPhrases: { findCustomPhraseByLanguageTag } })
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
@ -72,7 +72,7 @@ it('should ignore empty string values from the custom phrase', async () => {
|
|||
};
|
||||
|
||||
findCustomPhraseByLanguageTag.mockResolvedValueOnce(mockEnCustomPhraseWithEmptyStringValues);
|
||||
await expect(getPhrase(enTag, [enTag])).resolves.toEqual(
|
||||
await expect(getPhrases(enTag, [enTag])).resolves.toEqual(
|
||||
deepmerge(englishBuiltInPhrase, {
|
||||
languageTag: enTag,
|
||||
translation: {
|
||||
|
@ -87,19 +87,19 @@ 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(enTag, [enTag])).resolves.toEqual(
|
||||
await expect(getPhrases(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(enTag, [])).resolves.toEqual(englishBuiltInPhrase);
|
||||
await expect(getPhrases(enTag, [])).resolves.toEqual(englishBuiltInPhrase);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the language is not English', () => {
|
||||
it('should be custom phrase merged with built-in phrase when both of them exist', async () => {
|
||||
await expect(getPhrase(customizedLanguage, [customizedLanguage])).resolves.toEqual(
|
||||
await expect(getPhrases(customizedLanguage, [customizedLanguage])).resolves.toEqual(
|
||||
deepmerge(customizedBuiltInPhrase, customizedCustomPhrase)
|
||||
);
|
||||
});
|
||||
|
@ -107,11 +107,11 @@ 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 = trTrTag;
|
||||
const builtInOnlyPhrase = resource[trTrTag];
|
||||
await expect(getPhrase(builtInOnlyLanguage, [])).resolves.toEqual(builtInOnlyPhrase);
|
||||
await expect(getPhrases(builtInOnlyLanguage, [])).resolves.toEqual(builtInOnlyPhrase);
|
||||
});
|
||||
|
||||
it('should be built-in phrase when there is custom phrase and no built-in phrase', async () => {
|
||||
await expect(getPhrase(customOnlyLanguage, [customOnlyLanguage])).resolves.toEqual(
|
||||
await expect(getPhrases(customOnlyLanguage, [customOnlyLanguage])).resolves.toEqual(
|
||||
deepmerge(englishBuiltInPhrase, customOnlyCustomPhrase)
|
||||
);
|
||||
});
|
||||
|
|
|
@ -4,22 +4,28 @@ import type { CustomPhrase } from '@logto/schemas';
|
|||
import cleanDeep from 'clean-deep';
|
||||
import deepmerge from 'deepmerge';
|
||||
|
||||
import { findCustomPhraseByLanguageTag } from '#src/queries/custom-phrase.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
|
||||
export const createPhraseLibrary = (queries: Queries) => {
|
||||
const { findCustomPhraseByLanguageTag } = queries.customPhrases;
|
||||
|
||||
const getPhrases = async (supportedLanguage: string, customLanguages: string[]) => {
|
||||
if (!isBuiltInLanguageTag(supportedLanguage)) {
|
||||
return deepmerge<LocalePhrase, CustomPhrase>(
|
||||
resource.en,
|
||||
cleanDeep(await findCustomPhraseByLanguageTag(supportedLanguage))
|
||||
);
|
||||
}
|
||||
|
||||
if (!customLanguages.includes(supportedLanguage)) {
|
||||
return resource[supportedLanguage];
|
||||
}
|
||||
|
||||
export const getPhrase = async (supportedLanguage: string, customLanguages: string[]) => {
|
||||
if (!isBuiltInLanguageTag(supportedLanguage)) {
|
||||
return deepmerge<LocalePhrase, CustomPhrase>(
|
||||
resource.en,
|
||||
resource[supportedLanguage],
|
||||
cleanDeep(await findCustomPhraseByLanguageTag(supportedLanguage))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!customLanguages.includes(supportedLanguage)) {
|
||||
return resource[supportedLanguage];
|
||||
}
|
||||
|
||||
return deepmerge<LocalePhrase, CustomPhrase>(
|
||||
resource[supportedLanguage],
|
||||
cleanDeep(await findCustomPhraseByLanguageTag(supportedLanguage))
|
||||
);
|
||||
return { getPhrases };
|
||||
};
|
||||
|
|
|
@ -26,7 +26,7 @@ import type { LogtoConnector } from '#src/utils/connectors/types.js';
|
|||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
|
||||
mockEsm('#src/utils/connectors/platform.js', () => ({
|
||||
checkSocialConnectorTargetAndPlatformUniqueness: jest.fn(),
|
||||
|
|
|
@ -1,41 +1,42 @@
|
|||
import en from '@logto/phrases-ui/lib/locales/en.js';
|
||||
import { pickDefault, createMockUtils } from '@logto/shared/esm';
|
||||
import type { SignInExperience } from '@logto/schemas';
|
||||
import { pickDefault } from '@logto/shared/esm';
|
||||
|
||||
import { trTrTag, zhCnTag, zhHkTag } from '#src/__mocks__/custom-phrase.js';
|
||||
import { mockSignInExperience } from '#src/__mocks__/index.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const fallbackLanguage = trTrTag;
|
||||
const unsupportedLanguageX = 'xx-XX';
|
||||
const unsupportedLanguageY = 'yy-YY';
|
||||
|
||||
const { findDefaultSignInExperience } = await mockEsmWithActual(
|
||||
'#src/queries/sign-in-experience.js',
|
||||
() => ({
|
||||
findDefaultSignInExperience: jest.fn(async () => ({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: true,
|
||||
fallbackLanguage,
|
||||
},
|
||||
})),
|
||||
const findDefaultSignInExperience = jest.fn(
|
||||
async (): Promise<SignInExperience> => ({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: true,
|
||||
fallbackLanguage,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await mockEsmWithActual('#src/queries/custom-phrase.js', () => ({
|
||||
findAllCustomLanguageTags: async () => [trTrTag, zhCnTag],
|
||||
}));
|
||||
const tenantContext = new MockTenant(
|
||||
undefined,
|
||||
{
|
||||
customPhrases: { findAllCustomLanguageTags: async () => [trTrTag, zhCnTag] },
|
||||
signInExperiences: { findDefaultSignInExperience },
|
||||
},
|
||||
{ phrases: { getPhrases: jest.fn().mockResolvedValue(en) } }
|
||||
);
|
||||
|
||||
await mockEsmWithActual('#src/libraries/phrase.js', () => ({
|
||||
getPhrase: jest.fn().mockResolvedValue(en),
|
||||
}));
|
||||
const phraseRoutes = await pickDefault(import('./phrase.js'));
|
||||
|
||||
const phraseRequest = createRequester({
|
||||
anonymousRoutes: phraseRoutes,
|
||||
tenantContext,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -48,6 +49,7 @@ describe('when auto-detect is not enabled', () => {
|
|||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: false,
|
||||
// @ts-expect-error
|
||||
fallbackLanguage: unsupportedLanguageX,
|
||||
},
|
||||
});
|
||||
|
@ -88,6 +90,7 @@ describe('when auto-detect is enabled', () => {
|
|||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: true,
|
||||
// @ts-expect-error
|
||||
fallbackLanguage: unsupportedLanguageX,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,50 +1,57 @@
|
|||
import zhCN from '@logto/phrases-ui/lib/locales/zh-cn.js';
|
||||
import type { SignInExperience } from '@logto/schemas';
|
||||
import type { CustomPhrase, SignInExperience } from '@logto/schemas';
|
||||
import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas';
|
||||
import { pickDefault, createMockUtils } from '@logto/shared/esm';
|
||||
|
||||
import { zhCnTag } from '#src/__mocks__/custom-phrase.js';
|
||||
import { mockSignInExperience } from '#src/__mocks__/index.js';
|
||||
import { createMockTenantWithInteraction } from '#src/test-utils/tenant.js';
|
||||
import Queries from '#src/tenants/Queries.js';
|
||||
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
|
||||
const customizedLanguage = zhCnTag;
|
||||
|
||||
const { findDefaultSignInExperience } = mockEsm('#src/queries/sign-in-experience.js', () => ({
|
||||
findDefaultSignInExperience: jest.fn(
|
||||
async (): Promise<SignInExperience> => ({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: true,
|
||||
fallbackLanguage: customizedLanguage,
|
||||
},
|
||||
})
|
||||
),
|
||||
}));
|
||||
const findDefaultSignInExperience = jest.fn(
|
||||
async (): Promise<SignInExperience> => ({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
autoDetect: true,
|
||||
fallbackLanguage: customizedLanguage,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const { default: detectLanguageSpy } = mockEsm('#src/i18n/detect-language.js', () => ({
|
||||
default: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
const { findAllCustomLanguageTags } = mockEsm('#src/queries/custom-phrase.js', () => ({
|
||||
const customPhrases = {
|
||||
findAllCustomLanguageTags: jest.fn(async () => [customizedLanguage]),
|
||||
findCustomPhraseByLanguageTag: jest.fn(async (tag: string) => ({})),
|
||||
}));
|
||||
findCustomPhraseByLanguageTag: jest.fn(
|
||||
async (tag: string): Promise<CustomPhrase> => ({ languageTag: tag, translation: {} })
|
||||
),
|
||||
} satisfies Partial<Queries['customPhrases']>;
|
||||
const { findAllCustomLanguageTags } = customPhrases;
|
||||
|
||||
const { getPhrase } = await mockEsmWithActual('#src/libraries/phrase.js', () => ({
|
||||
getPhrase: jest.fn(async () => zhCN),
|
||||
}));
|
||||
const getPhrases = jest.fn(async () => zhCN);
|
||||
|
||||
const interactionDetails = jest.fn();
|
||||
const tenantContext = new MockTenant(
|
||||
createMockProvider(interactionDetails),
|
||||
{ customPhrases, signInExperiences: { findDefaultSignInExperience } },
|
||||
{ phrases: { getPhrases } }
|
||||
);
|
||||
|
||||
const phraseRoutes = await pickDefault(import('./phrase.js'));
|
||||
|
||||
const { createRequester } = await import('#src/utils/test-utils.js');
|
||||
const phraseRequest = createRequester({
|
||||
anonymousRoutes: phraseRoutes,
|
||||
tenantContext: createMockTenantWithInteraction(interactionDetails),
|
||||
tenantContext,
|
||||
});
|
||||
|
||||
describe('when the application is admin-console', () => {
|
||||
|
@ -78,10 +85,10 @@ describe('when the application is admin-console', () => {
|
|||
expect(findAllCustomLanguageTags).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call getPhrase with fallback language from Admin Console sign-in experience', async () => {
|
||||
it('should call getPhrases with fallback language from Admin Console sign-in experience', async () => {
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(getPhrase).toBeCalledTimes(1);
|
||||
expect(getPhrase).toBeCalledWith(adminConsoleSignInExperience.languageInfo.fallbackLanguage, [
|
||||
expect(getPhrases).toBeCalledTimes(1);
|
||||
expect(getPhrases).toBeCalledWith(adminConsoleSignInExperience.languageInfo.fallbackLanguage, [
|
||||
customizedLanguage,
|
||||
]);
|
||||
});
|
||||
|
@ -139,7 +146,7 @@ describe('when the application is not admin-console', () => {
|
|||
expect(findAllCustomLanguageTags).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call getPhrase with fallback language from default sign-in experience', async () => {
|
||||
it('should call getPhrases with fallback language from default sign-in experience', async () => {
|
||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
languageInfo: {
|
||||
|
@ -148,7 +155,7 @@ describe('when the application is not admin-console', () => {
|
|||
},
|
||||
});
|
||||
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
|
||||
expect(getPhrase).toBeCalledTimes(1);
|
||||
expect(getPhrase).toBeCalledWith(customizedLanguage, [customizedLanguage]);
|
||||
expect(getPhrases).toBeCalledTimes(1);
|
||||
expect(getPhrases).toBeCalledWith(customizedLanguage, [customizedLanguage]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,25 +2,28 @@ import { isBuiltInLanguageTag } from '@logto/phrases-ui';
|
|||
import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas';
|
||||
|
||||
import detectLanguage from '#src/i18n/detect-language.js';
|
||||
import { getPhrase } from '#src/libraries/phrase.js';
|
||||
import { findAllCustomLanguageTags } from '#src/queries/custom-phrase.js';
|
||||
import { findDefaultSignInExperience } from '#src/queries/sign-in-experience.js';
|
||||
|
||||
import type { AnonymousRouter, RouterInitArgs } from './types.js';
|
||||
|
||||
const getLanguageInfo = async (applicationId: unknown) => {
|
||||
if (applicationId === adminConsoleApplicationId) {
|
||||
return adminConsoleSignInExperience.languageInfo;
|
||||
}
|
||||
|
||||
const { languageInfo } = await findDefaultSignInExperience();
|
||||
|
||||
return languageInfo;
|
||||
};
|
||||
|
||||
export default function phraseRoutes<T extends AnonymousRouter>(
|
||||
...[router, { provider }]: RouterInitArgs<T>
|
||||
...[router, { provider, queries, libraries }]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
customPhrases: { findAllCustomLanguageTags },
|
||||
signInExperiences: { findDefaultSignInExperience },
|
||||
} = queries;
|
||||
const { getPhrases } = libraries.phrases;
|
||||
|
||||
const getLanguageInfo = async (applicationId: unknown) => {
|
||||
if (applicationId === adminConsoleApplicationId) {
|
||||
return adminConsoleSignInExperience.languageInfo;
|
||||
}
|
||||
|
||||
const { languageInfo } = await findDefaultSignInExperience();
|
||||
|
||||
return languageInfo;
|
||||
};
|
||||
|
||||
router.get('/phrase', async (ctx, next) => {
|
||||
const interaction = await provider
|
||||
.interactionDetails(ctx.req, ctx.res)
|
||||
|
@ -39,7 +42,7 @@ export default function phraseRoutes<T extends AnonymousRouter>(
|
|||
) ?? 'en';
|
||||
|
||||
ctx.set('Content-Language', language);
|
||||
ctx.body = await getPhrase(language, customLanguages);
|
||||
ctx.body = await getPhrases(language, customLanguages);
|
||||
|
||||
return next();
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { createConnectorLibrary } from '#src/libraries/connector.js';
|
||||
import { createPhraseLibrary } from '#src/libraries/phrase.js';
|
||||
import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js';
|
||||
import { createUserLibrary } from '#src/libraries/user.js';
|
||||
|
||||
|
@ -8,6 +9,7 @@ export default class Libraries {
|
|||
connectors = createConnectorLibrary(this.queries);
|
||||
users = createUserLibrary(this.queries);
|
||||
signInExperiences = createSignInExperienceLibrary(this.queries, this.connectors);
|
||||
phrases = createPhraseLibrary(this.queries);
|
||||
|
||||
constructor(public readonly queries: Queries) {}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue