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:
parent
dbdc632381
commit
7846386a90
18 changed files with 17 additions and 205 deletions
packages
phrases-ui/src/locales
de/error
en/error
es/error
fr/error
it/error
ja/error
ko/error
pl-pl/error
pt-br/error
pt-pt/error
ru/error
tr-tr/error
zh-cn/error
zh-hk/error
zh-tw/error
ui/src/hooks
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Reference in a new issue