feat: i18n better locale error handling (#949)
This commit is contained in:
parent
f29a5f36c8
commit
a31a47bc7f
@ -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'
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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 = (
|
||||||
|
@ -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', {
|
||||||
|
@ -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 {
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
9
packages/core/src/lib/hooks/useMediaQuery.ts
Normal file
9
packages/core/src/lib/hooks/useMediaQuery.ts
Normal 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)');
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user