diff --git a/packages/core/dev-test/backends/proxy/config.yml b/packages/core/dev-test/backends/proxy/config.yml index fbebe0db..77021776 100644 --- a/packages/core/dev-test/backends/proxy/config.yml +++ b/packages/core/dev-test/backends/proxy/config.yml @@ -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' diff --git a/packages/core/src/__tests__/testConfig.ts b/packages/core/src/__tests__/testConfig.ts index 070d8bd5..3494fcaf 100644 --- a/packages/core/src/__tests__/testConfig.ts +++ b/packages/core/src/__tests__/testConfig.ts @@ -1794,6 +1794,62 @@ const testConfig: Config = { }, ], }, + { + 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, + }, + ], + }, ], }; diff --git a/packages/core/src/components/entry-editor/EditorInterface.css b/packages/core/src/components/entry-editor/EditorInterface.css index e0665949..be4db87e 100644 --- a/packages/core/src/components/entry-editor/EditorInterface.css +++ b/packages/core/src/components/entry-editor/EditorInterface.css @@ -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 { diff --git a/packages/core/src/components/entry-editor/EditorInterface.tsx b/packages/core/src/components/entry-editor/EditorInterface.tsx index 5a8596b4..bc1ae4ec 100644 --- a/packages/core/src/components/entry-editor/EditorInterface.tsx +++ b/packages/core/src/components/entry-editor/EditorInterface.tsx @@ -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) => { 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( - () => ( -
- -
- ), - [collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t], + () => + locales + ?.filter(l => l !== defaultLocale) + .map(locale => ( +
+ +
+ )), + [ + collection, + defaultLocale, + entry, + fields, + fieldsErrors, + handleLocaleChange, + locales, + selectedLocale, + submitted, + t, + ], ); const previewEntry = useMemo( @@ -320,24 +348,35 @@ const EditorInterface = ({ ); const mobileLocaleEditor = useMemo( - () => ( -
- -
- ), - [collection, entry, fields, fieldsErrors, handleLocaleChange, selectedLocale, submitted, t], + () => + isSmallScreen ? ( +
+ +
+ ) : null, + [ + collection, + entry, + fields, + fieldsErrors, + handleLocaleChange, + isSmallScreen, + selectedLocale, + submitted, + t, + ], ); const editorWithPreview = ( diff --git a/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx b/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx index 06ee58af..bbd16d19 100644 --- a/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx +++ b/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx @@ -108,6 +108,7 @@ const EditorControlPane = ({ {i18n?.locales && locale ? (
{ + 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 ( + <> +
+ {e} + + ); + }); + }, [allFieldErrors, defaultLocale, excludeLocales, locale, locales]); + if (!canChangeLocale) { return (
@@ -36,17 +95,24 @@ const LocaleDropdown = ({ } return ( - - - {locales - .filter(locale => !excludeLocales.includes(locale)) - .map(locale => ( - onLocaleChange?.(locale)}> - {locale} - - ))} - - +
+ + + {locales + .filter(locale => !excludeLocales.includes(locale)) + .map(locale => ( + onLocaleChange?.(locale)}> + {locale} + + ))} + + + {otherLocaleErrors.length > 0 ? ( + + + + ) : null} +
); }; diff --git a/packages/core/src/lib/hooks/useMediaQuery.ts b/packages/core/src/lib/hooks/useMediaQuery.ts new file mode 100644 index 00000000..b32460a5 --- /dev/null +++ b/packages/core/src/lib/hooks/useMediaQuery.ts @@ -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)'); +} diff --git a/packages/core/src/reducers/selectors/entryDraft.ts b/packages/core/src/reducers/selectors/entryDraft.ts index 02f798aa..d2509fd6 100644 --- a/packages/core/src/reducers/selectors/entryDraft.ts +++ b/packages/core/src/reducers/selectors/entryDraft.ts @@ -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; } diff --git a/packages/core/src/widgets/markdown/plate/components/buttons/common/ToolbarButton.tsx b/packages/core/src/widgets/markdown/plate/components/buttons/common/ToolbarButton.tsx index 907c432f..ad1fe771 100644 --- a/packages/core/src/widgets/markdown/plate/components/buttons/common/ToolbarButton.tsx +++ b/packages/core/src/widgets/markdown/plate/components/buttons/common/ToolbarButton.tsx @@ -79,6 +79,7 @@ const ToolbarButton: FC = ({