feat: i18n better locale error handling (#949)

This commit is contained in:
Daniel Lautzenheiser 2023-10-24 12:41:14 -04:00 committed by GitHub
parent f29a5f36c8
commit a31a47bc7f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 294 additions and 55 deletions

View File

@ -504,7 +504,7 @@ collections:
name: file name: file
widget: file widget: file
- name: i18n_playground - name: i18n_playground
label: i18n Playground label: i18n (Multiple Files)
i18n: true i18n: true
folder: packages/core/dev-test/backends/proxy/_i18n_playground folder: packages/core/dev-test/backends/proxy/_i18n_playground
identifier_field: slug identifier_field: slug
@ -524,6 +524,54 @@ collections:
label: Date label: Date
widget: datetime widget: datetime
i18n: duplicate i18n: duplicate
- name: i18n_playground_multiple_folders
label: i18n (Multiple Folders)
i18n:
structure: multiple_folders
locales: [en, de, fr]
defaultLocale: en
folder: packages/core/dev-test/backends/proxy/_i18n_playground_multiple_folders
identifier_field: slug
create: true
fields:
# The slug field will be omitted from the translation.
- name: slug
label: Slug
widget: string
# same as 'i18n: translate'. Allows translation of the description field
- name: description
label: Description
widget: text
i18n: true
# The date field will be duplicated from the default locale.
- name: date
label: Date
widget: datetime
i18n: duplicate
- name: i18n_playground_single_file
label: i18n (Single File)
i18n:
structure: single_file
locales: [en, de, fr]
defaultLocale: en
folder: packages/core/dev-test/backends/proxy/_i18n_playground_multiple_folders
identifier_field: slug
create: true
fields:
# The slug field will be omitted from the translation.
- name: slug
label: Slug
widget: string
# same as 'i18n: translate'. Allows translation of the description field
- name: description
label: Description
widget: text
i18n: true
# The date field will be duplicated from the default locale.
- name: date
label: Date
widget: datetime
i18n: duplicate
- name: pages - name: pages
label: Nested Pages label: Nested Pages
label_singular: 'Page' label_singular: 'Page'

View File

@ -1794,6 +1794,62 @@ const testConfig: Config<RelationKitchenSinkPostField> = {
}, },
], ],
}, },
{
name: 'i18n_playground_multiple_folders',
label: 'i18n (Multiple Folders)',
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de', 'fr'],
defaultLocale: 'en',
},
folder: 'packages/core/dev-test/backends/proxy/_i18n_playground_multiple_folders',
identifier_field: 'slug',
create: true,
fields: [
{
name: 'slug',
label: 'Slug',
widget: 'string',
},
{
name: 'description',
label: 'Description',
widget: 'text',
i18n: true,
},
{
name: 'date',
label: 'Date',
widget: 'datetime',
i18n: 'duplicate',
},
],
},
{
name: 'i18n_playground_single_file',
label: 'i18n (Single File)',
i18n: {
structure: 'single_file',
locales: ['en', 'de', 'fr'],
defaultLocale: 'en',
},
folder: 'packages/core/dev-test/backends/proxy/_i18n_playground_single_file',
identifier_field: 'slug',
create: true,
fields: [
{
name: 'slug',
label: 'Slug',
widget: 'string',
},
{
name: 'description',
label: 'Description',
widget: 'text',
i18n: true,
},
],
},
], ],
}; };

View File

@ -14,19 +14,22 @@
} }
& .CMS_Editor_i18n { & .CMS_Editor_i18n {
@apply flex @apply hidden
w-full w-full
overflow-y-auto overflow-y-auto
h-main-mobile h-main-mobile
md:h-main; md:h-main;
&.CMS_Editor_i18n-active {
@apply flex;
}
} }
& .CMS_Editor_mobile-i18n { & .CMS_Editor_mobile-i18n {
@apply flex @apply flex
w-full w-full
overflow-y-auto overflow-y-auto
h-main-mobile h-main-mobile;
md:hidden;
} }
& .CMS_Editor_root { & .CMS_Editor_root {

View File

@ -4,6 +4,7 @@ import { ScrollSyncPane } from 'react-scroll-sync';
import { EDITOR_SIZE_COMPACT } from '@staticcms/core/constants/views'; import { EDITOR_SIZE_COMPACT } from '@staticcms/core/constants/views';
import { summaryFormatter } from '@staticcms/core/lib/formatters'; import { summaryFormatter } from '@staticcms/core/lib/formatters';
import useBreadcrumbs from '@staticcms/core/lib/hooks/useBreadcrumbs'; import useBreadcrumbs from '@staticcms/core/lib/hooks/useBreadcrumbs';
import { useIsSmallScreen } from '@staticcms/core/lib/hooks/useMediaQuery';
import { getI18nInfo, getPreviewEntry, hasI18n } from '@staticcms/core/lib/i18n'; import { getI18nInfo, getPreviewEntry, hasI18n } from '@staticcms/core/lib/i18n';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { import {
@ -34,6 +35,7 @@ export const classes = generateClassNames('Editor', [
'root', 'root',
'default', 'default',
'i18n', 'i18n',
'i18n-active',
'mobile-i18n', 'mobile-i18n',
'split-view', 'split-view',
'mobile-preview', 'mobile-preview',
@ -120,10 +122,12 @@ const EditorInterface = ({
}: TranslatedProps<EditorInterfaceProps>) => { }: TranslatedProps<EditorInterfaceProps>) => {
const config = useAppSelector(selectConfig); const config = useAppSelector(selectConfig);
const isSmallScreen = useIsSmallScreen();
const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {}; const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {};
const translatedLocales = useMemo( const translatedLocales = useMemo(
() => locales?.filter(locale => locale !== defaultLocale) ?? [], () => (isSmallScreen ? locales : locales?.filter(locale => locale !== defaultLocale)) ?? [],
[locales, defaultLocale], [isSmallScreen, locales, defaultLocale],
); );
const [previewActive, setPreviewActive] = useState( const [previewActive, setPreviewActive] = useState(
@ -140,6 +144,10 @@ const EditorInterface = ({
(i18nActive ? translatedLocales?.[0] : defaultLocale) ?? 'en', (i18nActive ? translatedLocales?.[0] : defaultLocale) ?? 'en',
); );
useEffect(() => {
setSelectedLocale((i18nActive ? translatedLocales?.[0] : defaultLocale) ?? 'en');
}, [defaultLocale, i18nActive, translatedLocales]);
useEffect(() => { useEffect(() => {
loadScroll(); loadScroll();
}, [loadScroll]); }, [loadScroll]);
@ -293,14 +301,23 @@ const EditorInterface = ({
); );
const editorLocale = useMemo( const editorLocale = useMemo(
() => ( () =>
<div key={selectedLocale} className={classes.i18n}> locales
?.filter(l => l !== defaultLocale)
.map(locale => (
<div
key={locale}
className={classNames(
classes.i18n,
selectedLocale === locale && classes['i18n-active'],
)}
>
<EditorControlPane <EditorControlPane
collection={collection} collection={collection}
entry={entry} entry={entry}
fields={fields} fields={fields}
fieldsErrors={fieldsErrors} fieldsErrors={fieldsErrors}
locale={selectedLocale} locale={locale}
onLocaleChange={handleLocaleChange} onLocaleChange={handleLocaleChange}
submitted={submitted} submitted={submitted}
canChangeLocale canChangeLocale
@ -309,8 +326,19 @@ const EditorInterface = ({
t={t} t={t}
/> />
</div> </div>
), )),
[collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t], [
collection,
defaultLocale,
entry,
fields,
fieldsErrors,
handleLocaleChange,
locales,
selectedLocale,
submitted,
t,
],
); );
const previewEntry = useMemo( const previewEntry = useMemo(
@ -320,7 +348,8 @@ const EditorInterface = ({
); );
const mobileLocaleEditor = useMemo( const mobileLocaleEditor = useMemo(
() => ( () =>
isSmallScreen ? (
<div key={selectedLocale} className={classes['mobile-i18n']}> <div key={selectedLocale} className={classes['mobile-i18n']}>
<EditorControlPane <EditorControlPane
collection={collection} collection={collection}
@ -336,8 +365,18 @@ const EditorInterface = ({
t={t} t={t}
/> />
</div> </div>
), ) : null,
[collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t], [
collection,
entry,
fields,
fieldsErrors,
handleLocaleChange,
isSmallScreen,
selectedLocale,
submitted,
t,
],
); );
const editorWithPreview = ( const editorWithPreview = (

View File

@ -108,6 +108,7 @@ const EditorControlPane = ({
{i18n?.locales && locale ? ( {i18n?.locales && locale ? (
<div className={classes.locale_dropdown_wrapper}> <div className={classes.locale_dropdown_wrapper}>
<LocaleDropdown <LocaleDropdown
locale={locale}
locales={i18n.locales} locales={i18n.locales}
defaultLocale={i18n.defaultLocale} defaultLocale={i18n.defaultLocale}
dropdownText={t('editor.editorControlPane.i18n.writingInLocale', { dropdownText={t('editor.editorControlPane.i18n.writingInLocale', {

View File

@ -1,4 +1,18 @@
.CMS_LocaleDropdown_root { .CMS_LocaleDropdown_root {
&:not(.CMS_LocaleDropdown_no-edit) {
@apply flex
gap-2
items-center;
}
}
.CMS_LocaleDropdown_dropdown {
}
.CMS_LocaleDropdown_errors-icon {
@apply w-7
h-7
text-red-500;
} }
.CMS_LocaleDropdown_no-edit { .CMS_LocaleDropdown_no-edit {

View File

@ -1,16 +1,31 @@
import React from 'react'; import Tooltip from '@mui/material/Tooltip';
import { Error as ErrorIcon } from '@styled-icons/material/Error';
import React, { useMemo } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import {
getEntryDataPath,
selectAllFieldErrors,
} from '@staticcms/core/reducers/selectors/entryDraft';
import { useAppSelector } from '@staticcms/core/store/hooks';
import Menu from '../../common/menu/Menu'; import Menu from '../../common/menu/Menu';
import MenuGroup from '../../common/menu/MenuGroup'; import MenuGroup from '../../common/menu/MenuGroup';
import MenuItemButton from '../../common/menu/MenuItemButton'; import MenuItemButton from '../../common/menu/MenuItemButton';
import type { ReactNode } from 'react';
import './LocaleDropdown.css'; import './LocaleDropdown.css';
export const classes = generateClassNames('LocaleDropdown', ['root', 'no-edit']); export const classes = generateClassNames('LocaleDropdown', [
'root',
'dropdown',
'errors-icon',
'no-edit',
]);
interface LocaleDropdownProps { interface LocaleDropdownProps {
locale: string;
locales: string[]; locales: string[];
defaultLocale: string; defaultLocale: string;
dropdownText: string; dropdownText: string;
@ -20,6 +35,7 @@ interface LocaleDropdownProps {
} }
const LocaleDropdown = ({ const LocaleDropdown = ({
locale,
locales, locales,
defaultLocale, defaultLocale,
dropdownText, dropdownText,
@ -27,6 +43,49 @@ const LocaleDropdown = ({
onLocaleChange, onLocaleChange,
excludeLocales = [defaultLocale], excludeLocales = [defaultLocale],
}: LocaleDropdownProps) => { }: LocaleDropdownProps) => {
const allFieldErrors = useAppSelector(selectAllFieldErrors);
const otherLocaleErrors = useMemo(() => {
return locales
.reduce((acc, l) => {
if (l === locale || excludeLocales.includes(l)) {
return acc;
}
const dataPath = getEntryDataPath(
{
currentLocale: l,
defaultLocale,
locales,
},
false,
).join('.');
acc.push(
...Object.keys(allFieldErrors).reduce((errors, key) => {
if (key.startsWith(dataPath)) {
errors.push(
...allFieldErrors[key].filter(e => e.message).map(e => `${l}: ${e.message!}`),
);
}
return errors;
}, [] as string[]),
);
return acc;
}, [] as ReactNode[])
.map((e, index) => {
if (index === 0) {
return e;
}
return (
<>
<br />
{e}
</>
);
});
}, [allFieldErrors, defaultLocale, excludeLocales, locale, locales]);
if (!canChangeLocale) { if (!canChangeLocale) {
return ( return (
<div className={classNames(classes.root, 'CMS_Button_root', classes['no-edit'])}> <div className={classNames(classes.root, 'CMS_Button_root', classes['no-edit'])}>
@ -36,7 +95,8 @@ const LocaleDropdown = ({
} }
return ( return (
<Menu label={dropdownText} rootClassName={classes.root}> <div className={classes.root}>
<Menu label={dropdownText} rootClassName={classes.dropdown}>
<MenuGroup> <MenuGroup>
{locales {locales
.filter(locale => !excludeLocales.includes(locale)) .filter(locale => !excludeLocales.includes(locale))
@ -47,6 +107,12 @@ const LocaleDropdown = ({
))} ))}
</MenuGroup> </MenuGroup>
</Menu> </Menu>
{otherLocaleErrors.length > 0 ? (
<Tooltip title={otherLocaleErrors}>
<ErrorIcon className={classes['errors-icon']} />
</Tooltip>
) : null}
</div>
); );
}; };

View File

@ -0,0 +1,9 @@
import { useMediaQuery } from '@mui/material';
export function useIsMobile() {
return useMediaQuery('(max-width: 768px)');
}
export function useIsSmallScreen() {
return useMediaQuery('(max-width: 1024px)');
}

View File

@ -17,6 +17,8 @@ export const selectFieldErrors =
return state.entryDraft.fieldsErrors[fullPath] ?? []; return state.entryDraft.fieldsErrors[fullPath] ?? [];
}; };
export const selectAllFieldErrors = (state: RootState) => state.entryDraft.fieldsErrors ?? {};
export function selectEditingDraft(state: RootState) { export function selectEditingDraft(state: RootState) {
return state.entryDraft.entry; return state.entryDraft.entry;
} }

View File

@ -79,6 +79,7 @@ const ToolbarButton: FC<ToolbarButtonProps> = ({
<Button <Button
key="button" key="button"
aria-label={label ?? tooltip} aria-label={label ?? tooltip}
title={label ?? tooltip}
variant="text" variant="text"
data-testid={`toolbar-button-${label ?? tooltip}`.replace(' ', '-').toLowerCase()} data-testid={`toolbar-button-${label ?? tooltip}`.replace(' ', '-').toLowerCase()}
onClick={handleOnClick} onClick={handleOnClick}