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
widget: file
- name: i18n_playground
label: i18n Playground
label: i18n (Multiple Files)
i18n: true
folder: packages/core/dev-test/backends/proxy/_i18n_playground
identifier_field: slug
@ -524,6 +524,54 @@ collections:
label: Date
widget: datetime
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
label: Nested Pages
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 {
@apply flex
@apply hidden
w-full
overflow-y-auto
h-main-mobile
md:h-main;
&.CMS_Editor_i18n-active {
@apply flex;
}
}
& .CMS_Editor_mobile-i18n {
@apply flex
w-full
overflow-y-auto
h-main-mobile
md:hidden;
h-main-mobile;
}
& .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 { summaryFormatter } from '@staticcms/core/lib/formatters';
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 classNames from '@staticcms/core/lib/util/classNames.util';
import {
@ -34,6 +35,7 @@ export const classes = generateClassNames('Editor', [
'root',
'default',
'i18n',
'i18n-active',
'mobile-i18n',
'split-view',
'mobile-preview',
@ -120,10 +122,12 @@ const EditorInterface = ({
}: TranslatedProps<EditorInterfaceProps>) => {
const config = useAppSelector(selectConfig);
const isSmallScreen = useIsSmallScreen();
const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {};
const translatedLocales = useMemo(
() => locales?.filter(locale => locale !== defaultLocale) ?? [],
[locales, defaultLocale],
() => (isSmallScreen ? locales : locales?.filter(locale => locale !== defaultLocale)) ?? [],
[isSmallScreen, locales, defaultLocale],
);
const [previewActive, setPreviewActive] = useState(
@ -140,6 +144,10 @@ const EditorInterface = ({
(i18nActive ? translatedLocales?.[0] : defaultLocale) ?? 'en',
);
useEffect(() => {
setSelectedLocale((i18nActive ? translatedLocales?.[0] : defaultLocale) ?? 'en');
}, [defaultLocale, i18nActive, translatedLocales]);
useEffect(() => {
loadScroll();
}, [loadScroll]);
@ -293,24 +301,44 @@ const EditorInterface = ({
);
const editorLocale = useMemo(
() => (
<div key={selectedLocale} className={classes.i18n}>
<EditorControlPane
collection={collection}
entry={entry}
fields={fields}
fieldsErrors={fieldsErrors}
locale={selectedLocale}
onLocaleChange={handleLocaleChange}
submitted={submitted}
canChangeLocale
context="i18nSplit"
hideBorder
t={t}
/>
</div>
),
[collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t],
() =>
locales
?.filter(l => l !== defaultLocale)
.map(locale => (
<div
key={locale}
className={classNames(
classes.i18n,
selectedLocale === locale && classes['i18n-active'],
)}
>
<EditorControlPane
collection={collection}
entry={entry}
fields={fields}
fieldsErrors={fieldsErrors}
locale={locale}
onLocaleChange={handleLocaleChange}
submitted={submitted}
canChangeLocale
context="i18nSplit"
hideBorder
t={t}
/>
</div>
)),
[
collection,
defaultLocale,
entry,
fields,
fieldsErrors,
handleLocaleChange,
locales,
selectedLocale,
submitted,
t,
],
);
const previewEntry = useMemo(
@ -320,24 +348,35 @@ const EditorInterface = ({
);
const mobileLocaleEditor = useMemo(
() => (
<div key={selectedLocale} className={classes['mobile-i18n']}>
<EditorControlPane
collection={collection}
entry={entry}
fields={fields}
fieldsErrors={fieldsErrors}
locale={selectedLocale}
onLocaleChange={handleLocaleChange}
allowDefaultLocale
submitted={submitted}
canChangeLocale
hideBorder
t={t}
/>
</div>
),
[collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t],
() =>
isSmallScreen ? (
<div key={selectedLocale} className={classes['mobile-i18n']}>
<EditorControlPane
collection={collection}
entry={entry}
fields={fields}
fieldsErrors={fieldsErrors}
locale={selectedLocale}
onLocaleChange={handleLocaleChange}
allowDefaultLocale
submitted={submitted}
canChangeLocale
hideBorder
t={t}
/>
</div>
) : null,
[
collection,
entry,
fields,
fieldsErrors,
handleLocaleChange,
isSmallScreen,
selectedLocale,
submitted,
t,
],
);
const editorWithPreview = (

View File

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

View File

@ -1,4 +1,18 @@
.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 {

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 { 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 MenuGroup from '../../common/menu/MenuGroup';
import MenuItemButton from '../../common/menu/MenuItemButton';
import type { ReactNode } from 'react';
import './LocaleDropdown.css';
export const classes = generateClassNames('LocaleDropdown', ['root', 'no-edit']);
export const classes = generateClassNames('LocaleDropdown', [
'root',
'dropdown',
'errors-icon',
'no-edit',
]);
interface LocaleDropdownProps {
locale: string;
locales: string[];
defaultLocale: string;
dropdownText: string;
@ -20,6 +35,7 @@ interface LocaleDropdownProps {
}
const LocaleDropdown = ({
locale,
locales,
defaultLocale,
dropdownText,
@ -27,6 +43,49 @@ const LocaleDropdown = ({
onLocaleChange,
excludeLocales = [defaultLocale],
}: 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) {
return (
<div className={classNames(classes.root, 'CMS_Button_root', classes['no-edit'])}>
@ -36,17 +95,24 @@ const LocaleDropdown = ({
}
return (
<Menu label={dropdownText} rootClassName={classes.root}>
<MenuGroup>
{locales
.filter(locale => !excludeLocales.includes(locale))
.map(locale => (
<MenuItemButton key={locale} onClick={() => onLocaleChange?.(locale)}>
{locale}
</MenuItemButton>
))}
</MenuGroup>
</Menu>
<div className={classes.root}>
<Menu label={dropdownText} rootClassName={classes.dropdown}>
<MenuGroup>
{locales
.filter(locale => !excludeLocales.includes(locale))
.map(locale => (
<MenuItemButton key={locale} onClick={() => onLocaleChange?.(locale)}>
{locale}
</MenuItemButton>
))}
</MenuGroup>
</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] ?? [];
};
export const selectAllFieldErrors = (state: RootState) => state.entryDraft.fieldsErrors ?? {};
export function selectEditingDraft(state: RootState) {
return state.entryDraft.entry;
}

View File

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