0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat(console): manage language (#1981)

This commit is contained in:
Xiao Yijun 2022-09-23 15:44:45 +08:00
parent f25ae4de14
commit 48832e5054
No known key found for this signature in database
GPG key ID: 6F648FC1262DB420
30 changed files with 657 additions and 20 deletions

View file

@ -41,4 +41,8 @@
&.large {
max-width: dim.$modal-layout-width-large;
}
&.xlarge {
max-width: dim.$modal-layout-width-xlarge;
}
}

View file

@ -16,7 +16,7 @@ type Props = {
footer?: ReactNode;
onClose?: () => void;
className?: string;
size?: 'medium' | 'large';
size?: 'medium' | 'large' | 'xlarge';
};
const ModalLayout = ({

View file

@ -1,18 +1,23 @@
import { languageOptions } from '@logto/phrases-ui';
import classNames from 'classnames';
import { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import FormField from '@/components/FormField';
import Select from '@/components/Select';
import Switch from '@/components/Switch';
import * as textButtonStyles from '@/components/TextButton/index.module.scss';
import { SignInExperienceForm } from '../types';
import ManageLanguageModal from './ManageLanguageModal';
import * as styles from './index.module.scss';
const LanguagesForm = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { watch, control, register } = useFormContext<SignInExperienceForm>();
const isAutoDetect = watch('languageInfo.autoDetect');
const [isManageLanguageFormOpen, setIsManageLanguageFormOpen] = useState(false);
return (
<>
@ -23,6 +28,14 @@ const LanguagesForm = () => {
label={t('sign_in_exp.others.languages.description')}
/>
</FormField>
<div
className={classNames(textButtonStyles.button, styles.manageLanguage)}
onClick={() => {
setIsManageLanguageFormOpen(true);
}}
>
{t('sign_in_exp.others.languages.manage_language')}
</div>
<FormField title="sign_in_exp.others.languages.default_language">
<Controller
name="languageInfo.fallbackLanguage"
@ -37,6 +50,12 @@ const LanguagesForm = () => {
: t('sign_in_exp.others.languages.default_language_description_fixed')}
</div>
</FormField>
<ManageLanguageModal
isOpen={isManageLanguageFormOpen}
onClose={() => {
setIsManageLanguageFormOpen(false);
}}
/>
</>
);
};

View file

@ -0,0 +1,16 @@
@use '@/scss/underscore' as _;
.sectionTitle {
@include _.subhead-cap;
background-color: var(--color-layer-light);
}
.sectionDataKey {
padding: _.unit(4) _.unit(5);
font: var(--font-body-medium);
color: var(--color-text);
}
.sectionBuiltInText {
padding: _.unit(2) 0;
}

View file

@ -0,0 +1,49 @@
import type { Translation as UiTranslation } from '@logto/phrases-ui';
import { FieldPath, useFormContext } from 'react-hook-form';
import TextInput from '@/components/TextInput';
import * as style from './EditSection.module.scss';
type EditSectionProps = {
dataKey: string;
data: Record<string, unknown>;
};
const EditSection = ({ dataKey, data }: EditSectionProps) => {
const { register } = useFormContext<UiTranslation>();
return (
<>
<tr>
<td colSpan={3} className={style.sectionTitle}>
{dataKey}
</td>
</tr>
{Object.entries(data).map(([field, value]) => {
if (typeof value !== 'string') {
return null;
}
const fieldKey = `${dataKey}.${field}`;
return (
<tr key={fieldKey}>
<td className={style.sectionDataKey}>{field}</td>
<td>
<TextInput readOnly value={value} className={style.sectionBuiltInText} />
</td>
<td>
{
// eslint-disable-next-line no-restricted-syntax
<TextInput {...register(fieldKey as FieldPath<UiTranslation>)} />
}
</td>
</tr>
);
})}
</>
);
};
export default EditSection;

View file

@ -0,0 +1,92 @@
@use '@/scss/underscore' as _;
.languageEditor {
flex-grow: 1;
.title {
padding: _.unit(6) _.unit(5);
font: var(--font-title-large);
color: var(--color-text);
> span {
margin-left: _.unit(2);
font: var(--font-body-medium);
color: var(--color-caption);
}
.builtInFlag {
display: inline-block;
font: var(--font-label-medium);
color: var(--color-text);
background-color: var(--color-surface-variant);
padding: _.unit(0.5) _.unit(2);
border-radius: 10px;
}
}
.content {
border-top: 1px solid var(--color-border);
height: 481px;
overflow-y: auto;
> table {
border: none;
> thead > tr {
> th {
font: var(--font-label-large);
color: var(--color-text);
background-color: var(--color-layer-1);
}
> th:first-child {
width: 300px;
padding: _.unit(1) _.unit(5);
}
}
> tbody > tr {
> td {
border: none;
}
> td:first-child {
padding: _.unit(1) _.unit(5);
}
}
}
.customValuesColumn {
display: flex;
align-items: center;
}
.clearButton {
display: flex;
margin-left: _.unit(2);
flex-direction: row-reverse;
}
.sectionTitle {
@include _.subhead-cap;
background-color: var(--color-layer-light);
}
.sectionDataKey {
padding: _.unit(4) _.unit(5);
font: var(--font-body-medium);
color: var(--color-text);
}
.sectionBuiltInText {
padding: _.unit(2) 0;
}
}
.footer {
border-top: 1px solid var(--color-border);
display: flex;
flex-direction: row-reverse;
padding: _.unit(5);
}
}

View file

@ -0,0 +1,140 @@
import type { LanguageKey } from '@logto/core-kit';
import resource, {
languageCodeAndDisplayNameMappings,
Translation as UiTranslation,
} from '@logto/phrases-ui';
import { useEffect, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Button from '@/components/Button';
import useApi, { RequestError } from '@/hooks/use-api';
import Delete from '@/icons/Delete';
import { createEmptyUiTranslation, flattenObject } from '../../utilities';
import EditSection from './EditSection';
import * as style from './LanguageEditor.module.scss';
import { CustomPhraseResponse } from './types';
type LanguageEditorProps = {
selectedLanguageKey: LanguageKey;
};
const emptyUiTranslation = createEmptyUiTranslation();
const LanguageEditor = ({ selectedLanguageKey }: LanguageEditorProps) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const isBuiltInLanguage = Object.keys(resource).includes(selectedLanguageKey);
const translationEntries = useMemo(
() => Object.entries(resource[selectedLanguageKey].translation),
[selectedLanguageKey]
);
const api = useApi();
const { data: customPhrase, mutate } = useSWR<CustomPhraseResponse, RequestError>(
`/api/custom-phrases/${selectedLanguageKey}`,
{
shouldRetryOnError: (error: unknown) => {
if (error instanceof RequestError) {
return error.status !== 404;
}
return true;
},
}
);
const formMethods = useForm<UiTranslation>();
const {
handleSubmit,
reset,
formState: { isSubmitting },
} = formMethods;
const onSubmit = handleSubmit(async (formData: UiTranslation) => {
const updatedCustomPhrase = await api
.put(`/api/custom-phrases/${selectedLanguageKey}`, {
json: {
...formData,
},
})
.json<CustomPhraseResponse>();
void mutate(updatedCustomPhrase);
toast.success(t('general.saved'));
});
useEffect(() => {
reset(customPhrase?.translation ?? emptyUiTranslation);
}, [customPhrase, reset]);
return (
<div className={style.languageEditor}>
<div className={style.title}>
{languageCodeAndDisplayNameMappings[selectedLanguageKey]}
<span>{selectedLanguageKey}</span>
{isBuiltInLanguage && (
<span className={style.builtInFlag}>
{t('sign_in_exp.others.manage_language.logto_provided')}
</span>
)}
</div>
<form
onSubmit={async (event) => {
// Note: Avoid propagating the 'submit' event to the outer sign-in-experience form.
event.stopPropagation();
return onSubmit(event);
}}
>
<div className={style.content}>
<table>
<thead>
<tr>
<th>{t('sign_in_exp.others.manage_language.key')}</th>
<th>{t('sign_in_exp.others.manage_language.logto_source_language')}</th>
<th>
<span className={style.customValuesColumn}>
{t('sign_in_exp.others.manage_language.custom_values')}
<Button
type="plain"
title="sign_in_exp.others.manage_language.clear_all"
className={style.clearButton}
icon={<Delete />}
onClick={() => {
reset(emptyUiTranslation);
}}
/>
</span>
</th>
</tr>
</thead>
<tbody>
<FormProvider {...formMethods}>
{translationEntries.map(([key, value]) => (
<EditSection key={key} dataKey={key} data={flattenObject(value)} />
))}
</FormProvider>
</tbody>
</table>
</div>
<div className={style.footer}>
<Button
isLoading={isSubmitting}
htmlType="submit"
type="primary"
size="large"
title="general.save"
/>
</div>
</form>
</div>
);
};
export default LanguageEditor;

View file

@ -0,0 +1,31 @@
@use '@/scss/underscore' as _;
.languageItem {
padding: _.unit(1.5) _.unit(3);
margin-bottom: _.unit(1);
cursor: pointer;
border-radius: 8px;
.languageName {
font: var(--font-title-medium);
color: var(--color-text);
}
.languageKey {
font: var(--font-label-large);
color: var(--color-caption);
}
&:hover {
background-color: var(--color-hover-variant);
}
&.selected {
background-color: var(--color-focused-variant);
.languageName,
.languageKey {
color: var(--color-text-link);
}
}
}

View file

@ -0,0 +1,22 @@
import { LanguageKey } from '@logto/core-kit';
import { languageCodeAndDisplayNameMappings } from '@logto/phrases-ui';
import classNames from 'classnames';
import * as style from './LanguageItem.module.scss';
type Props = {
languageKey: LanguageKey;
isSelected: boolean;
onClick: () => void;
};
const LanguageItem = ({ languageKey, isSelected, onClick }: Props) => {
return (
<div className={classNames(style.languageItem, isSelected && style.selected)} onClick={onClick}>
<div className={style.languageName}>{languageCodeAndDisplayNameMappings[languageKey]}</div>
<div className={style.languageKey}>{languageKey}</div>
</div>
);
};
export default LanguageItem;

View file

@ -0,0 +1,21 @@
@use '@/scss/underscore' as _;
.languageNav {
width: 185px;
padding: _.unit(3) _.unit(2);
flex-shrink: 0;
background-color: var(--color-layer-light);
border-right: 1px solid var(--color-border);
.addLanguageButton {
width: 100%;
border-color: var(--color-outline);
color: var(--color-text);
background-color: unset;
margin-bottom: _.unit(3);
.iconPlus {
color: var(--color-outline);
}
}
}

View file

@ -0,0 +1,42 @@
import { LanguageKey } from '@logto/core-kit';
import Button from '@/components/Button';
import Plus from '@/icons/Plus';
import LanguageItem from './LanguageItem';
import * as style from './LanguageNav.module.scss';
type Props = {
languageKeys: LanguageKey[];
selectedLanguage: LanguageKey;
onSelect: (languageKey: LanguageKey) => void;
};
const LanguageNav = ({ languageKeys, selectedLanguage, onSelect }: Props) => {
// TODO: LOG-4146 Add Custom Language
return (
<div className={style.languageNav}>
<Button
className={style.addLanguageButton}
icon={<Plus className={style.iconPlus} />}
title="sign_in_exp.others.manage_language.add_language"
type="outline"
size="medium"
/>
<div>
{languageKeys.map((key) => (
<LanguageItem
key={key}
languageKey={key}
isSelected={selectedLanguage === key}
onClick={() => {
onSelect(key);
}}
/>
))}
</div>
</div>
);
};
export default LanguageNav;

View file

@ -0,0 +1,7 @@
.container {
display: flex;
flex-direction: row;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}

View file

@ -0,0 +1,68 @@
import { getDefaultLanguage, LanguageKey } from '@logto/core-kit';
import { languageOptions as uiLanguageOptions } from '@logto/phrases-ui';
import { useEffect, useMemo, useState } from 'react';
import Modal from 'react-modal';
import useSWR from 'swr';
import ModalLayout from '@/components/ModalLayout';
import { RequestError } from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import LanguageEditor from './LanguageEditor';
import LanguageNav from './LanguageNav';
import * as style from './index.module.scss';
import { CustomPhraseResponse } from './types';
type ManageLanguageModalProps = {
isOpen: boolean;
onClose: () => void;
};
const ManageLanguageModal = ({ isOpen, onClose }: ManageLanguageModalProps) => {
const { data: customPhraseResponses } = useSWR<CustomPhraseResponse[], RequestError>(
'/api/custom-phrases'
);
const allLanguageKeys = useMemo(() => {
const uiBuiltInLanguageKeys = uiLanguageOptions.map((option) => option.value);
const customUiLanguageKeys = customPhraseResponses?.map(({ languageKey }) => languageKey);
const allKeys = customUiLanguageKeys?.length
? [...new Set([...uiBuiltInLanguageKeys, ...customUiLanguageKeys])]
: uiBuiltInLanguageKeys;
return allKeys.slice().sort();
}, [customPhraseResponses]);
const defaultLanguageKey = getDefaultLanguage(allLanguageKeys[0] ?? '');
const [selectedLanguageKey, setSelectedLanguageKey] = useState<LanguageKey>(defaultLanguageKey);
useEffect(() => {
if (!isOpen) {
setSelectedLanguageKey(defaultLanguageKey);
}
}, [allLanguageKeys, setSelectedLanguageKey, isOpen, defaultLanguageKey]);
return (
<Modal isOpen={isOpen} className={modalStyles.content} overlayClassName={modalStyles.overlay}>
<ModalLayout
title="sign_in_exp.others.manage_language.title"
subtitle="sign_in_exp.others.manage_language.subtitle"
size="xlarge"
onClose={onClose}
>
<div className={style.container}>
<LanguageNav
languageKeys={allLanguageKeys}
selectedLanguage={selectedLanguageKey}
onSelect={setSelectedLanguageKey}
/>
<LanguageEditor selectedLanguageKey={selectedLanguageKey} />
</div>
</ModalLayout>
</Modal>
);
};
export default ManageLanguageModal;

View file

@ -0,0 +1,7 @@
import type { LanguageKey } from '@logto/core-kit';
import type { Translation as UiTranslation } from '@logto/phrases-ui';
export type CustomPhraseResponse = {
languageKey: LanguageKey;
translation: UiTranslation;
};

View file

@ -29,6 +29,10 @@
color: var(--color-caption);
}
.manageLanguage {
margin-top: _.unit(2);
}
.defaultLanguageDescription {
padding-top: _.unit(2);
font: var(--font-body-medium);

View file

@ -1,3 +1,5 @@
import type { Translation as UiTranslation } from '@logto/phrases-ui';
import en from '@logto/phrases-ui/lib/locales/en';
import {
SignInExperience,
SignInMethodKey,
@ -98,3 +100,48 @@ export const compareSignInMethods = (
return Object.values(SignInMethodKey).every((key) => beforeMethods[key] === afterMethods[key]);
};
// TODO: LOG-4235 move this method into @silverhand/essentials
const isObject = (data: unknown): data is Record<string, unknown> =>
typeof data === 'object' && !Array.isArray(data);
export const flattenObject = (
object: Record<string, unknown>,
keyPrefix = ''
): Record<string, unknown> => {
return Object.keys(object).reduce((result, key) => {
const prefix = keyPrefix ? `${keyPrefix}.` : keyPrefix;
const dataKey = `${prefix}${key}`;
const data = object[key];
return {
...result,
...(isObject(data) ? flattenObject(data, dataKey) : { [dataKey]: data }),
};
}, {});
};
const emptyTranslation = (translation: Record<string, unknown>): Record<string, unknown> => {
return Object.entries(translation).reduce<Record<string, unknown>>((result, [key, value]) => {
if (isObject(value)) {
return {
...result,
[key]: emptyTranslation(value),
};
}
if (typeof value === 'string') {
return {
...result,
[key]: '',
};
}
return { ...result, [key]: value };
}, {});
};
export const createEmptyUiTranslation = () => {
// eslint-disable-next-line no-restricted-syntax
return emptyTranslation(en.translation) as UiTranslation;
};

View file

@ -1,3 +1,4 @@
$modal-layout-width-xlarge: 1224px;
$modal-layout-width-large: 784px;
$modal-layout-width-medium: 600px;
$modal-layout-width-small: 352px;

View file

@ -6,11 +6,11 @@ import koKR from './locales/ko-kr';
import ptPT from './locales/pt-pt';
import trTR from './locales/tr-tr';
import zhCN from './locales/zh-cn';
import { Resource } from './types';
import { Resource, Translation } from './types';
export { languageOptions } from './types';
export * from './types';
export type I18nKey = NormalizeKeyPaths<typeof en.translation>;
export type I18nKey = NormalizeKeyPaths<Translation>;
const resource: Resource = {
en,

View file

@ -1,4 +1,4 @@
import en from './en';
import { LocalePhrase } from '../types';
const translation = {
input: {
@ -84,7 +84,7 @@ const translation = {
},
};
const fr: typeof en = Object.freeze({
const fr: LocalePhrase = Object.freeze({
translation,
});

View file

@ -1,4 +1,4 @@
import en from './en';
import { LocalePhrase } from '../types';
const translation = {
input: {
@ -77,7 +77,7 @@ const translation = {
},
};
const koKR: typeof en = Object.freeze({
const koKR: LocalePhrase = Object.freeze({
translation,
});

View file

@ -1,4 +1,4 @@
import en from './en';
import { LocalePhrase } from '../types';
const translation = {
input: {
@ -78,7 +78,7 @@ const translation = {
},
};
const ptPT: typeof en = Object.freeze({
const ptPT: LocalePhrase = Object.freeze({
translation,
});

View file

@ -1,4 +1,4 @@
import en from './en';
import { LocalePhrase } from '../types';
const translation = {
input: {
@ -78,7 +78,7 @@ const translation = {
},
};
const trTR: typeof en = Object.freeze({
const trTR: LocalePhrase = Object.freeze({
translation,
});

View file

@ -1,4 +1,4 @@
import en from './en';
import { LocalePhrase } from '../types';
const translation = {
input: {
@ -77,7 +77,7 @@ const translation = {
},
};
const zhCN: typeof en = Object.freeze({
const zhCN: LocalePhrase = Object.freeze({
translation,
});

View file

@ -1,13 +1,14 @@
import { LanguageKey, languageKeyGuard } from '@logto/core-kit';
import en from './locales/en';
export type Translation = typeof en.translation;
export type LocalePhrase = { translation: Translation };
/* Copied from i18next/index.d.ts */
export type Resource = Record<LanguageKey, ResourceLanguage>;
export type Resource = Record<LanguageKey, LocalePhrase>;
export type ResourceLanguage = Record<string, ResourceKey>;
export type ResourceKey = string | Record<string, unknown>;
const languageCodeAndDisplayNameMappings: Record<LanguageKey, string> = {
export const languageCodeAndDisplayNameMappings: Record<LanguageKey, string> = {
en: 'English',
fr: 'Français',
'pt-PT': 'Português',

View file

@ -81,6 +81,17 @@ const sign_in_exp = {
default_language_description_fixed:
'When auto detect is off, the default language is the only language your software will show. Turn on auto detect for language customization.',
},
manage_language: {
title: 'Manage language',
subtitle:
'Localize the product experience by adding languages and translations. Your contribution can be set as the default language.',
add_language: 'Add Language',
logto_provided: 'Logto provided',
key: 'Key',
logto_source_language: 'Logto source language',
custom_values: 'Custom values',
clear_all: 'Clear all',
},
authentication: {
title: 'AUTHENTICATION',
enable_create_account: 'Enable create account',

View file

@ -83,6 +83,17 @@ const sign_in_exp = {
default_language_description_fixed:
'When auto detect is off, the default language is the only language your software will show. Turn on auto detect for language customization.', // UNTRANSLATED
},
manage_language: {
title: 'Manage language', // UNTRANSLATED
subtitle:
'Localize the product experience by adding languages and translations. Your contribution can be set as the default language.', // UNTRANSLATED
add_language: 'Add Language', // UNTRANSLATED
logto_provided: 'Logto provided', // UNTRANSLATED
key: 'Key', // UNTRANSLATED
logto_source_language: 'Logto source language', // UNTRANSLATED
custom_values: 'Custom values', // UNTRANSLATED
clear_all: 'Clear all', // UNTRANSLATED
},
authentication: {
title: 'AUTHENTICATION',
enable_create_account: 'Enable create account',

View file

@ -78,6 +78,17 @@ const sign_in_exp = {
default_language_description_fixed:
'When auto detect is off, the default language is the only language your software will show. Turn on auto detect for language customization.', // UNTRANSLATED
},
manage_language: {
title: 'Manage language', // UNTRANSLATED
subtitle:
'Localize the product experience by adding languages and translations. Your contribution can be set as the default language.', // UNTRANSLATED
add_language: 'Add Language', // UNTRANSLATED
logto_provided: 'Logto provided', // UNTRANSLATED
key: 'Key', // UNTRANSLATED
logto_source_language: 'Logto source language', // UNTRANSLATED
custom_values: 'Custom values', // UNTRANSLATED
clear_all: 'Clear all', // UNTRANSLATED
},
authentication: {
title: 'AUTHENTICATION',
enable_create_account: 'Enable create account',

View file

@ -81,6 +81,17 @@ const sign_in_exp = {
default_language_description_fixed:
'When auto detect is off, the default language is the only language your software will show. Turn on auto detect for language customization.', // UNTRANSLATED
},
manage_language: {
title: 'Manage language', // UNTRANSLATED
subtitle:
'Localize the product experience by adding languages and translations. Your contribution can be set as the default language.', // UNTRANSLATED
add_language: 'Add Language', // UNTRANSLATED
logto_provided: 'Logto provided', // UNTRANSLATED
key: 'Key', // UNTRANSLATED
logto_source_language: 'Logto source language', // UNTRANSLATED
custom_values: 'Custom values', // UNTRANSLATED
clear_all: 'Clear all', // UNTRANSLATED
},
authentication: {
title: 'AUTENTICAÇÃO',
enable_create_account: 'Permitir criar conta?',

View file

@ -82,6 +82,17 @@ const sign_in_exp = {
default_language_description_fixed:
'When auto detect is off, the default language is the only language your software will show. Turn on auto detect for language customization.', // UNTRANSLATED
},
manage_language: {
title: 'Manage language', // UNTRANSLATED
subtitle:
'Localize the product experience by adding languages and translations. Your contribution can be set as the default language.', // UNTRANSLATED
add_language: 'Add Language', // UNTRANSLATED
logto_provided: 'Logto provided', // UNTRANSLATED
key: 'Key', // UNTRANSLATED
logto_source_language: 'Logto source language', // UNTRANSLATED
custom_values: 'Custom values', // UNTRANSLATED
clear_all: 'Clear all', // UNTRANSLATED
},
authentication: {
title: 'AUTHENTICATION',
enable_create_account: 'Enable create account',

View file

@ -78,6 +78,17 @@ const sign_in_exp = {
default_language_description_fixed:
'When auto detect is off, the default language is the only language your software will show. Turn on auto detect for language customization.', // UNTRANSLATED
},
manage_language: {
title: 'Manage language', // UNTRANSLATED
subtitle:
'Localize the product experience by adding languages and translations. Your contribution can be set as the default language.', // UNTRANSLATED
add_language: 'Add Language', // UNTRANSLATED
logto_provided: 'Logto provided', // UNTRANSLATED
key: 'Key', // UNTRANSLATED
logto_source_language: 'Logto source language', // UNTRANSLATED
custom_values: 'Custom values', // UNTRANSLATED
clear_all: 'Clear all', // UNTRANSLATED
},
authentication: {
title: '身份验证',
enable_create_account: '启用创建帐号',