feat: i18n better locale error handling (#949)
This commit is contained in:
parent
f29a5f36c8
commit
a31a47bc7f
@ -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'
|
||||
|
@ -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 {
|
||||
@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 {
|
||||
|
@ -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,14 +301,23 @@ const EditorInterface = ({
|
||||
);
|
||||
|
||||
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
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsErrors={fieldsErrors}
|
||||
locale={selectedLocale}
|
||||
locale={locale}
|
||||
onLocaleChange={handleLocaleChange}
|
||||
submitted={submitted}
|
||||
canChangeLocale
|
||||
@ -309,8 +326,19 @@ const EditorInterface = ({
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t],
|
||||
)),
|
||||
[
|
||||
collection,
|
||||
defaultLocale,
|
||||
entry,
|
||||
fields,
|
||||
fieldsErrors,
|
||||
handleLocaleChange,
|
||||
locales,
|
||||
selectedLocale,
|
||||
submitted,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const previewEntry = useMemo(
|
||||
@ -320,7 +348,8 @@ const EditorInterface = ({
|
||||
);
|
||||
|
||||
const mobileLocaleEditor = useMemo(
|
||||
() => (
|
||||
() =>
|
||||
isSmallScreen ? (
|
||||
<div key={selectedLocale} className={classes['mobile-i18n']}>
|
||||
<EditorControlPane
|
||||
collection={collection}
|
||||
@ -336,8 +365,18 @@ const EditorInterface = ({
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t],
|
||||
) : null,
|
||||
[
|
||||
collection,
|
||||
entry,
|
||||
fields,
|
||||
fieldsErrors,
|
||||
handleLocaleChange,
|
||||
isSmallScreen,
|
||||
selectedLocale,
|
||||
submitted,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const editorWithPreview = (
|
||||
|
@ -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', {
|
||||
|
@ -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 {
|
||||
|
@ -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,7 +95,8 @@ const LocaleDropdown = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu label={dropdownText} rootClassName={classes.root}>
|
||||
<div className={classes.root}>
|
||||
<Menu label={dropdownText} rootClassName={classes.dropdown}>
|
||||
<MenuGroup>
|
||||
{locales
|
||||
.filter(locale => !excludeLocales.includes(locale))
|
||||
@ -47,6 +107,12 @@ const LocaleDropdown = ({
|
||||
))}
|
||||
</MenuGroup>
|
||||
</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] ?? [];
|
||||
};
|
||||
|
||||
export const selectAllFieldErrors = (state: RootState) => state.entryDraft.fieldsErrors ?? {};
|
||||
|
||||
export function selectEditingDraft(state: RootState) {
|
||||
return state.entryDraft.entry;
|
||||
}
|
||||
|
@ -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}
|
||||
|
Loading…
x
Reference in New Issue
Block a user