0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

refactor(ui): use native list translation

This commit is contained in:
Gao Sun 2023-09-07 17:57:28 +08:00
parent dbdc632381
commit 7846386a90
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
18 changed files with 17 additions and 205 deletions

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -6,7 +6,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.',
unsupported_characters: 'Unsupported character found.',
pwned: 'Avoid using simple passwords that are easy to guess.',
restricted_found: 'Avoid overusing {{list}}.',
restricted_found: 'Avoid overusing {{list, list}}.',
'restricted.repetition': 'repeated characters',
'restricted.sequence': 'sequential characters',
'restricted.personal_info': 'your personal information',

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -4,7 +4,7 @@ const password_rejected = {
character_types: 'At least {{min}} types of characters are required.', // UNTRANSLATED
unsupported_characters: 'Unsupported character found.', // UNTRANSLATED
pwned: 'Avoid using simple passwords that are easy to guess.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list}}.', // UNTRANSLATED
restricted_found: 'Avoid overusing {{list, list}}.', // UNTRANSLATED
'restricted.repetition': 'repeated characters', // UNTRANSLATED
'restricted.sequence': 'sequential characters', // UNTRANSLATED
'restricted.personal_info': 'your personal information', // UNTRANSLATED

View file

@ -1,107 +0,0 @@
import useListTranslation from './use-list-translation';
const mockT = jest.fn((key: string) => key);
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: (function_: () => void) => function_,
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: mockT,
}),
}));
describe('useListTranslation (en)', () => {
const translateList = useListTranslation();
beforeAll(() => {
mockT.mockImplementation((key: string) => {
switch (key) {
case 'list.or': {
return 'or';
}
case 'list.and': {
return 'and';
}
case 'list.separator': {
return ',';
}
default: {
return key;
}
}
});
});
it('returns undefined for an empty list', () => {
expect(translateList([])).toBeUndefined();
});
it('returns the first item for a list of one item', () => {
expect(translateList(['a'])).toBe('a');
});
it('returns the list with "or" for a list of two items', () => {
expect(translateList(['a', 'b'])).toBe('a or b');
});
it('returns the list with the Oxford comma for a list of three items', () => {
expect(translateList(['a', 'b', 'c'])).toBe('a, b, or c');
});
it('returns the list with the specified joint', () => {
expect(translateList(['a', 'b', 'c'], 'and')).toBe('a, b, and c');
});
});
describe('useListTranslation (zh)', () => {
const translateList = useListTranslation();
beforeAll(() => {
mockT.mockImplementation((key: string) => {
switch (key) {
case 'list.or': {
return '或';
}
case 'list.and': {
return '和';
}
case 'list.separator': {
return '、';
}
default: {
return key;
}
}
});
});
it('returns undefined for an empty list', () => {
expect(translateList([])).toBeUndefined();
});
it('returns the first item for a list of one item', () => {
expect(translateList(['苹果'])).toBe('苹果');
});
it('returns the list with "或" for a list of two items', () => {
expect(translateList(['苹果', '橘子'])).toBe('苹果或橘子');
});
it('returns the list with the AP style for a list of three items', () => {
expect(translateList(['苹果', '橘子', '香蕉'])).toBe('苹果、橘子或香蕉');
});
it('returns the list with the specified joint', () => {
expect(translateList(['苹果', '橘子', '香蕉'], 'and')).toBe('苹果、橘子和香蕉');
});
it('adds a space between CJK and non-CJK characters', () => {
expect(translateList(['苹果', '橘子', 'banana'])).toBe('苹果、橘子或 banana');
expect(translateList(['苹果', '橘子', 'banana'], 'and')).toBe('苹果、橘子和 banana');
expect(translateList(['banana', '苹果', '橘子'])).toBe('banana、苹果或橘子');
expect(translateList(['苹果', 'banana', '橘子'])).toBe('苹果、banana 或橘子');
expect(translateList(['苹果', 'banana', 'orange'])).toBe('苹果、banana 或 orange');
});
});

View file

@ -1,78 +0,0 @@
import { condString, conditionalArray } from '@silverhand/essentials';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
/**
* Returns whether the given character is a CJK character.
*
* @see https://stackoverflow.com/questions/43418812
*/
const isCjk = (char?: string) =>
Boolean(
char?.[0] && /[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFF66-\uFF9F]/.test(char[0])
);
/**
* Returns a function that translates a list of strings into a human-readable list. If the list
* is empty, `undefined` is returned.
*
* For non-CJK languages, the list is translated with the Oxford comma. For CJK languages, the
* list is translated with the AP style.
*
* CAUTION: This function may not be suitable for translating lists of non-English strings if the
* target language does not have the same rules for list translation as English.
*
* @example
* ```ts
* const translateList = useListTranslation();
*
* // en
* translateList([]); // undefined
* translateList(['a']); // 'a'
* translateList(['a', 'b']); // 'a and b'
* translateList(['a', 'b', 'c']); // 'a, b, or c'
* translateList(['a', 'b', 'c'], 'and'); // 'a, b, and c'
*
* // zh
* translateList(['a', 'b']); // 'a 或 b'
* translateList(['苹果', '橘子', '香蕉']); // '苹果、橘子或香蕉'
* translateList(['苹果', '橘子', 'banana']); // '苹果、橘子或 banana'
* ```
*/
const useListTranslation = () => {
const { t } = useTranslation();
return useCallback(
(list: string[], joint: 'or' | 'and' = 'or') => {
if (list.length === 0) {
return;
}
if (list.length === 1) {
return list[0];
}
const jointT = t(`list.${joint}`);
const prefix = list
.slice(0, -1)
.join(t('list.separator') + condString(!isCjk(jointT) && ' '));
const suffix = list.at(-1)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion -- `list` is not empty
if (!isCjk(jointT) && list.length > 2) {
// Oxford comma
return `${prefix}${t(`list.separator`)} ${jointT} ${suffix}`;
}
return conditionalArray(
prefix,
!isCjk(prefix.at(-1)) && ' ',
jointT,
!isCjk(suffix[0]) && ' ',
suffix
).join('');
},
[t]
);
};
export default useListTranslation;

View file

@ -3,14 +3,11 @@ import { type RequestErrorBody } from '@logto/schemas';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import useListTranslation from '@/hooks/use-list-translation';
/**
* Return an object with two functions for getting the error message from an array of {@link PasswordIssue} or a {@link RequestErrorBody}.
*/
const usePasswordErrorMessage = () => {
const { t } = useTranslation();
const translateList = useListTranslation();
const getErrorMessage = useCallback(
(issues: PasswordIssue[]) => {
// Errors that should be displayed first and alone
@ -39,11 +36,11 @@ const usePasswordErrorMessage = () => {
if (restrictedErrors.length > 0) {
return t('error.password_rejected.restricted_found', {
list: translateList(restrictedErrors),
list: restrictedErrors,
});
}
},
[translateList, t]
[t]
);
const getErrorMessageFromBody = useCallback(