0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): add custom language (#2029)

This commit is contained in:
Xiao Yijun 2022-09-30 16:19:16 +08:00 committed by GitHub
parent 3eb44e1e56
commit 800ac7fcd9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 451 additions and 142 deletions

View file

@ -9,6 +9,7 @@ import Select from '@/components/Select';
import Switch from '@/components/Switch';
import * as textButtonStyles from '@/components/TextButton/index.module.scss';
import useCustomPhrasesContext from '../hooks/use-custom-phrases-context';
import { SignInExperienceForm } from '../types';
import ManageLanguageModal from './ManageLanguageModal';
import * as styles from './index.module.scss';
@ -23,6 +24,9 @@ const LanguagesForm = ({ isManageLanguageVisible = false }: Props) => {
const isAutoDetect = watch('languageInfo.autoDetect');
const [isManageLanguageFormOpen, setIsManageLanguageFormOpen] = useState(false);
const { context: customPhrasesContext, Provider: CustomPhrasesContextProvider } =
useCustomPhrasesContext();
return (
<>
<div className={styles.title}>{t('sign_in_exp.others.languages.title')}</div>
@ -56,12 +60,14 @@ const LanguagesForm = ({ isManageLanguageVisible = false }: Props) => {
: t('sign_in_exp.others.languages.default_language_description_fixed')}
</div>
</FormField>
<ManageLanguageModal
isOpen={isManageLanguageFormOpen}
onClose={() => {
setIsManageLanguageFormOpen(false);
}}
/>
<CustomPhrasesContextProvider value={customPhrasesContext}>
<ManageLanguageModal
isOpen={isManageLanguageFormOpen}
onClose={() => {
setIsManageLanguageFormOpen(false);
}}
/>
</CustomPhrasesContextProvider>
</>
);
};

View file

@ -0,0 +1,24 @@
@use '@/scss/underscore' as _;
.addLanguageButton {
width: 100%;
border-color: var(--color-outline);
color: var(--color-text);
background: unset;
.iconPlus {
color: var(--color-outline);
}
}
.dropDownItem {
.languageName {
font: var(--font-label-large);
color: var(--color-text);
}
.languageTag {
font: var(--font-body-medium);
color: var(--color-caption);
}
}

View file

@ -0,0 +1,60 @@
import { languages, LanguageTag } from '@logto/language-kit';
import { useRef, useState } from 'react';
import Button from '@/components/Button';
import Dropdown, { DropdownItem } from '@/components/Dropdown';
import Plus from '@/icons/Plus';
import * as style from './AddLanguageSelector.module.scss';
type Props = {
options: LanguageTag[];
onSelect: (languageTag: LanguageTag) => void;
};
// TODO:(LOG-4147) Support Instant Search In Manage Language Editor Dropdown
const AddLanguageSelector = ({ options, onSelect }: Props) => {
const anchorRef = useRef<HTMLDivElement>(null);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
return (
<div>
<div ref={anchorRef}>
<Button
className={style.addLanguageButton}
icon={<Plus className={style.iconPlus} />}
title="sign_in_exp.others.manage_language.add_language"
type="outline"
size="medium"
onClick={() => {
setIsDropDownOpen(true);
}}
/>
</div>
<Dropdown
isFullWidth
anchorRef={anchorRef}
isOpen={isDropDownOpen}
onClose={() => {
setIsDropDownOpen(false);
}}
>
{options.map((languageTag) => (
<DropdownItem
key={languageTag}
onClick={() => {
onSelect(languageTag);
}}
>
<div className={style.dropDownItem}>
<div className={style.languageName}>{languages[languageTag]}</div>
<div className={style.languageTag}>{languageTag}</div>
</div>
</DropdownItem>
))}
</Dropdown>
</div>
);
};
export default AddLanguageSelector;

View file

@ -3,7 +3,7 @@ import resource, { isBuiltInLanguageTag } from '@logto/phrases-ui';
import en from '@logto/phrases-ui/lib/locales/en';
import { Translation } from '@logto/schemas';
import cleanDeep from 'clean-deep';
import { useEffect, useMemo } from 'react';
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
@ -13,21 +13,24 @@ import Button from '@/components/Button';
import useApi, { RequestError } from '@/hooks/use-api';
import Delete from '@/icons/Delete';
import { CustomPhrasesContext } from '../../hooks/use-custom-phrases-context';
import { CustomPhraseResponse } from '../../types';
import { createEmptyUiTranslation, flattenTranslation } from '../../utilities';
import EditSection from './EditSection';
import * as style from './LanguageEditor.module.scss';
import { CustomPhraseResponse } from './types';
type LanguageEditorProps = {
selectedLanguageTag: LanguageTag;
onFormStateChange: (isDirty: boolean) => void;
};
const emptyUiTranslation = createEmptyUiTranslation();
const LanguageEditor = ({ selectedLanguageTag, onFormStateChange }: LanguageEditorProps) => {
const LanguageEditor = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
selectedLanguageTag,
appendToCustomPhraseList,
setIsCurrentCustomPhraseDirty,
stopAddingLanguage,
} = useContext(CustomPhrasesContext);
const isBuiltIn = isBuiltInLanguageTag(selectedLanguageTag);
const translationEntries = useMemo(
@ -35,8 +38,6 @@ const LanguageEditor = ({ selectedLanguageTag, onFormStateChange }: LanguageEdit
[isBuiltIn, selectedLanguageTag]
);
const api = useApi();
const { data: customPhrase, mutate } = useSWR<CustomPhraseResponse, RequestError>(
`/api/custom-phrases/${selectedLanguageTag}`,
{
@ -50,34 +51,79 @@ const LanguageEditor = ({ selectedLanguageTag, onFormStateChange }: LanguageEdit
}
);
const formMethods = useForm<Translation>();
const defaultFormValues = useMemo(
() =>
customPhrase && Object.keys(customPhrase.translation).length > 0
? customPhrase.translation
: emptyUiTranslation,
[customPhrase]
);
const formMethods = useForm<Translation>({
defaultValues: defaultFormValues,
});
const {
handleSubmit,
reset,
formState: { isSubmitting, isDirty },
formState: { isSubmitting, isDirty, dirtyFields },
} = formMethods;
useEffect(() => {
onFormStateChange(isDirty);
}, [isDirty, onFormStateChange]);
/**
* Note: This is a workaround for dirty state checking,
* for the `isDirty` state does not work correctly when comparing form data with empty / undefined values.
* Reference: https://github.com/react-hook-form/react-hook-form/issues/4740
*/
setIsCurrentCustomPhraseDirty(isDirty && Object.keys(dirtyFields).length > 0);
}, [
/**
* Note: `isDirty` is used to trigger this `useEffect`; for `dirtyFields` object only marks filed dirty at field level.
* When `dirtyFields` is changed from `{keyA: false}` to `{keyA: true}`, this `useEffect` won't be triggered.
*/
isDirty,
dirtyFields,
setIsCurrentCustomPhraseDirty,
]);
const api = useApi();
const upsertCustomPhrase = useCallback(
async (languageTag: LanguageTag, translation: Translation) => {
const updatedCustomPhrase = await api
.put(`/api/custom-phrases/${languageTag}`, {
json: {
...cleanDeep(translation),
},
})
.json<CustomPhraseResponse>();
appendToCustomPhraseList(updatedCustomPhrase);
stopAddingLanguage();
return updatedCustomPhrase;
},
[api, appendToCustomPhraseList, stopAddingLanguage]
);
const onSubmit = handleSubmit(async (formData: Translation) => {
const updatedCustomPhrase = await api
.put(`/api/custom-phrases/${selectedLanguageTag}`, {
json: {
...cleanDeep(formData),
},
})
.json<CustomPhraseResponse>();
const updatedCustomPhrase = await upsertCustomPhrase(selectedLanguageTag, formData);
void mutate(updatedCustomPhrase);
toast.success(t('general.saved'));
});
useEffect(() => {
reset(customPhrase?.translation ?? emptyUiTranslation);
}, [customPhrase, reset]);
reset(defaultFormValues);
}, [
/**
* Note: trigger form reset when selectedLanguageTag changed,
* for the `defaultValues` will not change when switching between languages with unavailable custom phrases.
*/
selectedLanguageTag,
defaultFormValues,
reset,
]);
return (
<div className={style.languageEditor}>

View file

@ -1,5 +1,6 @@
import { languages, LanguageTag } from '@logto/language-kit';
import classNames from 'classnames';
import { useEffect, useRef } from 'react';
import * as style from './LanguageItem.module.scss';
@ -10,8 +11,20 @@ type Props = {
};
const LanguageItem = ({ languageTag, isSelected, onClick }: Props) => {
const itemRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isSelected) {
itemRef.current?.scrollIntoView(false);
}
}, [isSelected]);
return (
<div className={classNames(style.languageItem, isSelected && style.selected)} onClick={onClick}>
<div
ref={itemRef}
className={classNames(style.languageItem, isSelected && style.selected)}
onClick={onClick}
>
<div className={style.languageName}>{languages[languageTag]}</div>
<div className={style.languageTag}>{languageTag}</div>
</div>

View file

@ -7,15 +7,9 @@
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);
}
.languageItemList {
margin-top: _.unit(3);
height: 569px;
overflow-y: auto;
}
}

View file

@ -1,36 +1,62 @@
import { LanguageTag } from '@logto/language-kit';
import Button from '@/components/Button';
import Plus from '@/icons/Plus';
import { isLanguageTag, languages, LanguageTag } from '@logto/language-kit';
import { useContext } from 'react';
import { CustomPhrasesContext } from '../../hooks/use-custom-phrases-context';
import AddLanguageSelector from './AddLanguageSelector';
import LanguageItem from './LanguageItem';
import * as style from './LanguageNav.module.scss';
type Props = {
languageTags: LanguageTag[];
selectedLanguageTag: LanguageTag;
onSelect: (languageTag: LanguageTag) => void;
};
const LanguageNav = () => {
const {
displayingLanguages,
selectedLanguageTag,
isAddingLanguage,
isCurrentCustomPhraseDirty,
setConfirmationState,
setSelectedLanguageTag,
setPreSelectedLanguageTag,
setPreAddedLanguageTag,
startAddingLanguage,
} = useContext(CustomPhrasesContext);
const languageOptions = Object.keys(languages).filter(
(languageTag): languageTag is LanguageTag =>
isLanguageTag(languageTag) && !displayingLanguages.includes(languageTag)
);
const onAddLanguage = (languageTag: LanguageTag) => {
if (isCurrentCustomPhraseDirty || isAddingLanguage) {
setPreAddedLanguageTag(languageTag);
setConfirmationState('try-add-language');
return;
}
startAddingLanguage(languageTag);
};
const onSwitchLanguage = (languageTag: LanguageTag) => {
if (isCurrentCustomPhraseDirty || isAddingLanguage) {
setPreSelectedLanguageTag(languageTag);
setConfirmationState('try-switch-language');
return;
}
setSelectedLanguageTag(languageTag);
};
const LanguageNav = ({ languageTags, selectedLanguageTag, 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>
{languageTags.map((languageTag) => (
<AddLanguageSelector options={languageOptions} onSelect={onAddLanguage} />
<div className={style.languageItemList}>
{displayingLanguages.map((languageTag) => (
<LanguageItem
key={languageTag}
languageTag={languageTag}
isSelected={selectedLanguageTag === languageTag}
onClick={() => {
onSelect(languageTag);
onSwitchLanguage(languageTag);
}}
/>
))}

View file

@ -1,19 +1,15 @@
import { LanguageTag } from '@logto/language-kit';
import { builtInLanguages as builtInUiLanguages } from '@logto/phrases-ui';
import { useEffect, useMemo, useState } from 'react';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import useSWR from 'swr';
import ConfirmModal from '@/components/ConfirmModal';
import ModalLayout from '@/components/ModalLayout';
import { RequestError } from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { CustomPhrasesContext } from '../../hooks/use-custom-phrases-context';
import LanguageEditor from './LanguageEditor';
import LanguageNav from './LanguageNav';
import * as style from './index.module.scss';
import { CustomPhraseResponse } from './types';
type ManageLanguageModalProps = {
isOpen: boolean;
@ -22,37 +18,50 @@ type ManageLanguageModalProps = {
const ManageLanguageModal = ({ isOpen, onClose }: ManageLanguageModalProps) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data: customPhraseResponses } = useSWR<CustomPhraseResponse[], RequestError>(
'/api/custom-phrases'
);
const allLanguageTags = useMemo(
() =>
[
...new Set([
...builtInUiLanguages,
...(customPhraseResponses?.map(({ languageTag }) => languageTag) ?? []),
]),
]
.slice()
.sort(),
[customPhraseResponses]
);
const {
preSelectedLanguageTag,
preAddedLanguageTag,
isAddingLanguage,
isCurrentCustomPhraseDirty,
confirmationState,
setSelectedLanguageTag,
setPreSelectedLanguageTag,
setConfirmationState,
startAddingLanguage,
stopAddingLanguage,
resetSelectedLanguageTag,
} = useContext(CustomPhrasesContext);
const defaultLanguageTag = allLanguageTags[0] ?? 'en';
const onCloseModal = () => {
if (isAddingLanguage || isCurrentCustomPhraseDirty) {
setConfirmationState('try-close');
const [selectedLanguageTag, setSelectedLanguageTag] = useState<LanguageTag>(defaultLanguageTag);
const [isLanguageEditorDirty, setIsLanguageEditorDirty] = useState(false);
const [isUnsavedAlertOpen, setIsUnsavedAlertOpen] = useState(false);
const [preselectedLanguageTag, setPreselectedLanguageTag] = useState<LanguageTag>();
useEffect(() => {
if (!isOpen) {
setSelectedLanguageTag(defaultLanguageTag);
return;
}
}, [allLanguageTags, setSelectedLanguageTag, isOpen, defaultLanguageTag]);
onClose();
resetSelectedLanguageTag();
};
const onConfirmUnsavedChanges = () => {
stopAddingLanguage(true);
if (confirmationState === 'try-close') {
onClose();
}
if (confirmationState === 'try-switch-language' && preSelectedLanguageTag) {
setSelectedLanguageTag(preSelectedLanguageTag);
setPreSelectedLanguageTag(undefined);
}
if (confirmationState === 'try-add-language' && preAddedLanguageTag) {
startAddingLanguage(preAddedLanguageTag);
}
setConfirmationState('none');
};
return (
<Modal isOpen={isOpen} className={modalStyles.content} overlayClassName={modalStyles.overlay}>
@ -60,54 +69,20 @@ const ManageLanguageModal = ({ isOpen, onClose }: ManageLanguageModalProps) => {
title="sign_in_exp.others.manage_language.title"
subtitle="sign_in_exp.others.manage_language.subtitle"
size="xlarge"
onClose={() => {
if (isLanguageEditorDirty) {
setPreselectedLanguageTag(undefined);
setIsUnsavedAlertOpen(true);
return;
}
onClose();
}}
onClose={onCloseModal}
>
<div className={style.container}>
<LanguageNav
languageTags={allLanguageTags}
selectedLanguageTag={selectedLanguageTag}
onSelect={(languageTag) => {
if (isLanguageEditorDirty) {
setPreselectedLanguageTag(languageTag);
setIsUnsavedAlertOpen(true);
return;
}
setSelectedLanguageTag(languageTag);
}}
/>
<LanguageEditor
selectedLanguageTag={selectedLanguageTag}
onFormStateChange={setIsLanguageEditorDirty}
/>
<LanguageNav />
<LanguageEditor />
</div>
</ModalLayout>
<ConfirmModal
isOpen={isUnsavedAlertOpen}
isOpen={confirmationState !== 'none'}
cancelButtonText="general.stay_on_page"
onCancel={() => {
setIsUnsavedAlertOpen(false);
}}
onConfirm={() => {
setIsUnsavedAlertOpen(false);
if (preselectedLanguageTag) {
setSelectedLanguageTag(preselectedLanguageTag);
setPreselectedLanguageTag(undefined);
return;
}
onClose();
setConfirmationState('none');
}}
onConfirm={onConfirmUnsavedChanges}
>
{t('sign_in_exp.others.manage_language.unsaved_description')}
</ConfirmModal>

View file

@ -1,7 +0,0 @@
import type { LanguageTag } from '@logto/language-kit';
import type { Translation } from '@logto/schemas';
export type CustomPhraseResponse = {
languageTag: LanguageTag;
translation: Translation;
};

View file

@ -0,0 +1,165 @@
import { LanguageTag } from '@logto/language-kit';
import { builtInLanguages as builtInUiLanguages } from '@logto/phrases-ui';
import { createContext, useCallback, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { RequestError } from '@/hooks/use-api';
import { CustomPhraseResponse } from '../types';
const noop = () => {
throw new Error('Context provider not found');
};
export type ConfirmationState = 'none' | 'try-close' | 'try-switch-language' | 'try-add-language';
export type Context = {
displayingLanguages: LanguageTag[];
selectedLanguageTag: LanguageTag;
preSelectedLanguageTag: LanguageTag | undefined;
preAddedLanguageTag: LanguageTag | undefined;
isAddingLanguage: boolean;
isCurrentCustomPhraseDirty: boolean;
confirmationState: ConfirmationState;
setSelectedLanguageTag: React.Dispatch<React.SetStateAction<LanguageTag>>;
resetSelectedLanguageTag: () => void;
setPreSelectedLanguageTag: React.Dispatch<React.SetStateAction<LanguageTag | undefined>>;
setPreAddedLanguageTag: React.Dispatch<React.SetStateAction<LanguageTag | undefined>>;
setIsCurrentCustomPhraseDirty: React.Dispatch<React.SetStateAction<boolean>>;
appendToCustomPhraseList: (customPhrase: CustomPhraseResponse) => void;
setConfirmationState: React.Dispatch<React.SetStateAction<ConfirmationState>>;
startAddingLanguage: (languageTag: LanguageTag) => void;
stopAddingLanguage: (isCanceled?: boolean) => void;
};
export const CustomPhrasesContext = createContext<Context>({
displayingLanguages: [],
selectedLanguageTag: 'en',
preSelectedLanguageTag: undefined,
preAddedLanguageTag: undefined,
isAddingLanguage: false,
isCurrentCustomPhraseDirty: false,
confirmationState: 'none',
setSelectedLanguageTag: noop,
resetSelectedLanguageTag: noop,
setPreSelectedLanguageTag: noop,
setPreAddedLanguageTag: noop,
setIsCurrentCustomPhraseDirty: noop,
appendToCustomPhraseList: noop,
setConfirmationState: noop,
startAddingLanguage: noop,
stopAddingLanguage: noop,
});
const useCustomPhrasesContext = () => {
const { data: customPhraseList, mutate: mutateCustomPhraseList } = useSWR<
CustomPhraseResponse[],
RequestError
>('/api/custom-phrases');
const existedLanguageTags = useMemo(
() =>
[
...new Set([
...builtInUiLanguages,
...(customPhraseList?.map(({ languageTag }) => languageTag) ?? []),
]),
]
.slice()
.sort(),
[customPhraseList]
);
const [displayingLanguages, setDisplayingLanguages] =
useState<LanguageTag[]>(existedLanguageTags);
useEffect(() => {
setDisplayingLanguages(existedLanguageTags);
}, [existedLanguageTags]);
const defaultLanguageTag = useMemo(() => existedLanguageTags[0] ?? 'en', [existedLanguageTags]);
const [selectedLanguageTag, setSelectedLanguageTag] = useState<LanguageTag>(defaultLanguageTag);
const [preSelectedLanguageTag, setPreSelectedLanguageTag] = useState<LanguageTag>();
const [preAddedLanguageTag, setPreAddedLanguageTag] = useState<LanguageTag>();
const [isAddingLanguage, setIsAddingLanguage] = useState(false);
const [isCurrentCustomPhraseDirty, setIsCurrentCustomPhraseDirty] = useState(false);
const [confirmationState, setConfirmationState] = useState<ConfirmationState>('none');
const appendToCustomPhraseList = useCallback(
(customPhrase: CustomPhraseResponse) => {
void mutateCustomPhraseList([
customPhrase,
...(customPhraseList?.filter(
({ languageTag }) => languageTag !== customPhrase.languageTag
) ?? []),
]);
},
[customPhraseList, mutateCustomPhraseList]
);
const startAddingLanguage = useCallback(
(languageTag: LanguageTag) => {
setDisplayingLanguages([...new Set([languageTag, ...existedLanguageTags])].slice().sort());
setSelectedLanguageTag(languageTag);
setIsAddingLanguage(true);
},
[existedLanguageTags]
);
const stopAddingLanguage = useCallback(
(isCanceled = false) => {
if (isAddingLanguage) {
if (isCanceled) {
setDisplayingLanguages(existedLanguageTags);
}
setIsAddingLanguage(false);
}
},
[existedLanguageTags, isAddingLanguage]
);
const resetSelectedLanguageTag = useCallback(() => {
setSelectedLanguageTag(defaultLanguageTag);
}, [defaultLanguageTag]);
const context = useMemo<Context>(() => {
return {
displayingLanguages,
selectedLanguageTag,
preSelectedLanguageTag,
preAddedLanguageTag,
isAddingLanguage,
isCurrentCustomPhraseDirty,
confirmationState,
setSelectedLanguageTag,
resetSelectedLanguageTag,
setPreSelectedLanguageTag,
setPreAddedLanguageTag,
setIsCurrentCustomPhraseDirty,
appendToCustomPhraseList,
setConfirmationState,
startAddingLanguage,
stopAddingLanguage,
};
}, [
displayingLanguages,
selectedLanguageTag,
preSelectedLanguageTag,
preAddedLanguageTag,
isAddingLanguage,
isCurrentCustomPhraseDirty,
confirmationState,
resetSelectedLanguageTag,
appendToCustomPhraseList,
startAddingLanguage,
stopAddingLanguage,
]);
return {
context,
Provider: CustomPhrasesContext.Provider,
};
};
export default useCustomPhrasesContext;

View file

@ -1,4 +1,6 @@
import type { LanguageTag } from '@logto/language-kit';
import { SignInExperience, SignInMethodKey } from '@logto/schemas';
import type { Translation } from '@logto/schemas';
export type SignInExperienceForm = Omit<SignInExperience, 'signInMethods'> & {
signInMethods: {
@ -11,3 +13,8 @@ export type SignInExperienceForm = Omit<SignInExperience, 'signInMethods'> & {
};
createAccountEnabled: boolean;
};
export type CustomPhraseResponse = {
languageTag: LanguageTag;
translation: Translation;
};