@@ -96,21 +99,21 @@ const Login: React.FC = () => {
action={`${serverURL}${api}/${userSlug}/login`}
>
- Forgot password?
+ {t('forgotPassword')}
-
Login
+
{t('login')}
)}
{Array.isArray(afterLogin) && afterLogin.map((Component, i) =>
)}
diff --git a/src/admin/components/views/Logout/index.tsx b/src/admin/components/views/Logout/index.tsx
index 4daf402b96..58c3bafe40 100644
--- a/src/admin/components/views/Logout/index.tsx
+++ b/src/admin/components/views/Logout/index.tsx
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth';
import Minimal from '../../templates/Minimal';
@@ -14,6 +15,7 @@ const Logout: React.FC<{inactivity?: boolean}> = (props) => {
const { logOut } = useAuth();
const { routes: { admin } } = useConfig();
+ const { t } = useTranslation('authentication');
useEffect(() => {
logOut();
@@ -22,16 +24,16 @@ const Logout: React.FC<{inactivity?: boolean}> = (props) => {
return (
{inactivity && (
-
You have been logged out due to inactivity.
+ {t('loggedOutInactivity')}
)}
{!inactivity && (
- You have been logged out successfully.
+ {t('loggedOutSuccessfully')}
)}
= (props) => {
buttonStyle="secondary"
url={`${admin}/login`}
>
- Log back in
+ {t('logBackIn')}
diff --git a/src/admin/components/views/NotFound/index.tsx b/src/admin/components/views/NotFound/index.tsx
index b955f75a41..74757d2d60 100644
--- a/src/admin/components/views/NotFound/index.tsx
+++ b/src/admin/components/views/NotFound/index.tsx
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../utilities/Config';
import Eyebrow from '../../elements/Eyebrow';
import { useStepNav } from '../../elements/StepNav';
@@ -11,29 +12,30 @@ const baseClass = 'not-found';
const NotFound: React.FC = () => {
const { setStepNav } = useStepNav();
const { routes: { admin } } = useConfig();
+ const { t } = useTranslation('general');
useEffect(() => {
setStepNav([{
- label: 'Not Found',
+ label: t('notFound'),
}]);
- }, [setStepNav]);
+ }, [setStepNav, t]);
return (
- Nothing found
- Sorry—there is nothing to correspond with your request.
+ {t('nothingFound')}
+ {t('sorryNotFound')}
- Back to Dashboard
+ {t('backToDashboard')}
diff --git a/src/admin/components/views/ResetPassword/index.tsx b/src/admin/components/views/ResetPassword/index.tsx
index f600bb25d8..47a71d52d1 100644
--- a/src/admin/components/views/ResetPassword/index.tsx
+++ b/src/admin/components/views/ResetPassword/index.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { Link, useHistory, useParams } from 'react-router-dom';
+import { Trans, useTranslation } from 'react-i18next';
import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth';
import MinimalTemplate from '../../templates/Minimal';
@@ -9,10 +10,10 @@ import ConfirmPassword from '../../forms/field-types/ConfirmPassword';
import FormSubmit from '../../forms/Submit';
import Button from '../../elements/Button';
import Meta from '../../utilities/Meta';
+import HiddenInput from '../../forms/field-types/HiddenInput';
import './index.scss';
-import HiddenInput from '../../forms/field-types/HiddenInput';
const baseClass = 'reset-password';
@@ -22,6 +23,7 @@ const ResetPassword: React.FC = () => {
const { token } = useParams<{ token?: string }>();
const history = useHistory();
const { user, setToken } = useAuth();
+ const { t } = useTranslation('authentication');
const onSuccess = (data) => {
if (data.token) {
@@ -34,19 +36,20 @@ const ResetPassword: React.FC = () => {
return (
-
Already logged in
+
{t('alreadyLoggedIn')}
- To log in with another user, you should
- {' '}
- log out
- {' '}
- first.
+
+ log out
+
{
buttonStyle="secondary"
to={admin}
>
- Back to Dashboard
+ {t('general:backToDashboard')}
@@ -64,7 +67,7 @@ const ResetPassword: React.FC = () => {
return (
-
Reset Password
+
{t('resetPassword')}
diff --git a/src/admin/components/views/Unauthorized/index.tsx b/src/admin/components/views/Unauthorized/index.tsx
index cc984ba151..611c8d204a 100644
--- a/src/admin/components/views/Unauthorized/index.tsx
+++ b/src/admin/components/views/Unauthorized/index.tsx
@@ -1,32 +1,34 @@
import React from 'react';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../utilities/Config';
import Button from '../../elements/Button';
import Meta from '../../utilities/Meta';
import MinimalTemplate from '../../templates/Minimal';
const Unauthorized: React.FC = () => {
+ const { t } = useTranslation('general');
const config = useConfig();
const {
routes: { admin },
admin: {
- logoutRoute
+ logoutRoute,
},
} = config;
return (
- Unauthorized
- You are not allowed to access this page.
+ {t('error:unauthorized')}
+ {t('error:notAllowedToAccessPage')}
- Log out
+ {t('authentication:logOut')}
);
diff --git a/src/admin/components/views/Verify/index.tsx b/src/admin/components/views/Verify/index.tsx
index 8790b702c3..01a9fedd76 100644
--- a/src/admin/components/views/Verify/index.tsx
+++ b/src/admin/components/views/Verify/index.tsx
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth';
@@ -7,8 +8,8 @@ import MinimalTemplate from '../../templates/Minimal';
import Button from '../../elements/Button';
import Meta from '../../utilities/Meta';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
-
import Login from '../Login';
+
import './index.scss';
const baseClass = 'verify';
@@ -19,34 +20,41 @@ const Verify: React.FC<{ collection: SanitizedCollectionConfig }> = ({ collectio
const { user } = useAuth();
const { token } = useParams<{token?: string}>();
const { serverURL, routes: { admin: adminRoute }, admin: { user: adminUser } } = useConfig();
+ const { t, i18n } = useTranslation('authentication');
const isAdminUser = collectionSlug === adminUser;
const [verifyResult, setVerifyResult] = useState(null);
useEffect(() => {
async function verifyToken() {
- const result = await fetch(`${serverURL}/api/${collectionSlug}/verify/${token}`, { method: 'POST', credentials: 'include' });
+ const result = await fetch(`${serverURL}/api/${collectionSlug}/verify/${token}`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Accept-Language': i18n.language,
+ },
+ });
setVerifyResult(result);
}
verifyToken();
- }, [setVerifyResult, collectionSlug, serverURL, token]);
+ }, [setVerifyResult, collectionSlug, serverURL, token, i18n]);
if (user) {
return
;
}
const getText = () => {
- if (verifyResult?.status === 200) return 'Verified Successfully';
- if (verifyResult?.status === 202) return 'Already Activated';
- return 'Unable To Verify';
+ if (verifyResult?.status === 200) return t('verifiedSuccessfully');
+ if (verifyResult?.status === 202) return t('alreadyActivated');
+ return t('unableToVerify');
};
return (
@@ -60,7 +68,7 @@ const Verify: React.FC<{ collection: SanitizedCollectionConfig }> = ({ collectio
buttonStyle="secondary"
to={`${adminRoute}/login`}
>
- Login
+ {t('login')}
)}
diff --git a/src/admin/components/views/Version/Compare/index.tsx b/src/admin/components/views/Version/Compare/index.tsx
index 10a6d64bdd..d10ea7fe30 100644
--- a/src/admin/components/views/Version/Compare/index.tsx
+++ b/src/admin/components/views/Version/Compare/index.tsx
@@ -1,6 +1,7 @@
import React, { useState, useCallback, useEffect } from 'react';
import qs from 'qs';
import format from 'date-fns/format';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../utilities/Config';
import { Props } from './types';
import ReactSelect from '../../../elements/ReactSelect';
@@ -30,6 +31,7 @@ const CompareVersion: React.FC
= (props) => {
const [options, setOptions] = useState(baseOptions);
const [lastLoadedPage, setLastLoadedPage] = useState(1);
const [errorLoading, setErrorLoading] = useState('');
+ const { t, i18n } = useTranslation('version');
const getResults = useCallback(async ({
lastLoadedPage: lastLoadedPageArg,
@@ -61,10 +63,15 @@ const CompareVersion: React.FC = (props) => {
}
const search = qs.stringify(query);
- const response = await fetch(`${baseURL}?${search}`, { credentials: 'include' });
+ const response = await fetch(`${baseURL}?${search}`, {
+ credentials: 'include',
+ headers: {
+ 'Accept-Language': i18n.language,
+ },
+ });
if (response.ok) {
- const data: PaginatedDocs = await response.json();
+ const data: PaginatedDocs = await response.json();
if (data.docs.length > 0) {
setOptions((existingOptions) => [
...existingOptions,
@@ -76,9 +83,9 @@ const CompareVersion: React.FC = (props) => {
setLastLoadedPage(data.page);
}
} else {
- setErrorLoading('An error has occurred.');
+ setErrorLoading(t('error:unspecific'));
}
- }, [dateFormat, baseURL, parentID, versionID]);
+ }, [dateFormat, baseURL, parentID, versionID, t, i18n]);
const classes = [
'field-type',
@@ -97,12 +104,12 @@ const CompareVersion: React.FC = (props) => {
return (
- Compare version against:
+ {t('compareVersion')}
{!errorLoading && (
{
getResults({ lastLoadedPage: lastLoadedPage + 1 });
diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx
index 53c1b4f9e7..3466e68922 100644
--- a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx
+++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx
@@ -1,9 +1,11 @@
import React from 'react';
+import { useTranslation } from 'react-i18next';
import RenderFieldsToDiff from '../..';
import { Props } from '../types';
import Label from '../../Label';
import { ArrayField, BlockField, Field, fieldAffectsData } from '../../../../../../../fields/config/types';
import getUniqueListBy from '../../../../../../../utilities/getUniqueListBy';
+import { getTranslation } from '../../../../../../../utilities/getTranslation';
import './index.scss';
@@ -21,6 +23,7 @@ const Iterable: React.FC = ({
const versionRowCount = Array.isArray(version) ? version.length : 0;
const comparisonRowCount = Array.isArray(comparison) ? comparison.length : 0;
const maxRows = Math.max(versionRowCount, comparisonRowCount);
+ const { t, i18n } = useTranslation('version');
return (
@@ -46,7 +49,7 @@ const Iterable: React.FC
= ({
subFields = [
{
name: 'blockType',
- label: 'Block Type',
+ label: t('fields:blockType'),
type: 'text',
},
];
@@ -89,11 +92,7 @@ const Iterable: React.FC = ({
)}
{maxRows === 0 && (
- No
- {' '}
- {field.labels?.plural ?? 'rows'}
- {' '}
- found
+ {t('noRowsFound', { label: field.labels?.plural ? getTranslation(field.labels?.plural, i18n) : t('general:rows') })}
)}
diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.tsx
index df0b3c82fb..5724f3a7fb 100644
--- a/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.tsx
+++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/Text/index.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer';
+import { useTranslation } from 'react-i18next';
import Label from '../../Label';
import { diffStyles } from '../styles';
import { Props } from '../types';
@@ -10,8 +11,9 @@ const baseClass = 'text-diff';
const Text: React.FC = ({ field, locale, version, comparison, isRichText = false, diffMethod }) => {
let placeholder = '';
+ const { t } = useTranslation('general');
- if (version === comparison) placeholder = '[no value]';
+ if (version === comparison) placeholder = `[${t('noValue')}]`;
let versionToRender = version;
let comparisonToRender = comparison;
@@ -40,8 +42,6 @@ const Text: React.FC = ({ field, locale, version, comparison, isRichText
/>
);
-
- return null;
};
export default Text;
diff --git a/src/admin/components/views/Version/Restore/index.tsx b/src/admin/components/views/Version/Restore/index.tsx
index f09e754f5a..88052eadc3 100644
--- a/src/admin/components/views/Version/Restore/index.tsx
+++ b/src/admin/components/views/Version/Restore/index.tsx
@@ -2,10 +2,12 @@ import React, { Fragment, useCallback, useState } from 'react';
import { toast } from 'react-toastify';
import { Modal, useModal } from '@faceless-ui/modal';
import { useHistory } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../utilities/Config';
import { Button, MinimalTemplate, Pill } from '../../..';
import { Props } from './types';
import { requests } from '../../../../api';
+import { getTranslation } from '../../../../../utilities/getTranslation';
import './index.scss';
@@ -17,6 +19,7 @@ const Restore: React.FC = ({ collection, global, className, versionID, or
const history = useHistory();
const { toggleModal } = useModal();
const [processing, setProcessing] = useState(false);
+ const { t, i18n } = useTranslation('version');
let fetchURL = `${serverURL}${api}`;
let redirectURL: string;
@@ -25,28 +28,32 @@ const Restore: React.FC = ({ collection, global, className, versionID, or
if (collection) {
fetchURL = `${fetchURL}/${collection.slug}/versions/${versionID}`;
redirectURL = `${admin}/collections/${collection.slug}/${originalDocID}`;
- restoreMessage = `You are about to restore this ${collection.labels.singular} document to the state that it was in on ${versionDate}.`;
+ restoreMessage = t('aboutToRestore', { label: getTranslation(collection.labels.singular, i18n), versionDate });
}
if (global) {
fetchURL = `${fetchURL}/globals/${global.slug}/versions/${versionID}`;
redirectURL = `${admin}/globals/${global.slug}`;
- restoreMessage = `You are about to restore the global ${global.label} to the state that it was in on ${versionDate}.`;
+ restoreMessage = t('aboutToRestoreGlobal', { label: getTranslation(global.label, i18n), versionDate });
}
const handleRestore = useCallback(async () => {
setProcessing(true);
- const res = await requests.post(fetchURL);
+ const res = await requests.post(fetchURL, {
+ headers: {
+ 'Accept-Language': i18n.language,
+ },
+ });
if (res.status === 200) {
const json = await res.json();
toast.success(json.message);
history.push(redirectURL);
} else {
- toast.error('There was a problem while restoring this version.');
+ toast.error(t('problemRestoringVersion'));
}
- }, [history, fetchURL, redirectURL]);
+ }, [fetchURL, history, redirectURL, t, i18n]);
return (
@@ -54,26 +61,26 @@ const Restore: React.FC = ({ collection, global, className, versionID, or
onClick={() => toggleModal(modalSlug)}
className={[baseClass, className].filter(Boolean).join(' ')}
>
- Restore this version
+ {t('restoreThisVersion')}
- Confirm version restoration
+ {t('confirmVersionRestoration')}
{restoreMessage}
toggleModal(modalSlug)}
>
- Cancel
+ {t('general:cancel')}
- {processing ? 'Restoring...' : 'Confirm'}
+ {processing ? t('restoring') : t('general:confirm')}
diff --git a/src/admin/components/views/Version/SelectLocales/index.tsx b/src/admin/components/views/Version/SelectLocales/index.tsx
index f23e53bd37..3fee215bb1 100644
--- a/src/admin/components/views/Version/SelectLocales/index.tsx
+++ b/src/admin/components/views/Version/SelectLocales/index.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { useTranslation } from 'react-i18next';
import ReactSelect from '../../../elements/ReactSelect';
import { Props } from './types';
@@ -6,19 +7,23 @@ import './index.scss';
const baseClass = 'select-version-locales';
-const SelectLocales: React.FC = ({ onChange, value, options }) => (
-
-
- Show locales:
+const SelectLocales: React.FC
= ({ onChange, value, options }) => {
+ const { t } = useTranslation('version');
+
+ return (
+
+
+ {t('showLocales')}
+
+
-
-
-);
+ );
+};
export default SelectLocales;
diff --git a/src/admin/components/views/Version/Version.tsx b/src/admin/components/views/Version/Version.tsx
index 0225213ae7..60e3195357 100644
--- a/src/admin/components/views/Version/Version.tsx
+++ b/src/admin/components/views/Version/Version.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useRouteMatch } from 'react-router-dom';
import format from 'date-fns/format';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
@@ -16,7 +17,7 @@ import Restore from './Restore';
import SelectLocales from './SelectLocales';
import RenderFieldsToDiff from './RenderFieldsToDiff';
import fieldComponents from './RenderFieldsToDiff/fields';
-
+import { getTranslation } from '../../../../utilities/getTranslation';
import { Field, FieldAffectingData, fieldAffectsData } from '../../../../fields/config/types';
import { FieldPermissions } from '../../../../auth';
import { useLocale } from '../../utilities/Locale';
@@ -35,6 +36,7 @@ const VersionView: React.FC
= ({ collection, global }) => {
const [locales, setLocales] = useState(localeOptions);
const { permissions } = useAuth();
const locale = useLocale();
+ const { t, i18n } = useTranslation('version');
let originalDocFetchURL: string;
let versionFetchURL: string;
@@ -50,7 +52,7 @@ const VersionView: React.FC = ({ collection, global }) => {
originalDocFetchURL = `${serverURL}${api}/${slug}/${id}`;
versionFetchURL = `${serverURL}${api}/${slug}/versions/${versionID}`;
compareBaseURL = `${serverURL}${api}/${slug}/versions`;
- entityLabel = collection.labels.singular;
+ entityLabel = getTranslation(collection.labels.singular, i18n);
parentID = id;
fields = collection.fields;
fieldPermissions = permissions.collections[collection.slug].fields;
@@ -61,7 +63,7 @@ const VersionView: React.FC = ({ collection, global }) => {
originalDocFetchURL = `${serverURL}${api}/globals/${slug}`;
versionFetchURL = `${serverURL}${api}/globals/${slug}/versions/${versionID}`;
compareBaseURL = `${serverURL}${api}/globals/${slug}/versions`;
- entityLabel = global.label;
+ entityLabel = getTranslation(global.label, i18n);
fields = global.fields;
fieldPermissions = permissions.globals[global.slug].fields;
}
@@ -92,7 +94,7 @@ const VersionView: React.FC = ({ collection, global }) => {
docLabel = mostRecentDoc[useAsTitle];
}
} else {
- docLabel = '[Untitled]';
+ docLabel = `[${t('general:untitled')}]`;
}
} else {
docLabel = mostRecentDoc.id;
@@ -102,7 +104,7 @@ const VersionView: React.FC = ({ collection, global }) => {
nav = [
{
url: `${admin}/collections/${collection.slug}`,
- label: collection.labels.plural,
+ label: getTranslation(collection.labels.plural, i18n),
},
{
label: docLabel,
@@ -135,7 +137,7 @@ const VersionView: React.FC = ({ collection, global }) => {
}
setStepNav(nav);
- }, [setStepNav, collection, global, dateFormat, doc, mostRecentDoc, admin, id, locale]);
+ }, [setStepNav, collection, global, dateFormat, doc, mostRecentDoc, admin, id, locale, t, i18n]);
let metaTitle: string;
let metaDesc: string;
@@ -143,13 +145,13 @@ const VersionView: React.FC = ({ collection, global }) => {
if (collection) {
const useAsTitle = collection?.admin?.useAsTitle || 'id';
- metaTitle = `Version - ${formattedCreatedAt} - ${doc[useAsTitle]} - ${entityLabel}`;
- metaDesc = `Viewing version for the ${entityLabel} ${doc[useAsTitle]}`;
+ metaTitle = `${t('version')} - ${formattedCreatedAt} - ${doc[useAsTitle]} - ${entityLabel}`;
+ metaDesc = t('viewingVersion', { documentTitle: doc[useAsTitle], entityLabel });
}
if (global) {
- metaTitle = `Version - ${formattedCreatedAt} - ${entityLabel}`;
- metaDesc = `Viewing version for the global ${entityLabel}`;
+ metaTitle = `${t('version')} - ${formattedCreatedAt} - ${entityLabel}`;
+ metaDesc = t('viewingVersionGlobal', { entityLabel });
}
let comparison = compareDoc?.version;
@@ -171,9 +173,7 @@ const VersionView: React.FC = ({ collection, global }) => {
- {doc?.autosave ? 'Autosaved version ' : 'Version'}
- {' '}
- created on:
+ {t('versionCreatedOn', { version: t(doc?.autosave ? 'autosavedVersion' : 'version') })}
diff --git a/src/admin/components/views/Versions/columns.tsx b/src/admin/components/views/Versions/columns.tsx
index 6bdd375082..3fb2cbfc39 100644
--- a/src/admin/components/views/Versions/columns.tsx
+++ b/src/admin/components/views/Versions/columns.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { Link, useRouteMatch } from 'react-router-dom';
import format from 'date-fns/format';
+import type { TFunction } from 'react-i18next';
import { useConfig } from '../../utilities/Config';
import { Column } from '../../elements/Table/types';
import SortColumn from '../../elements/SortColumn';
@@ -37,13 +38,13 @@ const TextCell: React.FC<{children?: React.ReactNode}> = ({ children }) => (
);
-export const getColumns = (collection: SanitizedCollectionConfig, global: SanitizedGlobalConfig): Column[] => [
+export const getColumns = (collection: SanitizedCollectionConfig, global: SanitizedGlobalConfig, t: TFunction): Column[] => [
{
accessor: 'updatedAt',
components: {
Heading: (
),
@@ -62,7 +63,7 @@ export const getColumns = (collection: SanitizedCollectionConfig, global: Saniti
components: {
Heading: (
@@ -75,7 +76,7 @@ export const getColumns = (collection: SanitizedCollectionConfig, global: Saniti
components: {
Heading: (
@@ -85,7 +86,7 @@ export const getColumns = (collection: SanitizedCollectionConfig, global: Saniti
{row?.autosave && (
- Autosave
+ {t('autosave')}
@@ -93,14 +94,14 @@ export const getColumns = (collection: SanitizedCollectionConfig, global: Saniti
{row?.version._status === 'published' && (
- Published
+ {t('published')}
)}
{row?.version._status === 'draft' && (
- Draft
+ {t('draft')}
)}
diff --git a/src/admin/components/views/Versions/index.tsx b/src/admin/components/views/Versions/index.tsx
index 8dd0862efa..004d1ef26a 100644
--- a/src/admin/components/views/Versions/index.tsx
+++ b/src/admin/components/views/Versions/index.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useRouteMatch } from 'react-router-dom';
import format from 'date-fns/format';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../utilities/Config';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import Eyebrow from '../../elements/Eyebrow';
@@ -20,6 +21,7 @@ import { SanitizedCollectionConfig } from '../../../../collections/config/types'
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
import { shouldIncrementVersionCount } from '../../../../versions/shouldIncrementVersionCount';
import { Gutter } from '../../elements/Gutter';
+import { getTranslation } from '../../../../utilities/getTranslation';
import './index.scss';
@@ -29,7 +31,8 @@ const Versions: React.FC = ({ collection, global }) => {
const { serverURL, routes: { admin, api }, admin: { dateFormat } } = useConfig();
const { setStepNav } = useStepNav();
const { params: { id } } = useRouteMatch<{ id: string }>();
- const [tableColumns] = useState(() => getColumns(collection, global));
+ const { t, i18n } = useTranslation('version');
+ const [tableColumns] = useState(() => getColumns(collection, global, t));
const [fetchURL, setFetchURL] = useState('');
const { page, sort, limit } = useSearchParams();
@@ -42,7 +45,7 @@ const Versions: React.FC = ({ collection, global }) => {
if (collection) {
({ slug } = collection);
docURL = `${serverURL}${api}/${slug}/${id}`;
- entityLabel = collection.labels.singular;
+ entityLabel = getTranslation(collection.labels.singular, i18n);
entity = collection;
editURL = `${admin}/collections/${collection.slug}/${id}`;
}
@@ -50,7 +53,7 @@ const Versions: React.FC = ({ collection, global }) => {
if (global) {
({ slug } = global);
docURL = `${serverURL}${api}/globals/${slug}`;
- entityLabel = global.label;
+ entityLabel = getTranslation(global.label, i18n);
entity = global;
editURL = `${admin}/globals/${global.slug}`;
}
@@ -70,7 +73,7 @@ const Versions: React.FC = ({ collection, global }) => {
if (doc[useAsTitle]) {
docLabel = doc[useAsTitle];
} else {
- docLabel = '[Untitled]';
+ docLabel = `[${t('general:untitled')}]`;
}
} else {
docLabel = doc.id;
@@ -80,14 +83,14 @@ const Versions: React.FC = ({ collection, global }) => {
nav = [
{
url: `${admin}/collections/${collection.slug}`,
- label: collection.labels.plural,
+ label: getTranslation(collection.labels.plural, i18n),
},
{
label: docLabel,
url: editURL,
},
{
- label: 'Versions',
+ label: t('versions'),
},
];
}
@@ -96,16 +99,16 @@ const Versions: React.FC = ({ collection, global }) => {
nav = [
{
url: editURL,
- label: global.label,
+ label: getTranslation(global.label, i18n),
},
{
- label: 'Versions',
+ label: t('versions'),
},
];
}
setStepNav(nav);
- }, [setStepNav, collection, global, useAsTitle, doc, admin, id, editURL]);
+ }, [setStepNav, collection, global, useAsTitle, doc, admin, id, editURL, t, i18n]);
useEffect(() => {
const params = {
@@ -149,14 +152,14 @@ const Versions: React.FC = ({ collection, global }) => {
let metaTitle: string;
if (collection) {
- metaTitle = `Versions - ${doc[useAsTitle]} - ${entityLabel}`;
- metaDesc = `Viewing versions for the ${entityLabel} ${doc[useAsTitle]}`;
- heading = doc?.[useAsTitle] || '[Untitled]';
+ metaTitle = `${t('versions')} - ${doc[useAsTitle]} - ${entityLabel}`;
+ metaDesc = t('viewingVersions', { documentTitle: doc[useAsTitle], entityLabel });
+ heading = doc?.[useAsTitle] || `[${t('general:untitled')}]`;
}
if (global) {
- metaTitle = `Versions - ${entityLabel}`;
- metaDesc = `Viewing versions for the global ${entityLabel}`;
+ metaTitle = `${t('versions')} - ${entityLabel}`;
+ metaDesc = t('viewingVersionsGlobal', { entityLabel });
heading = entityLabel;
useIDLabel = false;
}
@@ -192,11 +195,8 @@ const Versions: React.FC = ({ collection, global }) => {
type={docStatus === 'published' ? 'success' : undefined}
className={`${baseClass}__parent-doc`}
>
- Current
- {' '}
- {docStatus}
- {' '}
- document -
+ {t('currentDocumentStatus', { docStatus })}
+ -
{' '}
{format(new Date(docUpdatedAt), dateFormat)}
@@ -205,7 +205,7 @@ const Versions: React.FC
= ({ collection, global }) => {
pillStyle="white"
to={editURL}
>
- Edit
+ {t('general:edit')}
@@ -234,7 +234,7 @@ const Versions: React.FC = ({ collection, global }) => {
-
{versionsData.totalPages > 1 && versionsData.totalPages !== versionsData.page ? (versionsData.limit * versionsData.page) : versionsData.totalDocs}
{' '}
- of
+ {t('of')}
{' '}
{versionsData.totalDocs}
@@ -249,7 +249,7 @@ const Versions: React.FC = ({ collection, global }) => {
)}
{versionsData?.totalDocs === 0 && (
- No further versions found
+ {t('noFurtherVersionsFound')}
)}
diff --git a/src/admin/components/views/collections/Edit/Auth/APIKey.tsx b/src/admin/components/views/collections/Edit/Auth/APIKey.tsx
index 3a5927a0be..e552ff65f4 100644
--- a/src/admin/components/views/collections/Edit/Auth/APIKey.tsx
+++ b/src/admin/components/views/collections/Edit/Auth/APIKey.tsx
@@ -1,5 +1,6 @@
import React, { useMemo, useState, useEffect } from 'react';
import { v4 as uuidv4 } from 'uuid';
+import { useTranslation } from 'react-i18next';
import useField from '../../../../forms/useField';
import Label from '../../../../forms/Label';
import CopyToClipboard from '../../../../elements/CopyToClipboard';
@@ -10,13 +11,14 @@ import GenerateConfirmation from '../../../../elements/GenerateConfirmation';
const path = 'apiKey';
const baseClass = 'api-key';
-const validate = (val) => text(val, { minLength: 24, maxLength: 48, data: {}, siblingData: {} });
const APIKey: React.FC = () => {
const [initialAPIKey, setInitialAPIKey] = useState(null);
const [highlightedField, setHighlightedField] = useState(false);
+ const { t } = useTranslation();
const apiKey = useFormFields(([fields]) => fields[path]);
+ const validate = (val) => text(val, { minLength: 24, maxLength: 48, data: {}, siblingData: {}, t });
const apiKeyValue = apiKey?.value;
diff --git a/src/admin/components/views/collections/Edit/Auth/index.tsx b/src/admin/components/views/collections/Edit/Auth/index.tsx
index efc8055280..1a693b79be 100644
--- a/src/admin/components/views/collections/Edit/Auth/index.tsx
+++ b/src/admin/components/views/collections/Edit/Auth/index.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { toast } from 'react-toastify';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../../utilities/Config';
import Email from '../../../../forms/field-types/Email';
import Password from '../../../../forms/field-types/Password';
@@ -21,6 +22,7 @@ const Auth: React.FC = (props) => {
const enableAPIKey = useFormFields(([fields]) => fields.enableAPIKey);
const dispatchFields = useFormFields((reducer) => reducer[1]);
const modified = useFormModified();
+ const { t, i18n } = useTranslation('authentication');
const {
serverURL,
@@ -44,6 +46,7 @@ const Auth: React.FC = (props) => {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
+ 'Accept-Language': i18n.language,
},
body: JSON.stringify({
email,
@@ -52,11 +55,11 @@ const Auth: React.FC = (props) => {
});
if (response.status === 200) {
- toast.success('Successfully unlocked', { autoClose: 3000 });
+ toast.success(t('successfullyUnlocked'), { autoClose: 3000 });
} else {
- toast.error('Successfully unlocked');
+ toast.error(t('failedToUnlock'));
}
- }, [serverURL, api, slug, email]);
+ }, [i18n, serverURL, api, slug, email, t]);
useEffect(() => {
if (!modified) {
@@ -73,7 +76,7 @@ const Auth: React.FC = (props) => {
{(changingPassword || requirePassword) && (
@@ -82,7 +85,7 @@ const Auth: React.FC = (props) => {
autoComplete="off"
required
name="password"
- label="New Password"
+ label={t('newPassword')}
/>
{!requirePassword && (
@@ -91,7 +94,7 @@ const Auth: React.FC = (props) => {
buttonStyle="secondary"
onClick={() => handleChangePassword(false)}
>
- Cancel
+ {t('general:cancel')}
)}
@@ -102,7 +105,7 @@ const Auth: React.FC = (props) => {
buttonStyle="secondary"
onClick={() => handleChangePassword(true)}
>
- Change Password
+ {t('changePassword')}
)}
{operation === 'update' && (
@@ -111,13 +114,13 @@ const Auth: React.FC = (props) => {
buttonStyle="secondary"
onClick={() => unlock()}
>
- Force Unlock
+ {t('forceUnlock')}
)}
{useAPIKey && (
{enableAPIKey?.value && (
@@ -127,7 +130,7 @@ const Auth: React.FC
= (props) => {
)}
{verify && (
)}
diff --git a/src/admin/components/views/collections/Edit/Default.tsx b/src/admin/components/views/collections/Edit/Default.tsx
index bdeb683dbc..0ebd9a6666 100644
--- a/src/admin/components/views/collections/Edit/Default.tsx
+++ b/src/admin/components/views/collections/Edit/Default.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { Link } from 'react-router-dom';
import format from 'date-fns/format';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../utilities/Config';
import Eyebrow from '../../../elements/Eyebrow';
import Form from '../../../forms/Form';
@@ -26,6 +27,7 @@ import SaveDraft from '../../../elements/SaveDraft';
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import { OperationContext } from '../../../utilities/OperationProvider';
import { Gutter } from '../../../elements/Gutter';
+import { getTranslation } from '../../../../../utilities/getTranslation';
import './index.scss';
@@ -34,6 +36,7 @@ const baseClass = 'collection-edit';
const DefaultEditView: React.FC = (props) => {
const { admin: { dateFormat }, routes: { admin } } = useConfig();
const { publishedDoc } = useDocumentInfo();
+ const { t, i18n } = useTranslation('general');
const {
collection,
@@ -92,9 +95,9 @@ const DefaultEditView: React.FC = (props) => {
>
{!disableEyebrow && (
@@ -107,7 +110,7 @@ const DefaultEditView: React.FC
= (props) => {
{customHeader && customHeader}
{!customHeader && (
-
+
)}
@@ -148,7 +151,7 @@ const DefaultEditView: React.FC = (props) => {
id="action-create"
to={`${admin}/collections/${slug}/create`}
>
- Create New
+ {t('createNew')}
{!disableDuplicate && isEditing && (
@@ -191,7 +194,7 @@ const DefaultEditView: React.FC = (props) => {
)}
{!collection.versions?.drafts && (
- Save
+ {t('save')}
)}
)}
@@ -244,7 +247,7 @@ const DefaultEditView: React.FC = (props) => {
)}
{versions && (
- Versions
+ {t('version:versions')}
= (props) => {
{data.updatedAt && (
- Last Modified
+ {t('lastModified')}
{format(new Date(data.updatedAt), dateFormat)}
)}
{(publishedDoc?.createdAt || data?.createdAt) && (
- Created
+ {t('created')}
{format(new Date(publishedDoc?.createdAt || data?.createdAt), dateFormat)}
)}
diff --git a/src/admin/components/views/collections/Edit/Upload/index.tsx b/src/admin/components/views/collections/Edit/Upload/index.tsx
index 3ae1e9204b..aff9227b7c 100644
--- a/src/admin/components/views/collections/Edit/Upload/index.tsx
+++ b/src/admin/components/views/collections/Edit/Upload/index.tsx
@@ -1,6 +1,7 @@
import React, {
useState, useRef, useEffect, useCallback,
} from 'react';
+import { useTranslation } from 'react-i18next';
import useField from '../../../../forms/useField';
import Button from '../../../../elements/Button';
import FileDetails from '../../../../elements/FileDetails';
@@ -31,6 +32,7 @@ const Upload: React.FC = (props) => {
const [dragging, setDragging] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const [replacingFile, setReplacingFile] = useState(false);
+ const { t } = useTranslation('upload');
const {
data = {} as Data,
@@ -173,9 +175,9 @@ const Upload: React.FC = (props) => {
buttonStyle="secondary"
onClick={() => setSelectingFile(true)}
>
- Select a file
+ {t('selectFile')}
- or drag and drop a file here
+ {t('dragAndDropHere')}
)}
diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx
index f82f19d871..b7afa27513 100644
--- a/src/admin/components/views/collections/Edit/index.tsx
+++ b/src/admin/components/views/collections/Edit/index.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Redirect, useRouteMatch, useHistory, useLocation } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../utilities/Config';
import { useAuth } from '../../../utilities/Auth';
import { useStepNav } from '../../../elements/StepNav';
@@ -16,6 +17,7 @@ import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import { Fields } from '../../../forms/Form/types';
import { usePreferences } from '../../../utilities/Preferences';
import { EditDepthContext } from '../../../utilities/EditDepth';
+import { getTranslation } from '../../../../../utilities/getTranslation';
const EditView: React.FC = (props) => {
const { collection: incomingCollection, isEditing } = props;
@@ -49,16 +51,17 @@ const EditView: React.FC = (props) => {
const { permissions, user } = useAuth();
const { getVersions, preferencesKey } = useDocumentInfo();
const { getPreference } = usePreferences();
+ const { t, i18n } = useTranslation('general');
const onSave = useCallback(async (json: any) => {
getVersions();
if (!isEditing) {
setRedirect(`${admin}/collections/${collection.slug}/${json?.doc?.id}`);
} else {
- const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc, user, id, operation: 'update', locale });
+ const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc, user, id, operation: 'update', locale, t });
setInitialState(state);
}
- }, [admin, collection, isEditing, getVersions, user, id, locale]);
+ }, [admin, collection, isEditing, getVersions, user, id, t, locale]);
const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI(
(isEditing ? `${serverURL}${api}/${slug}/${id}` : null),
@@ -70,7 +73,7 @@ const EditView: React.FC = (props) => {
useEffect(() => {
const nav: StepNavItem[] = [{
url: `${admin}/collections/${slug}`,
- label: pluralLabel,
+ label: getTranslation(pluralLabel, i18n),
}];
if (isEditing) {
@@ -81,7 +84,7 @@ const EditView: React.FC = (props) => {
if (dataToRender[useAsTitle]) {
label = dataToRender[useAsTitle];
} else {
- label = '[Untitled]';
+ label = `[${t('untitled')}]`;
}
} else {
label = dataToRender.id;
@@ -93,25 +96,25 @@ const EditView: React.FC = (props) => {
});
} else {
nav.push({
- label: 'Create New',
+ label: t('createNew'),
});
}
setStepNav(nav);
- }, [setStepNav, isEditing, pluralLabel, dataToRender, slug, useAsTitle, admin]);
+ }, [setStepNav, isEditing, pluralLabel, dataToRender, slug, useAsTitle, admin, t, i18n]);
useEffect(() => {
if (isLoadingDocument) {
return;
}
const awaitInitialState = async () => {
- const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender, user, operation: isEditing ? 'update' : 'create', id, locale });
+ const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender, user, operation: isEditing ? 'update' : 'create', id, locale, t });
await getPreference(preferencesKey);
setInitialState(state);
};
awaitInitialState();
- }, [dataToRender, fields, isEditing, id, user, locale, isLoadingDocument, preferencesKey, getPreference]);
+ }, [dataToRender, fields, isEditing, id, user, locale, isLoadingDocument, preferencesKey, getPreference, t]);
useEffect(() => {
if (redirect) {
diff --git a/src/admin/components/views/collections/List/Cell/cellTypes.spec.tsx b/src/admin/components/views/collections/List/Cell/cellTypes.spec.tsx
index c310376675..5806db6335 100644
--- a/src/admin/components/views/collections/List/Cell/cellTypes.spec.tsx
+++ b/src/admin/components/views/collections/List/Cell/cellTypes.spec.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { render } from '@testing-library/react';
-import { SelectField } from '../../../../../../fields/config/types';
+import { BlockField, SelectField } from '../../../../../../fields/config/types';
import BlocksCell from './field-types/Blocks';
import DateCell from './field-types/Date';
import Checkbox from './field-types/Checkbox';
@@ -11,9 +11,13 @@ jest.mock('../../../../utilities/Config', () => ({
useConfig: () => ({ admin: { dateFormat: 'MMMM do yyyy, h:mm a' } }),
}));
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (string) => string }),
+}));
+
describe('Cell Types', () => {
describe('Blocks', () => {
- const field = {
+ const field: BlockField = {
label: 'Blocks Content',
name: 'blocks',
labels: {
@@ -25,8 +29,10 @@ describe('Cell Types', () => {
{
slug: 'number',
labels: {
+ plural: 'Numbers',
singular: 'Number',
},
+ fields: [],
},
],
};
@@ -69,7 +75,7 @@ describe('Cell Types', () => {
field={field}
/>);
const el = container.querySelector('span');
- expect(el).toHaveTextContent('6 Blocks Content - Number, Number, Number, Number, Number and 1 more');
+ expect(el).toHaveTextContent('fields:itemsAndMore');
});
});
diff --git a/src/admin/components/views/collections/List/Cell/field-types/Array/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/Array/index.tsx
index 4429108b06..878dd066a7 100644
--- a/src/admin/components/views/collections/List/Cell/field-types/Array/index.tsx
+++ b/src/admin/components/views/collections/List/Cell/field-types/Array/index.tsx
@@ -1,5 +1,7 @@
import React from 'react';
+import { useTranslation } from 'react-i18next';
import { ArrayField } from '../../../../../../../../fields/config/types';
+import { getTranslation } from '../../../../../../../../utilities/getTranslation';
type Props = {
data: Record
@@ -7,8 +9,9 @@ type Props = {
}
const ArrayCell: React.FC = ({ data, field }) => {
+ const { t, i18n } = useTranslation('general');
const arrayFields = data ?? [];
- const label = `${arrayFields.length} ${field?.labels?.plural || 'Rows'}`;
+ const label = `${arrayFields.length} ${getTranslation(field?.labels?.plural || t('rows'), i18n)}`;
return (
{label}
diff --git a/src/admin/components/views/collections/List/Cell/field-types/Blocks/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/Blocks/index.tsx
index 96e59a61ef..5ce3f4f95a 100644
--- a/src/admin/components/views/collections/List/Cell/field-types/Blocks/index.tsx
+++ b/src/admin/components/views/collections/List/Cell/field-types/Blocks/index.tsx
@@ -1,10 +1,19 @@
import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { BlockField } from '../../../../../../../../fields/config/types';
+import { getTranslation } from '../../../../../../../../utilities/getTranslation';
-const BlocksCell = ({ data, field }) => {
+type Props = {
+ data: any
+ field: BlockField
+}
+
+const BlocksCell:React.FC = ({ data, field }: Props) => {
+ const { t, i18n } = useTranslation('fields');
const selectedBlocks = data ? data.map(({ blockType }) => blockType) : [];
- const blockLabels = field.blocks.map((s) => ({ slug: s.slug, label: s.labels.singular }));
+ const blockLabels = field.blocks.map((s) => ({ slug: s.slug, label: getTranslation(s.labels.singular, i18n) }));
- let label = `0 ${field.labels.plural}`;
+ let label = `0 ${getTranslation(field.labels.plural, i18n)}`;
const formatBlockList = (blocks) => blocks.map((b) => {
const filtered = blockLabels.filter((f) => f.slug === b)?.[0];
@@ -14,9 +23,9 @@ const BlocksCell = ({ data, field }) => {
const itemsToShow = 5;
if (selectedBlocks.length > itemsToShow) {
const more = selectedBlocks.length - itemsToShow;
- label = `${selectedBlocks.length} ${field.labels.plural} - ${formatBlockList(selectedBlocks.slice(0, itemsToShow))} and ${more} more`;
+ label = `${selectedBlocks.length} ${getTranslation(field.labels.plural, i18n)} - ${t('fields:itemsAndMore', { items: formatBlockList(selectedBlocks.slice(0, itemsToShow)), count: more })}`;
} else if (selectedBlocks.length > 0) {
- label = `${selectedBlocks.length} ${selectedBlocks.length === 1 ? field.labels.singular : field.labels.plural} - ${formatBlockList(selectedBlocks)}`;
+ label = `${selectedBlocks.length} ${getTranslation(selectedBlocks.length === 1 ? field.labels.singular : field.labels.plural, i18n)} - ${formatBlockList(selectedBlocks)}`;
}
return (
diff --git a/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx
index ba88508c08..d525b3be26 100644
--- a/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx
+++ b/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx
@@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../../../../utilities/Config';
import useIntersect from '../../../../../../../hooks/useIntersect';
import { useListRelationships } from '../../../RelationshipProvider';
+import { getTranslation } from '../../../../../../../../utilities/getTranslation';
import './index.scss';
@@ -16,6 +18,7 @@ const RelationshipCell = (props) => {
const [values, setValues] = useState([]);
const { getRelationships, documents } = useListRelationships();
const [hasRequested, setHasRequested] = useState(false);
+ const { t, i18n } = useTranslation('general');
const isAboveViewport = entry?.boundingClientRect?.top < window.innerHeight;
@@ -51,17 +54,20 @@ const RelationshipCell = (props) => {
const relatedCollection = collections.find(({ slug }) => slug === relationTo);
return (
- { document === false && `Untitled - ID: ${value}`}
- { document === null && 'Loading...'}
+ { document === false && `${t('untitled')} - ID: ${value}`}
+ { document === null && t('loading')}
{ document && (
- document[relatedCollection.admin.useAsTitle] ? document[relatedCollection.admin.useAsTitle] : `Untitled - ID: ${value}`
+ document[relatedCollection.admin.useAsTitle] ? document[relatedCollection.admin.useAsTitle] : `${t('untitled')} - ID: ${value}`
)}
{values.length > i + 1 && ', '}
);
})}
- { Array.isArray(cellData) && cellData.length > totalToShow && ` and ${cellData.length - totalToShow} more` }
- { values.length === 0 && `No <${field.label}>`}
+ {
+ Array.isArray(cellData) && cellData.length > totalToShow
+ && t('fields:itemsAndMore', { items: '', count: cellData.length - totalToShow })
+ }
+ { values.length === 0 && t('noLabel', { label: getTranslation(field.label, i18n) })}
);
};
diff --git a/src/admin/components/views/collections/List/Cell/field-types/Select/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/Select/index.tsx
index 985d89c839..df8d0effc8 100644
--- a/src/admin/components/views/collections/List/Cell/field-types/Select/index.tsx
+++ b/src/admin/components/views/collections/List/Cell/field-types/Select/index.tsx
@@ -1,11 +1,19 @@
import React from 'react';
+import { useTranslation } from 'react-i18next';
import { OptionObject, optionsAreObjects, SelectField } from '../../../../../../../../fields/config/types';
+import { getTranslation } from '../../../../../../../../utilities/getTranslation';
-const SelectCell = ({ data, field }: { data: any, field: SelectField }) => {
+type Props = {
+ data: any
+ field: SelectField
+}
+
+const SelectCell:React.FC = ({ data, field }: Props) => {
+ const { i18n } = useTranslation();
const findLabel = (items: string[]) => items.map((i) => {
const found = (field.options as OptionObject[])
.filter((f: OptionObject) => f.value === i)?.[0]?.label;
- return found;
+ return getTranslation(found, i18n);
}).join(', ');
let content = '';
@@ -18,6 +26,7 @@ const SelectCell = ({ data, field }: { data: any, field: SelectField }) => {
? data.join(', ') // hasMany
: data;
}
+
return (
{content}
diff --git a/src/admin/components/views/collections/List/Cell/field-types/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/index.tsx
index e36d4c1f63..ad3f4efa57 100644
--- a/src/admin/components/views/collections/List/Cell/field-types/index.tsx
+++ b/src/admin/components/views/collections/List/Cell/field-types/index.tsx
@@ -18,6 +18,7 @@ export default {
relationship,
richText,
select,
+ radio: select,
textarea,
upload: relationship,
};
diff --git a/src/admin/components/views/collections/List/Cell/index.tsx b/src/admin/components/views/collections/List/Cell/index.tsx
index bac2dfaa99..a88ebd91a6 100644
--- a/src/admin/components/views/collections/List/Cell/index.tsx
+++ b/src/admin/components/views/collections/List/Cell/index.tsx
@@ -1,9 +1,11 @@
import React from 'react';
import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../../utilities/Config';
import RenderCustomComponent from '../../../../utilities/RenderCustomComponent';
import cellComponents from './field-types';
import { Props } from './types';
+import { getTranslation } from '../../../../../../utilities/getTranslation';
const DefaultCell: React.FC = (props) => {
const {
@@ -19,6 +21,7 @@ const DefaultCell: React.FC = (props) => {
} = props;
const { routes: { admin } } = useConfig();
+ const { t, i18n } = useTranslation('general');
let WrapElement: React.ComponentType | string = 'span';
@@ -36,7 +39,7 @@ const DefaultCell: React.FC = (props) => {
if (!CellComponent) {
return (
- {(cellData === '' || typeof cellData === 'undefined') && ``}
+ {(cellData === '' || typeof cellData === 'undefined') && t('noLabel', { label: getTranslation(typeof field.label === 'function' ? 'data' : field.label || 'data', i18n) })}
{typeof cellData === 'string' && cellData}
{typeof cellData === 'number' && cellData}
{typeof cellData === 'object' && JSON.stringify(cellData)}
diff --git a/src/admin/components/views/collections/List/Default.tsx b/src/admin/components/views/collections/List/Default.tsx
index a0fe58bcc1..287979afab 100644
--- a/src/admin/components/views/collections/List/Default.tsx
+++ b/src/admin/components/views/collections/List/Default.tsx
@@ -1,5 +1,6 @@
import React, { Fragment } from 'react';
import { useHistory } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../utilities/Config';
import UploadGallery from '../../../elements/UploadGallery';
import Eyebrow from '../../../elements/Eyebrow';
@@ -14,6 +15,7 @@ import ViewDescription from '../../../elements/ViewDescription';
import PerPage from '../../../elements/PerPage';
import { Gutter } from '../../../elements/Gutter';
import { RelationshipProvider } from './RelationshipProvider';
+import { getTranslation } from '../../../../../utilities/getTranslation';
import './index.scss';
@@ -44,19 +46,20 @@ const DefaultList: React.FC = (props) => {
const { routes: { admin } } = useConfig();
const history = useHistory();
+ const { t, i18n } = useTranslation('general');
return (
- {pluralLabel}
+ {getTranslation(pluralLabel, i18n)}
{hasCreatePermission && (
- Create New
+ {t('createNew')}
)}
{description && (
@@ -94,24 +97,14 @@ const DefaultList: React.FC = (props) => {
{data.docs && data.docs.length === 0 && (
- No
- {' '}
- {pluralLabel}
- {' '}
- found. Either no
- {' '}
- {pluralLabel}
- {' '}
- exist yet or none match the filters you've specified above.
+ {t('noResults', { label: getTranslation(pluralLabel, i18n) })}
{hasCreatePermission && (
- Create new
- {' '}
- {singularLabel}
+ {t('createNewLabel', { label: getTranslation(singularLabel, i18n) })}
)}
@@ -134,7 +127,7 @@ const DefaultList: React.FC = (props) => {
-
{data.totalPages > 1 && data.totalPages !== data.page ? (data.limit * data.page) : data.totalDocs}
{' '}
- of
+ {t('of')}
{' '}
{data.totalDocs}
diff --git a/src/admin/components/views/collections/List/RelationshipProvider/index.tsx b/src/admin/components/views/collections/List/RelationshipProvider/index.tsx
index 17859115ec..88588f8559 100644
--- a/src/admin/components/views/collections/List/RelationshipProvider/index.tsx
+++ b/src/admin/components/views/collections/List/RelationshipProvider/index.tsx
@@ -1,5 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useReducer, useRef } from 'react';
import querystring from 'qs';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../../utilities/Config';
import { TypeWithID } from '../../../../../../collections/config/types';
import { reducer } from './reducer';
@@ -28,6 +29,7 @@ export const RelationshipProvider: React.FC<{ children?: React.ReactNode }> = ({
const [documents, dispatchDocuments] = useReducer(reducer, {});
const debouncedDocuments = useDebounce(documents, 100);
const config = useConfig();
+ const { i18n } = useTranslation();
const {
serverURL,
routes: { api },
@@ -52,7 +54,12 @@ export const RelationshipProvider: React.FC<{ children?: React.ReactNode }> = ({
};
const query = querystring.stringify(params, { addQueryPrefix: true });
- const result = await fetch(`${url}${query}`, { credentials: 'include' });
+ const result = await fetch(`${url}${query}`, {
+ credentials: 'include',
+ headers: {
+ 'Accept-Language': i18n.language,
+ },
+ });
if (result.ok) {
const json = await result.json();
if (json.docs) {
@@ -63,7 +70,7 @@ export const RelationshipProvider: React.FC<{ children?: React.ReactNode }> = ({
}
}
});
- }, [serverURL, api, debouncedDocuments]);
+ }, [i18n, serverURL, api, debouncedDocuments]);
const getRelationships = useCallback(async (relationships: { relationTo: string, value: number | string }[]) => {
dispatchDocuments({ type: 'REQUEST', docs: relationships });
diff --git a/src/admin/components/views/collections/List/buildColumns.tsx b/src/admin/components/views/collections/List/buildColumns.tsx
index 519895eb2d..7630f6dbd3 100644
--- a/src/admin/components/views/collections/List/buildColumns.tsx
+++ b/src/admin/components/views/collections/List/buildColumns.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import type { TFunction } from 'react-i18next';
import Cell from './Cell';
import SortColumn from '../../../elements/SortColumn';
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
@@ -6,7 +7,7 @@ import { Column } from '../../../elements/Table/types';
import { fieldIsPresentationalOnly } from '../../../../../fields/config/types';
import flattenFields from '../../../../../utilities/flattenTopLevelFields';
-const buildColumns = (collection: SanitizedCollectionConfig, columns: string[]): Column[] => {
+const buildColumns = (collection: SanitizedCollectionConfig, columns: string[], t: TFunction): Column[] => {
const flattenedFields = flattenFields([
...collection.fields,
{
@@ -17,12 +18,12 @@ const buildColumns = (collection: SanitizedCollectionConfig, columns: string[]):
{
name: 'updatedAt',
type: 'date',
- label: 'Updated At',
+ label: t('updatedAt'),
},
{
name: 'createdAt',
type: 'date',
- label: 'Created At',
+ label: t('createdAt'),
},
], true);
diff --git a/src/admin/components/views/collections/List/formatFields.tsx b/src/admin/components/views/collections/List/formatFields.tsx
index 67fd02cc19..ad2506aedf 100644
--- a/src/admin/components/views/collections/List/formatFields.tsx
+++ b/src/admin/components/views/collections/List/formatFields.tsx
@@ -1,7 +1,8 @@
+import { TFunction } from 'react-i18next';
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { Field, fieldAffectsData, fieldIsPresentationalOnly } from '../../../../../fields/config/types';
-const formatFields = (config: SanitizedCollectionConfig): Field[] => {
+const formatFields = (config: SanitizedCollectionConfig, t: TFunction): Field[] => {
const hasID = config.fields.findIndex((field) => fieldAffectsData(field) && field.name === 'id') > -1;
let fields: Field[] = config.fields.reduce((formatted, field) => {
if (!fieldIsPresentationalOnly(field) && (field.hidden === true || field?.admin?.disabled === true)) {
@@ -18,11 +19,11 @@ const formatFields = (config: SanitizedCollectionConfig): Field[] => {
fields = fields.concat([
{
name: 'createdAt',
- label: 'Created At',
+ label: t('general:createdAt'),
type: 'date',
}, {
name: 'updatedAt',
- label: 'Updated At',
+ label: t('general:updatedAt'),
type: 'date',
},
]);
@@ -32,7 +33,7 @@ const formatFields = (config: SanitizedCollectionConfig): Field[] => {
fields = fields.concat([
{
name: 'filename',
- label: 'Filename',
+ label: t('upload:fileName'),
type: 'text',
},
]);
diff --git a/src/admin/components/views/collections/List/index.tsx b/src/admin/components/views/collections/List/index.tsx
index 2b507b779f..b75bf7e1cb 100644
--- a/src/admin/components/views/collections/List/index.tsx
+++ b/src/admin/components/views/collections/List/index.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import queryString from 'qs';
+import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../utilities/Config';
import { useAuth } from '../../../utilities/Auth';
import usePayloadAPI from '../../../../hooks/usePayloadAPI';
@@ -46,12 +47,13 @@ const ListView: React.FC = (props) => {
const { getPreference, setPreference } = usePreferences();
const { page, sort, limit, where } = useSearchParams();
const history = useHistory();
+ const { t } = useTranslation('general');
const [fetchURL, setFetchURL] = useState('');
- const [fields] = useState(() => formatFields(collection));
+ const [fields] = useState(() => formatFields(collection, t));
const [tableColumns, setTableColumns] = useState(() => {
const initialColumns = getInitialColumns(fields, useAsTitle, defaultColumns);
- return buildColumns(collection, initialColumns);
+ return buildColumns(collection, initialColumns, t);
});
const collectionPermissions = permissions?.collections?.[slug];
@@ -104,7 +106,7 @@ const ListView: React.FC = (props) => {
(async () => {
const currentPreferences = await getPreference(preferenceKey);
if (currentPreferences?.columns) {
- setTableColumns(buildColumns(collection, currentPreferences?.columns));
+ setTableColumns(buildColumns(collection, currentPreferences?.columns, t));
}
const params = queryString.parse(history.location.search, { ignoreQueryPrefix: true, depth: 0 });
@@ -123,7 +125,7 @@ const ListView: React.FC = (props) => {
});
}
})();
- }, [collection, getPreference, preferenceKey, history]);
+ }, [collection, getPreference, preferenceKey, history, t]);
// /////////////////////////////////////
// When any preference-enabled values are updated,
@@ -141,8 +143,8 @@ const ListView: React.FC = (props) => {
}, [sort, limit, stringifiedActiveColumns, preferenceKey, setPreference]);
const setActiveColumns = useCallback((columns: string[]) => {
- setTableColumns(buildColumns(collection, columns));
- }, [collection]);
+ setTableColumns(buildColumns(collection, columns, t));
+ }, [collection, t]);
return (
{
initialData = {},
} = options;
+ const { i18n } = useTranslation();
const [data, setData] = useState(initialData);
const [params, setParams] = useState(initialParams);
const [isLoading, setIsLoading] = useState(true);
@@ -44,7 +46,11 @@ const usePayloadAPI: UsePayloadAPI = (url, options = {}) => {
setIsLoading(true);
try {
- const response = await requests.get(`${url}?${search}`);
+ const response = await requests.get(`${url}?${search}`, {
+ headers: {
+ 'Accept-Language': i18n.language,
+ },
+ });
if (response.status > 201) {
setIsError(true);
@@ -65,7 +71,7 @@ const usePayloadAPI: UsePayloadAPI = (url, options = {}) => {
setIsError(false);
setIsLoading(false);
}
- }, [url, locale, search]);
+ }, [url, locale, search, i18n.language]);
return [{ data, isLoading, isError }, { setParams }];
};
diff --git a/src/admin/index.tsx b/src/admin/index.tsx
index a8fee8c904..5364ab537f 100644
--- a/src/admin/index.tsx
+++ b/src/admin/index.tsx
@@ -17,12 +17,14 @@ import { LocaleProvider } from './components/utilities/Locale';
import Routes from './components/Routes';
import { StepNavProvider } from './components/elements/StepNav';
import { ThemeProvider } from './components/utilities/Theme';
+import { I18n } from './components/utilities/I18n';
import './scss/app.scss';
const Index = () => (
+
{
if (permissions?.[entityToGroup.type.toLowerCase()]?.[entityToGroup.entity.slug]?.read.permission) {
+ const translatedGroup = getTranslation(entityToGroup.entity.admin.group, i18n);
if (entityToGroup.entity.admin.group) {
- const existingGroup = groups.find((group) => group.label === entityToGroup.entity.admin.group);
+ const existingGroup = groups.find((group) => getTranslation(group.label, i18n) === translatedGroup) as Group;
let matchedGroup: Group = existingGroup;
if (!existingGroup) {
- matchedGroup = { label: entityToGroup.entity.admin.group, entities: [] };
+ matchedGroup = { label: translatedGroup, entities: [] };
groups.push(matchedGroup);
}
matchedGroup.entities.push(entityToGroup);
} else {
- const defaultGroup = groups.find((group) => group.label === entityToGroup.type);
+ const defaultGroup = groups.find((group) => getTranslation(group.label, i18n) === i18n.t(`general:${entityToGroup.type}`)) as Group;
defaultGroup.entities.push(entityToGroup);
}
}
@@ -41,11 +44,11 @@ export function groupNavItems(entities: EntityToGroup[], permissions: Permission
return groups;
}, [
{
- label: 'Collections',
+ label: i18n.t('general:collections') as string,
entities: [],
},
{
- label: 'Globals',
+ label: i18n.t('general:globals') as string,
entities: [],
},
]);
diff --git a/src/auth/baseFields/apiKey.ts b/src/auth/baseFields/apiKey.ts
index 5973583513..1d6afc849e 100644
--- a/src/auth/baseFields/apiKey.ts
+++ b/src/auth/baseFields/apiKey.ts
@@ -1,5 +1,8 @@
import crypto from 'crypto';
import { Field, FieldHook } from '../../fields/config/types';
+import { extractTranslations } from '../../translations/extractTranslations';
+
+const labels = extractTranslations(['authentication:enableAPIKey', 'authentication:apiKey']);
const encryptKey: FieldHook = ({ req, value }) => (value ? req.payload.encrypt(value as string) : undefined);
const decryptKey: FieldHook = ({ req, value }) => (value ? req.payload.decrypt(value as string) : undefined);
@@ -7,7 +10,7 @@ const decryptKey: FieldHook = ({ req, value }) => (value ? req.payload.decrypt(v
export default [
{
name: 'enableAPIKey',
- label: 'Enable API Key',
+ label: labels['authentication:enableAPIKey'],
type: 'checkbox',
defaultValue: false,
admin: {
@@ -18,7 +21,7 @@ export default [
},
{
name: 'apiKey',
- label: 'API Key',
+ label: labels['authentication:apiKey'],
type: 'text',
admin: {
components: {
diff --git a/src/auth/baseFields/auth.ts b/src/auth/baseFields/auth.ts
index e410a93fa7..c17ba9ae78 100644
--- a/src/auth/baseFields/auth.ts
+++ b/src/auth/baseFields/auth.ts
@@ -1,10 +1,13 @@
import { email } from '../../fields/validations';
import { Field } from '../../fields/config/types';
+import { extractTranslations } from '../../translations/extractTranslations';
+
+const labels = extractTranslations(['general:email']);
export default [
{
name: 'email',
- label: 'Email',
+ label: labels['general:email'],
type: 'email',
validate: email,
unique: true,
diff --git a/src/auth/defaultUser.ts b/src/auth/defaultUser.ts
index fa9db4c16e..515c3027eb 100644
--- a/src/auth/defaultUser.ts
+++ b/src/auth/defaultUser.ts
@@ -1,10 +1,13 @@
import { CollectionConfig } from '../collections/config/types';
+import { extractTranslations } from '../translations/extractTranslations';
+
+const labels = extractTranslations(['general:user', 'general:users']);
const defaultUser: CollectionConfig = {
slug: 'users',
labels: {
- singular: 'User',
- plural: 'Users',
+ singular: labels['general:user'],
+ plural: labels['general:users'],
},
admin: {
useAsTitle: 'email',
diff --git a/src/auth/executeAccess.ts b/src/auth/executeAccess.ts
index 57ea789764..1a931e54fc 100644
--- a/src/auth/executeAccess.ts
+++ b/src/auth/executeAccess.ts
@@ -6,7 +6,7 @@ const executeAccess = async (operation, access: Access): Promise =
const result = await access(operation);
if (!result) {
- if (!operation.disableErrors) throw new Forbidden();
+ if (!operation.disableErrors) throw new Forbidden(operation.req.t);
}
return result;
@@ -16,7 +16,7 @@ const executeAccess = async (operation, access: Access): Promise =
return true;
}
- if (!operation.disableErrors) throw new Forbidden();
+ if (!operation.disableErrors) throw new Forbidden(operation.req.t);
return false;
};
diff --git a/src/auth/getExecuteStaticAccess.ts b/src/auth/getExecuteStaticAccess.ts
index abd6dac586..cc1071d07d 100644
--- a/src/auth/getExecuteStaticAccess.ts
+++ b/src/auth/getExecuteStaticAccess.ts
@@ -47,7 +47,7 @@ const getExecuteStaticAccess = ({ config, Model }) => async (req: PayloadRequest
const doc = await Model.findOne(query);
if (!doc) {
- throw new Forbidden();
+ throw new Forbidden(req.t);
}
}
}
diff --git a/src/auth/graphql/resolvers/access.ts b/src/auth/graphql/resolvers/access.ts
index 72a150d3fe..6c06064017 100644
--- a/src/auth/graphql/resolvers/access.ts
+++ b/src/auth/graphql/resolvers/access.ts
@@ -25,7 +25,7 @@ function accessResolver(payload: Payload) {
return {
...accessResults,
...formatConfigNames(accessResults.collections, payload.config.collections),
- ...formatConfigNames(accessResults.globals, payload.config.globals)
+ ...formatConfigNames(accessResults.globals, payload.config.globals),
};
}
diff --git a/src/auth/operations/forgotPassword.ts b/src/auth/operations/forgotPassword.ts
index 35a472ce1e..18b41d72a5 100644
--- a/src/auth/operations/forgotPassword.ts
+++ b/src/auth/operations/forgotPassword.ts
@@ -46,6 +46,7 @@ async function forgotPassword(incomingArgs: Arguments): Promise {
disableEmail,
expiration,
req: {
+ t,
payload: {
config,
sendEmail: email,
@@ -78,12 +79,11 @@ async function forgotPassword(incomingArgs: Arguments): Promise {
const userJSON = user.toJSON({ virtuals: true });
if (!disableEmail) {
- let html = `You are receiving this because you (or someone else) have requested the reset of the password for your account.
- Please click on the following link, or paste this into your browser to complete the process:
+ let html = `${t('authentication:youAreReceivingResetPassword')}
${config.serverURL}${config.routes.admin}/reset/${token}
- If you did not request this, please ignore this email and your password will remain unchanged.`;
+ ${t('authentication:youDidNotRequestPassword')}`;
if (typeof collectionConfig.auth.forgotPassword.generateEmailHTML === 'function') {
html = await collectionConfig.auth.forgotPassword.generateEmailHTML({
@@ -93,7 +93,7 @@ async function forgotPassword(incomingArgs: Arguments): Promise {
});
}
- let subject = 'Reset your password';
+ let subject = t('authentication:resetYourPassword');
if (typeof collectionConfig.auth.forgotPassword.generateEmailSubject === 'function') {
subject = await collectionConfig.auth.forgotPassword.generateEmailSubject({
diff --git a/src/auth/operations/local/forgotPassword.ts b/src/auth/operations/local/forgotPassword.ts
index 81ea1fc46e..612889267a 100644
--- a/src/auth/operations/local/forgotPassword.ts
+++ b/src/auth/operations/local/forgotPassword.ts
@@ -2,6 +2,7 @@ import { PayloadRequest } from '../../../express/types';
import forgotPassword, { Result } from '../forgotPassword';
import { Payload } from '../../..';
import { getDataLoader } from '../../../collections/dataloader';
+import i18n from '../../../translations/init';
export type Options = {
collection: string
@@ -25,7 +26,9 @@ async function localForgotPassword(payload: Payload, options: Options): Promise<
const collection = payload.collections[collectionSlug];
req.payloadAPI = 'local';
+ req.i18n = i18n(payload.config.i18n);
+ if (!req.t) req.t = req.i18n.t;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
return forgotPassword({
diff --git a/src/auth/operations/local/login.ts b/src/auth/operations/local/login.ts
index 281fa3f17d..ef92235c08 100644
--- a/src/auth/operations/local/login.ts
+++ b/src/auth/operations/local/login.ts
@@ -4,6 +4,7 @@ import { PayloadRequest } from '../../../express/types';
import { TypeWithID } from '../../../collections/config/types';
import { Payload } from '../../..';
import { getDataLoader } from '../../../collections/dataloader';
+import i18n from '../../../translations/init';
export type Options = {
collection: string
@@ -37,9 +38,11 @@ async function localLogin(payload: Payload, options:
req.payloadAPI = 'local';
req.payload = payload;
+ req.i18n = i18n(payload.config.i18n);
req.locale = undefined;
req.fallbackLocale = undefined;
+ if (!req.t) req.t = req.i18n.t;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
const args = {
diff --git a/src/auth/operations/local/resetPassword.ts b/src/auth/operations/local/resetPassword.ts
index 1e04f07bf1..55f3541159 100644
--- a/src/auth/operations/local/resetPassword.ts
+++ b/src/auth/operations/local/resetPassword.ts
@@ -2,6 +2,7 @@ import { Payload } from '../../..';
import resetPassword, { Result } from '../resetPassword';
import { PayloadRequest } from '../../../express/types';
import { getDataLoader } from '../../../collections/dataloader';
+import i18n from '../../../translations/init';
export type Options = {
collection: string
@@ -25,7 +26,9 @@ async function localResetPassword(payload: Payload, options: Options): Promise
req.payload = payload;
req.payloadAPI = 'local';
+ req.i18n = i18n(payload.config.i18n);
+ if (!req.t) req.t = req.i18n.t;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
return unlock({
diff --git a/src/auth/operations/login.ts b/src/auth/operations/login.ts
index ccc6ab6efe..b67015c381 100644
--- a/src/auth/operations/login.ts
+++ b/src/auth/operations/login.ts
@@ -77,11 +77,11 @@ async function login(incomingArgs: Arguments): Promise {
const userDoc = await Model.findByUsername(email);
if (!userDoc || (args.collection.config.auth.verify && userDoc._verified === false)) {
- throw new AuthenticationError();
+ throw new AuthenticationError(req.t);
}
if (userDoc && isLocked(userDoc.lockUntil)) {
- throw new LockedAuth();
+ throw new LockedAuth(req.t);
}
const authResult = await userDoc.authenticate(password);
@@ -90,7 +90,7 @@ async function login(incomingArgs: Arguments): Promise {
if (!authResult.user) {
if (maxLoginAttemptsEnabled) await userDoc.incLoginAttempts();
- throw new AuthenticationError();
+ throw new AuthenticationError(req.t);
}
if (maxLoginAttemptsEnabled) {
diff --git a/src/auth/operations/logout.ts b/src/auth/operations/logout.ts
index 3472ff06e8..799c68302d 100644
--- a/src/auth/operations/logout.ts
+++ b/src/auth/operations/logout.ts
@@ -51,7 +51,7 @@ async function logout(incomingArgs: Arguments): Promise {
res.clearCookie(`${config.cookiePrefix}-token`, cookieOptions);
- return 'Logged out successfully.';
+ return req.t('authentication:loggedOutSuccessfully');
}
export default logout;
diff --git a/src/auth/operations/refresh.ts b/src/auth/operations/refresh.ts
index c9e7fc7bae..ca85ef1109 100644
--- a/src/auth/operations/refresh.ts
+++ b/src/auth/operations/refresh.ts
@@ -55,7 +55,7 @@ async function refresh(incomingArgs: Arguments): Promise {
expiresIn: args.collection.config.auth.tokenExpiration,
};
- if (typeof args.token !== 'string') throw new Forbidden();
+ if (typeof args.token !== 'string') throw new Forbidden(args.req.t);
const payload = jwt.verify(args.token, secret, {}) as Record;
delete payload.iat;
diff --git a/src/auth/operations/registerFirstUser.ts b/src/auth/operations/registerFirstUser.ts
index 8568fa8c51..96e7b6aa6f 100644
--- a/src/auth/operations/registerFirstUser.ts
+++ b/src/auth/operations/registerFirstUser.ts
@@ -39,7 +39,7 @@ async function registerFirstUser(args: Arguments): Promise {
const count = await Model.countDocuments({});
- if (count >= 1) throw new Forbidden();
+ if (count >= 1) throw new Forbidden(req.t);
// /////////////////////////////////////
// Register first user
diff --git a/src/auth/sendVerificationEmail.ts b/src/auth/sendVerificationEmail.ts
index 43dfdf64ea..77c972e1e0 100644
--- a/src/auth/sendVerificationEmail.ts
+++ b/src/auth/sendVerificationEmail.ts
@@ -32,12 +32,9 @@ async function sendVerificationEmail(args: Args): Promise {
} = args;
if (!disableEmail) {
- const defaultVerificationURL = `${config.serverURL}${config.routes.admin}/${collectionConfig.slug}/verify/${token}`;
+ const verificationURL = `${config.serverURL}${config.routes.admin}/${collectionConfig.slug}/verify/${token}`;
- let html = `A new account has just been created for you to access ${config.serverURL} .
- Please click on the following link or paste the URL below into your browser to verify your email:
- ${defaultVerificationURL}
- After verifying your email, you will be able to log in successfully.`;
+ let html = `${req.t('authentication:newAccountCreated', { interpolation: { escapeValue: false }, serverURL: config.serverURL, verificationURL })}`;
const verify = collectionConfig.auth.verify as VerifyConfig;
@@ -50,7 +47,7 @@ async function sendVerificationEmail(args: Args): Promise {
});
}
- let subject = 'Verify your email';
+ let subject = req.t('authentication:verifyYourEmail');
// Allow config to override email subject
if (typeof verify.generateEmailSubject === 'function') {
diff --git a/src/bin/generateTypes.ts b/src/bin/generateTypes.ts
index bc6ead5189..4ee3e6a152 100644
--- a/src/bin/generateTypes.ts
+++ b/src/bin/generateTypes.ts
@@ -2,6 +2,7 @@
import fs from 'fs';
import type { JSONSchema4 } from 'json-schema';
import { compile } from 'json-schema-to-typescript';
+import { singular } from 'pluralize';
import Logger from '../utilities/logger';
import { fieldAffectsData, Field, Option, FieldAffectingData, tabHasName } from '../fields/config/types';
import { SanitizedCollectionConfig } from '../collections/config/types';
@@ -10,6 +11,7 @@ import loadConfig from '../config/load';
import { SanitizedGlobalConfig } from '../globals/config/types';
import deepCopyObject from '../utilities/deepCopyObject';
import { groupOrTabHasRequiredSubfield } from '../utilities/groupOrTabHasRequiredSubfield';
+import { toWords } from '../utilities/formatLabels';
const nonOptionalFieldTypes = ['group', 'array', 'blocks'];
@@ -370,7 +372,7 @@ function generateFieldTypes(config: SanitizedConfig, fields: Field[]): {
function entityToJsonSchema(config: SanitizedConfig, incomingEntity: SanitizedCollectionConfig | SanitizedGlobalConfig): JSONSchema4 {
const entity = deepCopyObject(incomingEntity);
- const title = 'label' in entity ? entity.label : entity.labels.singular;
+ const title = entity.typescript?.interface ? entity.typescript.interface : singular(toWords(entity.slug, true));
const idField: FieldAffectingData = { type: 'text', name: 'id', required: true };
const customIdField = entity.fields.find((field) => fieldAffectsData(field) && field.name === 'id') as FieldAffectingData;
diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts
index 766d316c6a..5746f18b7e 100644
--- a/src/collections/config/schema.ts
+++ b/src/collections/config/schema.ts
@@ -10,8 +10,14 @@ const strategyBaseSchema = joi.object().keys({
const collectionSchema = joi.object().keys({
slug: joi.string().required(),
labels: joi.object({
- singular: joi.string(),
- plural: joi.string(),
+ singular: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ),
+ plural: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ),
}),
access: joi.object({
create: joi.func(),
@@ -22,12 +28,22 @@ const collectionSchema = joi.object().keys({
unlock: joi.func(),
admin: joi.func(),
}),
+ graphQL: joi.object().keys({
+ singularName: joi.string(),
+ pluralName: joi.string(),
+ }),
+ typescript: joi.object().keys({
+ interface: joi.string(),
+ }),
timestamps: joi.boolean(),
admin: joi.object({
useAsTitle: joi.string(),
defaultColumns: joi.array().items(joi.string()),
listSearchableFields: joi.array().items(joi.string()),
- group: joi.string(),
+ group: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ),
description: joi.alternatives().try(
joi.string(),
componentSchema,
diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts
index 3341c996da..9de6e1e161 100644
--- a/src/collections/config/types.ts
+++ b/src/collections/config/types.ts
@@ -173,7 +173,7 @@ export type CollectionAdminOptions = {
/**
* Place collections into a navigational group
* */
- group?: string;
+ group?: Record | string;
/**
* Custom description for collection
*/
@@ -209,9 +209,25 @@ export type CollectionConfig = {
* Label configuration
*/
labels?: {
- singular?: string;
- plural?: string;
+ singular?: Record | string;
+ plural?: Record | string;
};
+ /**
+ * GraphQL configuration
+ */
+ graphQL?: {
+ singularName?: string
+ pluralName?: string
+ }
+ /**
+ * Options used in typescript generation
+ */
+ typescript?: {
+ /**
+ * Typescript generation name given to the interface type
+ */
+ interface?: string
+ }
fields: Field[];
/**
* Collection admin options
diff --git a/src/collections/graphql/init.ts b/src/collections/graphql/init.ts
index 3f30988fb3..f10aecaa93 100644
--- a/src/collections/graphql/init.ts
+++ b/src/collections/graphql/init.ts
@@ -33,35 +33,44 @@ import { Field, fieldAffectsData } from '../../fields/config/types';
import buildObjectType, { ObjectTypeConfig } from '../../graphql/schema/buildObjectType';
import buildWhereInputType from '../../graphql/schema/buildWhereInputType';
import getDeleteResolver from './resolvers/delete';
+import { toWords, formatNames } from '../../utilities/formatLabels';
+import { Collection, SanitizedCollectionConfig } from '../config/types';
function initCollectionsGraphQL(payload: Payload): void {
Object.keys(payload.collections).forEach((slug) => {
- const collection = payload.collections[slug];
+ const collection: Collection = payload.collections[slug];
const {
config: {
- labels: {
- singular,
- plural,
- },
+ graphQL = {} as SanitizedCollectionConfig['graphQL'],
fields,
timestamps,
versions,
},
} = collection;
- const singularLabel = formatName(singular);
- let pluralLabel = formatName(plural);
+ let singularName;
+ let pluralName;
+ const fromSlug = formatNames(collection.config.slug);
+ if (graphQL.singularName) {
+ singularName = toWords(graphQL.singularName, true);
+ } else {
+ singularName = fromSlug.singular;
+ }
+ if (graphQL.pluralName) {
+ pluralName = toWords(graphQL.pluralName, true);
+ } else {
+ pluralName = fromSlug.plural;
+ }
// For collections named 'Media' or similar,
// there is a possibility that the singular name
// will equal the plural name. Append `all` to the beginning
// of potential conflicts
-
- if (singularLabel === pluralLabel) {
- pluralLabel = `all${singularLabel}`;
+ if (singularName === pluralName) {
+ pluralName = `all${singularName}`;
}
- collection.graphQL = {} as any;
+ collection.graphQL = {} as Collection['graphQL'];
const idField = fields.find((field) => fieldAffectsData(field) && field.name === 'id');
const idType = getCollectionIDType(collection.config);
@@ -106,17 +115,17 @@ function initCollectionsGraphQL(payload: Payload): void {
collection.graphQL.type = buildObjectType({
payload,
- name: singularLabel,
- parentName: singularLabel,
+ name: singularName,
+ parentName: singularName,
fields,
baseFields,
forceNullable: forceNullableObjectType,
});
collection.graphQL.whereInputType = buildWhereInputType(
- singularLabel,
+ singularName,
whereInputFields,
- singularLabel,
+ singularName,
);
if (collection.config.auth && !collection.config.auth.disableLocalStrategy) {
@@ -130,20 +139,20 @@ function initCollectionsGraphQL(payload: Payload): void {
collection.graphQL.mutationInputType = new GraphQLNonNull(buildMutationInputType(
payload,
- singularLabel,
+ singularName,
fields,
- singularLabel,
+ singularName,
));
collection.graphQL.updateMutationInputType = new GraphQLNonNull(buildMutationInputType(
payload,
- `${singularLabel}Update`,
+ `${singularName}Update`,
fields.filter((field) => !(fieldAffectsData(field) && field.name === 'id')),
- `${singularLabel}Update`,
+ `${singularName}Update`,
true,
));
- payload.Query.fields[singularLabel] = {
+ payload.Query.fields[singularName] = {
type: collection.graphQL.type,
args: {
id: { type: new GraphQLNonNull(idType) },
@@ -156,8 +165,8 @@ function initCollectionsGraphQL(payload: Payload): void {
resolve: findByIDResolver(collection),
};
- payload.Query.fields[pluralLabel] = {
- type: buildPaginatedListType(pluralLabel, collection.graphQL.type),
+ payload.Query.fields[pluralName] = {
+ type: buildPaginatedListType(pluralName, collection.graphQL.type),
args: {
where: { type: collection.graphQL.whereInputType },
draft: { type: GraphQLBoolean },
@@ -172,7 +181,7 @@ function initCollectionsGraphQL(payload: Payload): void {
resolve: findResolver(collection),
};
- payload.Mutation.fields[`create${singularLabel}`] = {
+ payload.Mutation.fields[`create${singularName}`] = {
type: collection.graphQL.type,
args: {
data: { type: collection.graphQL.mutationInputType },
@@ -184,7 +193,7 @@ function initCollectionsGraphQL(payload: Payload): void {
resolve: createResolver(collection),
};
- payload.Mutation.fields[`update${singularLabel}`] = {
+ payload.Mutation.fields[`update${singularName}`] = {
type: collection.graphQL.type,
args: {
id: { type: new GraphQLNonNull(idType) },
@@ -198,7 +207,7 @@ function initCollectionsGraphQL(payload: Payload): void {
resolve: updateResolver(collection),
};
- payload.Mutation.fields[`delete${singularLabel}`] = {
+ payload.Mutation.fields[`delete${singularName}`] = {
type: collection.graphQL.type,
args: {
id: { type: new GraphQLNonNull(idType) },
@@ -227,13 +236,13 @@ function initCollectionsGraphQL(payload: Payload): void {
collection.graphQL.versionType = buildObjectType({
payload,
- name: `${singularLabel}Version`,
+ name: `${singularName}Version`,
fields: versionCollectionFields,
- parentName: `${singularLabel}Version`,
+ parentName: `${singularName}Version`,
forceNullable: forceNullableObjectType,
});
- payload.Query.fields[`version${formatName(singularLabel)}`] = {
+ payload.Query.fields[`version${formatName(singularName)}`] = {
type: collection.graphQL.versionType,
args: {
id: { type: GraphQLString },
@@ -244,14 +253,14 @@ function initCollectionsGraphQL(payload: Payload): void {
},
resolve: findVersionByIDResolver(collection),
};
- payload.Query.fields[`versions${pluralLabel}`] = {
- type: buildPaginatedListType(`versions${formatName(pluralLabel)}`, collection.graphQL.versionType),
+ payload.Query.fields[`versions${pluralName}`] = {
+ type: buildPaginatedListType(`versions${formatName(pluralName)}`, collection.graphQL.versionType),
args: {
where: {
type: buildWhereInputType(
- `versions${singularLabel}`,
+ `versions${singularName}`,
versionCollectionFields,
- `versions${singularLabel}`,
+ `versions${singularName}`,
),
},
...(payload.config.localization ? {
@@ -264,7 +273,7 @@ function initCollectionsGraphQL(payload: Payload): void {
},
resolve: findVersionsResolver(collection),
};
- payload.Mutation.fields[`restoreVersion${formatName(singularLabel)}`] = {
+ payload.Mutation.fields[`restoreVersion${formatName(singularName)}`] = {
type: collection.graphQL.type,
args: {
id: { type: GraphQLString },
@@ -278,23 +287,23 @@ function initCollectionsGraphQL(payload: Payload): void {
name: 'email',
type: 'email',
required: true,
- }]
+ }];
collection.graphQL.JWT = buildObjectType({
payload,
name: formatName(`${slug}JWT`),
fields: [
- ...collection.config.fields.filter((field) => fieldAffectsData(field) && field.saveToJWT),
+ ...collection.config.fields.filter((field) => fieldAffectsData(field) && field.saveToJWT),
...authFields,
{
name: 'collection',
type: 'text',
required: true,
- }
+ },
],
parentName: formatName(`${slug}JWT`),
});
- payload.Query.fields[`me${singularLabel}`] = {
+ payload.Query.fields[`me${singularName}`] = {
type: new GraphQLObjectType({
name: formatName(`${slug}Me`),
fields: {
@@ -315,14 +324,14 @@ function initCollectionsGraphQL(payload: Payload): void {
resolve: me(collection),
};
- payload.Query.fields[`initialized${singularLabel}`] = {
+ payload.Query.fields[`initialized${singularName}`] = {
type: GraphQLBoolean,
resolve: init(collection),
};
- payload.Mutation.fields[`refreshToken${singularLabel}`] = {
+ payload.Mutation.fields[`refreshToken${singularName}`] = {
type: new GraphQLObjectType({
- name: formatName(`${slug}Refreshed${singularLabel}`),
+ name: formatName(`${slug}Refreshed${singularName}`),
fields: {
user: {
type: collection.graphQL.JWT,
@@ -341,14 +350,14 @@ function initCollectionsGraphQL(payload: Payload): void {
resolve: refresh(collection),
};
- payload.Mutation.fields[`logout${singularLabel}`] = {
+ payload.Mutation.fields[`logout${singularName}`] = {
type: GraphQLString,
resolve: logout(collection),
};
if (!collection.config.auth.disableLocalStrategy) {
if (collection.config.auth.maxLoginAttempts > 0) {
- payload.Mutation.fields[`unlock${singularLabel}`] = {
+ payload.Mutation.fields[`unlock${singularName}`] = {
type: new GraphQLNonNull(GraphQLBoolean),
args: {
email: { type: new GraphQLNonNull(GraphQLString) },
@@ -357,7 +366,7 @@ function initCollectionsGraphQL(payload: Payload): void {
};
}
- payload.Mutation.fields[`login${singularLabel}`] = {
+ payload.Mutation.fields[`login${singularName}`] = {
type: new GraphQLObjectType({
name: formatName(`${slug}LoginResult`),
fields: {
@@ -379,7 +388,7 @@ function initCollectionsGraphQL(payload: Payload): void {
resolve: login(collection),
};
- payload.Mutation.fields[`forgotPassword${singularLabel}`] = {
+ payload.Mutation.fields[`forgotPassword${singularName}`] = {
type: new GraphQLNonNull(GraphQLBoolean),
args: {
email: { type: new GraphQLNonNull(GraphQLString) },
@@ -389,7 +398,7 @@ function initCollectionsGraphQL(payload: Payload): void {
resolve: forgotPassword(collection),
};
- payload.Mutation.fields[`resetPassword${singularLabel}`] = {
+ payload.Mutation.fields[`resetPassword${singularName}`] = {
type: new GraphQLObjectType({
name: formatName(`${slug}ResetPassword`),
fields: {
@@ -404,7 +413,7 @@ function initCollectionsGraphQL(payload: Payload): void {
resolve: resetPassword(collection),
};
- payload.Mutation.fields[`verifyEmail${singularLabel}`] = {
+ payload.Mutation.fields[`verifyEmail${singularName}`] = {
type: GraphQLBoolean,
args: {
token: { type: GraphQLString },
diff --git a/src/collections/operations/create.ts b/src/collections/operations/create.ts
index 60476a045d..d51509823c 100644
--- a/src/collections/operations/create.ts
+++ b/src/collections/operations/create.ts
@@ -138,7 +138,7 @@ async function create(incomingArgs: Arguments): Promise {
// /////////////////////////////////////
if (!collectionConfig.upload.disableLocalStorage) {
- await uploadFiles(payload, filesToUpload);
+ await uploadFiles(payload, filesToUpload, req.t);
}
// /////////////////////////////////////
@@ -189,7 +189,7 @@ async function create(incomingArgs: Arguments): Promise {
} catch (error) {
// Handle user already exists from passport-local-mongoose
if (error.name === 'UserExistsError') {
- throw new ValidationError([{ message: error.message, field: 'email' }]);
+ throw new ValidationError([{ message: error.message, field: 'email' }], req.t);
}
throw error;
}
@@ -199,7 +199,7 @@ async function create(incomingArgs: Arguments): Promise {
} catch (error) {
// Handle uniqueness error from MongoDB
throw error.code === 11000 && error.keyValue
- ? new ValidationError([{ message: 'Value must be unique', field: Object.keys(error.keyValue)[0] }])
+ ? new ValidationError([{ message: req.t('error:valueMustBeUnique'), field: Object.keys(error.keyValue)[0] }], req.t)
: error;
}
}
diff --git a/src/collections/operations/delete.ts b/src/collections/operations/delete.ts
index 9974a92049..e0454ec1dc 100644
--- a/src/collections/operations/delete.ts
+++ b/src/collections/operations/delete.ts
@@ -46,6 +46,7 @@ async function deleteOperation(incomingArgs: Arguments): Promise {
id,
req,
req: {
+ t,
locale,
payload: {
config,
@@ -102,8 +103,8 @@ async function deleteOperation(incomingArgs: Arguments): Promise {
const docToDelete = await Model.findOne(query);
- if (!docToDelete && !hasWhereAccess) throw new NotFound();
- if (!docToDelete && hasWhereAccess) throw new Forbidden();
+ if (!docToDelete && !hasWhereAccess) throw new NotFound(t);
+ if (!docToDelete && hasWhereAccess) throw new Forbidden(t);
const resultToDelete = docToDelete.toJSON({ virtuals: true });
@@ -121,7 +122,7 @@ async function deleteOperation(incomingArgs: Arguments): Promise {
if (await fileExists(fileToDelete)) {
fs.unlink(fileToDelete, (err) => {
if (err) {
- throw new ErrorDeletingFile();
+ throw new ErrorDeletingFile(t);
}
});
}
@@ -132,7 +133,7 @@ async function deleteOperation(incomingArgs: Arguments): Promise {
if (await fileExists(sizeToDelete)) {
fs.unlink(sizeToDelete, (err) => {
if (err) {
- throw new ErrorDeletingFile();
+ throw new ErrorDeletingFile(t);
}
});
}
diff --git a/src/collections/operations/findByID.ts b/src/collections/operations/findByID.ts
index 471369b34f..1412f0c6c4 100644
--- a/src/collections/operations/findByID.ts
+++ b/src/collections/operations/findByID.ts
@@ -48,6 +48,7 @@ async function findByID(incomingArgs: Arguments): Pr
id,
req,
req: {
+ t,
locale,
payload,
},
@@ -89,7 +90,7 @@ async function findByID(incomingArgs: Arguments): Pr
// Find by ID
// /////////////////////////////////////
- if (!query.$and[0]._id) throw new NotFound();
+ if (!query.$and[0]._id) throw new NotFound(t);
if (!req.findByID) req.findByID = {};
@@ -108,7 +109,7 @@ async function findByID(incomingArgs: Arguments): Pr
if (!result) {
if (!disableErrors) {
- throw new NotFound();
+ throw new NotFound(t);
}
return null;
diff --git a/src/collections/operations/findVersionByID.ts b/src/collections/operations/findVersionByID.ts
index 8a5c79b6b3..2d313ffda4 100644
--- a/src/collections/operations/findVersionByID.ts
+++ b/src/collections/operations/findVersionByID.ts
@@ -30,6 +30,7 @@ async function findVersionByID = any>(args: Argumen
id,
req,
req: {
+ t,
locale,
payload,
},
@@ -78,14 +79,14 @@ async function findVersionByID = any>(args: Argumen
// Find by ID
// /////////////////////////////////////
- if (!query.$and[0]._id) throw new NotFound();
+ if (!query.$and[0]._id) throw new NotFound(t);
let result = await VersionsModel.findOne(query, {}).lean();
if (!result) {
if (!disableErrors) {
- if (!hasWhereAccess) throw new NotFound();
- if (hasWhereAccess) throw new Forbidden();
+ if (!hasWhereAccess) throw new NotFound(t);
+ if (hasWhereAccess) throw new Forbidden(t);
}
return null;
diff --git a/src/collections/operations/local/create.ts b/src/collections/operations/local/create.ts
index e2810e4dde..6bd6da46ea 100644
--- a/src/collections/operations/local/create.ts
+++ b/src/collections/operations/local/create.ts
@@ -6,7 +6,7 @@ import getFileByPath from '../../../uploads/getFileByPath';
import create from '../create';
import { getDataLoader } from '../../dataloader';
import { File } from '../../../uploads/types';
-
+import i18n from '../../../translations/init';
export type Options = {
collection: string
@@ -49,12 +49,14 @@ export default async function createLocal(payload: Payload, options: Op
req.locale = locale || req?.locale || (payload?.config?.localization ? payload?.config?.localization?.defaultLocale : null);
req.fallbackLocale = fallbackLocale || req?.fallbackLocale || null;
req.payload = payload;
+ req.i18n = i18n(payload.config.i18n);
req.files = {
file: (file ?? (await getFileByPath(filePath))) as UploadedFile,
};
if (typeof user !== 'undefined') req.user = user;
+ if (!req.t) req.t = req.i18n.t;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
return create({
diff --git a/src/collections/operations/local/delete.ts b/src/collections/operations/local/delete.ts
index 24a424c6f7..98f8601732 100644
--- a/src/collections/operations/local/delete.ts
+++ b/src/collections/operations/local/delete.ts
@@ -4,6 +4,7 @@ import { PayloadRequest } from '../../../express/types';
import { Payload } from '../../../index';
import deleteOperation from '../delete';
import { getDataLoader } from '../../dataloader';
+import i18n from '../../../translations/init';
export type Options = {
collection: string
@@ -36,8 +37,10 @@ export default async function deleteLocal(payload: P
locale,
fallbackLocale,
payload,
+ i18n: i18n(payload.config.i18n),
} as PayloadRequest;
+ if (!req.t) req.t = req.i18n.t;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
return deleteOperation({
diff --git a/src/collections/operations/local/find.ts b/src/collections/operations/local/find.ts
index 1fa20492c2..11fcc4f705 100644
--- a/src/collections/operations/local/find.ts
+++ b/src/collections/operations/local/find.ts
@@ -5,6 +5,7 @@ import { Payload } from '../../..';
import { PayloadRequest } from '../../../express/types';
import find from '../find';
import { getDataLoader } from '../../dataloader';
+import i18n from '../../../translations/init';
export type Options = {
collection: string
@@ -51,8 +52,10 @@ export default async function findLocal(payload: Pay
req.payloadAPI = 'local';
req.locale = locale || req?.locale || (payload?.config?.localization ? payload?.config?.localization?.defaultLocale : null);
req.fallbackLocale = fallbackLocale || req?.fallbackLocale || null;
+ req.i18n = i18n(payload.config.i18n);
req.payload = payload;
+ if (!req.t) req.t = req.i18n.t;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
if (typeof user !== 'undefined') req.user = user;
diff --git a/src/collections/operations/local/findByID.ts b/src/collections/operations/local/findByID.ts
index eb16079dfe..c7e9bf2297 100644
--- a/src/collections/operations/local/findByID.ts
+++ b/src/collections/operations/local/findByID.ts
@@ -4,6 +4,7 @@ import { Document } from '../../../types';
import findByID from '../findByID';
import { Payload } from '../../..';
import { getDataLoader } from '../../dataloader';
+import i18n from '../../../translations/init';
export type Options = {
collection: string
@@ -42,10 +43,12 @@ export default async function findByIDLocal(payload:
req.payloadAPI = 'local';
req.locale = locale || req?.locale || (payload?.config?.localization ? payload?.config?.localization?.defaultLocale : null);
req.fallbackLocale = fallbackLocale || req?.fallbackLocale || null;
+ req.i18n = i18n(payload.config.i18n);
req.payload = payload;
if (typeof user !== 'undefined') req.user = user;
+ if (!req.t) req.t = req.i18n.t;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
return findByID({
diff --git a/src/collections/operations/local/findVersionByID.ts b/src/collections/operations/local/findVersionByID.ts
index 2d12eac6cb..20bafa4539 100644
--- a/src/collections/operations/local/findVersionByID.ts
+++ b/src/collections/operations/local/findVersionByID.ts
@@ -4,6 +4,7 @@ import { PayloadRequest } from '../../../express/types';
import { TypeWithVersion } from '../../../versions/types';
import findVersionByID from '../findVersionByID';
import { getDataLoader } from '../../dataloader';
+import i18n from '../../../translations/init';
export type Options = {
collection: string
@@ -36,8 +37,10 @@ export default async function findVersionByIDLocal
req.payloadAPI = 'local';
req.locale = locale || req?.locale || this?.config?.localization?.defaultLocale;
req.fallbackLocale = fallbackLocale || req?.fallbackLocale || null;
+ req.i18n = i18n(payload.config.i18n);
req.payload = payload;
+ if (!req.t) req.t = req.i18n.t;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
return findVersionByID({
diff --git a/src/collections/operations/local/findVersions.ts b/src/collections/operations/local/findVersions.ts
index e83cbb9a5b..c21711d06a 100644
--- a/src/collections/operations/local/findVersions.ts
+++ b/src/collections/operations/local/findVersions.ts
@@ -5,6 +5,7 @@ import { TypeWithVersion } from '../../../versions/types';
import { PayloadRequest } from '../../../express/types';
import findVersions from '../findVersions';
import { getDataLoader } from '../../dataloader';
+import i18nInit from '../../../translations/init';
export type Options = {
collection: string
@@ -37,14 +38,17 @@ export default async function findVersionsLocal = a
const collection = payload.collections[collectionSlug];
+ const i18n = i18nInit(payload.config.i18n);
const req = {
user,
payloadAPI: 'local',
locale,
fallbackLocale,
payload,
+ i18n,
} as PayloadRequest;
+ if (!req.t) req.t = req.i18n.t;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
return findVersions({
diff --git a/src/collections/operations/local/restoreVersion.ts b/src/collections/operations/local/restoreVersion.ts
index ab9f717480..f016468740 100644
--- a/src/collections/operations/local/restoreVersion.ts
+++ b/src/collections/operations/local/restoreVersion.ts
@@ -4,6 +4,7 @@ import { Document } from '../../../types';
import { TypeWithVersion } from '../../../versions/types';
import { getDataLoader } from '../../dataloader';
import restoreVersion from '../restoreVersion';
+import i18nInit from '../../../translations/init';
export type Options = {
collection: string
@@ -29,13 +30,15 @@ export default async function restoreVersionLocal =
} = options;
const collection = payload.collections[collectionSlug];
-
+ const i18n = i18nInit(payload.config.i18n);
const req = {
user,
payloadAPI: 'local',
locale,
fallbackLocale,
payload,
+ i18n,
+ t: i18n.t,
} as PayloadRequest;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
diff --git a/src/collections/operations/local/update.ts b/src/collections/operations/local/update.ts
index db7923aaed..8a72469ce8 100644
--- a/src/collections/operations/local/update.ts
+++ b/src/collections/operations/local/update.ts
@@ -4,6 +4,7 @@ import getFileByPath from '../../../uploads/getFileByPath';
import update from '../update';
import { PayloadRequest } from '../../../express/types';
import { getDataLoader } from '../../dataloader';
+import i18nInit from '../../../translations/init';
export type Options = {
collection: string
@@ -41,6 +42,7 @@ export default async function updateLocal(payload: Payload, options: Op
} = options;
const collection = payload.collections[collectionSlug];
+ const i18n = i18nInit(payload.config.i18n);
const req = {
user,
@@ -48,11 +50,13 @@ export default async function updateLocal(payload: Payload, options: Op
locale,
fallbackLocale,
payload,
+ i18n,
files: {
file: file ?? await getFileByPath(filePath),
},
} as PayloadRequest;
+ if (!req.t) req.t = req.i18n.t;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
const args = {
diff --git a/src/collections/operations/restoreVersion.ts b/src/collections/operations/restoreVersion.ts
index c21fc2915f..58d0495831 100644
--- a/src/collections/operations/restoreVersion.ts
+++ b/src/collections/operations/restoreVersion.ts
@@ -32,6 +32,7 @@ async function restoreVersion(args: Arguments): Prom
showHiddenFields,
depth,
req: {
+ t,
locale,
payload,
},
@@ -53,7 +54,7 @@ async function restoreVersion(args: Arguments): Prom
});
if (!rawVersion) {
- throw new NotFound();
+ throw new NotFound(t);
}
rawVersion = rawVersion.toJSON({ virtuals: true });
@@ -91,8 +92,8 @@ async function restoreVersion(args: Arguments): Prom
const doc = await Model.findOne(query);
- if (!doc && !hasWherePolicy) throw new NotFound();
- if (!doc && hasWherePolicy) throw new Forbidden();
+ if (!doc && !hasWherePolicy) throw new NotFound(t);
+ if (!doc && hasWherePolicy) throw new Forbidden(t);
// /////////////////////////////////////
// fetch previousDoc
diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts
index 731c9b1f2b..71396800b1 100644
--- a/src/collections/operations/update.ts
+++ b/src/collections/operations/update.ts
@@ -57,6 +57,7 @@ async function update(incomingArgs: Arguments): Promise {
id,
req,
req: {
+ t,
locale,
payload,
payload: {
@@ -109,8 +110,8 @@ async function update(incomingArgs: Arguments): Promise {
const doc = await Model.findOne(query) as UserDocument;
- if (!doc && !hasWherePolicy) throw new NotFound();
- if (!doc && hasWherePolicy) throw new Forbidden();
+ if (!doc && !hasWherePolicy) throw new NotFound(t);
+ if (!doc && hasWherePolicy) throw new Forbidden(t);
let docWithLocales: Document = doc.toJSON({ virtuals: true });
docWithLocales = JSON.stringify(docWithLocales);
@@ -174,7 +175,7 @@ async function update(incomingArgs: Arguments): Promise {
// /////////////////////////////////////
if (!collectionConfig.upload.disableLocalStorage) {
- await uploadFiles(payload, filesToUpload);
+ await uploadFiles(payload, filesToUpload, t);
}
// /////////////////////////////////////
@@ -273,7 +274,7 @@ async function update(incomingArgs: Arguments): Promise {
// Handle uniqueness error from MongoDB
throw error.code === 11000 && error.keyValue
- ? new ValidationError([{ message: 'Value must be unique', field: Object.keys(error.keyValue)[0] }])
+ ? new ValidationError([{ message: 'Value must be unique', field: Object.keys(error.keyValue)[0] }], t)
: error;
}
diff --git a/src/collections/requestHandlers/create.ts b/src/collections/requestHandlers/create.ts
index f8155bbe12..29b23aff6f 100644
--- a/src/collections/requestHandlers/create.ts
+++ b/src/collections/requestHandlers/create.ts
@@ -4,6 +4,7 @@ import { PayloadRequest } from '../../express/types';
import formatSuccessResponse from '../../express/responses/formatSuccess';
import { Document } from '../../types';
import create from '../operations/create';
+import { getTranslation } from '../../utilities/getTranslation';
export type CreateResult = {
message: string
@@ -21,7 +22,7 @@ export default async function createHandler(req: PayloadRequest, res: Response,
});
return res.status(httpStatus.CREATED).json({
- ...formatSuccessResponse(`${req.collection.config.labels.singular} successfully created.`, 'message'),
+ ...formatSuccessResponse(req.t('general:successfullyCreated', { label: getTranslation(req.collection.config.labels.singular, req.i18n) }), 'message'),
doc,
});
} catch (error) {
diff --git a/src/collections/requestHandlers/delete.ts b/src/collections/requestHandlers/delete.ts
index 4fa52366b6..61aa3cdf84 100644
--- a/src/collections/requestHandlers/delete.ts
+++ b/src/collections/requestHandlers/delete.ts
@@ -20,7 +20,7 @@ export default async function deleteHandler(req: PayloadRequest, res: Response,
});
if (!doc) {
- return res.status(httpStatus.NOT_FOUND).json(new NotFound());
+ return res.status(httpStatus.NOT_FOUND).json(new NotFound(req.t));
}
return res.status(httpStatus.OK).send(doc);
diff --git a/src/collections/requestHandlers/restoreVersion.ts b/src/collections/requestHandlers/restoreVersion.ts
index b1b52f7785..54fa60f96d 100644
--- a/src/collections/requestHandlers/restoreVersion.ts
+++ b/src/collections/requestHandlers/restoreVersion.ts
@@ -22,7 +22,7 @@ export default async function restoreVersionHandler(req: PayloadRequest, res: Re
try {
const doc = await restoreVersion(options);
return res.status(httpStatus.OK).json({
- ...formatSuccessResponse('Restored successfully.', 'message'),
+ ...formatSuccessResponse(req.t('version:restoredSuccessfully'), 'message'),
doc,
});
} catch (error) {
diff --git a/src/collections/requestHandlers/update.ts b/src/collections/requestHandlers/update.ts
index 22028290e9..5ef8952991 100644
--- a/src/collections/requestHandlers/update.ts
+++ b/src/collections/requestHandlers/update.ts
@@ -30,10 +30,10 @@ export default async function updateHandler(req: PayloadRequest, res: Response,
autosave,
});
- let message = 'Updated successfully.';
+ let message = req.t('general:updatedSuccessfully');
- if (draft) message = 'Draft saved successfully.';
- if (autosave) message = 'Autosaved successfully.';
+ if (draft) message = req.t('versions:draftSavedSuccessfully');
+ if (autosave) message = req.t('versions:autosavedSuccessfully');
return res.status(httpStatus.OK).json({
...formatSuccessResponse(message, 'message'),
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 7a43edc510..a52b0fb17f 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -99,6 +99,7 @@ export default joi.object({
}),
webpack: joi.func(),
}),
+ i18n: joi.object(),
defaultDepth: joi.number()
.min(0)
.max(30),
diff --git a/src/config/types.ts b/src/config/types.ts
index cbe6ed545c..8962cef638 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -8,6 +8,7 @@ import GraphQL from 'graphql';
import { ConnectOptions } from 'mongoose';
import React from 'react';
import { LoggerOptions } from 'pino';
+import type { InitOptions as i18nInitOptions } from 'i18next';
import { Payload } from '..';
import { AfterErrorHook, CollectionConfig, SanitizedCollectionConfig } from '../collections/config/types';
import { GlobalConfig, SanitizedGlobalConfig } from '../globals/config/types';
@@ -187,6 +188,7 @@ export type Config = {
collections?: CollectionConfig[];
endpoints?: Endpoint[];
globals?: GlobalConfig[];
+ i18n?: i18nInitOptions;
serverURL?: string;
cookiePrefix?: string;
csrf?: string[];
@@ -250,4 +252,4 @@ export type SanitizedConfig = Omit, 'collections' | 'global
paths: { [key: string]: string };
}
-export type EntityDescription = string | (() => string) | React.ComponentType
+export type EntityDescription = string | Record | (() => string) | React.ComponentType
diff --git a/src/errors/AuthenticationError.ts b/src/errors/AuthenticationError.ts
index f05a6adb9b..5cfca8499b 100644
--- a/src/errors/AuthenticationError.ts
+++ b/src/errors/AuthenticationError.ts
@@ -1,9 +1,10 @@
import httpStatus from 'http-status';
+import type { TFunction } from 'i18next';
import APIError from './APIError';
class AuthenticationError extends APIError {
- constructor() {
- super('The email or password provided is incorrect.', httpStatus.UNAUTHORIZED);
+ constructor(t?: TFunction) {
+ super(t ? t('error:emailOrPasswordIncorrect') : 'The email or password provided is incorrect.', httpStatus.UNAUTHORIZED);
}
}
diff --git a/src/errors/ErrorDeletingFile.ts b/src/errors/ErrorDeletingFile.ts
index dfee2c106a..0b46c1c669 100644
--- a/src/errors/ErrorDeletingFile.ts
+++ b/src/errors/ErrorDeletingFile.ts
@@ -1,9 +1,10 @@
import httpStatus from 'http-status';
+import type { TFunction } from 'i18next';
import APIError from './APIError';
class ErrorDeletingFile extends APIError {
- constructor() {
- super('There was an error deleting file.', httpStatus.INTERNAL_SERVER_ERROR);
+ constructor(t?: TFunction) {
+ super(t ? t('error:deletingFile') : 'There was an error deleting file.', httpStatus.INTERNAL_SERVER_ERROR);
}
}
diff --git a/src/errors/FileUploadError.ts b/src/errors/FileUploadError.ts
index 30d8736d62..95a25a5aba 100644
--- a/src/errors/FileUploadError.ts
+++ b/src/errors/FileUploadError.ts
@@ -1,9 +1,10 @@
import httpStatus from 'http-status';
+import type { TFunction } from 'i18next';
import APIError from './APIError';
class FileUploadError extends APIError {
- constructor() {
- super('There was a problem while uploading the file.', httpStatus.BAD_REQUEST);
+ constructor(t?: TFunction) {
+ super(t ? t('problemUploadingFile') : 'There was a problem while uploading the file.', httpStatus.BAD_REQUEST);
}
}
diff --git a/src/errors/Forbidden.ts b/src/errors/Forbidden.ts
index 5f1965d25b..e947d7f40b 100644
--- a/src/errors/Forbidden.ts
+++ b/src/errors/Forbidden.ts
@@ -1,9 +1,10 @@
import httpStatus from 'http-status';
+import type { TFunction } from 'i18next';
import APIError from './APIError';
class Forbidden extends APIError {
- constructor() {
- super('You are not allowed to perform this action.', httpStatus.FORBIDDEN);
+ constructor(t?: TFunction) {
+ super(t ? t('error:notAllowedToPerformAction') : 'You are not allowed to perform this action.', httpStatus.FORBIDDEN);
}
}
diff --git a/src/errors/LockedAuth.ts b/src/errors/LockedAuth.ts
index d0ab8e8551..ef577b0bdb 100644
--- a/src/errors/LockedAuth.ts
+++ b/src/errors/LockedAuth.ts
@@ -1,9 +1,10 @@
import httpStatus from 'http-status';
+import type { TFunction } from 'i18next';
import APIError from './APIError';
class LockedAuth extends APIError {
- constructor() {
- super('This user is locked due to having too many failed login attempts.', httpStatus.UNAUTHORIZED);
+ constructor(t?: TFunction) {
+ super(t ? t('error:userLocked') : 'This user is locked due to having too many failed login attempts.', httpStatus.UNAUTHORIZED);
}
}
diff --git a/src/errors/MissingFile.ts b/src/errors/MissingFile.ts
index d9694bbca8..4f69fbbbf2 100644
--- a/src/errors/MissingFile.ts
+++ b/src/errors/MissingFile.ts
@@ -1,9 +1,10 @@
import httpStatus from 'http-status';
+import type { TFunction } from 'i18next';
import APIError from './APIError';
class MissingFile extends APIError {
- constructor() {
- super('No files were uploaded.', httpStatus.BAD_REQUEST);
+ constructor(t?: TFunction) {
+ super(t ? t('error:noFilesUploaded') : 'No files were uploaded.', httpStatus.BAD_REQUEST);
}
}
diff --git a/src/errors/NotFound.ts b/src/errors/NotFound.ts
index 8dfb78cfb9..4f1c80d243 100644
--- a/src/errors/NotFound.ts
+++ b/src/errors/NotFound.ts
@@ -1,9 +1,10 @@
import httpStatus from 'http-status';
+import type { TFunction } from 'i18next';
import APIError from './APIError';
class NotFound extends APIError {
- constructor() {
- super('The requested resource was not found.', httpStatus.NOT_FOUND);
+ constructor(t?: TFunction) {
+ super(t ? t('error:notFound') : 'The requested resource was not found.', httpStatus.NOT_FOUND);
}
}
diff --git a/src/errors/UnathorizedError.ts b/src/errors/UnathorizedError.ts
index d58356d6da..0e48daf9dc 100644
--- a/src/errors/UnathorizedError.ts
+++ b/src/errors/UnathorizedError.ts
@@ -1,9 +1,10 @@
import httpStatus from 'http-status';
+import type { TFunction } from 'i18next';
import APIError from './APIError';
class UnauthorizedError extends APIError {
- constructor() {
- super('Unauthorized, you must be logged in to make this request.', httpStatus.UNAUTHORIZED);
+ constructor(t?: TFunction) {
+ super(t ? t('error:unauthorized') : 'Unauthorized, you must be logged in to make this request.', httpStatus.UNAUTHORIZED);
}
}
diff --git a/src/errors/ValidationError.ts b/src/errors/ValidationError.ts
index fa111eff76..aa688461e1 100644
--- a/src/errors/ValidationError.ts
+++ b/src/errors/ValidationError.ts
@@ -1,9 +1,15 @@
import httpStatus from 'http-status';
+import type { TFunction } from 'i18next';
import APIError from './APIError';
class ValidationError extends APIError {
- constructor(results: {message: string, field: string}[]) {
- super(`The following field${results.length === 1 ? ' is' : 's are'} invalid: ${results.map((f) => f.field).join(', ')}`, httpStatus.BAD_REQUEST, results);
+ constructor(results: {message: string, field: string}[], t?: TFunction) {
+ const message = t ? t('error:followingFieldsInvalid', { count: results.length }) : `The following field${results.length === 1 ? ' is' : 's are'} invalid:`;
+ super(
+ `${message} ${results.map((f) => f.field).join(', ')}`,
+ httpStatus.BAD_REQUEST,
+ results,
+ );
}
}
diff --git a/src/express/middleware/i18n.ts b/src/express/middleware/i18n.ts
new file mode 100644
index 0000000000..2df96d4509
--- /dev/null
+++ b/src/express/middleware/i18n.ts
@@ -0,0 +1,18 @@
+import i18next from 'i18next';
+import type { InitOptions } from 'i18next';
+import i18nHTTPMiddleware from 'i18next-http-middleware';
+import deepmerge from 'deepmerge';
+import { Handler } from 'express';
+import { defaultOptions } from '../../translations/defaultOptions';
+
+const i18nMiddleware = (options: InitOptions): Handler => {
+ i18next.use(i18nHTTPMiddleware.LanguageDetector)
+ .init({
+ preload: defaultOptions.supportedLngs,
+ ...deepmerge(defaultOptions, options || {}),
+ });
+
+ return i18nHTTPMiddleware.handle(i18next);
+};
+
+export { i18nMiddleware };
diff --git a/src/express/middleware/index.ts b/src/express/middleware/index.ts
index e4bb08ce2b..f213452b2a 100644
--- a/src/express/middleware/index.ts
+++ b/src/express/middleware/index.ts
@@ -13,6 +13,7 @@ import { Payload } from '../..';
import { PayloadRequest } from '../types';
import corsHeaders from './corsHeaders';
import convertPayload from './convertPayload';
+import { i18nMiddleware } from './i18n';
const middleware = (payload: Payload): any => {
const rateLimitOptions: {
@@ -34,6 +35,7 @@ const middleware = (payload: Payload): any => {
...(payload.config.express.preMiddleware || []),
rateLimit(rateLimitOptions),
passport.initialize(),
+ i18nMiddleware(payload.config.i18n),
identifyAPI('REST'),
methodOverride('X-HTTP-Method-Override'),
qsMiddleware({ depth: 10, arrayLimit: 1000 }),
diff --git a/src/express/types.ts b/src/express/types.ts
index 9618e19910..a33b325361 100644
--- a/src/express/types.ts
+++ b/src/express/types.ts
@@ -1,4 +1,5 @@
import { Request } from 'express';
+import type { i18n as Ii18n, TFunction } from 'i18next';
import DataLoader from 'dataloader';
import { UploadedFile } from 'express-fileupload';
import { Payload } from '../index';
@@ -17,6 +18,8 @@ export declare type PayloadRequest = Request & {
files?: {
file: UploadedFile;
};
+ i18n: Ii18n;
+ t: TFunction;
user: T & User | null;
payloadUploadSizes?: Record;
findByID?: {
diff --git a/src/fields/config/sanitize.ts b/src/fields/config/sanitize.ts
index 6cc387c45d..19155aab0b 100644
--- a/src/fields/config/sanitize.ts
+++ b/src/fields/config/sanitize.ts
@@ -20,7 +20,7 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[]
}
// Auto-label
- if ('name' in field && field.name && typeof field.label !== 'string' && field.label !== false) {
+ if ('name' in field && field.name && typeof field.label !== 'object' && typeof field.label !== 'string' && field.label !== false) {
field.label = toWords(field.name);
}
@@ -45,7 +45,7 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[]
field.fields.push(baseIDField);
}
- if ((field.type === 'blocks' || field.type === 'array') && field.label !== false) {
+ if ((field.type === 'blocks' || field.type === 'array') && field.label) {
field.labels = field.labels || formatLabels(field.name);
}
diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts
index 298a462627..fcdb0d11f5 100644
--- a/src/fields/config/schema.ts
+++ b/src/fields/config/schema.ts
@@ -10,6 +10,7 @@ export const baseAdminComponentFields = joi.object().keys({
export const baseAdminFields = joi.object().keys({
description: joi.alternatives().try(
joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
componentSchema,
),
position: joi.string().valid('sidebar'),
@@ -26,6 +27,7 @@ export const baseAdminFields = joi.object().keys({
export const baseField = joi.object().keys({
label: joi.alternatives().try(
+ joi.object().pattern(joi.string(), [joi.string()]),
joi.string(),
joi.valid(false),
),
@@ -68,7 +70,10 @@ export const text = baseField.keys({
minLength: joi.number(),
maxLength: joi.number(),
admin: baseAdminFields.keys({
- placeholder: joi.string(),
+ placeholder: joi.alternatives().try(
+ joi.object().pattern(joi.string(), [joi.string()]),
+ joi.string(),
+ ),
autoComplete: joi.string(),
}),
});
@@ -139,7 +144,10 @@ export const select = baseField.keys({
joi.string(),
joi.object({
value: joi.string().required().allow(''),
- label: joi.string().required(),
+ label: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ),
}),
),
).required(),
@@ -163,7 +171,10 @@ export const radio = baseField.keys({
joi.string(),
joi.object({
value: joi.string().required().allow(''),
- label: joi.string().required(),
+ label: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ).required(),
}),
),
).required(),
@@ -195,7 +206,10 @@ export const collapsible = baseField.keys({
const tab = baseField.keys({
name: joi.string().when('localized', { is: joi.exist(), then: joi.required() }),
localized: joi.boolean(),
- label: joi.string().required(),
+ label: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ).required(),
fields: joi.array().items(joi.link('#field')).required(),
description: joi.alternatives().try(
joi.string(),
@@ -234,8 +248,14 @@ export const array = baseField.keys({
maxRows: joi.number(),
fields: joi.array().items(joi.link('#field')).required(),
labels: joi.object({
- singular: joi.string(),
- plural: joi.string(),
+ singular: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ),
+ plural: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ),
}),
defaultValue: joi.alternatives().try(
joi.array().items(joi.object()),
@@ -304,17 +324,32 @@ export const blocks = baseField.keys({
maxRows: joi.number(),
name: joi.string().required(),
labels: joi.object({
- singular: joi.string(),
- plural: joi.string(),
+ singular: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ),
+ plural: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ),
}),
blocks: joi.array().items(
joi.object({
slug: joi.string().required(),
imageURL: joi.string(),
imageAltText: joi.string(),
+ graphQL: joi.object().keys({
+ singularName: joi.string(),
+ }),
labels: joi.object({
- singular: joi.string(),
- plural: joi.string(),
+ singular: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ),
+ plural: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ),
}),
fields: joi.array().items(joi.link('#field')),
}),
diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts
index 3e2e4b310d..1b8fea33d8 100644
--- a/src/fields/config/types.ts
+++ b/src/fields/config/types.ts
@@ -1,6 +1,7 @@
/* eslint-disable no-use-before-define */
import { CSSProperties } from 'react';
import { Editor } from 'slate';
+import type { TFunction } from 'i18next';
import { Operation, Where } from '../../types';
import { TypeWithID } from '../../collections/config/types';
import { PayloadRequest } from '../../express/types';
@@ -72,8 +73,8 @@ type Admin = {
}
export type Labels = {
- singular: string;
- plural: string;
+ singular: Record | string;
+ plural: Record | string;
};
export type ValidateOptions = {
@@ -83,12 +84,13 @@ export type ValidateOptions = {
user?: Partial
operation?: Operation
payload?: Payload
+ t: TFunction
} & F;
export type Validate = (value?: T, options?: ValidateOptions>) => string | true | Promise;
export type OptionObject = {
- label: string
+ label: Record | string
value: string
}
@@ -96,7 +98,7 @@ export type Option = OptionObject | string
export interface FieldBase {
name: string;
- label?: string | false;
+ label?: Record | string | false;
required?: boolean;
unique?: boolean;
index?: boolean;
@@ -123,7 +125,7 @@ export type NumberField = FieldBase & {
type: 'number';
admin?: Admin & {
autoComplete?: string
- placeholder?: string
+ placeholder?: Record | string
step?: number
}
min?: number
@@ -135,7 +137,7 @@ export type TextField = FieldBase & {
maxLength?: number
minLength?: number
admin?: Admin & {
- placeholder?: string
+ placeholder?: Record | string
autoComplete?: string
}
}
@@ -143,7 +145,7 @@ export type TextField = FieldBase & {
export type EmailField = FieldBase & {
type: 'email';
admin?: Admin & {
- placeholder?: string
+ placeholder?: Record | string
autoComplete?: string
}
}
@@ -153,7 +155,7 @@ export type TextareaField = FieldBase & {
maxLength?: number
minLength?: number
admin?: Admin & {
- placeholder?: string
+ placeholder?: Record | string
rows?: number
}
}
@@ -165,7 +167,7 @@ export type CheckboxField = FieldBase & {
export type DateField = FieldBase & {
type: 'date';
admin?: Admin & {
- placeholder?: string
+ placeholder?: Record | string
date?: ConditionalDateProps
}
}
@@ -205,7 +207,7 @@ type TabBase = {
export type NamedTab = TabBase & FieldBase
export type UnnamedTab = TabBase & Omit & {
- label: string
+ label: Record | string
localized?: never
}
@@ -224,7 +226,7 @@ export type TabAsField = Tab & {
export type UIField = {
name: string
- label?: string
+ label?: Record | string
admin: {
position?: string
width?: string
@@ -313,7 +315,7 @@ export type RichTextLeaf = 'bold' | 'italic' | 'underline' | 'strikethrough' | '
export type RichTextField = FieldBase & {
type: 'richText';
admin?: Admin & {
- placeholder?: string
+ placeholder?: Record | string
elements?: RichTextElement[];
leaves?: RichTextLeaf[];
hideGutter?: boolean
@@ -358,6 +360,9 @@ export type Block = {
fields: Field[];
imageURL?: string;
imageAltText?: string;
+ graphQL?: {
+ singularName?: string
+ }
}
export type BlockField = FieldBase & {
diff --git a/src/fields/hooks/beforeChange/index.ts b/src/fields/hooks/beforeChange/index.ts
index 7acebbca38..c1d986d7b1 100644
--- a/src/fields/hooks/beforeChange/index.ts
+++ b/src/fields/hooks/beforeChange/index.ts
@@ -49,7 +49,7 @@ export const beforeChange = async ({
});
if (errors.length > 0) {
- throw new ValidationError(errors);
+ throw new ValidationError(errors, req.t);
}
mergeLocaleActions.forEach((action) => action());
diff --git a/src/fields/hooks/beforeChange/promise.ts b/src/fields/hooks/beforeChange/promise.ts
index 7f459e49f3..ea97983c96 100644
--- a/src/fields/hooks/beforeChange/promise.ts
+++ b/src/fields/hooks/beforeChange/promise.ts
@@ -112,6 +112,7 @@ export const promise = async ({
operation,
user: req.user,
payload: req.payload,
+ t: req.t,
});
if (typeof validationResult === 'string') {
diff --git a/src/fields/validations.spec.ts b/src/fields/validations.spec.ts
index eb867a635b..2a7b5342f9 100644
--- a/src/fields/validations.spec.ts
+++ b/src/fields/validations.spec.ts
@@ -1,16 +1,13 @@
import { text, textarea, password, select, point, number } from './validations';
import { ValidateOptions } from './config/types';
-const minLengthMessage = (length: number) => `This value must be longer than the minimum length of ${length} characters.`;
-const maxLengthMessage = (length: number) => `This value must be shorter than the max length of ${length} characters.`;
-const minValueMessage = (value: number, min: number) => `"${value}" is less than the min allowed value of ${min}.`;
-const maxValueMessage = (value: number, max: number) => `"${value}" is greater than the max allowed value of ${max}.`;
-const requiredMessage = 'This field is required.';
-const validNumberMessage = 'Please enter a valid number.';
+const t = jest.fn((string) => string);
+
let options: ValidateOptions = {
operation: 'create',
data: undefined,
siblingData: undefined,
+ t,
};
describe('Field Validations', () => {
@@ -23,7 +20,7 @@ describe('Field Validations', () => {
it('should show required message', () => {
const val = undefined;
const result = text(val, { ...options, required: true });
- expect(result).toBe(requiredMessage);
+ expect(result).toBe('validation:required');
});
it('should handle undefined', () => {
const val = undefined;
@@ -33,12 +30,12 @@ describe('Field Validations', () => {
it('should validate maxLength', () => {
const val = 'toolong';
const result = text(val, { ...options, maxLength: 5 });
- expect(result).toBe(maxLengthMessage(5));
+ expect(result).toBe('validation:shorterThanMax');
});
it('should validate minLength', () => {
const val = 'short';
const result = text(val, { ...options, minLength: 10 });
- expect(result).toBe(minLengthMessage(10));
+ expect(result).toBe('validation:longerThanMin');
});
it('should validate maxLength with no value', () => {
const val = undefined;
@@ -62,7 +59,7 @@ describe('Field Validations', () => {
it('should show required message', () => {
const val = undefined;
const result = textarea(val, { ...options, required: true });
- expect(result).toBe(requiredMessage);
+ expect(result).toBe('validation:required');
});
it('should handle undefined', () => {
@@ -73,13 +70,13 @@ describe('Field Validations', () => {
it('should validate maxLength', () => {
const val = 'toolong';
const result = textarea(val, { ...options, maxLength: 5 });
- expect(result).toBe(maxLengthMessage(5));
+ expect(result).toBe('validation:shorterThanMax');
});
it('should validate minLength', () => {
const val = 'short';
const result = textarea(val, { ...options, minLength: 10 });
- expect(result).toBe(minLengthMessage(10));
+ expect(result).toBe('validation:longerThanMin');
});
it('should validate maxLength with no value', () => {
const val = undefined;
@@ -104,7 +101,7 @@ describe('Field Validations', () => {
it('should show required message', () => {
const val = undefined;
const result = password(val, { ...options, required: true });
- expect(result).toBe(requiredMessage);
+ expect(result).toBe('validation:required');
});
it('should handle undefined', () => {
const val = undefined;
@@ -114,12 +111,12 @@ describe('Field Validations', () => {
it('should validate maxLength', () => {
const val = 'toolong';
const result = password(val, { ...options, maxLength: 5 });
- expect(result).toBe(maxLengthMessage(5));
+ expect(result).toBe('validation:shorterThanMax');
});
it('should validate minLength', () => {
const val = 'short';
const result = password(val, { ...options, minLength: 10 });
- expect(result).toBe(minLengthMessage(10));
+ expect(result).toBe('validation:longerThanMin');
});
it('should validate maxLength with no value', () => {
const val = undefined;
@@ -333,7 +330,7 @@ describe('Field Validations', () => {
it('should show invalid number message', () => {
const val = 'test';
const result = number(val, { ...options });
- expect(result).toBe(validNumberMessage);
+ expect(result).toBe('validation:enterNumber');
});
it('should handle empty value', () => {
const val = '';
@@ -343,17 +340,17 @@ describe('Field Validations', () => {
it('should handle required value', () => {
const val = '';
const result = number(val, { ...options, required: true });
- expect(result).toBe(validNumberMessage);
+ expect(result).toBe('validation:enterNumber');
});
it('should validate minValue', () => {
const val = 2.4;
const result = number(val, { ...options, min: 2.5 });
- expect(result).toBe(minValueMessage(val, 2.5));
+ expect(result).toBe('validation:lessThanMin');
});
it('should validate maxValue', () => {
const val = 1.25;
const result = number(val, { ...options, max: 1 });
- expect(result).toBe(maxValueMessage(val, 1));
+ expect(result).toBe('validation:greaterThanMax');
});
});
});
diff --git a/src/fields/validations.ts b/src/fields/validations.ts
index 141458f65b..5469feb308 100644
--- a/src/fields/validations.ts
+++ b/src/fields/validations.ts
@@ -24,83 +24,82 @@ import canUseDOM from '../utilities/canUseDOM';
import { isValidID } from '../utilities/isValidID';
import { getIDType } from '../utilities/getIDType';
-const defaultMessage = 'This field is required.';
-
-export const number: Validate = (value: string, { required, min, max }) => {
+export const number: Validate = (value: string, { t, required, min, max }) => {
const parsedValue = parseFloat(value);
if ((value && typeof parsedValue !== 'number') || (required && Number.isNaN(parsedValue)) || (value && Number.isNaN(parsedValue))) {
- return 'Please enter a valid number.';
+ return t('validation:enterNumber');
}
if (typeof max === 'number' && parsedValue > max) {
- return `"${value}" is greater than the max allowed value of ${max}.`;
+ return t('validation:greaterThanMax', { value, max });
}
if (typeof min === 'number' && parsedValue < min) {
- return `"${value}" is less than the min allowed value of ${min}.`;
+ return t('validation:lessThanMin', { value, min });
}
if (required && typeof parsedValue !== 'number') {
- return defaultMessage;
+ return t('validation:required');
}
return true;
};
-export const text: Validate = (value: string, { minLength, maxLength: fieldMaxLength, required, payload }) => {
+export const text: Validate = (value: string, { t, minLength, maxLength: fieldMaxLength, required, payload }) => {
let maxLength: number;
if (typeof payload?.config?.defaultMaxTextLength === 'number') maxLength = payload.config.defaultMaxTextLength;
if (typeof fieldMaxLength === 'number') maxLength = fieldMaxLength;
if (value && maxLength && value.length > maxLength) {
- return `This value must be shorter than the max length of ${maxLength} characters.`;
+ return t('validation:shorterThanMax', { maxLength });
}
if (value && minLength && value?.length < minLength) {
- return `This value must be longer than the minimum length of ${minLength} characters.`;
+ return t('validation:longerThanMin', { minLength });
}
if (required) {
if (typeof value !== 'string' || value?.length === 0) {
- return defaultMessage;
+ return t('validation:required');
}
}
return true;
};
-export const password: Validate = (value: string, { required, maxLength: fieldMaxLength, minLength, payload }) => {
+export const password: Validate = (value: string, { t, required, maxLength: fieldMaxLength, minLength, payload }) => {
let maxLength: number;
if (typeof payload?.config?.defaultMaxTextLength === 'number') maxLength = payload.config.defaultMaxTextLength;
if (typeof fieldMaxLength === 'number') maxLength = fieldMaxLength;
if (value && maxLength && value.length > maxLength) {
- return `This value must be shorter than the max length of ${maxLength} characters.`;
+ return t('validation:shorterThanMax', { maxLength });
}
if (value && minLength && value.length < minLength) {
- return `This value must be longer than the minimum length of ${minLength} characters.`;
+ return t('validation:longerThanMin', { minLength });
}
if (required && !value) {
- return defaultMessage;
+ return t('validation:required');
}
return true;
};
-export const email: Validate = (value: string, { required }) => {
+export const email: Validate = (value: string, { t, required }) => {
if ((value && !/\S+@\S+\.\S+/.test(value))
|| (!value && required)) {
- return 'Please enter a valid email address.';
+ return t('validation:emailAddress');
}
return true;
};
export const textarea: Validate = (value: string, {
+ t,
required,
maxLength: fieldMaxLength,
minLength,
@@ -111,64 +110,64 @@ export const textarea: Validate = (value: strin
if (typeof payload?.config?.defaultMaxTextLength === 'number') maxLength = payload.config.defaultMaxTextLength;
if (typeof fieldMaxLength === 'number') maxLength = fieldMaxLength;
if (value && maxLength && value.length > maxLength) {
- return `This value must be shorter than the max length of ${maxLength} characters.`;
+ return t('validation:shorterThanMax', { maxLength });
}
if (value && minLength && value.length < minLength) {
- return `This value must be longer than the minimum length of ${minLength} characters.`;
+ return t('validation:longerThanMin', { minLength });
}
if (required && !value) {
- return defaultMessage;
+ return t('validation:required');
}
return true;
};
-export const code: Validate = (value: string, { required }) => {
+export const code: Validate = (value: string, { t, required }) => {
if (required && value === undefined) {
- return defaultMessage;
+ return t('validation:required');
}
return true;
};
-export const richText: Validate = (value, { required }) => {
+export const richText: Validate = (value, { t, required }) => {
if (required) {
const stringifiedDefaultValue = JSON.stringify(defaultRichTextValue);
if (value && JSON.stringify(value) !== stringifiedDefaultValue) return true;
- return 'This field is required.';
+ return t('validation:required');
}
return true;
};
-export const checkbox: Validate = (value: boolean, { required }) => {
+export const checkbox: Validate = (value: boolean, { t, required }) => {
if ((value && typeof value !== 'boolean')
|| (required && typeof value !== 'boolean')) {
- return 'This field can only be equal to true or false.';
+ return t('validation:trueOrFalse');
}
return true;
};
-export const date: Validate = (value, { required }) => {
+export const date: Validate = (value, { t, required }) => {
if (value && !isNaN(Date.parse(value.toString()))) { /* eslint-disable-line */
return true;
}
if (value) {
- return `"${value}" is not a valid date.`;
+ return t('validation:notValidDate', { value });
}
if (required) {
- return defaultMessage;
+ return t('validation:required');
}
return true;
};
-const validateFilterOptions: Validate = async (value, { filterOptions, id, user, data, siblingData, relationTo, payload }) => {
+const validateFilterOptions: Validate = async (value, { t, filterOptions, id, user, data, siblingData, relationTo, payload }) => {
if (!canUseDOM && typeof filterOptions !== 'undefined' && value) {
const options: {
[collection: string]: (string | number)[]
@@ -235,7 +234,7 @@ const validateFilterOptions: Validate = async (value, { filterOptions, id, user,
if (invalidRelationships.length > 0) {
return invalidRelationships.reduce((err, invalid, i) => {
return `${err} ${JSON.stringify(invalid)}${invalidRelationships.length === i + 1 ? ',' : ''} `;
- }, 'This field has the following invalid selections:') as string;
+ }, t('validation:invalidSelections')) as string;
}
return true;
@@ -246,7 +245,7 @@ const validateFilterOptions: Validate = async (value, { filterOptions, id, user,
export const upload: Validate = (value: string, options) => {
if (!value && options.required) {
- return defaultMessage;
+ return options.t('validation:required');
}
if (!canUseDOM && typeof value !== 'undefined' && value !== null) {
@@ -254,7 +253,7 @@ export const upload: Validate = (value: string, o
const type = getIDType(idField);
if (!isValidID(value, type)) {
- return 'This field is not a valid upload ID';
+ return options.t('validation:validUploadID');
}
}
@@ -263,7 +262,7 @@ export const upload: Validate = (value: string, o
export const relationship: Validate = async (value: RelationshipValue, options) => {
if ((!value || (Array.isArray(value) && value.length === 0)) && options.required) {
- return defaultMessage;
+ return options.t('validation:required');
}
if (!canUseDOM && typeof value !== 'undefined' && value !== null) {
@@ -308,63 +307,63 @@ export const relationship: Validate = async
return validateFilterOptions(value, options);
};
-export const array: Validate = (value, { minRows, maxRows, required }) => {
+export const array: Validate = (value, { t, minRows, maxRows, required }) => {
if (minRows && value < minRows) {
- return `This field requires at least ${minRows} row(s).`;
+ return t('validation:requiresAtLeast', { count: minRows, label: t('rows') });
}
if (maxRows && value > maxRows) {
- return `This field requires no more than ${maxRows} row(s).`;
+ return t('validation:requiresNoMoreThan', { count: maxRows, label: t('rows') });
}
if (!value && required) {
- return 'This field requires at least one row.';
+ return t('validation:requiresAtLeast', { count: 1, label: t('row') });
}
return true;
};
-export const select: Validate = (value, { options, hasMany, required }) => {
+export const select: Validate = (value, { t, options, hasMany, required }) => {
if (Array.isArray(value) && value.some((input) => !options.some((option) => (option === input || (typeof option !== 'string' && option?.value === input))))) {
- return 'This field has an invalid selection';
+ return t('validation:invalidSelection');
}
if (typeof value === 'string' && !options.some((option) => (option === value || (typeof option !== 'string' && option.value === value)))) {
- return 'This field has an invalid selection';
+ return t('validation:invalidSelection');
}
if (required && (
(typeof value === 'undefined' || value === null) || (hasMany && Array.isArray(value) && (value as [])?.length === 0))
) {
- return defaultMessage;
+ return t('validation:required');
}
return true;
};
-export const radio: Validate = (value, { options, required }) => {
+export const radio: Validate = (value, { t, options, required }) => {
const stringValue = String(value);
if ((typeof value !== 'undefined' || !required) && (options.find((option) => String(typeof option !== 'string' && option?.value) === stringValue))) return true;
- return defaultMessage;
+ return t('validation:required');
};
-export const blocks: Validate = (value, { maxRows, minRows, required }) => {
+export const blocks: Validate = (value, { t, maxRows, minRows, required }) => {
if (minRows && value < minRows) {
- return `This field requires at least ${minRows} row(s).`;
+ return t('validation:requiresAtLeast', { count: minRows, label: t('rows') });
}
if (maxRows && value > maxRows) {
- return `This field requires no more than ${maxRows} row(s).`;
+ return t('validation:requiresNoMoreThan', { count: maxRows, label: t('rows') });
}
if (!value && required) {
- return 'This field requires at least one row.';
+ return t('validation:requiresAtLeast', { count: 1, label: t('row') });
}
return true;
};
-export const point: Validate = (value: [number | string, number | string] = ['', ''], { required }) => {
+export const point: Validate = (value: [number | string, number | string] = ['', ''], { t, required }) => {
const lng = parseFloat(String(value[0]));
const lat = parseFloat(String(value[1]));
if (required && (
@@ -372,11 +371,11 @@ export const point: Validate = (value: [number | s
|| (Number.isNaN(lng) || Number.isNaN(lat))
|| (Array.isArray(value) && value.length !== 2)
)) {
- return 'This field requires two numbers';
+ return t('validation:requiresTwoNumbers');
}
if ((value[1] && Number.isNaN(lng)) || (value[0] && Number.isNaN(lat))) {
- return 'This field has an invalid input';
+ return t('validation:invalidInput');
}
return true;
diff --git a/src/globals/config/schema.ts b/src/globals/config/schema.ts
index 9887830d61..5c57cc2c35 100644
--- a/src/globals/config/schema.ts
+++ b/src/globals/config/schema.ts
@@ -4,9 +4,15 @@ import { endpointsSchema } from '../../config/schema';
const globalSchema = joi.object().keys({
slug: joi.string().required(),
- label: joi.string(),
+ label: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ),
admin: joi.object({
- group: joi.string(),
+ group: joi.alternatives().try(
+ joi.string(),
+ joi.object().pattern(joi.string(), [joi.string()]),
+ ),
hideAPIURL: joi.boolean(),
description: joi.alternatives().try(
joi.string(),
@@ -18,6 +24,12 @@ const globalSchema = joi.object().keys({
}),
}),
}),
+ typescript: joi.object().keys({
+ interface: joi.string(),
+ }),
+ graphQL: joi.object().keys({
+ name: joi.string(),
+ }),
hooks: joi.object({
beforeValidate: joi.array().items(joi.func()),
beforeChange: joi.array().items(joi.func()),
diff --git a/src/globals/config/types.ts b/src/globals/config/types.ts
index 14a14a0552..5d90cf491b 100644
--- a/src/globals/config/types.ts
+++ b/src/globals/config/types.ts
@@ -1,6 +1,7 @@
import React from 'react';
import { Model, Document } from 'mongoose';
import { DeepRequired } from 'ts-essentials';
+import { GraphQLNonNull, GraphQLObjectType } from 'graphql';
import { PayloadRequest } from '../../express/types';
import { Access, Endpoint, GeneratePreviewURL } from '../../config/types';
import { Field } from '../../fields/config/types';
@@ -46,7 +47,19 @@ export interface GlobalModel extends Model {
export type GlobalConfig = {
slug: string
- label?: string
+ label?: Record | string
+ graphQL?: {
+ name?: string
+ }
+ /**
+ * Options used in typescript generation
+ */
+ typescript?: {
+ /**
+ * Typescript generation name given to the interface type
+ */
+ interface?: string
+ }
preview?: GeneratePreviewURL
versions?: IncomingGlobalVersions | boolean
hooks?: {
@@ -76,9 +89,15 @@ export type GlobalConfig = {
}
}
-export interface SanitizedGlobalConfig extends Omit, 'fields' | 'versions'> {
+export interface SanitizedGlobalConfig extends Omit, 'fields' | 'versions' | 'graphQL'> {
fields: Field[]
versions: SanitizedGlobalVersions
+ graphQL?: {
+ name?: string
+ type: GraphQLObjectType
+ mutationInputType: GraphQLNonNull
+ versionType?: GraphQLObjectType
+ }
}
export type Globals = {
diff --git a/src/globals/graphql/init.ts b/src/globals/graphql/init.ts
index 89da370e63..d2be0e7486 100644
--- a/src/globals/graphql/init.ts
+++ b/src/globals/graphql/init.ts
@@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign */
import { GraphQLNonNull, GraphQLBoolean, GraphQLInt, GraphQLString } from 'graphql';
+import { singular } from 'pluralize';
import formatName from '../../graphql/utilities/formatName';
import { buildVersionGlobalFields } from '../../versions/buildGlobalFields';
import buildPaginatedListType from '../../graphql/schema/buildPaginatedListType';
@@ -13,39 +14,40 @@ import buildObjectType from '../../graphql/schema/buildObjectType';
import buildMutationInputType from '../../graphql/schema/buildMutationInputType';
import buildWhereInputType from '../../graphql/schema/buildWhereInputType';
import { Field } from '../../fields/config/types';
+import { toWords } from '../../utilities/formatLabels';
+import { SanitizedGlobalConfig } from '../config/types';
function initGlobalsGraphQL(payload: Payload): void {
if (payload.config.globals) {
Object.keys(payload.globals.config).forEach((slug) => {
const global = payload.globals.config[slug];
const {
- label,
fields,
versions,
} = global;
- const formattedLabel = formatName(label);
+ const formattedName = global.graphQL?.name ? global.graphQL.name : singular(toWords(global.slug, true));
- global.graphQL = {};
+ global.graphQL = {} as SanitizedGlobalConfig['graphQL'];
const forceNullableObjectType = Boolean(versions?.drafts);
global.graphQL.type = buildObjectType({
payload,
- name: formattedLabel,
- parentName: formattedLabel,
+ name: formattedName,
+ parentName: formattedName,
fields,
forceNullable: forceNullableObjectType,
});
global.graphQL.mutationInputType = new GraphQLNonNull(buildMutationInputType(
payload,
- formattedLabel,
+ formattedName,
fields,
- formattedLabel,
+ formattedName,
));
- payload.Query.fields[formattedLabel] = {
+ payload.Query.fields[formattedName] = {
type: global.graphQL.type,
args: {
draft: { type: GraphQLBoolean },
@@ -57,7 +59,7 @@ function initGlobalsGraphQL(payload: Payload): void {
resolve: findOneResolver(global),
};
- payload.Mutation.fields[`update${formattedLabel}`] = {
+ payload.Mutation.fields[`update${formattedName}`] = {
type: global.graphQL.type,
args: {
data: { type: global.graphQL.mutationInputType },
@@ -90,13 +92,13 @@ function initGlobalsGraphQL(payload: Payload): void {
global.graphQL.versionType = buildObjectType({
payload,
- name: `${formattedLabel}Version`,
- parentName: `${formattedLabel}Version`,
+ name: `${formattedName}Version`,
+ parentName: `${formattedName}Version`,
fields: versionGlobalFields,
forceNullable: forceNullableObjectType,
});
- payload.Query.fields[`version${formatName(formattedLabel)}`] = {
+ payload.Query.fields[`version${formatName(formattedName)}`] = {
type: global.graphQL.versionType,
args: {
id: { type: GraphQLString },
@@ -107,14 +109,14 @@ function initGlobalsGraphQL(payload: Payload): void {
},
resolve: findVersionByIDResolver(global),
};
- payload.Query.fields[`versions${formattedLabel}`] = {
- type: buildPaginatedListType(`versions${formatName(formattedLabel)}`, global.graphQL.versionType),
+ payload.Query.fields[`versions${formattedName}`] = {
+ type: buildPaginatedListType(`versions${formatName(formattedName)}`, global.graphQL.versionType),
args: {
where: {
type: buildWhereInputType(
- `versions${formattedLabel}`,
+ `versions${formattedName}`,
versionGlobalFields,
- `versions${formattedLabel}`,
+ `versions${formattedName}`,
),
},
...(payload.config.localization ? {
@@ -127,7 +129,7 @@ function initGlobalsGraphQL(payload: Payload): void {
},
resolve: findVersionsResolver(global),
};
- payload.Mutation.fields[`restoreVersion${formatName(formattedLabel)}`] = {
+ payload.Mutation.fields[`restoreVersion${formatName(formattedName)}`] = {
type: global.graphQL.type,
args: {
id: { type: GraphQLString },
diff --git a/src/globals/operations/findVersionByID.ts b/src/globals/operations/findVersionByID.ts
index 7d570d9845..3b4c24b4d3 100644
--- a/src/globals/operations/findVersionByID.ts
+++ b/src/globals/operations/findVersionByID.ts
@@ -27,6 +27,7 @@ async function findVersionByID = any>(args: Argumen
id,
req,
req: {
+ t,
payload,
locale,
},
@@ -71,14 +72,14 @@ async function findVersionByID = any>(args: Argumen
// Find by ID
// /////////////////////////////////////
- if (!query.$and[0]._id) throw new NotFound();
+ if (!query.$and[0]._id) throw new NotFound(t);
let result = await VersionsModel.findOne(query, {}).lean();
if (!result) {
if (!disableErrors) {
- if (!hasWhereAccess) throw new NotFound();
- if (hasWhereAccess) throw new Forbidden();
+ if (!hasWhereAccess) throw new NotFound(t);
+ if (hasWhereAccess) throw new Forbidden(t);
}
return null;
diff --git a/src/globals/operations/local/findOne.ts b/src/globals/operations/local/findOne.ts
index a17ea0080c..8d9f04cab7 100644
--- a/src/globals/operations/local/findOne.ts
+++ b/src/globals/operations/local/findOne.ts
@@ -4,6 +4,7 @@ import { PayloadRequest } from '../../../express/types';
import { Document } from '../../../types';
import { TypeWithID } from '../../config/types';
import findOne from '../findOne';
+import i18nInit from '../../../translations/init';
export type Options = {
slug: string
@@ -29,6 +30,7 @@ export default async function findOneLocal(payload:
} = options;
const globalConfig = payload.globals.config.find((config) => config.slug === globalSlug);
+ const i18n = i18nInit(payload.config.i18n);
const req = {
user,
@@ -36,6 +38,8 @@ export default async function findOneLocal(payload:
locale,
fallbackLocale,
payload,
+ i18n,
+ t: i18n.t,
} as PayloadRequest;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
diff --git a/src/globals/operations/local/findVersionByID.ts b/src/globals/operations/local/findVersionByID.ts
index 007bc519e7..8d9cf748a2 100644
--- a/src/globals/operations/local/findVersionByID.ts
+++ b/src/globals/operations/local/findVersionByID.ts
@@ -4,6 +4,7 @@ import { PayloadRequest } from '../../../express/types';
import { Document } from '../../../types';
import { TypeWithVersion } from '../../../versions/types';
import findVersionByID from '../findVersionByID';
+import i18nInit from '../../../translations/init';
export type Options = {
slug: string
@@ -31,6 +32,7 @@ export default async function findVersionByIDLocal
} = options;
const globalConfig = payload.globals.config.find((config) => config.slug === globalSlug);
+ const i18n = i18nInit(payload.config.i18n);
const req = {
user,
@@ -38,6 +40,8 @@ export default async function findVersionByIDLocal
locale,
fallbackLocale,
payload,
+ i18n,
+ t: i18n.t,
} as PayloadRequest;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
diff --git a/src/globals/operations/local/findVersions.ts b/src/globals/operations/local/findVersions.ts
index 619c22ce3f..321b153173 100644
--- a/src/globals/operations/local/findVersions.ts
+++ b/src/globals/operations/local/findVersions.ts
@@ -5,6 +5,7 @@ import { Payload } from '../../..';
import { PayloadRequest } from '../../../express/types';
import findVersions from '../findVersions';
import { getDataLoader } from '../../../collections/dataloader';
+import i18nInit from '../../../translations/init';
export type Options = {
slug: string
@@ -36,6 +37,7 @@ export default async function findVersionsLocal = a
} = options;
const globalConfig = payload.globals.config.find((config) => config.slug === globalSlug);
+ const i18n = i18nInit(payload.config.i18n);
const req = {
user,
@@ -43,6 +45,8 @@ export default async function findVersionsLocal = a
locale,
fallbackLocale,
payload,
+ i18n,
+ t: i18n.t,
} as PayloadRequest;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
diff --git a/src/globals/operations/local/restoreVersion.ts b/src/globals/operations/local/restoreVersion.ts
index 595336caa1..97a83bd03e 100644
--- a/src/globals/operations/local/restoreVersion.ts
+++ b/src/globals/operations/local/restoreVersion.ts
@@ -4,6 +4,7 @@ import { PayloadRequest } from '../../../express/types';
import { Document } from '../../../types';
import { TypeWithVersion } from '../../../versions/types';
import restoreVersion from '../restoreVersion';
+import i18nInit from '../../../translations/init';
export type Options = {
slug: string
@@ -29,6 +30,7 @@ export default async function restoreVersionLocal =
} = options;
const globalConfig = payload.globals.config.find((config) => config.slug === globalSlug);
+ const i18n = i18nInit(payload.config.i18n);
const req = {
user,
@@ -36,6 +38,8 @@ export default async function restoreVersionLocal =
payload,
locale,
fallbackLocale,
+ i18n,
+ t: i18n.t,
} as PayloadRequest;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
diff --git a/src/globals/operations/local/update.ts b/src/globals/operations/local/update.ts
index efffb5c928..264235f1b9 100644
--- a/src/globals/operations/local/update.ts
+++ b/src/globals/operations/local/update.ts
@@ -4,6 +4,7 @@ import { PayloadRequest } from '../../../express/types';
import { TypeWithID } from '../../config/types';
import update from '../update';
import { getDataLoader } from '../../../collections/dataloader';
+import i18nInit from '../../../translations/init';
export type Options = {
slug: string
@@ -31,6 +32,7 @@ export default async function updateLocal(payload: P
} = options;
const globalConfig = payload.globals.config.find((config) => config.slug === globalSlug);
+ const i18n = i18nInit(payload.config.i18n);
const req = {
user,
@@ -38,6 +40,8 @@ export default async function updateLocal(payload: P
locale,
fallbackLocale,
payload,
+ i18n,
+ t: i18n.t,
} as PayloadRequest;
if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req);
diff --git a/src/globals/operations/restoreVersion.ts b/src/globals/operations/restoreVersion.ts
index ec6bd35818..ad0c5f8a4a 100644
--- a/src/globals/operations/restoreVersion.ts
+++ b/src/globals/operations/restoreVersion.ts
@@ -23,6 +23,7 @@ async function restoreVersion = any>(args: Argument
globalConfig,
req,
req: {
+ t,
payload,
payload: {
globals: {
@@ -53,7 +54,7 @@ async function restoreVersion = any>(args: Argument
});
if (!rawVersion) {
- throw new NotFound();
+ throw new NotFound(t);
}
rawVersion = rawVersion.toJSON({ virtuals: true });
diff --git a/src/globals/requestHandlers/restoreVersion.ts b/src/globals/requestHandlers/restoreVersion.ts
index b1a45f7bdb..1071f99890 100644
--- a/src/globals/requestHandlers/restoreVersion.ts
+++ b/src/globals/requestHandlers/restoreVersion.ts
@@ -18,7 +18,7 @@ export default function restoreVersionHandler(globalConfig: SanitizedGlobalConfi
try {
const doc = await restoreVersion(options);
return res.status(httpStatus.OK).json({
- ...formatSuccessResponse('Restored successfully.', 'message'),
+ ...formatSuccessResponse(req.t('version:restoredSuccessfully'), 'message'),
doc,
});
} catch (error) {
diff --git a/src/globals/requestHandlers/update.ts b/src/globals/requestHandlers/update.ts
index b933f82821..043de27351 100644
--- a/src/globals/requestHandlers/update.ts
+++ b/src/globals/requestHandlers/update.ts
@@ -25,10 +25,10 @@ export default function updateHandler(globalConfig: SanitizedGlobalConfig): Upda
autosave,
});
- let message = 'Saved successfully.';
+ let message = req.t('general:updatedSuccessfully');
- if (draft) message = 'Draft saved successfully.';
- if (autosave) message = 'Autosaved successfully.';
+ if (draft) message = req.t('versions:draftSavedSuccessfully');
+ if (autosave) message = req.t('versions:autosavedSuccessfully');
return res.status(httpStatus.OK).json({ message, result });
} catch (error) {
diff --git a/src/graphql/schema/buildBlockType.ts b/src/graphql/schema/buildBlockType.ts
index 33dc5a4255..512f6d6074 100644
--- a/src/graphql/schema/buildBlockType.ts
+++ b/src/graphql/schema/buildBlockType.ts
@@ -1,8 +1,8 @@
/* eslint-disable no-param-reassign */
import { Payload } from '../..';
import { Block } from '../../fields/config/types';
-import formatName from '../utilities/formatName';
import buildObjectType from './buildObjectType';
+import { toWords } from '../../utilities/formatLabels';
type Args = {
payload: Payload
@@ -17,13 +17,13 @@ function buildBlockType({
}: Args): void {
const {
slug,
- labels: {
- singular,
- },
+ graphQL: {
+ singularName,
+ } = {},
} = block;
if (!payload.types.blockTypes[slug]) {
- const formattedBlockName = formatName(singular);
+ const formattedBlockName = singularName || toWords(slug, true);
payload.types.blockTypes[slug] = buildObjectType({
payload,
name: formattedBlockName,
diff --git a/src/graphql/schema/buildMutationInputType.ts b/src/graphql/schema/buildMutationInputType.ts
index b8344fa147..8faddc44c0 100644
--- a/src/graphql/schema/buildMutationInputType.ts
+++ b/src/graphql/schema/buildMutationInputType.ts
@@ -127,7 +127,7 @@ function buildMutationInputType(payload: Payload, name: string, fields: Field[],
let type: PayloadGraphQLRelationshipType;
if (Array.isArray(relationTo)) {
- const fullName = `${combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label)}RelationshipInput`;
+ const fullName = `${combineParentName(parentName, toWords(field.name, true))}RelationshipInput`;
type = new GraphQLInputObjectType({
name: fullName,
fields: {
@@ -155,7 +155,7 @@ function buildMutationInputType(payload: Payload, name: string, fields: Field[],
};
},
array: (inputObjectTypeConfig: InputObjectTypeConfig, field: ArrayField) => {
- const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label);
+ const fullName = combineParentName(parentName, toWords(field.name, true));
let type: GraphQLType | GraphQLList = buildMutationInputType(payload, fullName, field.fields, fullName);
type = new GraphQLList(withNullableType(field, type, forceNullable));
return {
@@ -165,7 +165,7 @@ function buildMutationInputType(payload: Payload, name: string, fields: Field[],
},
group: (inputObjectTypeConfig: InputObjectTypeConfig, field: GroupField) => {
const requiresAtLeastOneField = groupOrTabHasRequiredSubfield(field);
- const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label);
+ const fullName = combineParentName(parentName, toWords(field.name, true));
let type: GraphQLType = buildMutationInputType(payload, fullName, field.fields, fullName);
if (requiresAtLeastOneField) type = new GraphQLNonNull(type);
return {
diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts
index 9fa951fc62..8fc2572af3 100644
--- a/src/graphql/schema/buildObjectType.ts
+++ b/src/graphql/schema/buildObjectType.ts
@@ -140,9 +140,9 @@ function buildObjectType({
},
}),
upload: (objectTypeConfig: ObjectTypeConfig, field: UploadField) => {
- const { relationTo, label } = field;
+ const { relationTo } = field;
- const uploadName = combineParentName(parentName, label === false ? toWords(field.name, true) : label);
+ const uploadName = combineParentName(parentName, toWords(field.name, true));
// If the relationshipType is undefined at this point,
// it can be assumed that this blockType can have a relationship
@@ -243,10 +243,10 @@ function buildObjectType({
};
},
relationship: (objectTypeConfig: ObjectTypeConfig, field: RelationshipField) => {
- const { relationTo, label } = field;
+ const { relationTo } = field;
const isRelatedToManyCollections = Array.isArray(relationTo);
const hasManyValues = field.hasMany;
- const relationshipName = combineParentName(parentName, label === false ? toWords(field.name, true) : label);
+ const relationshipName = combineParentName(parentName, toWords(field.name, true));
let type;
let relationToType = null;
@@ -416,7 +416,7 @@ function buildObjectType({
};
},
array: (objectTypeConfig: ObjectTypeConfig, field: ArrayField) => {
- const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label);
+ const fullName = combineParentName(parentName, toWords(field.name, true));
const type = buildObjectType({
payload,
@@ -434,7 +434,7 @@ function buildObjectType({
};
},
group: (objectTypeConfig: ObjectTypeConfig, field: GroupField) => {
- const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label);
+ const fullName = combineParentName(parentName, toWords(field.name, true));
const type = buildObjectType({
payload,
name: fullName,
@@ -458,7 +458,7 @@ function buildObjectType({
return payload.types.blockTypes[block.slug];
});
- const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label);
+ const fullName = combineParentName(parentName, toWords(field.name, true));
const type = new GraphQLList(new GraphQLNonNull(new GraphQLUnionType({
name: fullName,
diff --git a/src/graphql/schema/buildPoliciesType.ts b/src/graphql/schema/buildPoliciesType.ts
index f8d1e241ef..2aa07ca354 100644
--- a/src/graphql/schema/buildPoliciesType.ts
+++ b/src/graphql/schema/buildPoliciesType.ts
@@ -6,6 +6,7 @@ import { SanitizedCollectionConfig } from '../../collections/config/types';
import { SanitizedGlobalConfig } from '../../globals/config/types';
import { Field } from '../../fields/config/types';
import { Payload } from '../..';
+import { toWords } from '../../utilities/formatLabels';
type OperationType = 'create' | 'read' | 'update' | 'delete' | 'unlock' | 'readVersions';
@@ -77,14 +78,14 @@ const buildFields = (label, fieldsToBuild) => fieldsToBuild.reduce((builtFields,
return builtFields;
}, {});
-const buildEntity = (label: string, entityFields: Field[], operations: OperationType[]) => {
- const formattedLabel = formatName(label);
+const buildEntity = (name: string, entityFields: Field[], operations: OperationType[]) => {
+ const formattedName = toWords(name, true);
const fields = {
fields: {
type: new GraphQLObjectType({
- name: formatName(`${formattedLabel}Fields`),
- fields: buildFields(`${formattedLabel}Fields`, entityFields),
+ name: formatName(`${formattedName}Fields`),
+ fields: buildFields(`${formattedName}Fields`, entityFields),
}),
},
};
@@ -94,7 +95,7 @@ const buildEntity = (label: string, entityFields: Field[], operations: Operation
fields[operation] = {
type: new GraphQLObjectType({
- name: `${formattedLabel}${capitalizedOperation}Access`,
+ name: `${formattedName}${capitalizedOperation}Access`,
fields: {
permission: { type: new GraphQLNonNull(GraphQLBoolean) },
where: { type: GraphQLJSONObject },
@@ -126,8 +127,8 @@ export default function buildPoliciesType(payload: Payload): GraphQLObjectType {
fields[formatName(collection.slug)] = {
type: new GraphQLObjectType({
- name: formatName(`${collection.labels.singular}Access`),
- fields: buildEntity(collection.labels.singular, collection.fields, collectionOperations),
+ name: formatName(`${collection.slug}Access`),
+ fields: buildEntity(collection.slug, collection.fields, collectionOperations),
}),
};
});
@@ -141,8 +142,8 @@ export default function buildPoliciesType(payload: Payload): GraphQLObjectType {
fields[formatName(global.slug)] = {
type: new GraphQLObjectType({
- name: formatName(`${global.label}Access`),
- fields: buildEntity(global.label, global.fields, globalOperations),
+ name: formatName(`${global?.graphQL?.name || global.slug}Access`),
+ fields: buildEntity(global?.graphQL?.name || global.slug, global.fields, globalOperations),
}),
};
});
diff --git a/src/init.ts b/src/init.ts
index fbb4d299d1..73979623bc 100644
--- a/src/init.ts
+++ b/src/init.ts
@@ -2,10 +2,7 @@
import express, { NextFunction, Response } from 'express';
import crypto from 'crypto';
import mongoose from 'mongoose';
-import {
-
- InitOptions,
-} from './config/types';
+import { InitOptions } from './config/types';
import authenticate from './express/middleware/authenticate';
import connectMongoose from './mongoose/connect';
diff --git a/src/preferences/operations/delete.ts b/src/preferences/operations/delete.ts
index e9ca8fa009..231cd4aee5 100644
--- a/src/preferences/operations/delete.ts
+++ b/src/preferences/operations/delete.ts
@@ -20,7 +20,7 @@ async function deleteOperation(args: PreferenceRequest): Promise {
} = args;
if (!user) {
- throw new UnauthorizedError();
+ throw new UnauthorizedError(req.t);
}
if (!overrideAccess) {
diff --git a/src/preferences/operations/findOne.ts b/src/preferences/operations/findOne.ts
index f481892723..b1461309aa 100644
--- a/src/preferences/operations/findOne.ts
+++ b/src/preferences/operations/findOne.ts
@@ -17,7 +17,7 @@ async function findOne(args: PreferenceRequest): Promise {
} = args;
if (!user) {
- throw new UnauthorizedError();
+ throw new UnauthorizedError(req.t);
}
if (!overrideAccess) {
diff --git a/src/preferences/operations/update.ts b/src/preferences/operations/update.ts
index 32a3046af1..5359fe2af2 100644
--- a/src/preferences/operations/update.ts
+++ b/src/preferences/operations/update.ts
@@ -20,7 +20,7 @@ async function update(args: PreferenceUpdateRequest) {
} = args;
if (!user) {
- throw new UnauthorizedError();
+ throw new UnauthorizedError(req.t);
}
if (!overrideAccess) {
diff --git a/src/preferences/requestHandlers/delete.ts b/src/preferences/requestHandlers/delete.ts
index 6060e406b1..aee7f56f8b 100644
--- a/src/preferences/requestHandlers/delete.ts
+++ b/src/preferences/requestHandlers/delete.ts
@@ -13,7 +13,7 @@ export default async function deleteHandler(req: PayloadRequest, res: Response,
});
return res.status(httpStatus.OK).json({
- ...formatSuccessResponse('Deleted successfully.', 'message'),
+ ...formatSuccessResponse(req.t('deletedSuccessfully'), 'message'),
});
} catch (error) {
return next(error);
diff --git a/src/preferences/requestHandlers/findOne.ts b/src/preferences/requestHandlers/findOne.ts
index 9d4c8d2c90..8ae683e50b 100644
--- a/src/preferences/requestHandlers/findOne.ts
+++ b/src/preferences/requestHandlers/findOne.ts
@@ -12,7 +12,7 @@ export default async function findOneHandler(req: PayloadRequest, res: Response,
key: req.params.key,
});
- return res.status(httpStatus.OK).json(result || { message: 'No Preference Found', value: null });
+ return res.status(httpStatus.OK).json(result || { message: req.t('general:notFound'), value: null });
} catch (error) {
return next(error);
}
diff --git a/src/preferences/requestHandlers/update.ts b/src/preferences/requestHandlers/update.ts
index 3a8c5fdd51..3fb75eece9 100644
--- a/src/preferences/requestHandlers/update.ts
+++ b/src/preferences/requestHandlers/update.ts
@@ -17,7 +17,7 @@ export default async function updateHandler(req: PayloadRequest, res: Response,
});
return res.status(httpStatus.OK).json({
- ...formatSuccessResponse('Updated successfully.', 'message'),
+ ...formatSuccessResponse(req.t('general:updatedSuccessfully'), 'message'),
doc,
});
} catch (error) {
diff --git a/src/translations/de.json b/src/translations/de.json
new file mode 100644
index 0000000000..7d94b32d25
--- /dev/null
+++ b/src/translations/de.json
@@ -0,0 +1,290 @@
+{
+ "$schema": "./translation-schema.json",
+ "authentication": {
+ "account": "Konto",
+ "accountOfCurrentUser": "Aktuelles Benutzerkonto",
+ "alreadyActivated": "Bereits aktiviert",
+ "alreadyLoggedIn": "Bereits eingeloggt",
+ "apiKey": "API Key",
+ "backToLogin": "Zurück zum Login",
+ "beginCreateFirstUser": "Erstelle deinen ersten Benutzer um zu beginnen",
+ "changePassword": "Passwort ändern",
+ "checkYourEmailForPasswordReset": "Du solltest eine E-Mail mit einem Link zum sicheren Zurücksetzen deines Passworts erhalten haben.",
+ "confirmGeneration": "Generation bestätigen",
+ "confirmPassword": "Passwort bestätigen",
+ "createFirstUser": "Ersten Benutzer erstellen",
+ "emailNotValid": "Die angegebene E-Mail Adresse ist nicht valide",
+ "emailSent": "E-Mail verschickt",
+ "enableAPIKey": "API-Key aktivieren",
+ "failedToUnlock": "Konnte nicht entsperren",
+ "forceUnlock": "Entsperrung erzwingen",
+ "forgotPassword": "Passwort vergessen",
+ "forgotPasswordEmailInstructions": "Bitte gebe deine E-Mail Adresse an. Du wirst eine E-Mail mit Instruktionen zum Zurücksetzen deines Passworts erhalten.",
+ "forgotPasswordQuestion": "Passwort vergessen?",
+ "generate": "Generieren",
+ "generateNewAPIKey": "Generiere neuen API-Key",
+ "generatingNewAPIKeyWillInvalidate": "Generierung eines neuen API-Keys wird den vorherigen Key <1>ungültig1> machen. Bist du sicher, dass du fortfahren möchtest?",
+ "lockUntil": "Sperre bis",
+ "logBackIn": "Wieder anmelden",
+ "logOut": "Ausloggen",
+ "loggedIn": "Um dich mit einem anderen Benutzer einzuloggen, musst du dich zuerst <0>ausloggen0>.",
+ "loggedInChangePassword": "Um dein Passwort zu ändern, gehe in dein <0>Konto0> und ändere dort dein Passwort.",
+ "loggedOutInactivity": "Du wurdest auf Grund von Inaktivität ausgeloggt.",
+ "loggedOutSuccessfully": "Du wurdest erfolgreich ausgeloggt.",
+ "login": "Login",
+ "loginAttempts": "Login-Versuche",
+ "loginUser": "Benutzerlogin",
+ "loginWithAnotherUser": "Um dich mit einem anderen Benutzer einzuloggen, musst du dich zuerst <0>ausloggen0>.",
+ "logout": "Logout",
+ "logoutUser": "Benutzer-Logout",
+ "newAPIKeyGenerated": "Neuer API-Key wurde generiert",
+ "newAccountCreated": "Ein neues Konto wurde gerade für dich auf {{serverURL}} erstellt. Bitte klicke den folgenden Link oder kopiere die URL in deinen Browser um deine E-Mail Adresse zu verifizieren: {{verificationURL}} Nachdem du deine E-Mail Adresse verifiziert hast, kannst du dich erfolgreich einloggen.",
+ "newPassword": "Neues Passwort",
+ "resetPassword": "Passwort zurücksetzen",
+ "resetPasswordExpiration": "Passwort-Ablauf zurücksetzen",
+ "resetPasswordToken": "Passwort-Token zurücksetzen",
+ "resetYourPassword": "Passwort zurücksetzen",
+ "stayLoggedIn": "Eingeloggt bleiben",
+ "successfullyUnlocked": "Erfolgreich entsperrt",
+ "unableToVerify": "Konnte nicht verifiziert werden",
+ "verified": "Verifiziert",
+ "verifiedSuccessfully": "Erfolgreich verifiziert",
+ "verify": "Verifizieren",
+ "verifyUser": "Benutzer Verifizieren",
+ "verifyYourEmail": "Deine E-Mail Adresse verifizieren",
+ "youAreInactive": "Du warst seit einiger Zeit inaktiv und wirst in kurzer Zeit zu deiner eigenen Sicherheit ausgeloggt. Möchtest du eingeloggt bleiben?",
+ "youAreReceivingResetPassword": "Du erhälst diese Nachricht, weil du (oder jemand anderes) das Zurücksetzen deines Passworts für deinen Account angefordert hat. Bitte klicke auf den folgenden Link, oder kopiere die URL in deinen Browser den Prozess abzuschließen:",
+ "youDidNotRequestPassword": "Solltest du dies nicht angefordert haben, ignoriere diese E-mail und dein Passswort verbleibt unverändert."
+ },
+ "error": {
+ "accountAlreadyActivated": "Dieses Konto wurde bereits aktiviert",
+ "autosaving": "Es gab ein Problem während der automatischen Speicherung für dieses Dokument",
+ "correctInvalidFields": "Bitte invalide Felder korrigieren.",
+ "deletingTitle": "Es gab ein Problem, während der Löschung von {{title}}. Bitte überprüfe deine Verbindung und versuche es erneut.",
+ "emailOrPasswordIncorrect": "Die E-Mail Adresse oder das verwandte Passwort sind inkorrekt.",
+ "followingFieldsInvalid_many": "Die folgenden Felder sind nicht korrekt:",
+ "followingFieldsInvalid_one": "Das folgende Feld ist nicht korrekt:",
+ "incorrectCollection": "Falsche Sammlung",
+ "invalidFileType": "Inkorrekter Datei-Typ",
+ "invalidFileTypeValue": "Inkorreter Datei-Typ: {{value}}",
+ "loadingDocument": "Es gab ein Problem das Dokument mit der ID {{id}} zu laden.",
+ "missingEmail": "E-Mail Adresse fehlt",
+ "missingIDOfDocument": "ID des zu speichernden Dokuments fehlt",
+ "missingIDOfVersion": "ID der Version fehlt",
+ "missingRequiredData": "Erforderliche Daten fehlen.",
+ "noFilesUploaded": "Es wurden keine Dateien hochgeladen.",
+ "noMatchedField": "Kein übereinstimmendes Feld für \"{{label}}\" gefunden",
+ "noUser": "Kein Benutzer",
+ "notAllowedToAccessPage": "Du hast keine Berechtigung um auf diese Seite zuzugreifen.",
+ "notAllowedToPerformAction": "Du hast keine Berechtigung diese Aktion auszuführen.",
+ "notFound": "Die angeforderte Ressource wurde nicht gefunden.",
+ "problemUploadingFile": "Es gab ein Problem während des Dateiuploads.",
+ "tokenInvalidOrExpired": "Token ist entweder invalide oder abgelaufen.",
+ "unPublishingDocument": "Es gab ein Problem während des depublizierens dieses Dokuments.",
+ "unauthorized": "Nicht autorisiert - du musst eingeloggt sein um diese Anfrage zu stellen.",
+ "unknown": "Ein unbekannter Fehler ist aufgetreten",
+ "unspecific": "Ein Fehler ist aufgetreten",
+ "userLocked": "Dieser Benutzer ist auf Grund zu vieler unerfolgreicher Login-Versuche gesperrt.",
+ "valueMustBeUnique": "Wert muss einzigartig sein",
+ "verificationTokenInvalid": "Verifizierungs-Token ist nicht korret."
+ },
+ "fields": {
+ "block": "block",
+ "blocks": "blocks",
+ "addLabel": "{{label}} hinzufügen",
+ "addNew": "Neu",
+ "addNewLabel": "{{label}} hinzufügen",
+ "addRelationship": "Relation hinzufügen",
+ "blockType": "Block Typ",
+ "chooseFromExisting": "Von existierenden auswähle",
+ "chooseLabel": "{{label}} auswählen",
+ "collapseAll": "Alle einklappen",
+ "editLabelData": "{{label}} bearbeiten",
+ "itemsAndMore": "{{items}} und {{count}} mehr",
+ "labelRelationship": "{{label}} Relation",
+ "latitude": "Breite",
+ "linkedTo": "Verlinkt zu <0>label0>",
+ "longitude": "Längengrad",
+ "newLabel": "{{label}} erstellen",
+ "passwordsDoNotMatch": "Passwörter stimmen nicht überein.",
+ "relatedDocument": "Verwandtes Dokument",
+ "relationTo": "Relation zu",
+ "removeUpload": "Hochgeladene Datei löschen",
+ "saveChanges": "Änderungen speichern",
+ "searchForBlock": "Nach Block suchen",
+ "selectExistingLabel": "Existierendes {{label}} auswählen",
+ "showAll": "alle anzeigen",
+ "swapUpload": "Datei austauschen",
+ "toggleBlock": "Block umschalten",
+ "uploadNewLabel": "Neue {{label}} hochladen"
+ },
+ "general": {
+ "aboutToDelete": "Du bist dabei {{label}} <1>{{title}}1> zu löschen. Bist du dir sicher?",
+ "addBelow": "Unten hinzufügen",
+ "addFilter": "Filter hinzufügen",
+ "adminTheme": "Admin Theme",
+ "and": "Und",
+ "automatic": "Automatisch",
+ "backToDashboard": "Zurück zum Dashboard",
+ "cancel": "Abbrechen",
+ "changesNotSaved": "Deine Änderungen wurden nicht gespeichert. Wenn du diese Seite nun verlässt, wirst du deine Änderungen verlieren.",
+ "collections": "Sammlungen",
+ "columnToSort": "Spalten zum Sortieren",
+ "columns": "Spalten",
+ "confirm": "Bestätigen",
+ "confirmDeletion": "Löschung bestätigen",
+ "confirmDuplication": "Duplizieren bestätigen",
+ "copied": "Kopiert",
+ "copy": "Kopieren",
+ "create": "Erstellen",
+ "createNew": "Neu Erstellen",
+ "createNewLabel": "Neues {{label}} erstellen",
+ "created": "Erstellt",
+ "createdAt": "Erstellt am",
+ "creating": "Erstelle",
+ "dark": "Dunkel",
+ "dashboard": "Dashboard",
+ "delete": "Löschen",
+ "deletedSuccessfully": "Erfolgreich gelöscht.",
+ "deleting": "Lösche...",
+ "descending": "Absteigend",
+ "duplicate": "Duplizieren",
+ "duplicateWithoutSaving": "Dupliziere ohne Änderungen zu speichern",
+ "editLabel": "{{label}} bearbeiten",
+ "editing": "Bearbeite",
+ "email": "Email",
+ "emailAddress": "Email Address",
+ "enterAValue": "Geben Sie einen Wert ein",
+ "filter": "Filter",
+ "filterWhere": "Filtern Sie {{label}} wo",
+ "filters": "Filter",
+ "globals": "Globalen",
+ "language": "Sprache",
+ "lastModified": "Zuletzt geändert",
+ "leaveAnyway": "Trotzdem verlassen",
+ "leaveWithoutSaving": "Ohne speichern verlassen",
+ "light": "Hell",
+ "loading": "Wird geladen...",
+ "locales": "Sprachen",
+ "moveDown": "Abwärts",
+ "moveUp": "Aufwärts",
+ "newPassword": "Neues Passwort",
+ "noFiltersSet": "Keine Filter gesetzt",
+ "noLabel": "",
+ "noResults": "Kein {{label}} gefunden. Entweder kein {{label}} existiert oder es gibt keine Übereinstimmung zu den von dir verwendeten Filtern.",
+ "noValue": "Kein Wert",
+ "none": "Keiner",
+ "notFound": "Nicht gefunden",
+ "nothingFound": "Keine Ergebnisse",
+ "of": "von",
+ "or": "oder",
+ "order": "Reihenfolge",
+ "pageNotFound": "Seite nicht gefunden",
+ "password": "Passwort",
+ "payloadSettings": "Payload Einstellungen",
+ "perPage": "Pro Seite: {{limit}}",
+ "remove": "Entfernen",
+ "row": "Zeile",
+ "rows": "Zeilen",
+ "save": "Speichern",
+ "saving": "speichert...",
+ "searchBy": "Suche nach {{label}}",
+ "selectValue": "Wert auswählen",
+ "sorryNotFound": "Entschuldige, es entspricht nichts deiner Anfrage",
+ "sort": "Sortieren",
+ "stayOnThisPage": "Auf dieser Seite bleiben",
+ "submissionSuccessful": "Einrichung erfolgreich.",
+ "submit": "Abschicken",
+ "successfullyCreated": "{{label}} erfolgreich erstellt.",
+ "successfullyDuplicated": "{{label}} wurde erfolgreich dupliziert.",
+ "thisLanguage": "Deutsch",
+ "titleDeleted": "{{label}} {{title}} wurde erfolgreich gelöscht.",
+ "unauthorized": "Nicht authorisiert",
+ "unsavedChangesDuplicate": "Du hast ungespeicherte Änderungen, möchtest du weitermachen oder duplizieren?",
+ "untitled": "ohne Titel",
+ "updatedAt": "Aktualisiert am",
+ "updatedSuccessfully": "Erfolgreich geupdated.",
+ "user": "Benutzer",
+ "users": "Benutzer",
+ "welcome": "Willkommen"
+ },
+ "upload": {
+ "dragAndDropHere": "oder ziehe eine Datei hier",
+ "fileName": "Dateiname",
+ "fileSize": "Dateigröße",
+ "height": "Höhe",
+ "lessInfo": "Weniger Info",
+ "moreInfo": "Mehr Info",
+ "selectCollectionToBrowse": "Wähle eine Sammlung aus um zu suchen",
+ "selectFile": "Datei auswählen",
+ "sizes": "Größen",
+ "width": "Breite"
+ },
+ "validation": {
+ "emailAddress": "Bitte gebe eine korrekte E-Mail Adresse an.",
+ "enterNumber": "Bitte gebe eine valide Nummer an,",
+ "fieldHasNo": "Dieses Feld hat kein {{label}}",
+ "greaterThanMax": "\"{{value}}\" ist größer als der maximal erlaubte Wert von {{max}}.",
+ "invalidInput": "Dieses Feld hat einen inkorrekten Wert.",
+ "invalidSelection": "Dieses Feld hat eine inkorrekte Auswahl.",
+ "invalidSelections": "'Dieses Feld enthält die folgenden inkorrekten Auswahlen:'",
+ "lessThanMin": "\"{{value}}\" ist weniger als der minimale erlaubte Wert von {{min}}.",
+ "longerThanMin": "Dieser Wert muss länger als die minimale Länge von {{minLength}} Zeichen sein.",
+ "requiresNoMoreThan": "Dieses Feld kann nicht mehr als {{count}} {{label}} enthalten.",
+ "notValidDate": "\"{{value}}\" ist kein valides Datum.",
+ "required": "Pflichtfeld",
+ "requiresAtLeast": "Dieses Feld muss mindestens {{minRows}} {{label}} enthalten.",
+ "requiresTwoNumbers": "Dieses Feld muss zwei Nummern enthalten.",
+ "shorterThanMax": "Dieser Wert muss kürzer als die maximale Länge von {{maxLength}} sein.",
+ "trueOrFalse": "Dieses Feld kann nur wahr oder falsch sein.",
+ "validUploadID": "'Dieses Feld enthält keine valide Upload-ID.'"
+ },
+ "version": {
+ "aboutToRestore": "Du bist dabei {{label}} zu dem Stand vom {{versionDate}} zurücksetzen.",
+ "aboutToRestoreGlobal": "Du bist dabei die Globale {{label}} zu dem Stand vom {{versionDate}} zurückzusetzen.",
+ "aboutToRevertToPublished": "Du bist dabei dieses Dokument zum Stand des ersten Veröffentlichungsdatum zurückzusetzen - Bist du dir sicher?",
+ "aboutToUnpublish": "Du bist dabei dieses Dokument zu depublizieren - bist du dir sicher?",
+ "autosave": "Automatische Speicherung",
+ "autosavedSuccessfully": "Erfolgreich automatisch gespeichert.",
+ "autosavedVersion": "Automatisch gespeicherte Version",
+ "changed": "Geändert",
+ "compareVersion": "Vergleiche Version zu:",
+ "confirmRevertToSaved": "Bestätige das Zurücksetzen auf gespeichert",
+ "confirmUnpublish": "Bestätige depublizieren",
+ "confirmVersionRestoration": "Bestätige Wiederherstellung der Version",
+ "currentDocumentStatus": "Aktueller Dokumentenstatus: {{docStatus}}",
+ "draft": "Entwurf",
+ "draftSavedSuccessfully": "Entwurf erfolgreich gespeichert.",
+ "lastSavedAgo": "Zuletzt gespeichert {{distance, relativetime(minutes)}}",
+ "noFurtherVersionsFound": "Keine weiteren Versionen vorhanden",
+ "noRowsFound": "Kein {{label}} gefunden",
+ "preview": "Vorschau",
+ "problemRestoringVersion": "Es gab ein Problem bei der Wiederherstellung dieser Version",
+ "publishChanges": "Änderungen veröffentlichen",
+ "published": "Veröffentlicht",
+ "restoreThisVersion": "Diese Version wiederherstellen",
+ "restoredSuccessfully": "Erfolgreich wiederhergestellt.",
+ "restoring": "wiederherstellen...",
+ "revertToPublished": "Zurücksetzen zu veröffentlicht",
+ "reverting": "zurücksetzen...",
+ "saveDraft": "Entwurf speichern",
+ "selectLocales": "Wähle angezeigte Sprache",
+ "selectVersionToCompare": "Wähle Version zum Vergleich",
+ "showLocales": "Sprachen anzeigen:",
+ "status": "Status",
+ "type": "Typ",
+ "unpublish": "Depuplizieren",
+ "unpublishing": "depubliziere...",
+ "version": "Version",
+ "versionCount_many": "{{count}} Versionen gefunden",
+ "versionCount_none": "Keine Versionen gefunden",
+ "versionCount_one": "{{count}} Version gefunden",
+ "versionCreatedOn": "{{version}} erstellt am:",
+ "versionID": "Version ID",
+ "versions": "Versionen",
+ "viewingVersion": "Betrachte Version für {{entityLabel}} {{documentTitle}}",
+ "viewingVersionGlobal": "`Betrachte Version für die Globale {{entityLabel}}",
+ "viewingVersions": "Betrachte Versionen für {{entityLabel}} {{documentTitle}}",
+ "viewingVersionsGlobal": "`Betrachte Versionen für die Globale {{entityLabel}}"
+ }
+}
diff --git a/src/translations/defaultOptions.ts b/src/translations/defaultOptions.ts
new file mode 100644
index 0000000000..fd29d2eeba
--- /dev/null
+++ b/src/translations/defaultOptions.ts
@@ -0,0 +1,9 @@
+import type { InitOptions } from 'i18next';
+import translations from './index';
+
+export const defaultOptions: InitOptions = {
+ fallbackLng: 'en',
+ debug: false,
+ supportedLngs: Object.keys(translations),
+ resources: translations,
+};
diff --git a/src/translations/en.json b/src/translations/en.json
new file mode 100644
index 0000000000..8a57362c4b
--- /dev/null
+++ b/src/translations/en.json
@@ -0,0 +1,291 @@
+{
+ "$schema": "./translation-schema.json",
+ "authentication": {
+ "account": "Account",
+ "accountOfCurrentUser": "Account of current user",
+ "alreadyActivated": "Already Activated",
+ "alreadyLoggedIn": "Already logged in",
+ "apiKey": "API Key",
+ "backToLogin": "Back to login",
+ "beginCreateFirstUser": "To begin, create your first user.",
+ "changePassword": "Change Password",
+ "checkYourEmailForPasswordReset": "Check your email for a link that will allow you to securely reset your password.",
+ "confirmGeneration": "Confirm Generation",
+ "confirmPassword": "Confirm Password",
+ "createFirstUser": "Create first user",
+ "emailNotValid": "The email provided is not valid",
+ "emailSent": "Email Sent",
+ "enableAPIKey": "Enable API Key",
+ "failedToUnlock": "Failed to unlock",
+ "forceUnlock": "Force Unlock",
+ "forgotPassword": "Forgot Password",
+ "forgotPasswordEmailInstructions": "Please enter your email below. You will receive an email message with instructions on how to reset your password.",
+ "forgotPasswordQuestion": "Forgot password?",
+ "generate": "Generate",
+ "generateNewAPIKey": "Generate new API key",
+ "generatingNewAPIKeyWillInvalidate": "Generating a new API key will <1>invalidate1> the previous key. Are you sure you wish to continue?",
+ "lockUntil": "Lock Until",
+ "logBackIn": "Log back in",
+ "logOut": "Log out",
+ "loggedIn": "To log in with another user, you should <0>log out0> first.",
+ "loggedInChangePassword": "To change your password, go to your <0>account0> and edit your password there.",
+ "loggedOutInactivity": "You have been logged out due to inactivity.",
+ "loggedOutSuccessfully": "You have been logged out successfully.",
+ "login": "Login",
+ "loginAttempts": "Login Attempts",
+ "loginUser": "Login user",
+ "loginWithAnotherUser": "To log in with another user, you should <0>log out0> first.",
+ "logout": "Logout",
+ "logoutUser": "Logout user",
+ "newAPIKeyGenerated": "New API Key Generated.",
+ "newAccountCreated": "A new account has just been created for you to access {{serverURL}} Please click on the following link or paste the URL below into your browser to verify your email: {{verificationURL}} After verifying your email, you will be able to log in successfully.",
+ "newPassword": "New Password",
+ "resetPassword": "Reset Password",
+ "resetPasswordExpiration": "Reset Password Expiration",
+ "resetPasswordToken": "Reset Password Token",
+ "resetYourPassword": "Reset Your Password",
+ "stayLoggedIn": "Stay logged in",
+ "successfullyUnlocked": "Successfully unlocked",
+ "unableToVerify": "Unable to Verify",
+ "verified": "Verified",
+ "verifiedSuccessfully": "Verified Successfully",
+ "verify": "Verify",
+ "verifyUser": "Verify User",
+ "verifyYourEmail": "Verify your email",
+ "youAreInactive": "You haven't been active in a little while and will shortly be automatically logged out for your own security. Would you like to stay logged in?",
+ "youAreReceivingResetPassword": "You are receiving this because you (or someone else) have requested the reset of the password for your account. Please click on the following link, or paste this into your browser to complete the process:",
+ "youDidNotRequestPassword": "If you did not request this, please ignore this email and your password will remain unchanged."
+ },
+ "error": {
+ "accountAlreadyActivated": "This account has already been activated.",
+ "autosaving": "There was a problem while autosaving this document.",
+ "correctInvalidFields": "Please correct invalid fields.",
+ "deletingFile": "There was an error deleting file.",
+ "deletingTitle": "There was an error while deleting {{title}}. Please check your connection and try again.",
+ "emailOrPasswordIncorrect": "The email or password provided is incorrect.",
+ "followingFieldsInvalid_many": "The following fields are invalid:",
+ "followingFieldsInvalid_one": "The following field is invalid:",
+ "incorrectCollection": "Incorrect Collection",
+ "invalidFileType": "Invalid file type",
+ "invalidFileTypeValue": "Invalid file type: {{value}}",
+ "loadingDocument": "There was a problem loading the document with ID of {{id}}.",
+ "missingEmail": "Missing email.",
+ "missingIDOfDocument": "Missing ID of document to update.",
+ "missingIDOfVersion": "Missing ID of version.",
+ "missingRequiredData": "Missing required data.",
+ "noFilesUploaded": "No files were uploaded.",
+ "noMatchedField": "No matched field found for \"{{label}}\"",
+ "noUser": "No User",
+ "notAllowedToAccessPage": "You are not allowed to access this page.",
+ "notAllowedToPerformAction": "You are not allowed to perform this action.",
+ "notFound": "The requested resource was not found.",
+ "problemUploadingFile": "There was a problem while uploading the file.",
+ "tokenInvalidOrExpired": "Token is either invalid or has expired.",
+ "unPublishingDocument": "There was a problem while un-publishing this document.",
+ "unauthorized": "Unauthorized, you must be logged in to make this request.",
+ "unknown": "An unknown error has occurred.",
+ "unspecific": "An error has occurred.",
+ "userLocked": "This user is locked due to having too many failed login attempts.",
+ "valueMustBeUnique": "Value must be unique",
+ "verificationTokenInvalid": "Verification token is invalid."
+ },
+ "fields": {
+ "block": "block",
+ "blocks": "blocks",
+ "addLabel": "Add {{label}}",
+ "addNew": "Add new",
+ "addNewLabel": "Add new {{label}}",
+ "addRelationship": "Add relationship",
+ "blockType": "Block Type",
+ "chooseFromExisting": "Choose from existing",
+ "chooseLabel": "Choose {{label}}",
+ "collapseAll": "Collapse All",
+ "editLabelData": "Edit {{label}} data",
+ "itemsAndMore": "{{items}} and {{count}} more",
+ "labelRelationship": "{{label}} Relationship",
+ "latitude": "Latitude",
+ "linkedTo": "Linked to <0>label0>",
+ "longitude": "Longitude",
+ "newLabel": "New {{label}}",
+ "passwordsDoNotMatch": "Passwords do not match.",
+ "relatedDocument": "Related Document",
+ "relationTo": "Relation To",
+ "removeUpload": "Remove Upload",
+ "saveChanges": "Save changes",
+ "searchForBlock": "Search for a block",
+ "selectExistingLabel": "Select existing {{label}}",
+ "showAll": "Show All",
+ "swapUpload": "Swap Upload",
+ "toggleBlock": "Toggle block",
+ "uploadNewLabel": "Upload new {{label}}"
+ },
+ "general": {
+ "aboutToDelete": "You are about to delete the {{label}} <1>{{title}}1>. Are you sure?",
+ "addBelow": "Add Below",
+ "addFilter": "Add Filter",
+ "adminTheme": "Admin Theme",
+ "and": "And",
+ "automatic": "Automatic",
+ "backToDashboard": "Back to Dashboard",
+ "cancel": "Cancel",
+ "changesNotSaved": "Your changes have not been saved. If you leave now, you will lose your changes.",
+ "collections": "Collections",
+ "columnToSort": "Column to Sort",
+ "columns": "Columns",
+ "confirm": "Confirm",
+ "confirmDeletion": "Confirm deletion",
+ "confirmDuplication": "Confirm duplication",
+ "copied": "Copied",
+ "copy": "Copy",
+ "create": "Create",
+ "createNew": "Create New",
+ "createNewLabel": "Create new {{label}}",
+ "created": "Created",
+ "createdAt": "Created At",
+ "creating": "Creating",
+ "dark": "Dark",
+ "dashboard": "Dashboard",
+ "delete": "Delete",
+ "deletedSuccessfully": "Deleted successfully.",
+ "deleting": "Deleting...",
+ "descending": "Descending",
+ "duplicate": "Duplicate",
+ "duplicateWithoutSaving": "Duplicate without saving changes",
+ "editLabel": "Edit {{label}}",
+ "editing": "Editing",
+ "email": "Email",
+ "emailAddress": "Email Address",
+ "enterAValue": "Enter a value",
+ "filter": "Filter",
+ "filterWhere": "Filter {{label}} where",
+ "filters": "Filters",
+ "globals": "Globals",
+ "language": "Language",
+ "lastModified": "Last Modified",
+ "leaveAnyway": "Leave anyway",
+ "leaveWithoutSaving": "Leave without saving",
+ "light": "Light",
+ "loading": "Loading...",
+ "locales": "Locales",
+ "moveDown": "Move Down",
+ "moveUp": "Move Up",
+ "newPassword": "New Password",
+ "noFiltersSet": "No filters set",
+ "noLabel": "",
+ "noResults": "No {{label}} found. Either no {{label}} exist yet or none match the filters you've specified above.",
+ "noValue": "No value",
+ "none": "None",
+ "notFound": "Not Found",
+ "nothingFound": "Nothing found",
+ "of": "of",
+ "or": "Or",
+ "order": "Order",
+ "pageNotFound": "Page not found",
+ "password": "Password",
+ "payloadSettings": "Payload Settings",
+ "perPage": "Per Page: {{limit}}",
+ "remove": "Remove",
+ "row": "Row",
+ "rows": "Rows",
+ "save": "Save",
+ "saving": "Saving...",
+ "searchBy": "Search by {{label}}",
+ "selectValue": "Select a value",
+ "sorryNotFound": "Sorry—there is nothing to correspond with your request.",
+ "sort": "Sort",
+ "stayOnThisPage": "Stay on this page",
+ "submissionSuccessful": "Submission Successful.",
+ "submit": "Submit",
+ "successfullyCreated": "{{label}} successfully created.",
+ "successfullyDuplicated": "{{label}} successfully duplicated.",
+ "thisLanguage": "English",
+ "titleDeleted": "{{label}} \"{{title}}\" successfully deleted.",
+ "unauthorized": "Unauthorized",
+ "unsavedChangesDuplicate": "You have unsaved changes. Would you like to continue to duplicate?",
+ "untitled": "Untitled",
+ "updatedAt": "Updated At",
+ "updatedSuccessfully": "Updated successfully.",
+ "user": "User",
+ "users": "Users",
+ "welcome": "Welcome"
+ },
+ "upload": {
+ "dragAndDropHere": "or drag and drop a file here",
+ "fileName": "File Name",
+ "fileSize": "File Size",
+ "height": "Height",
+ "lessInfo": "Less info",
+ "moreInfo": "More info",
+ "selectCollectionToBrowse": "Select a Collection to Browse",
+ "selectFile": "Select a file",
+ "sizes": "Sizes",
+ "width": "Width"
+ },
+ "validation": {
+ "emailAddress": "Please enter a valid email address.",
+ "enterNumber": "Please enter a valid number.",
+ "fieldHasNo": "This field has no {{label}}",
+ "greaterThanMax": "\"{{value}}\" is greater than the max allowed value of {{max}}.",
+ "invalidInput": "This field has an invalid input.",
+ "invalidSelection": "This field has an invalid selection.",
+ "invalidSelections": "This field has the following invalid selections:",
+ "lessThanMin": "\"{{value}}\" is less than the min allowed value of {{min}}.",
+ "longerThanMin": "This value must be longer than the minimum length of {{minLength}} characters.",
+ "notValidDate": "\"{{value}}\" is not a valid date.",
+ "required": "This field is required.",
+ "requiresAtLeast": "This field requires at least {{count}} {{label}}.",
+ "requiresNoMoreThan": "This field requires no more than {{count}} {{label}}.",
+ "requiresTwoNumbers": "This field requires two numbers.",
+ "shorterThanMax": "This value must be shorter than the max length of {{maxLength}} characters.",
+ "trueOrFalse": "This field can only be equal to true or false.",
+ "validUploadID": "This field is not a valid upload ID."
+ },
+ "version": {
+ "aboutToRestore": "You are about to restore this {{label}} document to the state that it was in on {{versionDate}}.",
+ "aboutToRestoreGlobal": "You are about to restore the global {{label}} to the state that it was in on {{versionDate}}.",
+ "aboutToRevertToPublished": "You are about to revert this document's changes to its published state. Are you sure?",
+ "aboutToUnpublish": "You are about to unpublish this document. Are you sure?",
+ "autosave": "Autosave",
+ "autosavedSuccessfully": "Autosaved successfully.",
+ "autosavedVersion": "Autosaved version",
+ "changed": "Changed",
+ "compareVersion": "Compare version against:",
+ "confirmRevertToSaved": "Confirm revert to saved",
+ "confirmUnpublish": "Confirm unpublish",
+ "confirmVersionRestoration": "Confirm version Restoration",
+ "currentDocumentStatus": "Current {{docStatus}} document",
+ "draft": "Draft",
+ "draftSavedSuccessfully": "Draft saved successfully.",
+ "lastSavedAgo": "Last saved {{distance, relativetime(minutes)}}",
+ "noFurtherVersionsFound": "No further versions found",
+ "noRowsFound": "No {{label}} found",
+ "preview": "Preview",
+ "problemRestoringVersion": "There was a problem restoring this version",
+ "publishChanges": "Publish changes",
+ "published": "Published",
+ "restoreThisVersion": "Restore this version",
+ "restoredSuccessfully": "Restored Successfully.",
+ "restoring": "Restoring...",
+ "revertToPublished": "Revert to published",
+ "reverting": "Reverting...",
+ "saveDraft": "Save Draft",
+ "selectLocales": "Select locales to display",
+ "selectVersionToCompare": "Select a version to compare",
+ "showLocales": "Show locales:",
+ "status": "Status",
+ "type": "Type",
+ "unpublish": "Unpublish",
+ "unpublishing": "Unpublishing...",
+ "version": "Version",
+ "versionCount_many": "{{count}} versions found",
+ "versionCount_none": "No versions found",
+ "versionCount_one": "{{count}} version found",
+ "versionCreatedOn": "{{version}} created on:",
+ "versionID": "Version ID",
+ "versions": "Versions",
+ "viewingVersion": "Viewing version for the {{entityLabel}} {{documentTitle}}",
+ "viewingVersionGlobal": "Viewing version for the global {{entityLabel}}",
+ "viewingVersions": "Viewing versions for the {{entityLabel}} {{documentTitle}}",
+ "viewingVersionsGlobal": "Viewing versions for the global {{entityLabel}}"
+ }
+}
diff --git a/src/translations/es.json b/src/translations/es.json
new file mode 100644
index 0000000000..0c10ee513d
--- /dev/null
+++ b/src/translations/es.json
@@ -0,0 +1,290 @@
+{
+ "$schema": "./translation-schema.json",
+ "authentication": {
+ "account": "Cuenta",
+ "accountOfCurrentUser": "Cuenta del usuario actual",
+ "alreadyActivated": "Ya Activado",
+ "alreadyLoggedIn": "Sesión iniciada",
+ "apiKey": "API Key",
+ "backToLogin": "Regresar al inicio de sesión",
+ "beginCreateFirstUser": "Para empezar, crea tu primer usuario.",
+ "changePassword": "Cambiar contraseña",
+ "checkYourEmailForPasswordReset": "Revisa tu correo con el enlace para restablecer tu contraseña de forma segura.",
+ "confirmGeneration": "Confirmar Generación",
+ "confirmPassword": "Confirmar Contraseña",
+ "createFirstUser": "Crear al primer usuario",
+ "emailNotValid": "El correo proporcionado es inválido",
+ "emailSent": "Correo Enviado",
+ "enableAPIKey": "Habilitar Clave API",
+ "failedToUnlock": "Desbloqueo Fallido",
+ "forceUnlock": "Forzar Desbloqueo",
+ "forgotPassword": "Olvidé mi contraseña",
+ "forgotPasswordEmailInstructions": "Por favor introduce tu correo electrónico. Recibirás un mensaje con las instrucciones para restablecer tu contraseña.",
+ "forgotPasswordQuestion": "¿Olvidaste tu contraseña?",
+ "generate": "Generar",
+ "generateNewAPIKey": "Generar Nueva Clave de API",
+ "generatingNewAPIKeyWillInvalidate": "Generar una nueva clave de API <1>invalidará1> la clave anterior. ¿Deseas continuar?",
+ "lockUntil": "Lock Until",
+ "logBackIn": "Volver a iniciar sesión",
+ "logOut": "Cerrar sesión",
+ "loggedIn": "Para iniciar sesión con otro usuario, primero <0>cierra tu sesión0>.",
+ "loggedInChangePassword": "Para cambiar tu contraseña, entra a <0>tu cuenta0> y edita la contraseña desde ahí.",
+ "loggedOutInactivity": "Tú sesión se cerró debido a inactividad.",
+ "loggedOutSuccessfully": "Tú sesión se cerró correctamente.",
+ "login": "Iniciar sesión",
+ "loginAttempts": "Login Attempts",
+ "loginUser": "Iniciar sesión de usuario",
+ "loginWithAnotherUser": "Para iniciar sesión con otro usuario, primero <0>cierra tu sesión0>.",
+ "logout": "Cerrar sesión",
+ "logoutUser": "Cerrar sesión de usuario",
+ "newAPIKeyGenerated": "Nueva Clave de API Generada.",
+ "newAccountCreated": "A new account has just been created for you to access {{serverURL}} Please click on the following link or paste the URL below into your browser to verify your email: {{verificationURL}} After verifying your email, you will be able to log in successfully.",
+ "newPassword": "Nueva Contraseña",
+ "resetPassword": "Restablecer Contraseña",
+ "resetPasswordExpiration": "Reset Password Expiration",
+ "resetPasswordToken": "Reset Password Token",
+ "resetYourPassword": "Restablecer tu Contraseña",
+ "stayLoggedIn": "Mantener sesión abierta",
+ "successfullyUnlocked": "Desbloqueado correctamente",
+ "unableToVerify": "No se pudo Verificar",
+ "verified": "Verificado",
+ "verifiedSuccessfully": "Verificación Correcta",
+ "verify": "Verificar",
+ "verifyUser": "Verificar Usuario",
+ "verifyYourEmail": "Verify your email",
+ "youAreInactive": "Has estado inactivo por un tiempo y por tu seguridad se cerrará tu sesión automáticamente. ¿Deseas mantener tu sesión activa?",
+ "youAreReceivingResetPassword": "Estás recibiendo esto porque tú (o alguien más) ha solicitado restablecer la contraseña de tu cuenta. Por favor haz click en el siguiente enlace o pégalo en tu navegador para completar el proceso:",
+ "youDidNotRequestPassword": "Si tú no solicitaste esto, por favor ignora este correo y tu contraseña no se cambiará."
+ },
+ "error": {
+ "accountAlreadyActivated": "Esta cuenta ya fue activada.",
+ "autosaving": "Ocurrió un problema al autoguardar este documento.",
+ "correctInvalidFields": "Por favor corrige los campos inválidos.",
+ "deletingTitle": "Ocurrió un error al eliminar {{title}}. Por favor revisa tu conexión y vuelve a intentarlo.",
+ "emailOrPasswordIncorrect": "El correo o la contraseña introducida es incorrecta.",
+ "followingFieldsInvalid_many": "Los siguientes campos son inválidos:",
+ "followingFieldsInvalid_one": "El siguiente campo es inválido:",
+ "incorrectCollection": "Colección Incorrecta",
+ "invalidFileType": "Invalid file type",
+ "invalidFileTypeValue": "Invalid file type: {{value}}",
+ "loadingDocument": "Ocurrió un problema al cargar el documento con la ID {{id}}.",
+ "missingEmail": "Falta el correo.",
+ "missingIDOfDocument": "Falta la ID del documento a actualizar.",
+ "missingIDOfVersion": "Falta la ID de la versión.",
+ "missingRequiredData": "Falta la información obligatoria.",
+ "noFilesUploaded": "No se subieron archivos.",
+ "noMatchedField": "No se encontró un campo para \"{{label}}\"",
+ "noUser": "Sin usuario",
+ "notAllowedToAccessPage": "You are not allowed to access this page.",
+ "notAllowedToPerformAction": "You are not allowed to perform this action.",
+ "notFound": "No se encontró el recurso solicitado.",
+ "problemUploadingFile": "Ocurrió un problema al subir el archivo.",
+ "tokenInvalidOrExpired": "El token es inválido o ya expiró.",
+ "unPublishingDocument": "Ocurrió un error al despublicar este documento.",
+ "unauthorized": "No autorizado, debes iniciar sesión para realizar esta solicitud.",
+ "unknown": "Ocurrió un error desconocido.",
+ "unspecific": "Ocurrió un error.",
+ "userLocked": "Este usuario ha sido bloqueado debido a que tiene muchos intentos fallidos para iniciar sesión.",
+ "valueMustBeUnique": "Value must be unique",
+ "verificationTokenInvalid": "Token de verificación inválido."
+ },
+ "fields": {
+ "block": "bloque",
+ "blocks": "bloques",
+ "addLabel": "Añadir {{label}}",
+ "addNew": "Añadir nuevo",
+ "addNewLabel": "Añadir {{label}}",
+ "addRelationship": "Añadir relación",
+ "blockType": "Tipo de bloque",
+ "chooseFromExisting": "Elegir existente",
+ "chooseLabel": "Elegir {{label}}",
+ "collapseAll": "Colapsar todo",
+ "editLabelData": "Editar información de {{label}}",
+ "itemsAndMore": "{{items}} y {{count}} más",
+ "labelRelationship": "Relación de {{label}}",
+ "latitude": "Latitud",
+ "linkedTo": "Enlazado a <0>label0>",
+ "longitude": "Longitud",
+ "newLabel": "Nuevo {{label}}",
+ "passwordsDoNotMatch": "Las contraseñas no coinciden.",
+ "relatedDocument": "Documento Relacionado",
+ "relationTo": "Relación con",
+ "removeUpload": "Quitar Carga",
+ "saveChanges": "Guardar cambios",
+ "searchForBlock": "Buscar bloque",
+ "selectExistingLabel": "Seleccionar {{label}} existente",
+ "showAll": "Mostrar todo",
+ "swapUpload": "Cambiar carga",
+ "toggleBlock": "Alternar bloque",
+ "uploadNewLabel": "Subir nuevo {{label}}"
+ },
+ "general": {
+ "aboutToDelete": "Estás por eliminar el {{label}} <1>{{title}}1>. ¿Estás seguro?",
+ "addBelow": "Agrega abajo",
+ "addFilter": "Añadir filtro",
+ "adminTheme": "Tema del admin",
+ "and": "Y",
+ "automatic": "Automático",
+ "backToDashboard": "Volver al Tablero",
+ "cancel": "Cancelar",
+ "changesNotSaved": "Tus cambios no han sido guardados. Si te sales ahora, se perderán tus cambios.",
+ "collections": "Colecciones",
+ "columnToSort": "Columna de ordenado",
+ "columns": "Columnas",
+ "confirm": "Confirmar",
+ "confirmDeletion": "Confirmar eliminación",
+ "confirmDuplication": "Confirmar duplicado",
+ "copied": "Copiado",
+ "copy": "Copiar",
+ "create": "Crear",
+ "createNew": "Crear nuevo",
+ "createNewLabel": "Crear nuevo {{label}}",
+ "created": "Creado",
+ "createdAt": "Fecha de creación",
+ "creating": "Creando",
+ "dark": "Oscuro",
+ "dashboard": "Tablero",
+ "delete": "Eliminar",
+ "deletedSuccessfully": "Borrado exitosamente.",
+ "deleting": "Eliminando...",
+ "descending": "Descendiente",
+ "duplicate": "Duplicar",
+ "duplicateWithoutSaving": "Duplicar sin guardar cambios",
+ "editLabel": "Editar {{label}}",
+ "editing": "Editando",
+ "email": "Correo electrónico",
+ "emailAddress": "Dirección de Correo Electrónico",
+ "enterAValue": "Introduce un valor",
+ "filter": "Filtro",
+ "filterWhere": "Filtrar {{label}} donde",
+ "filters": "Filtros",
+ "globals": "Globales",
+ "language": "Idioma",
+ "lastModified": "Última modificación",
+ "leaveAnyway": "Salir de todos modos",
+ "leaveWithoutSaving": "Salir sin guardar",
+ "light": "Claro",
+ "loading": "Cargando",
+ "locales": "Locales",
+ "moveDown": "Mover abajo",
+ "moveUp": "Mover arriba",
+ "newPassword": "Nueva contraseña",
+ "noFiltersSet": "No hay filtros establecidos",
+ "noLabel": "",
+ "noResults": "No encontramos {{label}}. O no existen {{label}} todavía o no hay coincidencias con los filtros introducidos arriba.",
+ "noValue": "Sin valor",
+ "none": "Ninguna",
+ "notFound": "No encontrado",
+ "nothingFound": "No se encontró nada",
+ "of": "de",
+ "or": "O",
+ "order": "Orden",
+ "pageNotFound": "Página no encontrada",
+ "password": "Contraseña",
+ "payloadSettings": "Configuración de la carga",
+ "perPage": "Por página: {{limit}}",
+ "remove": "Remover",
+ "row": "Fila",
+ "rows": "Filas",
+ "save": "Guardar",
+ "saving": "Guardando...",
+ "searchBy": "Buscar por {{label}}",
+ "selectValue": "Selecciona un valor",
+ "sorryNotFound": "Lo sentimos—No hay nada que corresponda con tu solicitud.",
+ "sort": "Ordenar",
+ "stayOnThisPage": "Permanecer en esta página",
+ "submissionSuccessful": "Envío realizado correctamente.",
+ "submit": "Enviar",
+ "successfullyCreated": "{{label}} creado correctamente.",
+ "successfullyDuplicated": "{{label}} duplicado correctamente.",
+ "thisLanguage": "Español",
+ "titleDeleted": "{{label}} {{title}} eliminado correctamente.",
+ "unauthorized": "No autorizado",
+ "unsavedChangesDuplicate": "Tienes cambios sin guardar. ¿Deseas continuar para duplicar?",
+ "untitled": "Sin título",
+ "updatedAt": "Fecha de modificado",
+ "updatedSuccessfully": "Actualizado con éxito.",
+ "user": "usuario",
+ "users": "usuarios",
+ "welcome": "Bienvenido"
+ },
+ "upload": {
+ "dragAndDropHere": "o arrastra un archivo aquí",
+ "fileName": "Nombre del archivo",
+ "fileSize": "File Size",
+ "height": "Height",
+ "lessInfo": "Menos info",
+ "moreInfo": "Más info",
+ "selectCollectionToBrowse": "Selecciona una Colección",
+ "selectFile": "Selecciona un archivo",
+ "sizes": "Sizes",
+ "width": "Width"
+ },
+ "validation": {
+ "emailAddress": "Por favor introduce un correo electrónico válido.",
+ "enterNumber": "Por favor introduce un número válido.",
+ "fieldHasNo": "Este campo no tiene {{label}}",
+ "greaterThanMax": "\"{{value}}\" es mayor que el valor máximo permitido de {{max}}.",
+ "invalidInput": "La información en este campo es inválida.",
+ "invalidSelection": "La selección en este campo es inválida.",
+ "invalidSelections": "'Este campo tiene las siguientes selecciones inválidas:'",
+ "lessThanMin": "\"{{value}}\" es menor que el valor mínimo permitido de {{min}}.",
+ "longerThanMin": "Este dato debe ser más largo que el mínimo de {{minLength}} caracteres.",
+ "notValidDate": "\"{{value}}\" es una fecha inválida.",
+ "required": "Este campo es obligatorio.",
+ "requiresAtLeast": "Este campo require al menos {{count}} {{label}}.",
+ "requiresNoMoreThan": "Este campo require no más de {{count}} {{label}}",
+ "requiresTwoNumbers": "Este campo requiere dos números.",
+ "shorterThanMax": "Este dato debe ser más corto que el máximo de {{maxLength}} caracteres.",
+ "trueOrFalse": "Este campo solamente puede ser verdadero o falso.",
+ "validUploadID": "'Este campo no es una ID de subida válida.'"
+ },
+ "version": {
+ "aboutToRestore": "Estás a punto de restaurar este documento de {{label}} al estado en el que estaba en la fecha {{versionDate}}.",
+ "aboutToRestoreGlobal": "Estás a punto de restaurar el {{label}} global al estado en el que estaba en la fecha {{versionDate}}.",
+ "aboutToRevertToPublished": "Estás a punto de revertir los cambios de este documento a su estado publicado. ¿Estás seguro?",
+ "aboutToUnpublish": "Estás a punto de despublicar este documento. ¿Estás seguro?",
+ "autosave": "Autoguardar",
+ "autosavedSuccessfully": "Guardado automáticamente con éxito.",
+ "autosavedVersion": "Versión Autoguardada",
+ "changed": "Modificado",
+ "compareVersion": "Comparar versión con:",
+ "confirmRevertToSaved": "Confirmar revertir a guardado",
+ "confirmUnpublish": "Confirmar despublicado",
+ "confirmVersionRestoration": "Confirmar restauración de versión",
+ "currentDocumentStatus": "Documento {{docStatus}} actual",
+ "draft": "Borrador",
+ "draftSavedSuccessfully": "Borrador guardado con éxito.",
+ "lastSavedAgo": "Último guardado {{distance, relativetime(minutes)}}",
+ "noFurtherVersionsFound": "No se encontraron más versiones",
+ "noRowsFound": "No encontramos {{label}}",
+ "preview": "Previsualizar",
+ "problemRestoringVersion": "Ocurrió un problema al restaurar esta versión",
+ "publishChanges": "Publicar cambios",
+ "published": "Publicado",
+ "restoreThisVersion": "Restaurar esta versión",
+ "restoredSuccessfully": "Restaurado éxito.",
+ "restoring": "Restaurando...",
+ "revertToPublished": "Revertir a publicado",
+ "reverting": "Revirtiendo...",
+ "saveDraft": "Guardar Borrador",
+ "selectLocales": "Selecciona idiomas a mostrar",
+ "selectVersionToCompare": "Selecciona versión a comparar",
+ "showLocales": "Mostrar idiomas:",
+ "status": "Estado",
+ "type": "Tipo",
+ "unpublish": "Despublicar",
+ "unpublishing": "Despublicando...",
+ "version": "Versión",
+ "versionCount_many": "{{count}} versiones encontradas",
+ "versionCount_none": "No encontramos versiones",
+ "versionCount_one": "{{count}} versión encontrada",
+ "versionCreatedOn": "{{version}} creada el:",
+ "versionID": "ID de Versión",
+ "versions": "Versiones",
+ "viewingVersion": "Viendo versión para {{entityLabel}} {{documentTitle}}",
+ "viewingVersionGlobal": "`Viendo versión para el global {{entityLabel}}",
+ "viewingVersions": "Viendo versiones para {{entityLabel}} {{documentTitle}}",
+ "viewingVersionsGlobal": "`Viendo versiones para el global {{entityLabel}}"
+ }
+}
diff --git a/src/translations/extractTranslations.ts b/src/translations/extractTranslations.ts
new file mode 100644
index 0000000000..904b3f673a
--- /dev/null
+++ b/src/translations/extractTranslations.ts
@@ -0,0 +1,15 @@
+import translations from './index';
+
+export const extractTranslations = (keys: string[]): Record> => {
+ const result = {};
+ keys.forEach((key) => {
+ result[key] = {};
+ });
+ Object.entries(translations).forEach(([language, resource]) => {
+ keys.forEach((key) => {
+ const [section, target] = key.split(':');
+ result[key][language] = resource[section][target];
+ });
+ });
+ return result;
+};
diff --git a/src/translations/fr.json b/src/translations/fr.json
new file mode 100644
index 0000000000..794be78c27
--- /dev/null
+++ b/src/translations/fr.json
@@ -0,0 +1,291 @@
+{
+ "$schema": "./translation-schema.json",
+ "authentication": {
+ "account": "Compte",
+ "accountOfCurrentUser": "Compte de l'utilisateur actuel",
+ "alreadyActivated": "Déjà activé",
+ "alreadyLoggedIn": "Déjà connecté",
+ "apiKey": "Clé API",
+ "backToLogin": "Retour à la connexion",
+ "beginCreateFirstUser": "Pour commencer, créez votre premier utilisateur.",
+ "changePassword": "Changer le mot de passe",
+ "checkYourEmailForPasswordReset": "Vérifiez votre e-mail, nous vous avons envoyé un lien qui vous permettra de réinitialiser votre mot de passe en toute sécurité.",
+ "confirmGeneration": "Confirmer la génération",
+ "confirmPassword": "Confirmez le mot de passe",
+ "createFirstUser": "Créer le premier utilisateur",
+ "emailNotValid": "L'adresse e-mail fourni n'est pas valide",
+ "emailSent": "E-mail envoyé",
+ "enableAPIKey": "Activer la clé API",
+ "failedToUnlock": "Déverrouillage échoué",
+ "forceUnlock": "Forcer le déverrouillage",
+ "forgotPassword": "Mot de passe oublié",
+ "forgotPasswordEmailInstructions": "Veuillez saisir votre e-mail ci-dessous. Vous recevrez un e-mail avec des instructions concernant comment réinitialiser votre mot de passe.",
+ "forgotPasswordQuestion": "Mot de passe oublié?",
+ "generate": "Générer",
+ "generateNewAPIKey": "Générer une nouvelle clé API",
+ "generatingNewAPIKeyWillInvalidate": "La génération d'une nouvelle clé API <1>invalidera1> la clé précédente. Êtes-vous sûr de vouloir continuer?",
+ "lockUntil": "Verrouiller jusqu'à",
+ "logBackIn": "Se reconnecter",
+ "logOut": "Se déconnecter",
+ "loggedIn": "Pour vous connecter en tant qu'un autre utilisateur, vous devez d'abord vous <0>déconnecter0>.",
+ "loggedInChangePassword": "Pour changer votre mot de passe, rendez-vous sur votre <0>compte0> puis modifiez-y votre mot de passe.",
+ "loggedOutInactivity": "Vous avez été déconnecté pour cause d'inactivité.",
+ "loggedOutSuccessfully": "Vous avez été déconnecté avec succès.",
+ "login": "Se connecter",
+ "loginAttempts": "Tentatives de connexion",
+ "loginUser": "Connecter l'utilisateur",
+ "loginWithAnotherUser": "Pour vous connecter en tant qu'un autre utilisateur, vous devez d'abord vous <0>déconnecter0>.",
+ "logout": "Se déconnecter",
+ "logoutUser": "Déconnecter l'utilisateur",
+ "newAPIKeyGenerated": "Nouvelle clé API générée.",
+ "newAccountCreated": "Un nouveau compte vient d'être créé pour vous permettre d'accéder