From 800ac7fcd9592875df29d897e3a704fc6a73fee1 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Fri, 30 Sep 2022 16:19:16 +0800 Subject: [PATCH] feat(console): add custom language (#2029) --- .../components/LanguagesForm.tsx | 18 +- .../AddLanguageSelector.module.scss | 24 +++ .../AddLanguageSelector.tsx | 60 +++++++ .../ManageLanguageModal/LanguageEditor.tsx | 94 +++++++--- .../ManageLanguageModal/LanguageItem.tsx | 15 +- .../LanguageNav.module.scss | 14 +- .../ManageLanguageModal/LanguageNav.tsx | 68 +++++--- .../components/ManageLanguageModal/index.tsx | 121 +++++-------- .../components/ManageLanguageModal/types.ts | 7 - .../hooks/use-custom-phrases-context.ts | 165 ++++++++++++++++++ .../src/pages/SignInExperience/types.ts | 7 + 11 files changed, 451 insertions(+), 142 deletions(-) create mode 100644 packages/console/src/pages/SignInExperience/components/ManageLanguageModal/AddLanguageSelector.module.scss create mode 100644 packages/console/src/pages/SignInExperience/components/ManageLanguageModal/AddLanguageSelector.tsx delete mode 100644 packages/console/src/pages/SignInExperience/components/ManageLanguageModal/types.ts create mode 100644 packages/console/src/pages/SignInExperience/hooks/use-custom-phrases-context.ts diff --git a/packages/console/src/pages/SignInExperience/components/LanguagesForm.tsx b/packages/console/src/pages/SignInExperience/components/LanguagesForm.tsx index 7ed8ff7a9..eea6279b7 100644 --- a/packages/console/src/pages/SignInExperience/components/LanguagesForm.tsx +++ b/packages/console/src/pages/SignInExperience/components/LanguagesForm.tsx @@ -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 ( <>
{t('sign_in_exp.others.languages.title')}
@@ -56,12 +60,14 @@ const LanguagesForm = ({ isManageLanguageVisible = false }: Props) => { : t('sign_in_exp.others.languages.default_language_description_fixed')} - { - setIsManageLanguageFormOpen(false); - }} - /> + + { + setIsManageLanguageFormOpen(false); + }} + /> + ); }; diff --git a/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/AddLanguageSelector.module.scss b/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/AddLanguageSelector.module.scss new file mode 100644 index 000000000..34d9dbdee --- /dev/null +++ b/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/AddLanguageSelector.module.scss @@ -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); + } +} diff --git a/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/AddLanguageSelector.tsx b/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/AddLanguageSelector.tsx new file mode 100644 index 000000000..d6a8339bb --- /dev/null +++ b/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/AddLanguageSelector.tsx @@ -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(null); + const [isDropDownOpen, setIsDropDownOpen] = useState(false); + + return ( +
+
+
+ { + setIsDropDownOpen(false); + }} + > + {options.map((languageTag) => ( + { + onSelect(languageTag); + }} + > +
+
{languages[languageTag]}
+
{languageTag}
+
+
+ ))} +
+
+ ); +}; + +export default AddLanguageSelector; diff --git a/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageEditor.tsx b/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageEditor.tsx index 223819068..25c34ea3c 100644 --- a/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageEditor.tsx +++ b/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageEditor.tsx @@ -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( `/api/custom-phrases/${selectedLanguageTag}`, { @@ -50,34 +51,79 @@ const LanguageEditor = ({ selectedLanguageTag, onFormStateChange }: LanguageEdit } ); - const formMethods = useForm(); + const defaultFormValues = useMemo( + () => + customPhrase && Object.keys(customPhrase.translation).length > 0 + ? customPhrase.translation + : emptyUiTranslation, + [customPhrase] + ); + + const formMethods = useForm({ + 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(); + + 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(); - + 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 (
diff --git a/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageItem.tsx b/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageItem.tsx index 11cbdef4a..d6b7f1b1e 100644 --- a/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageItem.tsx +++ b/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageItem.tsx @@ -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(null); + + useEffect(() => { + if (isSelected) { + itemRef.current?.scrollIntoView(false); + } + }, [isSelected]); + return ( -
+
{languages[languageTag]}
{languageTag}
diff --git a/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageNav.module.scss b/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageNav.module.scss index 8ee751fe7..7c36698b0 100644 --- a/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageNav.module.scss +++ b/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageNav.module.scss @@ -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; } } diff --git a/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageNav.tsx b/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageNav.tsx index 0f8232048..37be151c3 100644 --- a/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageNav.tsx +++ b/packages/console/src/pages/SignInExperience/components/ManageLanguageModal/LanguageNav.tsx @@ -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 (
-