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:
parent
3eb44e1e56
commit
800ac7fcd9
11 changed files with 451 additions and 142 deletions
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import type { LanguageTag } from '@logto/language-kit';
|
||||
import type { Translation } from '@logto/schemas';
|
||||
|
||||
export type CustomPhraseResponse = {
|
||||
languageTag: LanguageTag;
|
||||
translation: Translation;
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue