-
+
{title}
-
+
{actions && (
{actions}
@@ -30,6 +32,7 @@ const Card: React.FC
= (props) => {
)}
{onClick && (
@@ -58,6 +59,7 @@ const SortColumn: React.FC = (props) => {
buttonStyle="none"
className={descClasses.join(' ')}
onClick={() => setSort(desc)}
+ aria-label={t('sortByLabelDirection', { label: getTranslation(label, i18n), direction: t('descending') })}
>
diff --git a/src/admin/components/elements/ViewDescription/index.tsx b/src/admin/components/elements/ViewDescription/index.tsx
index a253f18426..52f7912852 100644
--- a/src/admin/components/elements/ViewDescription/index.tsx
+++ b/src/admin/components/elements/ViewDescription/index.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Props, isComponent } from './types';
import { getTranslation } from '../../../../utilities/getTranslation';
+
import './index.scss';
const ViewDescription: React.FC = (props) => {
diff --git a/src/admin/components/elements/WhereBuilder/field-types.tsx b/src/admin/components/elements/WhereBuilder/field-types.tsx
index 9cfb1cd1a4..41f92ef837 100644
--- a/src/admin/components/elements/WhereBuilder/field-types.tsx
+++ b/src/admin/components/elements/WhereBuilder/field-types.tsx
@@ -57,6 +57,16 @@ const geo = [
},
];
+const within = {
+ label: 'within',
+ value: 'within',
+};
+
+const intersects = {
+ label: 'intersects',
+ value: 'intersects',
+};
+
const like = {
label: 'isLike',
value: 'like',
@@ -86,7 +96,7 @@ const fieldTypeConditions = {
},
json: {
component: 'Text',
- operators: [...base, like, contains],
+ operators: [...base, like, contains, within, intersects],
},
richText: {
component: 'Text',
@@ -102,7 +112,7 @@ const fieldTypeConditions = {
},
point: {
component: 'Point',
- operators: [...geo],
+ operators: [...geo, within, intersects],
},
upload: {
component: 'Text',
diff --git a/src/admin/components/elements/WhereBuilder/index.tsx b/src/admin/components/elements/WhereBuilder/index.tsx
index d215c42b77..3bf71a4918 100644
--- a/src/admin/components/elements/WhereBuilder/index.tsx
+++ b/src/admin/components/elements/WhereBuilder/index.tsx
@@ -13,6 +13,7 @@ import { useSearchParams } from '../../utilities/SearchParams';
import validateWhereQuery from './validateWhereQuery';
import { Where } from '../../../../types';
import { getTranslation } from '../../../../utilities/getTranslation';
+import { transformWhereQuery } from './transformWhereQuery';
import './index.scss';
@@ -43,6 +44,10 @@ const reduceFields = (fields, i18n) => flattenTopLevelFields(fields).reduce((red
return reduced;
}, []);
+/**
+ * The WhereBuilder component is used to render the filter controls for a collection's list view.
+ * It is part of the {@link ListControls} component which is used to render the controls (search, filter, where).
+ */
const WhereBuilder: React.FC = (props) => {
const {
collection,
@@ -59,16 +64,30 @@ const WhereBuilder: React.FC = (props) => {
const params = useSearchParams();
const { t, i18n } = useTranslation('general');
+ // This handles initializing the where conditions from the search query (URL). That way, if you pass in
+ // query params to the URL, the where conditions will be initialized from those and displayed in the UI.
+ // Example: /admin/collections/posts?where[or][0][and][0][text][equals]=example%20post
const [conditions, dispatchConditions] = useReducer(reducer, params.where, (whereFromSearch) => {
- if (modifySearchQuery && validateWhereQuery(whereFromSearch)) {
- return whereFromSearch.or;
- }
+ if (modifySearchQuery && whereFromSearch) {
+ if (validateWhereQuery(whereFromSearch)) {
+ return whereFromSearch.or;
+ }
+ // Transform the where query to be in the right format. This will transform something simple like [text][equals]=example%20post to the right format
+ const transformedWhere = transformWhereQuery(whereFromSearch);
+
+ if (validateWhereQuery(transformedWhere)) {
+ return transformedWhere.or;
+ }
+
+ console.warn('Invalid where query in URL. Ignoring.');
+ }
return [];
});
const [reducedFields] = useState(() => reduceFields(collection.fields, i18n));
+ // This handles updating the search query (URL) when the where conditions change
useThrottledEffect(() => {
const currentParams = queryString.parse(history.location.search, { ignoreQueryPrefix: true, depth: 10 }) as { where: Where };
@@ -83,8 +102,11 @@ const WhereBuilder: React.FC = (props) => {
];
}, []) : [];
+ const hasNewWhereConditions = conditions.length > 0;
+
+
const newWhereQuery = {
- ...typeof currentParams?.where === 'object' ? currentParams.where : {},
+ ...typeof currentParams?.where === 'object' && (validateWhereQuery(currentParams?.where) || !hasNewWhereConditions) ? currentParams.where : {},
or: [
...conditions,
...paramsToKeep,
@@ -94,7 +116,6 @@ const WhereBuilder: React.FC = (props) => {
if (handleChange) handleChange(newWhereQuery as Where);
const hasExistingConditions = typeof currentParams?.where === 'object' && 'or' in currentParams.where;
- const hasNewWhereConditions = conditions.length > 0;
if (modifySearchQuery && ((hasExistingConditions && !hasNewWhereConditions) || hasNewWhereConditions)) {
history.replace({
diff --git a/src/admin/components/elements/WhereBuilder/transformWhereQuery.ts b/src/admin/components/elements/WhereBuilder/transformWhereQuery.ts
new file mode 100644
index 0000000000..1edb9a8bcf
--- /dev/null
+++ b/src/admin/components/elements/WhereBuilder/transformWhereQuery.ts
@@ -0,0 +1,51 @@
+import type { Where } from '../../../../types';
+
+/**
+ * Something like [or][0][and][0][text][equals]=example%20post will work and pass through the validateWhereQuery check.
+ * However, something like [text][equals]=example%20post will not work and will fail the validateWhereQuery check,
+ * even though it is a valid Where query. This needs to be transformed here.
+ */
+export const transformWhereQuery = (whereQuery): Where => {
+ if (!whereQuery) {
+ return {};
+ }
+ // Check if 'whereQuery' has 'or' field but no 'and'. This is the case for "correct" queries
+ if (whereQuery.or && !whereQuery.and) {
+ return {
+ or: whereQuery.or.map((query) => {
+ // ...but if the or query does not have an and, we need to add it
+ if(!query.and) {
+ return {
+ and: [query]
+ }
+ }
+ return query;
+ }),
+ };
+ }
+
+ // Check if 'whereQuery' has 'and' field but no 'or'.
+ if (whereQuery.and && !whereQuery.or) {
+ return {
+ or: [
+ {
+ and: whereQuery.and,
+ },
+ ],
+ };
+ }
+
+ // Check if 'whereQuery' has neither 'or' nor 'and'.
+ if (!whereQuery.or && !whereQuery.and) {
+ return {
+ or: [
+ {
+ and: [whereQuery], // top-level siblings are considered 'and'
+ },
+ ],
+ };
+ }
+
+ // If 'whereQuery' has 'or' and 'and', just return it as it is.
+ return whereQuery;
+};
diff --git a/src/admin/components/elements/WhereBuilder/validateWhereQuery.ts b/src/admin/components/elements/WhereBuilder/validateWhereQuery.ts
index f0f62656fc..f1789e73dc 100644
--- a/src/admin/components/elements/WhereBuilder/validateWhereQuery.ts
+++ b/src/admin/components/elements/WhereBuilder/validateWhereQuery.ts
@@ -1,8 +1,37 @@
-import { Where } from '../../../../types';
+import type { Operator, Where } from '../../../../types';
+import { validOperators } from '../../../../types/constants';
const validateWhereQuery = (whereQuery): whereQuery is Where => {
if (whereQuery?.or?.length > 0 && whereQuery?.or?.[0]?.and && whereQuery?.or?.[0]?.and?.length > 0) {
- return true;
+ // At this point we know that the whereQuery has 'or' and 'and' fields,
+ // now let's check the structure and content of these fields.
+
+ const isValid = whereQuery.or.every((orQuery) => {
+ if (orQuery.and && Array.isArray(orQuery.and)) {
+ return orQuery.and.every((andQuery) => {
+ if (typeof andQuery !== 'object') {
+ return false;
+ }
+ const andKeys = Object.keys(andQuery);
+ // If there are no keys, it's not a valid WhereField.
+ if (andKeys.length === 0) {
+ return false;
+ }
+ // eslint-disable-next-line no-restricted-syntax
+ for (const key of andKeys) {
+ const operator = Object.keys(andQuery[key])[0];
+ // Check if the key is a valid Operator.
+ if (!operator || !validOperators.includes(operator as Operator)) {
+ return false;
+ }
+ }
+ return true;
+ });
+ }
+ return false;
+ });
+
+ return isValid;
}
return false;
diff --git a/src/admin/components/forms/field-types/Checkbox/Input.tsx b/src/admin/components/forms/field-types/Checkbox/Input.tsx
index 8532a8c609..040cb82148 100644
--- a/src/admin/components/forms/field-types/Checkbox/Input.tsx
+++ b/src/admin/components/forms/field-types/Checkbox/Input.tsx
@@ -1,62 +1,74 @@
import React from 'react';
import Check from '../../../icons/Check';
import Label from '../../Label';
+import Line from '../../../icons/Line';
import './index.scss';
const baseClass = 'custom-checkbox';
type CheckboxInputProps = {
- onToggle: React.MouseEventHandler
+ onToggle: React.FormEventHandler
inputRef?: React.MutableRefObject
readOnly?: boolean
checked?: boolean
+ partialChecked?: boolean
name?: string
id?: string
label?: string
+ 'aria-label'?: string
required?: boolean
}
+
export const CheckboxInput: React.FC = (props) => {
const {
onToggle,
checked,
+ partialChecked,
inputRef,
name,
id,
label,
+ 'aria-label': ariaLabel,
readOnly,
required,
} = props;
return (
-
-
-
-
+ )}
+
);
};
diff --git a/src/admin/components/forms/field-types/Checkbox/index.scss b/src/admin/components/forms/field-types/Checkbox/index.scss
index 9f295af29c..8bada90490 100644
--- a/src/admin/components/forms/field-types/Checkbox/index.scss
+++ b/src/admin/components/forms/field-types/Checkbox/index.scss
@@ -4,10 +4,6 @@
position: relative;
margin-bottom: $baseline;
- input[type=checkbox] {
- display: none;
- }
-
.tooltip:not([aria-hidden="true"]) {
right: auto;
position: relative;
@@ -22,16 +18,11 @@
.custom-checkbox {
+ display: inline-flex;
+
label {
padding-bottom: 0;
- }
-
- input {
- // hidden HTML checkbox
- position: absolute;
- top: 0;
- left: 0;
- opacity: 0;
+ padding-left: base(.5);
}
[dir=rtl] &__input {
margin-right: 0;
@@ -39,19 +30,76 @@
}
&__input {
- // visible checkbox
@include formInput;
+ display: flex;
padding: 0;
line-height: 0;
position: relative;
width: $baseline;
height: $baseline;
- margin-right: base(.5);
+
+ & input[type="checkbox"] {
+ position: absolute;
+ // Without the extra 4px, there is an uncheckable area due to the border of the parent element
+ width: calc(100% + 4px);
+ height: calc(100% + 4px);
+ padding: 0;
+ margin: 0;
+ margin-left: -2px;
+ margin-top: -2px;
+ opacity: 0;
+ border-radius: 0;
+ z-index: 1;
+ cursor: pointer;
+ }
+ }
+
+ &__icon {
+ position: absolute;
svg {
opacity: 0;
}
}
+
+
+ &:not(&--read-only) {
+ &:active,
+ &:focus-within,
+ &:focus {
+ .custom-checkbox__input, & input[type="checkbox"] {
+ @include inputShadowActive;
+
+ outline: 0;
+ box-shadow: 0 0 3px 3px var(--theme-success-400)!important;
+ border: 1px solid var(--theme-elevation-150);
+ }
+ }
+
+ &:hover {
+ .custom-checkbox__input, & input[type="checkbox"] {
+ border-color: var(--theme-elevation-250);
+ }
+ }
+ }
+
+ &:not(&--read-only):not(&--checked) {
+ &:hover {
+ cursor: pointer;
+
+ svg {
+ opacity: 0.2;
+ }
+ }
+ }
+
+ &--checked {
+ .custom-checkbox__icon {
+ svg {
+ opacity: 1;
+ }
+ }
+ }
&--read-only {
.custom-checkbox__input {
@@ -62,40 +110,6 @@
color: var(--theme-elevation-400);
}
}
-
- button {
- @extend %btn-reset;
- display: flex;
- align-items: center;
- cursor: pointer;
-
- &:focus,
- &:active {
- outline: none;
- }
-
- &:focus {
- .custom-checkbox__input {
- box-shadow: 0 0 3px 3px var(--theme-success-400);
- }
- }
-
- &:hover {
- svg {
- opacity: .2;
- }
- }
- }
-
- &--checked {
- button {
- .custom-checkbox__input {
- svg {
- opacity: 1;
- }
- }
- }
- }
}
html[data-theme=light] {
diff --git a/src/admin/components/forms/field-types/Checkbox/index.tsx b/src/admin/components/forms/field-types/Checkbox/index.tsx
index 1fdb7785d0..be798ad00c 100644
--- a/src/admin/components/forms/field-types/Checkbox/index.tsx
+++ b/src/admin/components/forms/field-types/Checkbox/index.tsx
@@ -88,6 +88,7 @@ const Checkbox: React.FC
= (props) => {
label={getTranslation(label || name, i18n)}
name={path}
checked={Boolean(value)}
+ readOnly={readOnly}
/>
= (props) => {
const {
@@ -143,9 +143,17 @@ const NumberField: React.FC = (props) => {
isMulti
isSortable
isClearable
+ noOptionsMessage={({ inputValue }) => {
+ const isOverHasMany = Array.isArray(value) && value.length >= maxRows;
+ if (isOverHasMany) {
+ return t('validation:limitReached', { value: value.length + 1, max: maxRows });
+ }
+ return t('general:noOptions');
+ }}
filterOption={(option, rawInput) => {
// eslint-disable-next-line no-restricted-globals
- return isNumber(rawInput)
+ const isOverHasMany = Array.isArray(value) && value.length >= maxRows;
+ return isNumber(rawInput) && !isOverHasMany;
}}
numberOnly
/>
diff --git a/src/admin/components/forms/field-types/Relationship/select-components/MultiValueLabel/index.tsx b/src/admin/components/forms/field-types/Relationship/select-components/MultiValueLabel/index.tsx
index 5aadcd34fc..9b7fb300b0 100644
--- a/src/admin/components/forms/field-types/Relationship/select-components/MultiValueLabel/index.tsx
+++ b/src/admin/components/forms/field-types/Relationship/select-components/MultiValueLabel/index.tsx
@@ -6,6 +6,7 @@ import Tooltip from '../../../../../elements/Tooltip';
import Edit from '../../../../../icons/Edit';
import { useAuth } from '../../../../../utilities/Auth';
import { Option } from '../../types';
+
import './index.scss';
const baseClass = 'relationship--multi-value-label';
diff --git a/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.tsx
index 6a6195c099..b3c03f9712 100644
--- a/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.tsx
+++ b/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.tsx
@@ -6,11 +6,11 @@ import FormSubmit from '../../../../../Submit';
import { Props } from './types';
import fieldTypes from '../../../..';
import RenderFields from '../../../../../RenderFields';
-
-import './index.scss';
import useHotkey from '../../../../../../../hooks/useHotkey';
import { useEditDepth } from '../../../../../../utilities/EditDepth';
+import './index.scss';
+
const baseClass = 'rich-text-link-edit-modal';
export const LinkDrawer: React.FC = ({
diff --git a/src/admin/components/views/Account/Default.tsx b/src/admin/components/views/Account/Default.tsx
index 1ea8c0df67..662da8a3ac 100644
--- a/src/admin/components/views/Account/Default.tsx
+++ b/src/admin/components/views/Account/Default.tsx
@@ -130,9 +130,11 @@ const DefaultAccount: React.FC = (props) => {
{t('general:payloadSettings')}
(language.value === i18n.language))}
options={languageOptions}
onChange={({ value }) => (i18n.changeLanguage(value))}
diff --git a/src/admin/components/views/Dashboard/Default.tsx b/src/admin/components/views/Dashboard/Default.tsx
index 056d638920..b4b3836c97 100644
--- a/src/admin/components/views/Dashboard/Default.tsx
+++ b/src/admin/components/views/Dashboard/Default.tsx
@@ -24,7 +24,7 @@ const Dashboard: React.FC = (props) => {
} = props;
const { push } = useHistory();
- const { i18n } = useTranslation('general');
+ const { t, i18n } = useTranslation('general');
const {
routes: {
@@ -77,12 +77,14 @@ const Dashboard: React.FC = (props) => {
{entities.map(({ entity, type }, entityIndex) => {
let title: string;
+ let buttonAriaLabel: string;
let createHREF: string;
let onClick: () => void;
let hasCreatePermission: boolean;
if (type === EntityType.collection) {
title = getTranslation(entity.labels.plural, i18n);
+ buttonAriaLabel = t('showAllLabel', { label: title });
onClick = () => push({ pathname: `${admin}/collections/${entity.slug}` });
createHREF = `${admin}/collections/${entity.slug}/create`;
hasCreatePermission = permissions?.collections?.[entity.slug]?.create?.permission;
@@ -90,6 +92,7 @@ const Dashboard: React.FC = (props) => {
if (type === EntityType.global) {
title = getTranslation(entity.label, i18n);
+ buttonAriaLabel = t('editLabel', { label: getTranslation(entity.label, i18n) });
onClick = () => push({ pathname: `${admin}/globals/${entity.slug}` });
}
@@ -97,8 +100,10 @@ const Dashboard: React.FC = (props) => {
-
= (props) => {
round
buttonStyle="icon-label"
iconStyle="with-border"
+ aria-label={t('createNewLabel', { label: getTranslation(entity.labels.singular, i18n) })}
/>
) : undefined}
/>
diff --git a/src/admin/components/views/ForgotPassword/index.tsx b/src/admin/components/views/ForgotPassword/index.tsx
index 83611870f0..293c115a65 100644
--- a/src/admin/components/views/ForgotPassword/index.tsx
+++ b/src/admin/components/views/ForgotPassword/index.tsx
@@ -11,7 +11,6 @@ import FormSubmit from '../../forms/Submit';
import Button from '../../elements/Button';
import Meta from '../../utilities/Meta';
-
import './index.scss';
const baseClass = 'forgot-password';
diff --git a/src/admin/components/views/ResetPassword/index.tsx b/src/admin/components/views/ResetPassword/index.tsx
index 5ea2518736..9e5bf5ee29 100644
--- a/src/admin/components/views/ResetPassword/index.tsx
+++ b/src/admin/components/views/ResetPassword/index.tsx
@@ -13,7 +13,6 @@ import Button from '../../elements/Button';
import Meta from '../../utilities/Meta';
import HiddenInput from '../../forms/field-types/HiddenInput';
-
import './index.scss';
const baseClass = 'reset-password';
diff --git a/src/admin/components/views/collections/List/Default.tsx b/src/admin/components/views/collections/List/Default.tsx
index c0190272b4..eb32be8448 100644
--- a/src/admin/components/views/collections/List/Default.tsx
+++ b/src/admin/components/views/collections/List/Default.tsx
@@ -100,7 +100,10 @@ const DefaultList: React.FC = (props) => {
{getTranslation(pluralLabel, i18n)}
{hasCreatePermission && (
-
+
{t('createNew')}
)}
diff --git a/src/admin/components/views/collections/List/SelectAll/index.scss b/src/admin/components/views/collections/List/SelectAll/index.scss
deleted file mode 100644
index 573462a888..0000000000
--- a/src/admin/components/views/collections/List/SelectAll/index.scss
+++ /dev/null
@@ -1,34 +0,0 @@
-@import '../../../../../scss/styles.scss';
-
-.select-all {
- button {
- @extend %btn-reset;
- display: flex;
- align-items: center;
- cursor: pointer;
-
- &:focus:not(:focus-visible),
- &:active {
- outline: none;
- }
-
- &:hover {
- svg {
- opacity: .2;
- }
- }
-
- &:focus-visible {
- outline-offset: var(--accessibility-outline-offset);
- }
- }
-
- &__input {
- @include formInput;
- padding: 0;
- line-height: 0;
- position: relative;
- width: $baseline;
- height: $baseline;
- }
-}
diff --git a/src/admin/components/views/collections/List/SelectAll/index.tsx b/src/admin/components/views/collections/List/SelectAll/index.tsx
index 18b0b3dcb3..bcc3e373e8 100644
--- a/src/admin/components/views/collections/List/SelectAll/index.tsx
+++ b/src/admin/components/views/collections/List/SelectAll/index.tsx
@@ -1,31 +1,22 @@
import React from 'react';
+import { useTranslation } from 'react-i18next';
+
import { SelectAllStatus, useSelection } from '../SelectionProvider';
-import Check from '../../../../icons/Check';
-import Line from '../../../../icons/Line';
-import './index.scss';
-
-const baseClass = 'select-all';
+import { CheckboxInput } from '../../../../forms/field-types/Checkbox/Input';
const SelectAll: React.FC = () => {
+ const { t } = useTranslation('general');
const { selectAll, toggleAll } = useSelection();
return (
-
-
-
+ toggleAll()}
+ />
);
};
diff --git a/src/admin/components/views/collections/List/SelectRow/index.tsx b/src/admin/components/views/collections/List/SelectRow/index.tsx
index acae082fe7..f879c62bcc 100644
--- a/src/admin/components/views/collections/List/SelectRow/index.tsx
+++ b/src/admin/components/views/collections/List/SelectRow/index.tsx
@@ -1,31 +1,17 @@
import React from 'react';
import { useSelection } from '../SelectionProvider';
-import Check from '../../../../icons/Check';
+import { CheckboxInput } from '../../../../forms/field-types/Checkbox/Input';
import './index.scss';
-const baseClass = 'select-row';
-
const SelectRow: React.FC<{ id: string | number }> = ({ id }) => {
const { selected, setSelection } = useSelection();
return (
-
-
-
+ setSelection(id)}
+ />
);
};
diff --git a/src/admin/components/views/collections/List/index.tsx b/src/admin/components/views/collections/List/index.tsx
index 6b03c65348..10f9b71c0c 100644
--- a/src/admin/components/views/collections/List/index.tsx
+++ b/src/admin/components/views/collections/List/index.tsx
@@ -16,6 +16,12 @@ import { useSearchParams } from '../../../utilities/SearchParams';
import { TableColumnsProvider } from '../../../elements/TableColumns';
import type { Field } from '../../../../../fields/config/types';
+/**
+ * The ListView component is table which lists the collection's documents.
+ * The default list view can be found at the {@link DefaultList} component.
+ * Users can also create pass their own custom list view component instead
+ * of using the default one.
+ */
const ListView: React.FC = (props) => {
const {
collection,
diff --git a/src/auth/operations/forgotPassword.ts b/src/auth/operations/forgotPassword.ts
index dfc0187554..8b51550cd1 100644
--- a/src/auth/operations/forgotPassword.ts
+++ b/src/auth/operations/forgotPassword.ts
@@ -2,6 +2,7 @@ import crypto from 'crypto';
import { APIError } from '../../errors';
import { PayloadRequest } from '../../express/types';
import { Collection } from '../../collections/config/types';
+import { buildAfterOperation } from '../../collections/operations/utils';
export type Arguments = {
collection: Collection
@@ -134,6 +135,16 @@ async function forgotPassword(incomingArgs: Arguments): Promise {
await hook({ args, context: req.context });
}, Promise.resolve());
+ // /////////////////////////////////////
+ // afterOperation - Collection
+ // /////////////////////////////////////
+
+ token = await buildAfterOperation({
+ operation: 'forgotPassword',
+ args,
+ result: token,
+ });
+
return token;
}
diff --git a/src/auth/operations/login.ts b/src/auth/operations/login.ts
index bc2746de69..4b46a6109a 100644
--- a/src/auth/operations/login.ts
+++ b/src/auth/operations/login.ts
@@ -10,6 +10,7 @@ import { User } from '../types';
import { Collection } from '../../collections/config/types';
import { afterRead } from '../../fields/hooks/afterRead';
import unlock from './unlock';
+import { buildAfterOperation } from '../../collections/operations/utils';
import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts';
import { authenticateLocalStrategy } from '../strategies/local/authenticate';
import { getFieldsToSign } from './getFieldsToSign';
@@ -240,16 +241,29 @@ async function login(
}) || user;
}, Promise.resolve());
- // /////////////////////////////////////
- // Return results
- // /////////////////////////////////////
- if (shouldCommit) await payload.db.commitTransaction(req.transactionID);
-
- return {
+ let result: Result & { user: GeneratedTypes['collections'][TSlug] } = {
token,
user,
exp: (jwt.decode(token) as jwt.JwtPayload).exp,
};
+
+ // /////////////////////////////////////
+ // afterOperation - Collection
+ // /////////////////////////////////////
+
+ result = await buildAfterOperation({
+ operation: 'login',
+ args,
+ result,
+ });
+
+ // /////////////////////////////////////
+ // Return results
+ // /////////////////////////////////////
+
+ if (shouldCommit) await payload.db.commitTransaction(req.transactionID);
+
+ return result;
} catch (error: unknown) {
await killTransaction(req);
throw error;
diff --git a/src/auth/operations/refresh.ts b/src/auth/operations/refresh.ts
index aef6896606..6ae63854ed 100644
--- a/src/auth/operations/refresh.ts
+++ b/src/auth/operations/refresh.ts
@@ -1,11 +1,12 @@
+import url from 'url';
import jwt from 'jsonwebtoken';
import { Response } from 'express';
-import url from 'url';
import { Collection, BeforeOperationHook } from '../../collections/config/types';
import { Forbidden } from '../../errors';
import getCookieExpiration from '../../utilities/getCookieExpiration';
import { Document } from '../../types';
import { PayloadRequest } from '../../express/types';
+import { buildAfterOperation } from '../../collections/operations/utils';
import { getFieldsToSign } from './getFieldsToSign';
export type Result = {
@@ -97,7 +98,7 @@ async function refresh(incomingArgs: Arguments): Promise {
args.res.cookie(`${config.cookiePrefix}-token`, refreshedToken, cookieOptions);
}
- let response: Result = {
+ let result: Result = {
user,
refreshedToken,
exp,
@@ -110,20 +111,31 @@ async function refresh(incomingArgs: Arguments): Promise {
await collectionConfig.hooks.afterRefresh.reduce(async (priorHook, hook) => {
await priorHook;
- response = (await hook({
+ result = (await hook({
req: args.req,
res: args.res,
exp,
token: refreshedToken,
context: args.req.context,
- })) || response;
+ })) || result;
}, Promise.resolve());
+
+ // /////////////////////////////////////
+ // afterOperation - Collection
+ // /////////////////////////////////////
+
+ result = await buildAfterOperation({
+ operation: 'refresh',
+ args,
+ result,
+ });
+
// /////////////////////////////////////
// Return results
// /////////////////////////////////////
- return response;
+ return result;
}
export default refresh;
diff --git a/src/bin/index.ts b/src/bin/index.ts
index a85bf7d863..ff12d162f0 100755
--- a/src/bin/index.ts
+++ b/src/bin/index.ts
@@ -17,7 +17,7 @@ const swcOptions = {
tsx: true,
},
paths: undefined,
- baseUrl: __dirname,
+ baseUrl: path.resolve(),
},
module: {
type: 'commonjs',
diff --git a/src/collections/config/defaults.ts b/src/collections/config/defaults.ts
index fde30c9eca..7972c72c36 100644
--- a/src/collections/config/defaults.ts
+++ b/src/collections/config/defaults.ts
@@ -29,6 +29,7 @@ export const defaults = {
afterRead: [],
beforeDelete: [],
afterDelete: [],
+ afterOperation: [],
beforeLogin: [],
afterLogin: [],
afterLogout: [],
diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts
index 0e089815d6..984c28f2fe 100644
--- a/src/collections/config/schema.ts
+++ b/src/collections/config/schema.ts
@@ -103,6 +103,7 @@ const collectionSchema = joi.object().keys({
afterRead: joi.array().items(joi.func()),
beforeDelete: joi.array().items(joi.func()),
afterDelete: joi.array().items(joi.func()),
+ afterOperation: joi.array().items(joi.func()),
beforeLogin: joi.array().items(joi.func()),
afterLogin: joi.array().items(joi.func()),
afterLogout: joi.array().items(joi.func()),
diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts
index 79451ce2fa..2567ced913 100644
--- a/src/collections/config/types.ts
+++ b/src/collections/config/types.ts
@@ -15,6 +15,7 @@ import {
CustomSaveButtonProps,
CustomSaveDraftButtonProps,
} from '../../admin/components/elements/types';
+import { AfterOperationArg, AfterOperationMap } from '../operations/utils';
import type { Props as ListProps } from '../../admin/components/views/collections/List/types';
import type { Props as EditProps } from '../../admin/components/views/collections/Edit/types';
@@ -110,6 +111,13 @@ export type AfterDeleteHook = (args: {
context: RequestContext;
}) => any;
+
+export type AfterOperationHook<
+ T extends TypeWithID = any,
+> = (
+ arg: AfterOperationArg,
+ ) => Promise[keyof AfterOperationMap]>>;
+
export type AfterErrorHook = (err: Error, res: unknown, context: RequestContext) => { response: any, status: number } | void;
export type BeforeLoginHook = (args: {
@@ -301,6 +309,7 @@ export type CollectionConfig = {
afterMe?: AfterMeHook[];
afterRefresh?: AfterRefreshHook[];
afterForgotPassword?: AfterForgotPasswordHook[];
+ afterOperation?: AfterOperationHook[];
};
/**
* Custom rest api endpoints, set false to disable all rest endpoints for this collection.
diff --git a/src/collections/operations/create.ts b/src/collections/operations/create.ts
index 45e7b05568..e5f51365fa 100644
--- a/src/collections/operations/create.ts
+++ b/src/collections/operations/create.ts
@@ -22,13 +22,16 @@ import { afterRead } from '../../fields/hooks/afterRead';
import { generateFileData } from '../../uploads/generateFileData';
import { saveVersion } from '../../versions/saveVersion';
import { mapAsync } from '../../utilities/mapAsync';
+import { buildAfterOperation } from './utils';
import { registerLocalStrategy } from '../../auth/strategies/local/register';
import { initTransaction } from '../../utilities/initTransaction';
import { killTransaction } from '../../utilities/killTransaction';
const unlinkFile = promisify(fs.unlink);
-export type Arguments = {
+export type CreateUpdateType = { [field: string | number | symbol]: unknown }
+
+export type Arguments = {
collection: Collection
req: PayloadRequest
depth?: number
@@ -349,6 +352,17 @@ async function create(
}) || result;
}, Promise.resolve());
+ // /////////////////////////////////////
+ // afterOperation - Collection
+ // /////////////////////////////////////
+
+ result = await buildAfterOperation({
+ operation: 'create',
+ args,
+ result,
+ });
+
+
// Remove temp files if enabled, as express-fileupload does not do this automatically
if (config.upload?.useTempFiles && collectionConfig.upload) {
const { files } = req;
diff --git a/src/collections/operations/delete.ts b/src/collections/operations/delete.ts
index c3d7bb4c61..26dd793030 100644
--- a/src/collections/operations/delete.ts
+++ b/src/collections/operations/delete.ts
@@ -14,6 +14,7 @@ import { validateQueryPaths } from '../../database/queryValidation/validateQuery
import { combineQueries } from '../../database/combineQueries';
import { initTransaction } from '../../utilities/initTransaction';
import { killTransaction } from '../../utilities/killTransaction';
+import { buildAfterOperation } from './utils';
export type Arguments = {
depth?: number
@@ -247,12 +248,24 @@ async function deleteOperation({
+ operation: 'delete',
+ args,
+ result,
+ });
+
+ if (shouldCommit) await payload.db.commitTransaction(req.transactionID);
+
+ return result;
} catch (error: unknown) {
await killTransaction(req);
throw error;
diff --git a/src/collections/operations/deleteByID.ts b/src/collections/operations/deleteByID.ts
index 807811219b..b01644533c 100644
--- a/src/collections/operations/deleteByID.ts
+++ b/src/collections/operations/deleteByID.ts
@@ -12,6 +12,7 @@ import { combineQueries } from '../../database/combineQueries';
import { deleteUserPreferences } from '../../preferences/deleteUserPreferences';
import { killTransaction } from '../../utilities/killTransaction';
import { initTransaction } from '../../utilities/initTransaction';
+import { buildAfterOperation } from './utils';
export type Arguments = {
depth?: number
@@ -192,6 +193,16 @@ async function deleteByID(inc
}) || result;
}, Promise.resolve());
+ // /////////////////////////////////////
+ // afterOperation - Collection
+ // /////////////////////////////////////
+
+ result = await buildAfterOperation({
+ operation: 'deleteByID',
+ args,
+ result,
+ });
+
// /////////////////////////////////////
// 8. Return results
// /////////////////////////////////////
diff --git a/src/collections/operations/find.ts b/src/collections/operations/find.ts
index e31e797ffc..5ee5feaa85 100644
--- a/src/collections/operations/find.ts
+++ b/src/collections/operations/find.ts
@@ -11,6 +11,7 @@ import { buildVersionCollectionFields } from '../../versions/buildCollectionFiel
import { combineQueries } from '../../database/combineQueries';
import { initTransaction } from '../../utilities/initTransaction';
import { killTransaction } from '../../utilities/killTransaction';
+import { buildAfterOperation } from './utils';
export type Arguments = {
collection: Collection
@@ -234,6 +235,16 @@ async function find>(
})),
};
+ // /////////////////////////////////////
+ // afterOperation - Collection
+ // /////////////////////////////////////
+
+ result = await buildAfterOperation({
+ operation: 'find',
+ args,
+ result,
+ });
+
// /////////////////////////////////////
// Return results
// /////////////////////////////////////
diff --git a/src/collections/operations/findByID.ts b/src/collections/operations/findByID.ts
index e628c842c5..b3eb8d9838 100644
--- a/src/collections/operations/findByID.ts
+++ b/src/collections/operations/findByID.ts
@@ -10,6 +10,7 @@ import { combineQueries } from '../../database/combineQueries';
import type { FindOneArgs } from '../../database/types';
import { initTransaction } from '../../utilities/initTransaction';
import { killTransaction } from '../../utilities/killTransaction';
+import { buildAfterOperation } from './utils';
export type Arguments = {
collection: Collection
@@ -192,6 +193,16 @@ async function findByID(
}) || result;
}, Promise.resolve());
+ // /////////////////////////////////////
+ // afterOperation - Collection
+ // /////////////////////////////////////
+
+ result = await buildAfterOperation({
+ operation: 'findByID',
+ args,
+ result: result as any,
+ }); // TODO: fix this typing
+
// /////////////////////////////////////
// Return results
// /////////////////////////////////////
diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts
index 2e883a1188..165cf068c2 100644
--- a/src/collections/operations/update.ts
+++ b/src/collections/operations/update.ts
@@ -22,8 +22,10 @@ import { appendVersionToQueryKey } from '../../versions/drafts/appendVersionToQu
import { buildVersionCollectionFields } from '../../versions/buildCollectionFields';
import { initTransaction } from '../../utilities/initTransaction';
import { killTransaction } from '../../utilities/killTransaction';
+import { CreateUpdateType } from './create';
+import { buildAfterOperation } from './utils';
-export type Arguments = {
+export type Arguments = {
collection: Collection
req: PayloadRequest
where: Where
@@ -361,6 +363,7 @@ async function update(
collectionConfig,
});
+
// /////////////////////////////////////
// Return results
// /////////////////////////////////////
@@ -377,12 +380,24 @@ async function update(
const awaitedDocs = await Promise.all(promises);
- if (shouldCommit) await payload.db.commitTransaction(req.transactionID);
-
- return {
+ let result = {
docs: awaitedDocs.filter(Boolean),
errors,
};
+
+ // /////////////////////////////////////
+ // afterOperation - Collection
+ // /////////////////////////////////////
+
+ result = await buildAfterOperation({
+ operation: 'update',
+ args,
+ result,
+ });
+
+ if (shouldCommit) await payload.db.commitTransaction(req.transactionID);
+
+ return result;
} catch (error: unknown) {
await killTransaction(req);
throw error;
diff --git a/src/collections/operations/updateByID.ts b/src/collections/operations/updateByID.ts
index 56e22c241b..ae57a9da49 100644
--- a/src/collections/operations/updateByID.ts
+++ b/src/collections/operations/updateByID.ts
@@ -16,6 +16,7 @@ import { generateFileData } from '../../uploads/generateFileData';
import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion';
import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles';
import { unlinkTempFiles } from '../../uploads/unlinkTempFiles';
+import { buildAfterOperation } from './utils';
import { generatePasswordSaltHash } from '../../auth/strategies/local/generatePasswordSaltHash';
import { combineQueries } from '../../database/combineQueries';
import type { FindOneArgs } from '../../database/types';
@@ -341,6 +342,16 @@ async function updateByID(
}) || result;
}, Promise.resolve());
+ // /////////////////////////////////////
+ // afterOperation - Collection
+ // /////////////////////////////////////
+
+ result = await buildAfterOperation({
+ operation: 'updateByID',
+ args,
+ result,
+ });
+
await unlinkTempFiles({
req,
config,
diff --git a/src/collections/operations/utils.ts b/src/collections/operations/utils.ts
new file mode 100644
index 0000000000..d27974c77d
--- /dev/null
+++ b/src/collections/operations/utils.ts
@@ -0,0 +1,71 @@
+import find from './find';
+import update from './update';
+import deleteOperation from './delete';
+import create from './create';
+import login from '../../auth/operations/login';
+import refresh from '../../auth/operations/refresh';
+import findByID from './findByID';
+import updateByID from './updateByID';
+import deleteByID from './deleteByID';
+import { AfterOperationHook, TypeWithID } from '../config/types';
+import forgotPassword from '../../auth/operations/forgotPassword';
+
+export type AfterOperationMap<
+ T extends TypeWithID,
+> = {
+ create: typeof create, // todo: pass correct generic
+ find: typeof find,
+ findByID: typeof findByID,
+ update: typeof update, // todo: pass correct generic
+ updateByID: typeof updateByID, // todo: pass correct generic
+ delete: typeof deleteOperation, // todo: pass correct generic
+ deleteByID: typeof deleteByID, // todo: pass correct generic
+ login: typeof login,
+ refresh: typeof refresh,
+ forgotPassword: typeof forgotPassword,
+}
+export type AfterOperationArg =
+ | { operation: 'create'; result: Awaited['create']>>, args: Parameters['create']>[0] }
+ | { operation: 'find'; result: Awaited['find']>>, args: Parameters['find']>[0] }
+ | { operation: 'findByID'; result: Awaited['findByID']>>, args: Parameters['findByID']>[0] }
+ | { operation: 'update'; result: Awaited['update']>>, args: Parameters['update']>[0] }
+ | { operation: 'updateByID'; result: Awaited['updateByID']>>, args: Parameters['updateByID']>[0] }
+ | { operation: 'delete'; result: Awaited['delete']>>, args: Parameters['delete']>[0] }
+ | { operation: 'deleteByID'; result: Awaited['deleteByID']>>, args: Parameters['deleteByID']>[0] }
+ | { operation: 'login'; result: Awaited['login']>>, args: Parameters['login']>[0] }
+ | { operation: 'refresh'; result: Awaited['refresh']>>, args: Parameters['refresh']>[0] }
+ | { operation: 'forgotPassword'; result: Awaited['forgotPassword']>>, args: Parameters['forgotPassword']>[0] };
+
+// export type AfterOperationHook = typeof buildAfterOperation;
+
+export const buildAfterOperation = async <
+ T extends TypeWithID = any,
+ O extends keyof AfterOperationMap = keyof AfterOperationMap
+>
+(
+ operationArgs: AfterOperationArg & { operation: O },
+): Promise[O]>>> => {
+ const {
+ operation,
+ args,
+ result,
+ } = operationArgs;
+
+ let newResult = result;
+
+ await args.collection.config.hooks.afterOperation.reduce(async (priorHook, hook: AfterOperationHook) => {
+ await priorHook;
+
+ const hookResult = await hook({
+ operation,
+ args,
+ result: newResult,
+ } as AfterOperationArg);
+
+ if (hookResult !== undefined) {
+ newResult = hookResult;
+ }
+ }, Promise.resolve());
+
+ return newResult;
+};
diff --git a/src/config/sanitize.ts b/src/config/sanitize.ts
index 8c54ffcc63..efc3710d7b 100644
--- a/src/config/sanitize.ts
+++ b/src/config/sanitize.ts
@@ -11,48 +11,47 @@ import getPreferencesCollection from '../preferences/preferencesCollection';
import { migrationsCollection } from '../database/migrations/migrationsCollection';
import getDefaultBundler from '../bundlers/webpack/bundler';
-const sanitizeAdmin = (config: SanitizedConfig): SanitizedConfig['admin'] => {
- const adminConfig = config.admin;
+const sanitizeAdminConfig = (configToSanitize: Config): Partial => {
+ const sanitizedConfig = { ...configToSanitize };
// add default user collection if none provided
- if (!adminConfig?.user) {
- const firstCollectionWithAuth = config.collections.find(({ auth }) => Boolean(auth));
+ if (!sanitizedConfig?.admin?.user) {
+ const firstCollectionWithAuth = sanitizedConfig.collections.find(({ auth }) => Boolean(auth));
if (firstCollectionWithAuth) {
- adminConfig.user = firstCollectionWithAuth.slug;
+ sanitizedConfig.admin.user = firstCollectionWithAuth.slug;
} else {
- adminConfig.user = 'users';
- const sanitizedDefaultUser = sanitizeCollection(config, defaultUserCollection);
- config.collections.push(sanitizedDefaultUser);
+ sanitizedConfig.admin.user = defaultUserCollection.slug;
+ sanitizedConfig.collections.push(defaultUserCollection);
}
}
- if (!config.collections.find(({ slug }) => slug === adminConfig.user)) {
- throw new InvalidConfiguration(`${config.admin.user} is not a valid admin user collection`);
+ if (!sanitizedConfig.collections.find(({ slug }) => slug === sanitizedConfig.admin.user)) {
+ throw new InvalidConfiguration(`${sanitizedConfig.admin.user} is not a valid admin user collection`);
}
// add default bundler if none provided
- if (!adminConfig.bundler) {
- adminConfig.bundler = getDefaultBundler();
+ if (!sanitizedConfig.admin.bundler) {
+ sanitizedConfig.admin.bundler = getDefaultBundler();
}
- return adminConfig;
+ return sanitizedConfig as Partial;
};
-export const sanitizeConfig = (config: Config): SanitizedConfig => {
- const sanitizedConfig: Config = merge(defaults, config, {
+export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => {
+ const configWithDefaults: Config = merge(defaults, incomingConfig, {
isMergeableObject: isPlainObject,
}) as Config;
- sanitizedConfig.admin = sanitizeAdmin(sanitizedConfig as SanitizedConfig);
+ const config: Partial = sanitizeAdminConfig(configWithDefaults);
- if (sanitizedConfig.localization && sanitizedConfig.localization.locales?.length > 0) {
+ if (config.localization && config.localization.locales?.length > 0) {
// clone localization config so to not break everything
- const firstLocale = sanitizedConfig.localization.locales[0];
+ const firstLocale = config.localization.locales[0];
if (typeof firstLocale === 'string') {
- (sanitizedConfig.localization as SanitizedLocalizationConfig).localeCodes = [...(sanitizedConfig.localization as LocalizationConfigWithNoLabels).locales];
+ (config.localization as SanitizedLocalizationConfig).localeCodes = [...(config.localization as unknown as LocalizationConfigWithNoLabels).locales];
// is string[], so convert to Locale[]
- (sanitizedConfig.localization as SanitizedLocalizationConfig).locales = (sanitizedConfig.localization as LocalizationConfigWithNoLabels).locales.map((locale) => ({
+ (config.localization as SanitizedLocalizationConfig).locales = (config.localization as unknown as LocalizationConfigWithNoLabels).locales.map((locale) => ({
label: locale,
code: locale,
rtl: false,
@@ -60,35 +59,36 @@ export const sanitizeConfig = (config: Config): SanitizedConfig => {
}));
} else {
// is Locale[], so convert to string[] for localeCodes
- (sanitizedConfig.localization as SanitizedLocalizationConfig).localeCodes = (sanitizedConfig.localization as SanitizedLocalizationConfig).locales.reduce((locales, locale) => {
+ (config.localization as SanitizedLocalizationConfig).localeCodes = (config.localization as SanitizedLocalizationConfig).locales.reduce((locales, locale) => {
locales.push(locale.code);
return locales;
}, [] as string[]);
- (sanitizedConfig.localization as SanitizedLocalizationConfig).locales = (sanitizedConfig.localization as LocalizationConfigWithLabels).locales.map((locale) => ({
+ (config.localization as SanitizedLocalizationConfig).locales = (config.localization as LocalizationConfigWithLabels).locales.map((locale) => ({
...locale,
toString: () => locale.code,
}));
}
}
- sanitizedConfig.collections.push(getPreferencesCollection(sanitizedConfig));
- sanitizedConfig.collections.push(migrationsCollection);
- sanitizedConfig.collections = sanitizedConfig.collections.map((collection) => sanitizeCollection(sanitizedConfig, collection));
- checkDuplicateCollections(sanitizedConfig.collections);
+ configWithDefaults.collections.push(getPreferencesCollection(configWithDefaults));
+ configWithDefaults.collections.push(migrationsCollection);
- if (sanitizedConfig.globals.length > 0) {
- sanitizedConfig.globals = sanitizeGlobals(sanitizedConfig.collections, sanitizedConfig.globals);
+ config.collections = config.collections.map((collection) => sanitizeCollection(configWithDefaults, collection));
+ checkDuplicateCollections(config.collections);
+
+ if (config.globals.length > 0) {
+ config.globals = sanitizeGlobals(config.collections, config.globals);
}
- if (typeof sanitizedConfig.serverURL === 'undefined') {
- sanitizedConfig.serverURL = '';
+ if (typeof config.serverURL === 'undefined') {
+ config.serverURL = '';
}
- if (sanitizedConfig.serverURL !== '') {
- sanitizedConfig.csrf.push(sanitizedConfig.serverURL);
+ if (config.serverURL !== '') {
+ config.csrf.push(config.serverURL);
}
- return sanitizedConfig as SanitizedConfig;
+ return config as SanitizedConfig;
};
diff --git a/src/database/queryValidation/types.ts b/src/database/queryValidation/types.ts
index 1adc876454..829f0aae7b 100644
--- a/src/database/queryValidation/types.ts
+++ b/src/database/queryValidation/types.ts
@@ -1,8 +1,6 @@
import { CollectionPermission, GlobalPermission } from '../../auth';
import { Field, FieldAffectingData, TabAsField, UIField } from '../../fields/config/types';
-export const validOperators = ['like', 'contains', 'in', 'all', 'not_in', 'greater_than_equal', 'greater_than', 'less_than_equal', 'less_than', 'not_equals', 'equals', 'exists', 'near'];
-
export type EntityPolicies = {
collections?: {
[collectionSlug: string]: CollectionPermission;
diff --git a/src/database/queryValidation/validateQueryPaths.ts b/src/database/queryValidation/validateQueryPaths.ts
index c686a70edb..03190ecaab 100644
--- a/src/database/queryValidation/validateQueryPaths.ts
+++ b/src/database/queryValidation/validateQueryPaths.ts
@@ -1,6 +1,6 @@
/* eslint-disable no-restricted-syntax */
/* eslint-disable no-await-in-loop */
-import { PayloadRequest, Where, WhereField } from '../../types';
+import { Operator, PayloadRequest, Where } from '../../types';
import QueryError from '../../errors/QueryError';
import { SanitizedCollectionConfig } from '../../collections/config/types';
import { SanitizedGlobalConfig } from '../../globals/config/types';
@@ -8,21 +8,9 @@ import flattenFields from '../../utilities/flattenTopLevelFields';
import { Field, FieldAffectingData } from '../../fields/config/types';
import { validateSearchParam } from './validateSearchParams';
import deepCopyObject from '../../utilities/deepCopyObject';
-import { EntityPolicies, validOperators } from './types';
-
-const flattenWhere = (query: Where): WhereField[] => Object.entries(query).reduce((flattenedConstraints, [key, val]) => {
- if ((key === 'and' || key === 'or') && Array.isArray(val)) {
- return [
- ...flattenedConstraints,
- ...val.map((subVal) => flattenWhere(subVal)),
- ];
- }
-
- return [
- ...flattenedConstraints,
- { [key]: val },
- ];
-}, []);
+import { EntityPolicies } from './types';
+import flattenWhereToOperators from '../flattenWhereToOperators';
+import { validOperators } from '../../types/constants';
type Args = {
where: Where
@@ -54,13 +42,13 @@ export async function validateQueryPaths({
const fields = flattenFields(versionFields || (globalConfig || collectionConfig).fields) as FieldAffectingData[];
if (typeof where === 'object') {
// const flattenedWhere = flattenWhere(where);
- const whereFields = flattenWhere(where);
+ const whereFields = flattenWhereToOperators(where);
// We need to determine if the whereKey is an AND, OR, or a schema path
const promises = [];
whereFields.map(async (constraint) => {
Object.keys(constraint).map(async (path) => {
Object.entries(constraint[path]).map(async ([operator, val]) => {
- if (validOperators.includes(operator)) {
+ if (validOperators.includes(operator as Operator)) {
promises.push(validateSearchParam({
collectionConfig: deepCopyObject(collectionConfig),
globalConfig: deepCopyObject(globalConfig),
diff --git a/src/fields/hooks/beforeChange/promise.ts b/src/fields/hooks/beforeChange/promise.ts
index 4adb440b2a..f13e3564fb 100644
--- a/src/fields/hooks/beforeChange/promise.ts
+++ b/src/fields/hooks/beforeChange/promise.ts
@@ -3,10 +3,8 @@ import merge from 'deepmerge';
import { Field, fieldAffectsData, TabAsField, tabHasName } from '../../config/types';
import { Operation } from '../../../types';
import { PayloadRequest, RequestContext } from '../../../express/types';
-import getValueWithDefault from '../../getDefaultValue';
import { traverseFields } from './traverseFields';
import { getExistingRowDoc } from './getExistingRowDoc';
-import { cloneDataFromOriginalDoc } from './cloneDataFromOriginalDoc';
type Args = {
data: Record
@@ -28,8 +26,6 @@ type Args = {
// This function is responsible for the following actions, in order:
// - Run condition
-// - Merge original document data into incoming data
-// - Compute default values for undefined fields
// - Execute field hooks
// - Validate data
// - Transform data for storage
@@ -59,26 +55,6 @@ export const promise = async ({
const operationLocale = req.locale || defaultLocale;
if (fieldAffectsData(field)) {
- if (typeof siblingData[field.name] === 'undefined') {
- // If no incoming data, but existing document data is found, merge it in
- if (typeof siblingDoc[field.name] !== 'undefined') {
- if (field.localized && typeof siblingDocWithLocales[field.name] === 'object' && siblingDocWithLocales[field.name] !== null) {
- siblingData[field.name] = cloneDataFromOriginalDoc(siblingDocWithLocales[field.name][req.locale]);
- } else {
- siblingData[field.name] = cloneDataFromOriginalDoc(siblingDoc[field.name]);
- }
-
- // Otherwise compute default value
- } else if (typeof field.defaultValue !== 'undefined') {
- siblingData[field.name] = await getValueWithDefault({
- value: siblingData[field.name],
- defaultValue: field.defaultValue,
- locale: req.locale,
- user: req.user,
- });
- }
- }
-
// skip validation if the field is localized and the incoming data is null
if (field.localized && operationLocale !== defaultLocale) {
if (['array', 'blocks'].includes(field.type) && siblingData[field.name] === null) {
diff --git a/src/fields/hooks/beforeValidate/promise.ts b/src/fields/hooks/beforeValidate/promise.ts
index 5d6e0aa8bd..5f206dc3fc 100644
--- a/src/fields/hooks/beforeValidate/promise.ts
+++ b/src/fields/hooks/beforeValidate/promise.ts
@@ -1,6 +1,9 @@
/* eslint-disable no-param-reassign */
import { PayloadRequest, RequestContext } from '../../../express/types';
import { Field, fieldAffectsData, TabAsField, tabHasName, valueIsValueWithRelation } from '../../config/types';
+import getValueWithDefault from '../../getDefaultValue';
+import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc';
+import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc';
import { traverseFields } from './traverseFields';
type Args = {
@@ -20,6 +23,8 @@ type Args = {
// - Sanitize incoming data
// - Execute field hooks
// - Execute field access control
+// - Merge original document data into incoming data
+// - Compute default values for undefined fields
export const promise = async ({
data,
@@ -189,6 +194,22 @@ export const promise = async ({
delete siblingData[field.name];
}
}
+
+ if (typeof siblingData[field.name] === 'undefined') {
+ // If no incoming data, but existing document data is found, merge it in
+ if (typeof siblingDoc[field.name] !== 'undefined') {
+ siblingData[field.name] = cloneDataFromOriginalDoc(siblingDoc[field.name]);
+
+ // Otherwise compute default value
+ } else if (typeof field.defaultValue !== 'undefined') {
+ siblingData[field.name] = await getValueWithDefault({
+ value: siblingData[field.name],
+ defaultValue: field.defaultValue,
+ locale: req.locale,
+ user: req.user,
+ });
+ }
+ }
}
// Traverse subfields
@@ -231,7 +252,7 @@ export const promise = async ({
overrideAccess,
req,
siblingData: row,
- siblingDoc: siblingDoc[field.name]?.[i] || {},
+ siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]),
context,
}));
});
@@ -258,7 +279,7 @@ export const promise = async ({
overrideAccess,
req,
siblingData: row,
- siblingDoc: siblingDoc[field.name]?.[i] || {},
+ siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]),
context,
}));
}
@@ -291,8 +312,11 @@ export const promise = async ({
let tabSiblingData;
let tabSiblingDoc;
if (tabHasName(field)) {
- tabSiblingData = typeof siblingData[field.name] === 'object' ? siblingData[field.name] : {};
- tabSiblingDoc = typeof siblingDoc[field.name] === 'object' ? siblingDoc[field.name] : {};
+ if (typeof siblingData[field.name] !== 'object') siblingData[field.name] = {};
+ if (typeof siblingDoc[field.name] !== 'object') siblingDoc[field.name] = {};
+
+ tabSiblingData = siblingData[field.name] as Record;
+ tabSiblingDoc = siblingDoc[field.name] as Record;
} else {
tabSiblingData = siblingData;
tabSiblingDoc = siblingDoc;
diff --git a/src/graphql/schema/buildWhereInputType.ts b/src/graphql/schema/buildWhereInputType.ts
index 7be5b2f160..e5c02133cc 100644
--- a/src/graphql/schema/buildWhereInputType.ts
+++ b/src/graphql/schema/buildWhereInputType.ts
@@ -15,13 +15,20 @@ import formatName from '../utilities/formatName';
import { withOperators } from './withOperators';
import fieldToSchemaMap from './fieldToWhereInputSchemaMap';
-// buildWhereInputType is similar to buildObjectType and operates
-// on a field basis with a few distinct differences.
-//
-// 1. Everything needs to be a GraphQLInputObjectType or scalar / enum
-// 2. Relationships, groups, repeaters and flex content are not
-// directly searchable. Instead, we need to build a chained pathname
-// using dot notation so MongoDB can properly search nested paths.
+/** This does as the function name suggests. It builds a where GraphQL input type
+ * for all the fields which are passed to the function.
+ * Each field has different operators which may be valid for a where input type.
+ * For example, a text field may have a "contains" operator, but a number field
+ * may not.
+ *
+ * buildWhereInputType is similar to buildObjectType and operates
+ * on a field basis with a few distinct differences.
+ *
+ * 1. Everything needs to be a GraphQLInputObjectType or scalar / enum
+ * 2. Relationships, groups, repeaters and flex content are not
+ * directly searchable. Instead, we need to build a chained pathname
+ * using dot notation so MongoDB can properly search nested paths.
+ */
const buildWhereInputType = (name: string, fields: Field[], parentName: string): GraphQLInputObjectType => {
// This is the function that builds nested paths for all
// field types with nested paths.
diff --git a/src/graphql/schema/operators.ts b/src/graphql/schema/operators.ts
index 2a2fbc1413..1c41f9d2d8 100644
--- a/src/graphql/schema/operators.ts
+++ b/src/graphql/schema/operators.ts
@@ -4,6 +4,7 @@ const operators = {
contains: ['in', 'not_in', 'all'],
comparison: ['greater_than_equal', 'greater_than', 'less_than_equal', 'less_than'],
geo: ['near'],
+ geojson: ['within', 'intersects'],
};
export default operators;
diff --git a/src/graphql/schema/withOperators.ts b/src/graphql/schema/withOperators.ts
index 3308a2b75d..6fba79f709 100644
--- a/src/graphql/schema/withOperators.ts
+++ b/src/graphql/schema/withOperators.ts
@@ -9,124 +9,211 @@ import operators from './operators';
type staticTypes = 'number' | 'text' | 'email' | 'textarea' | 'richText' | 'json' | 'code' | 'checkbox' | 'date' | 'upload' | 'point' | 'relationship'
+type dynamicTypes = 'radio' | 'select'
+
+const GeoJSONObject = new GraphQLInputObjectType({
+ name: 'GeoJSONObject',
+ fields: {
+ type: { type: GraphQLString },
+ coordinates: {
+ type: GraphQLJSON,
+ },
+ },
+});
+
type DefaultsType = {
[key in staticTypes]: {
- type: GraphQLType | ((field: FieldAffectingData, parentName: string) => GraphQLType);
- operators: string[];
+ operators: {
+ name: string;
+ type: GraphQLType | ((field: FieldAffectingData, parentName: string) => GraphQLType);
+ }[];
}
} & {
- radio: {
- type: (field: FieldAffectingData, parentName: string) => GraphQLType;
- operators: string[];
- }
- select: {
- type: (field: FieldAffectingData, parentName: string) => GraphQLType;
- operators: string[];
+ [key in dynamicTypes]: {
+ operators: {
+ name: string;
+ type: ((field: FieldAffectingData, parentName: string) => GraphQLType);
+ }[];
}
}
const defaults: DefaultsType = {
number: {
- type: (field: NumberField): GraphQLType => {
- return field?.name === 'id' ? GraphQLInt : GraphQLFloat;
- },
- operators: [...operators.equality, ...operators.comparison],
+ operators: [
+ ...[...operators.equality, ...operators.comparison].map((operator) => ({
+ name: operator,
+ type: (field: NumberField): GraphQLType => {
+ return field?.name === 'id' ? GraphQLInt : GraphQLFloat;
+ },
+ })),
+ ],
},
text: {
- type: GraphQLString,
- operators: [...operators.equality, ...operators.partial, ...operators.contains],
+ operators: [
+ ...[...operators.equality, ...operators.partial, ...operators.contains].map((operator) => ({
+ name: operator,
+ type: GraphQLString,
+ })),
+ ],
},
email: {
- type: EmailAddressResolver,
- operators: [...operators.equality, ...operators.partial, ...operators.contains],
+ operators: [
+ ...[...operators.equality, ...operators.partial, ...operators.contains].map((operator) => ({
+ name: operator,
+ type: EmailAddressResolver,
+ })),
+ ],
},
textarea: {
- type: GraphQLString,
- operators: [...operators.equality, ...operators.partial],
+ operators: [
+ ...[...operators.equality, ...operators.partial].map((operator) => ({
+ name: operator,
+ type: GraphQLString,
+ })),
+ ],
},
richText: {
- type: GraphQLJSON,
- operators: [...operators.equality, ...operators.partial],
+ operators: [
+ ...[...operators.equality, ...operators.partial].map((operator) => ({
+ name: operator,
+ type: GraphQLJSON,
+ })),
+ ],
},
json: {
- type: GraphQLJSON,
- operators: [...operators.equality, ...operators.partial],
+ operators: [
+ ...[...operators.equality, ...operators.partial, ...operators.geojson].map((operator) => ({
+ name: operator,
+ type: GraphQLJSON,
+ })),
+ ],
},
code: {
- type: GraphQLString,
- operators: [...operators.equality, ...operators.partial],
+ operators: [
+ ...[...operators.equality, ...operators.partial].map((operator) => ({
+ name: operator,
+ type: GraphQLString,
+ })),
+ ],
},
radio: {
- type: (field: RadioField, parentName): GraphQLType => new GraphQLEnumType({
- name: `${combineParentName(parentName, field.name)}_Input`,
- values: field.options.reduce((values, option) => {
- if (optionIsObject(option)) {
- return {
- ...values,
- [formatName(option.value)]: {
- value: option.value,
- },
- };
- }
+ operators: [
+ ...[...operators.equality, ...operators.partial].map((operator) => ({
+ name: operator,
+ type: (field: RadioField, parentName): GraphQLType => new GraphQLEnumType({
+ name: `${combineParentName(parentName, field.name)}_Input`,
+ values: field.options.reduce((values, option) => {
+ if (optionIsObject(option)) {
+ return {
+ ...values,
+ [formatName(option.value)]: {
+ value: option.value,
+ },
+ };
+ }
- return {
- ...values,
- [formatName(option)]: {
- value: option,
- },
- };
- }, {}),
- }),
- operators: [...operators.equality, ...operators.contains],
+ return {
+ ...values,
+ [formatName(option)]: {
+ value: option,
+ },
+ };
+ }, {}),
+ }),
+ })),
+ ],
},
date: {
- type: DateTimeResolver,
- operators: [...operators.equality, ...operators.comparison, 'like'],
+ operators: [
+ ...[...operators.equality, ...operators.comparison, 'like'].map((operator) => ({
+ name: operator,
+ type: DateTimeResolver,
+ })),
+ ],
},
point: {
- type: new GraphQLList(GraphQLFloat),
- operators: [...operators.equality, ...operators.comparison, ...operators.geo],
+ operators: [
+ ...[...operators.equality, ...operators.comparison, ...operators.geo].map((operator) => ({
+ name: operator,
+ type: new GraphQLList(GraphQLFloat),
+ })),
+ ...operators.geojson.map((operator) => ({
+ name: operator,
+ /**
+ * @example:
+ * within: {
+ * type: "Polygon",
+ * coordinates: [[
+ * [0.0, 0.0],
+ * [1.0, 1.0],
+ * [1.0, 0.0],
+ * [0.0, 0.0],
+ * ]],
+ * }
+ * @example
+ * intersects: {
+ * type: "Point",
+ * coordinates: [ 0.5, 0.5 ]
+ * }
+ */
+ type: GeoJSONObject,
+ })),
+ ],
},
relationship: {
- type: (field: RelationshipField): GraphQLType => {
- return field?.hasMany === true ? new GraphQLList(GraphQLString) : GraphQLString;
- },
- operators: [...operators.equality, ...operators.contains],
+ operators: [
+ ...[...operators.equality, ...operators.contains].map((operator) => ({
+ name: operator,
+ type: GraphQLString,
+ })),
+ ],
},
upload: {
- type: GraphQLString,
- operators: [...operators.equality],
+ operators: [
+ ...operators.equality.map((operator) => ({
+ name: operator,
+ type: GraphQLString,
+ })),
+ ],
},
checkbox: {
- type: GraphQLBoolean,
- operators: [...operators.equality],
+ operators: [
+ ...operators.equality.map((operator) => ({
+ name: operator,
+ type: GraphQLBoolean,
+ })),
+ ],
},
select: {
- type: (field: SelectField, parentName): GraphQLType => new GraphQLEnumType({
- name: `${combineParentName(parentName, field.name)}_Input`,
- values: field.options.reduce((values, option) => {
- if (typeof option === 'object' && option.value) {
- return {
- ...values,
- [formatName(option.value)]: {
- value: option.value,
- },
- };
- }
+ operators: [
+ ...[...operators.equality, ...operators.contains].map((operator) => ({
+ name: operator,
+ type: (field: SelectField, parentName): GraphQLType => new GraphQLEnumType({
+ name: `${combineParentName(parentName, field.name)}_Input`,
+ values: field.options.reduce((values, option) => {
+ if (typeof option === 'object' && option.value) {
+ return {
+ ...values,
+ [formatName(option.value)]: {
+ value: option.value,
+ },
+ };
+ }
- if (typeof option === 'string') {
- return {
- ...values,
- [option]: {
- value: option,
- },
- };
- }
+ if (typeof option === 'string') {
+ return {
+ ...values,
+ [option]: {
+ value: option,
+ },
+ };
+ }
- return values;
- }, {}),
- }),
- operators: [...operators.equality, ...operators.contains],
+ return values;
+ }, {}),
+ }),
+ })),
+ ],
},
// array: n/a
// group: n/a
@@ -137,32 +224,64 @@ const defaults: DefaultsType = {
const listOperators = ['in', 'not_in', 'all'];
+const gqlTypeCache: Record = {};
+
+/**
+ * In GraphQL, you can use "where" as an argument to filter a collection. Example:
+ * { Posts(where: { title: { equals: "Hello" } }) { text } }
+ * This function defines the operators for a field's condition in the "where" argument of the collection (it thus gets called for every field).
+ * For example, in the example above, it would control that
+ * - "equals" is a valid operator for the "title" field
+ * - the accepted type of the "equals" argument has to be a string.
+ *
+ * @param field the field for which their valid operators inside a "where" argument is being defined
+ * @param parentName the name of the parent field (if any)
+ * @returns all the operators (including their types) which can be used as a condition for a given field inside a where
+ */
export const withOperators = (field: FieldAffectingData, parentName: string): GraphQLInputObjectType => {
if (!defaults?.[field.type]) throw new Error(`Error: ${field.type} has no defaults configured.`);
const name = `${combineParentName(parentName, field.name)}_operator`;
+ // Get the default operators for the field type which are hard-coded above
const fieldOperators = [...defaults[field.type].operators];
- if (!('required' in field) || !field.required) fieldOperators.push('exists');
- const initialGqlType: GraphQLType = typeof defaults[field.type].type === 'function'
- ? defaults[field.type].type(field, parentName)
- : defaults?.[field.type].type;
+ if (!('required' in field) || !field.required) {
+ fieldOperators.push({
+ name: 'exists',
+ type: fieldOperators[0].type,
+ });
+ }
+
return new GraphQLInputObjectType({
name,
fields: fieldOperators.reduce((objectTypeFields, operator) => {
- let gqlType = initialGqlType;
+ // Get the type of the operator. It can be either static, or dynamic (=> a function)
+ let gqlType: GraphQLType = typeof operator.type === 'function'
+ ? operator.type(field, parentName)
+ : operator.type;
- if (listOperators.includes(operator)) {
+ // GraphQL does not allow types with duplicate names, so we use this cache to avoid that.
+ // Without this, select and radio fields would have the same name, and GraphQL would throw an error
+ // This usually only happens if a custom type is returned from the operator.type function
+ if (typeof operator.type === 'function' && 'name' in gqlType) {
+ if (gqlTypeCache[gqlType.name]) {
+ gqlType = gqlTypeCache[gqlType.name];
+ } else {
+ gqlTypeCache[gqlType.name] = gqlType;
+ }
+ }
+
+ if (listOperators.includes(operator.name)) {
gqlType = new GraphQLList(gqlType);
- } else if (operator === 'exists') {
+ } else if (operator.name === 'exists') {
gqlType = GraphQLBoolean;
}
return {
...objectTypeFields,
- [operator]: {
+ [operator.name]: {
type: gqlType,
},
};
diff --git a/src/translations/ar.json b/src/translations/ar.json
index 32e4030b16..a4fa10aaa7 100644
--- a/src/translations/ar.json
+++ b/src/translations/ar.json
@@ -63,8 +63,8 @@
"deletingFile": "حدث خطأ أثناء حذف الملف.",
"deletingTitle": "حدث خطأ أثناء حذف {{title}}. يرجى التحقق من الاتصال الخاص بك والمحاولة مرة أخرى.",
"emailOrPasswordIncorrect": "البريد الإلكتروني أو كلمة المرور المقدمة غير صحيحة.",
- "followingFieldsInvalid_other": "الحقول التالية غير صالحة:",
"followingFieldsInvalid_one": "الحقل التالي غير صالح:",
+ "followingFieldsInvalid_other": "الحقول التالية غير صالحة:",
"incorrectCollection": "مجموعة غير صحيحة",
"invalidFileType": "نوع ملف غير صالح",
"invalidFileTypeValue": "نوع ملف غير صالح: {{value}}",
@@ -173,27 +173,28 @@
"deletedSuccessfully": "تمّ الحذف بنجاح.",
"deleting": "يتمّ الحذف...",
"descending": "تنازلي",
- "duplicate": "تكرار",
- "duplicateWithoutSaving": "تكرار بدون حفظ التّغييرات",
+ "deselectAllRows": "إلغاء تحديد جميع الصفوف",
+ "duplicate": "استنساخ",
+ "duplicateWithoutSaving": "استنساخ بدون حفظ التغييرات",
"edit": "تعديل",
"editLabel": "تعديل {{label}}",
"editing": "جاري التعديل",
"editingLabel_many": "تعديل {{count}} {{label}}",
"editingLabel_one": "تعديل {{count}} {{label}}",
"editingLabel_other": "تعديل {{count}} {{label}}",
- "error": "خطأ",
- "errors": "أخطاء",
"email": "البريد الإلكتروني",
"emailAddress": "عنوان البريد الإلكتروني",
"enterAValue": "أدخل قيمة",
- "fallbackToDefaultLocale": "يتمّ استخدام اللّغة الافتراضيّة",
- "filter": "فلتر",
- "filterWhere": "فلتر {{label}} أينما",
- "filters": "فلاتر",
- "globals": "المجموعات العامّة",
- "language": "اللّغة",
- "lastModified": "آخر تعديل في",
- "leaveAnyway": "المغادرة على أيّة حال",
+ "error": "خطأ",
+ "errors": "أخطاء",
+ "fallbackToDefaultLocale": "الرجوع إلى اللغة الافتراضية",
+ "filter": "تصفية",
+ "filterWhere": "تصفية {{label}} حيث",
+ "filters": "عوامل التصفية",
+ "globals": "عامة",
+ "language": "اللغة",
+ "lastModified": "آخر تعديل",
+ "leaveAnyway": "المغادرة على أي حال",
"leaveWithoutSaving": "المغادرة بدون حفظ",
"light": "فاتح",
"loading": "يتمّ التّحميل",
@@ -201,13 +202,14 @@
"moveDown": "التّحريك إلى الأسفل",
"moveUp": "التّحريك إلى الأعلى",
"newPassword": "كلمة مرور جديدة",
- "noFiltersSet": "لم يتمّ تحديد فلتر",
- "noLabel": "<لا يوجد {{label}}>",
- "noResults": "لم يتمّ العثور على {{label}}. إمّا أنّه لا يوجد {{label}} حتّى الآن أو أنّه لا يتطابق أيّ منها مع الفلاتر التّي حدّدتها أعلاه.",
- "noValue": "لا توجد قيمة",
- "none": "None",
- "notFound": "غير معثور عليه",
- "nothingFound": "لم يتمّ العثور على شيء",
+ "noFiltersSet": "لم يتم تعيين أي عوامل تصفية",
+ "noLabel": "<لا {{label}}>",
+ "noOptions": "لا خيارات",
+ "noResults": "لا يوجد {{label}}. إما أن لا {{label}} موجودة حتى الآن أو لا تتطابق مع عوامل التصفية التي حددتها أعلاه.",
+ "noValue": "لا يوجد قيمة",
+ "none": "لا شيء",
+ "notFound": "غير موجود",
+ "nothingFound": "لم يتم العثور على شيء",
"of": "من",
"or": "أو",
"order": "التّرتيب",
@@ -219,13 +221,16 @@
"row": "سطر",
"rows": "أسطُر",
"save": "حفظ",
- "saving": "يتمّ الحفظ...",
- "searchBy": "البحث بواسطة {{label}}",
- "selectAll": "اختر الكلّ {{count}} {{label}}",
- "selectValue": "اختر قيمة",
- "selectedCount": "{{count}} {{label}} تمّ اختيارها",
- "sorryNotFound": "عذرًا - ليس هناك ما يتوافق مع طلبك.",
+ "saving": "جاري الحفظ...",
+ "searchBy": "البحث عن طريق {{label}}",
+ "selectAll": "تحديد كل {{count}} {{label}}",
+ "selectAllRows": "حدد جميع الصفوف",
+ "selectValue": "اختيار قيمة",
+ "selectedCount": "تم تحديد {{count}} {{label}}",
+ "showAllLabel": "عرض كل {{label}}",
+ "sorryNotFound": "عذرًا - لا يوجد شيء يتوافق مع طلبك.",
"sort": "ترتيب",
+ "sortByLabelDirection": "رتّب حسب {{label}} {{direction}}",
"stayOnThisPage": "البقاء على هذه الصفحة",
"submissionSuccessful": "تمت الإرسال بنجاح.",
"submit": "إرسال",
@@ -282,6 +287,7 @@
"invalidSelection": "هذا الحقل لديه اختيار غير صالح.",
"invalidSelections": "هذا الحقل لديه الاختيارات الغير صالحة التالية:",
"lessThanMin": "{{value}} أقل من الحد الأدنى المسموح به {{label}} الذي يبلغ {{min}}.",
+ "limitReached": "تم الوصول إلى الحد الأقصى، يمكن إضافة {{max}} عناصر فقط.",
"longerThanMin": "يجب أن يكون هذا القيمة أطول من الحد الأدنى للطول الذي هو {{minLength}} أحرف.",
"notValidDate": "\"{{value}}\" ليس تاريخا صالحا.",
"required": "هذا الحقل مطلوب.",
diff --git a/src/translations/az.json b/src/translations/az.json
index 7ec01e7c13..194ba7ebc2 100644
--- a/src/translations/az.json
+++ b/src/translations/az.json
@@ -65,6 +65,7 @@
"emailOrPasswordIncorrect": "Təqdim olunan e-poçt və ya şifrə yanlışdır.",
"followingFieldsInvalid_many": "Aşağıdakı sahələr yanlışdır:",
"followingFieldsInvalid_one": "Aşağıdakı sahə yanlışdır:",
+ "followingFieldsInvalid_other": "Aşağıdaki sahələr yanlışdır:",
"incorrectCollection": "Yanlış Kolleksiya",
"invalidFileType": "Yanlış fayl növü",
"invalidFileTypeValue": "Yanlış fayl növü: {{value}}",
@@ -173,6 +174,7 @@
"deletedSuccessfully": "Uğurla silindi.",
"deleting": "Silinir...",
"descending": "Azalan",
+ "deselectAllRows": "Bütün sıraları seçimi ləğv edin",
"duplicate": "Dublikat",
"duplicateWithoutSaving": "Dəyişiklikləri saxlamadan dublikatla",
"edit": "Redaktə et",
@@ -203,6 +205,7 @@
"newPassword": "Yeni şifrə",
"noFiltersSet": "Filter təyin edilməyib",
"noLabel": "",
+ "noOptions": "Heç bir seçim yoxdur",
"noResults": "Heç bir {{label}} tapılmadı. Ya hələ {{label}} yoxdur, ya da yuxarıda göstərdiyiniz filtrlərə uyğun gəlmir.",
"noValue": "Dəyər yoxdur",
"none": "Heç bir",
@@ -222,10 +225,13 @@
"saving": "Saxlanılır...",
"searchBy": "{{label}} ilə axtar",
"selectAll": "Bütün {{count}} {{label}} seç",
+ "selectAllRows": "Bütün sıraları seçin",
"selectValue": "Dəyər seçin",
"selectedCount": "{{count}} {{label}} seçildi",
+ "showAllLabel": "Bütün {{label}}-ı göstər",
"sorryNotFound": "Üzr istəyirik - sizin tələbinizə uyğun heç nə yoxdur.",
"sort": "Sırala",
+ "sortByLabelDirection": "{{label}} {{direction}} ilə sırala",
"stayOnThisPage": "Bu səhifədə qal",
"submissionSuccessful": "Təqdimat uğurlu oldu.",
"submit": "Təqdim et",
@@ -282,6 +288,7 @@
"invalidSelection": "Bu sahədə yanlış seçim edilmişdir.",
"invalidSelections": "Bu sahədə aşağıdakı yanlış seçimlər edilmişdir:",
"lessThanMin": "{{value}} icazə verilən minimal {{label}} olan {{min}}-dən kiçikdir.",
+ "limitReached": "Limitə çatdınız, yalnız {{max}} element əlavə edilə bilər.",
"longerThanMin": "Bu dəyər {{minLength}} simvoldan uzun olmalıdır.",
"notValidDate": "\"{{value}}\" doğru tarix deyil.",
"required": "Bu sahə mütləq doldurulmalıdır.",
@@ -346,4 +353,4 @@
"viewingVersions": "{{entityLabel}} {{documentTitle}} üçün versiyaları göstərir",
"viewingVersionsGlobal": "Qlobal {{entityLabel}} üçün versiyaları göstərir"
}
-}
\ No newline at end of file
+}
diff --git a/src/translations/bg.json b/src/translations/bg.json
index 3c43e3fa06..5e9f000325 100644
--- a/src/translations/bg.json
+++ b/src/translations/bg.json
@@ -63,8 +63,8 @@
"deletingFile": "Имаше грешка при изтриването на файла.",
"deletingTitle": "Имаше проблем при изтриването на {{title}}. Моля провери връзката си и опитай отново.",
"emailOrPasswordIncorrect": "Имейлът или паролата не са правилни.",
- "followingFieldsInvalid_other": "Следните полета са некоректни:",
"followingFieldsInvalid_one": "Следното поле е некоректно:",
+ "followingFieldsInvalid_other": "Следните полета са некоректни:",
"incorrectCollection": "Некоректно събиране",
"invalidFileType": "Невалиден тип на файл",
"invalidFileTypeValue": "Невалиден тип на файл: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Изтрито успешно.",
"deleting": "Изтриване...",
"descending": "Низходящо",
+ "deselectAllRows": "Деселектирайте всички редове",
"duplicate": "Дупликирай",
"duplicateWithoutSaving": "Дупликирай без да запазваш промените",
"edit": "Редактирай",
@@ -203,6 +204,7 @@
"newPassword": "Нова парола",
"noFiltersSet": "Няма зададени филтри",
"noLabel": "<Няма {{label}}>",
+ "noOptions": "Няма опции",
"noResults": "{{label}} не е открит. {{label}} не съществува или никой не отговаря на зададените филтри.",
"noValue": "Няма стойност",
"none": "Никакъв",
@@ -222,10 +224,13 @@
"saving": "Запазване...",
"searchBy": "Търси по {{label}}",
"selectAll": "Избери всички {{count}} {{label}}",
+ "selectAllRows": "Изберете всички редове",
"selectValue": "Избери стойност",
"selectedCount": "{{count}} {{label}} избрани",
+ "showAllLabel": "Покажи всички {{label}}",
"sorryNotFound": "Съжаляваме-няма нищо, което да отговаря на търсенето ти.",
"sort": "Сортирай",
+ "sortByLabelDirection": "Сортирай по {{label}} {{direction}}",
"stayOnThisPage": "Остани на тази страница",
"submissionSuccessful": "Успешно подаване.",
"submit": "Подай",
@@ -261,6 +266,7 @@
"near": "близко"
},
"upload": {
+ "dragAndDrop": "Дръпни и пусни файл",
"dragAndDropHere": "или дръпни и пусни файла тук",
"fileName": "Име на файла",
"fileSize": "Големина на файла",
@@ -269,7 +275,6 @@
"moreInfo": "Повече информация",
"selectCollectionToBrowse": "Избери колекция, която да разгледаш",
"selectFile": "Избери файл",
- "dragAndDrop": "Дръпни и пусни файл",
"sizes": "Големини",
"width": "Ширина"
},
@@ -282,6 +287,7 @@
"invalidSelection": "Това поле има невалидна селекция.",
"invalidSelections": "Това поле има следните невалидни селекции:",
"lessThanMin": "{{value}} е по-малко от минимално допустимото {{label}} от {{min}}.",
+ "limitReached": "Достигнат е лимитът, могат да бъдат добавени само {{max}} елемента.",
"longerThanMin": "Тази стойност трябва да е по-голяма от минималната стойност от {{minLength}} символа.",
"notValidDate": "\"{{value}}\" не е валидна дата.",
"required": "Това поле е задължително.",
@@ -327,8 +333,8 @@
"saveDraft": "Запази чернова",
"selectLocales": "Избери локализации за показване",
"selectVersionToCompare": "Избери версия за сравняване",
- "showingVersionsFor": "Показване на версии за:",
"showLocales": "Покажи преводи:",
+ "showingVersionsFor": "Показване на версии за:",
"status": "Статус",
"type": "Тип",
"unpublish": "Скрий",
diff --git a/src/translations/cs.json b/src/translations/cs.json
index a301f4e77d..3ef2fc97f0 100644
--- a/src/translations/cs.json
+++ b/src/translations/cs.json
@@ -63,8 +63,8 @@
"deletingFile": "Při mazání souboru došlo k chybě.",
"deletingTitle": "Při mazání {{title}} došlo k chybě. Zkontrolujte své připojení a zkuste to znovu.",
"emailOrPasswordIncorrect": "Zadaný email nebo heslo není správné.",
- "followingFieldsInvalid_other": "Následující pole jsou neplatná:",
"followingFieldsInvalid_one": "Následující pole je neplatné:",
+ "followingFieldsInvalid_other": "Následující pole jsou neplatná:",
"incorrectCollection": "Nesprávná kolekce",
"invalidFileType": "Neplatný typ souboru",
"invalidFileTypeValue": "Neplatný typ souboru: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Úspěšně odstraněno.",
"deleting": "Odstraňování...",
"descending": "Sestupně",
+ "deselectAllRows": "Zrušte výběr všech řádků",
"duplicate": "Duplikovat",
"duplicateWithoutSaving": "Duplikovat bez uložení změn",
"edit": "Upravit",
@@ -203,6 +204,7 @@
"newPassword": "Nové heslo",
"noFiltersSet": "Nenastaveny žádné filtry",
"noLabel": "<Žádný {{label}}>",
+ "noOptions": "Žádné možnosti",
"noResults": "Nebyly nalezeny žádné {{label}}. Buď ještě neexistují žádné {{label}}, nebo žádné nesplňují filtry, které jste zadali výše.",
"noValue": "Žádná hodnota",
"none": "Žádné",
@@ -222,10 +224,13 @@
"saving": "Ukládání...",
"searchBy": "Vyhledat podle {{label}}",
"selectAll": "Vybrat vše {{count}} {{label}}",
+ "selectAllRows": "Vyberte všechny řádky",
"selectValue": "Vyberte hodnotu",
"selectedCount": "Vybráno {{count}} {{label}}",
+ "showAllLabel": "Zobrazit všechny {{label}}",
"sorryNotFound": "Je nám líto, ale neexistuje nic, co by odpovídalo vašemu požadavku.",
"sort": "Třídit",
+ "sortByLabelDirection": "Seřadit podle {{label}} {{direction}}",
"stayOnThisPage": "Zůstat na této stránce",
"submissionSuccessful": "Odeslání úspěšné.",
"submit": "Odeslat",
@@ -247,20 +252,21 @@
"welcome": "Vítejte"
},
"operators": {
+ "contains": "obsahuje",
"equals": "rovná se",
- "isNotEqualTo": "není rovno",
- "isIn": "je v",
- "isNotIn": "není in",
"exists": "existuje",
"isGreaterThan": "je větší než",
+ "isGreaterThanOrEqualTo": "je větší nebo rovno",
+ "isIn": "je v",
"isLessThan": "je menší než",
"isLessThanOrEqualTo": "je menší nebo rovno",
- "isGreaterThanOrEqualTo": "je větší nebo rovno",
- "near": "blízko",
"isLike": "je jako",
- "contains": "obsahuje"
+ "isNotEqualTo": "není rovno",
+ "isNotIn": "není in",
+ "near": "blízko"
},
"upload": {
+ "dragAndDrop": "Přetáhněte soubor",
"dragAndDropHere": "nebo sem přetáhněte soubor",
"fileName": "Název souboru",
"fileSize": "Velikost souboru",
@@ -269,7 +275,6 @@
"moreInfo": "Více informací",
"selectCollectionToBrowse": "Vyberte kolekci pro procházení",
"selectFile": "Vyberte soubor",
- "dragAndDrop": "Přetáhněte soubor",
"sizes": "Velikosti",
"width": "Šířka"
},
@@ -282,6 +287,7 @@
"invalidSelection": "Toto pole má neplatný výběr.",
"invalidSelections": "Toto pole má následující neplatné výběry:",
"lessThanMin": "{{value}} je nižší než minimálně povolená {{label}} {{min}}.",
+ "limitReached": "Dosáhnutý limit, mohou být přidány pouze {{max}} položky.",
"longerThanMin": "Tato hodnota musí být delší než minimální délka {{minLength}} znaků.",
"notValidDate": "\"{{value}}\" není platné datum.",
"required": "Toto pole je povinné.",
diff --git a/src/translations/de.json b/src/translations/de.json
index ab45d0a4c3..4f980a132c 100644
--- a/src/translations/de.json
+++ b/src/translations/de.json
@@ -63,8 +63,8 @@
"deletingFile": "Beim Löschen der Datei ist ein Fehler aufgetreten.",
"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 Passwort sind nicht korrekt.",
- "followingFieldsInvalid_other": "Die folgenden Felder sind nicht korrekt:",
"followingFieldsInvalid_one": "Das folgende Feld ist nicht korrekt:",
+ "followingFieldsInvalid_other": "Die folgenden Felder sind nicht korrekt:",
"incorrectCollection": "Falsche Sammlung",
"invalidFileType": "Ungültiger Datei-Typ",
"invalidFileTypeValue": "Ungültiger Datei-Typ: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Erfolgreich gelöscht.",
"deleting": "Lösche...",
"descending": "Absteigend",
+ "deselectAllRows": "Alle Zeilen abwählen",
"duplicate": "Duplizieren",
"duplicateWithoutSaving": "Dupliziere ohne Änderungen zu speichern",
"edit": "Bearbeiten",
@@ -184,9 +185,9 @@
"email": "E-Mail",
"emailAddress": "E-Mail-Adresse",
"enterAValue": "Gib einen Wert ein",
- "fallbackToDefaultLocale": "Rückgriff auf das Standardgebietsschema",
"error": "Fehler",
"errors": "Fehler",
+ "fallbackToDefaultLocale": "Rückgriff auf das Standardgebietsschema",
"filter": "Filter",
"filterWhere": "Filter {{label}} wo",
"filters": "Filter",
@@ -203,6 +204,7 @@
"newPassword": "Neues Passwort",
"noFiltersSet": "Keine Filter gesetzt",
"noLabel": "",
+ "noOptions": "Keine Optionen",
"noResults": "Keine {{label}} gefunden. Entweder es existieren keine {{label}} oder es gibt keine Übereinstimmung zu den von dir verwendeten Filtern.",
"noValue": "Kein Wert",
"none": "Kein",
@@ -222,10 +224,13 @@
"saving": "Speichert...",
"searchBy": "Suche nach {{label}}",
"selectAll": "Alle auswählen {{count}} {{label}}",
+ "selectAllRows": "Wählen Sie alle Zeilen aus",
"selectValue": "Wert auswählen",
"selectedCount": "{{count}} {{label}} ausgewählt",
+ "showAllLabel": "Zeige alle {{label}}",
"sorryNotFound": "Entschuldige, es entspricht nichts deiner Anfrage",
"sort": "Sortieren",
+ "sortByLabelDirection": "Sortieren nach {{label}} {{direction}}",
"stayOnThisPage": "Auf dieser Seite bleiben",
"submissionSuccessful": "Einrichung erfolgreich.",
"submit": "Senden",
@@ -282,6 +287,7 @@
"invalidSelection": "Dieses Feld hat eine inkorrekte Auswahl.",
"invalidSelections": "'Dieses Feld enthält die folgenden inkorrekten Auswahlen:'",
"lessThanMin": "{{value}} ist kleiner als der minimal erlaubte {{label}} von {{min}}.",
+ "limitReached": "Limit erreicht, es können nur {{max}} Elemente hinzugefügt werden.",
"longerThanMin": "Dieser Wert muss länger als die minimale Länge von {{minLength}} Zeichen sein.",
"notValidDate": "\"{{value}}\" ist kein gültiges Datum.",
"required": "Pflichtfeld",
diff --git a/src/translations/en.json b/src/translations/en.json
index 0ef74f751c..33579bf864 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Deleted successfully.",
"deleting": "Deleting...",
"descending": "Descending",
+ "deselectAllRows": "Deselect all rows",
"duplicate": "Duplicate",
"duplicateWithoutSaving": "Duplicate without saving changes",
"edit": "Edit",
@@ -203,6 +204,7 @@
"newPassword": "New Password",
"noFiltersSet": "No filters set",
"noLabel": "",
+ "noOptions": "No options",
"noResults": "No {{label}} found. Either no {{label}} exist yet or none match the filters you've specified above.",
"noValue": "No value",
"none": "None",
@@ -222,10 +224,13 @@
"saving": "Saving...",
"searchBy": "Search by {{label}}",
"selectAll": "Select all {{count}} {{label}}",
+ "selectAllRows": "Select all rows",
"selectValue": "Select a value",
"selectedCount": "{{count}} {{label}} selected",
+ "showAllLabel": "Show all {{label}}",
"sorryNotFound": "Sorry—there is nothing to correspond with your request.",
"sort": "Sort",
+ "sortByLabelDirection": "Sort by {{label}} {{direction}}",
"stayOnThisPage": "Stay on this page",
"submissionSuccessful": "Submission Successful.",
"submit": "Submit",
@@ -276,6 +281,7 @@
"validation": {
"emailAddress": "Please enter a valid email address.",
"enterNumber": "Please enter a valid number.",
+ "limitReached": "Limit reached, only {{max}} items can be added.",
"fieldHasNo": "This field has no {{label}}",
"greaterThanMax": "{{value}} is greater than the max allowed {{label}} of {{max}}.",
"invalidInput": "This field has an invalid input.",
diff --git a/src/translations/es.json b/src/translations/es.json
index c8212f4769..6db37da7c5 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -63,8 +63,8 @@
"deletingFile": "Ocurrió un error al eliminar el archivo.",
"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_other": "Los siguientes campos son inválidos:",
"followingFieldsInvalid_one": "El siguiente campo es inválido:",
+ "followingFieldsInvalid_other": "Los siguientes campos son inválidos:",
"incorrectCollection": "Colección Incorrecta",
"invalidFileType": "Tipo de archivo inválido",
"invalidFileTypeValue": "Tipo de archivo inválido: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Borrado exitosamente.",
"deleting": "Eliminando...",
"descending": "Descendente",
+ "deselectAllRows": "Deselecciona todas las filas",
"duplicate": "Duplicar",
"duplicateWithoutSaving": "Duplicar sin guardar cambios",
"edit": "Editar",
@@ -203,6 +204,7 @@
"newPassword": "Nueva contraseña",
"noFiltersSet": "No hay filtros establecidos",
"noLabel": "",
+ "noOptions": "Sin opciones",
"noResults": "No encontramos {{label}}. Puede que no existan {{label}} todavía o no hay coincidencias con los filtros introducidos arriba.",
"noValue": "Sin valor",
"none": "Ninguna",
@@ -222,10 +224,13 @@
"saving": "Guardando...",
"searchBy": "Buscar por {{label}}",
"selectAll": "Seleccionar todo {{count}} {{label}}",
+ "selectAllRows": "Selecciona todas las filas",
"selectValue": "Selecciona un valor",
"selectedCount": "{{count}} {{label}} seleccionado",
+ "showAllLabel": "Muestra todas {{label}}",
"sorryNotFound": "Lo sentimos. No hay nada que corresponda con tu solicitud.",
"sort": "Ordenar",
+ "sortByLabelDirection": "Ordenar por {{label}} {{direction}}",
"stayOnThisPage": "Permanecer en esta página",
"submissionSuccessful": "Envío realizado correctamente.",
"submit": "Enviar",
@@ -282,6 +287,7 @@
"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 {{label}} mínimo permitido de {{min}}.",
+ "limitReached": "Se ha alcanzado el límite, solo se pueden agregar {{max}} elementos.",
"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.",
diff --git a/src/translations/fa.json b/src/translations/fa.json
index 5fb0855591..550d971906 100644
--- a/src/translations/fa.json
+++ b/src/translations/fa.json
@@ -63,8 +63,8 @@
"deletingFile": "هنگام حذف فایل خطایی روی داد.",
"deletingTitle": "هنگام حذف {{title}} خطایی رخ داد. لطفاً وضعیت اتصال اینترنت خود را بررسی کنید.",
"emailOrPasswordIncorrect": "رایانامه یا گذرواژه ارائه شده نادرست است.",
- "followingFieldsInvalid_other": "کادرهای زیر نامعتبر هستند:",
"followingFieldsInvalid_one": "کادر زیر نامعتبر است:",
+ "followingFieldsInvalid_other": "کادرهای زیر نامعتبر هستند:",
"incorrectCollection": "مجموعه نادرست",
"invalidFileType": "نوع رسانه نامعتبر است",
"invalidFileTypeValue": "نوع رسانه نامعتبر: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "با موفقیت حذف شد.",
"deleting": "در حال حذف...",
"descending": "رو به پایین",
+ "deselectAllRows": "تمام سطرها را از انتخاب خارج کنید",
"duplicate": "تکراری",
"duplicateWithoutSaving": "رونوشت بدون ذخیره کردن تغییرات",
"edit": "نگارش",
@@ -203,6 +204,7 @@
"newPassword": "گذرواژه تازه",
"noFiltersSet": "هیچ علامتگذاری تنظیم نشده",
"noLabel": "",
+ "noOptions": "بدون گزینه",
"noResults": "هیچ {{label}} یافت نشد. {{label}} یا هنوز وجود ندارد یا هیچ کدام با علامتگذاریهایی که در بالا مشخص کرده اید مطابقت ندارد.",
"noValue": "بدون مقدار",
"none": "هیچ یک",
@@ -222,10 +224,13 @@
"saving": "در حال ذخیره...",
"searchBy": "جستجو بر اساس {{label}}",
"selectAll": "انتخاب همه {{count}} {{label}}",
+ "selectAllRows": "انتخاب تمام سطرها",
"selectValue": "یک مقدار را انتخاب کنید",
"selectedCount": "{{count}} {{label}} انتخاب شد",
+ "showAllLabel": "نمایش همه {{label}}",
"sorryNotFound": "متأسفانه چیزی برای مطابقت با درخواست شما وجود ندارد.",
"sort": "مرتبسازی",
+ "sortByLabelDirection": "مرتب کردن بر اساس {{label}} {{direction}}",
"stayOnThisPage": "ماندن در این برگه",
"submissionSuccessful": "با موفقیت ثبت شد.",
"submit": "فرستادن",
@@ -282,6 +287,7 @@
"invalidSelection": "این کادر دارای یک انتخاب نامعتبر است.",
"invalidSelections": "این کادر دارای انتخابهای نامعتبر زیر است:",
"lessThanMin": "{{value}} کمتر از حداقل مجاز برای {{label}} است که {{min}} است.",
+ "limitReached": "محدودیت رسیده است، فقط {{max}} مورد می تواند اضافه شود.",
"longerThanMin": "ورودی باید بیش از حداقل {{minLength}} واژه باشد.",
"notValidDate": "\"{{value}}\" یک تاریخ معتبر نیست.",
"required": "این کادر اجباری است.",
diff --git a/src/translations/fr.json b/src/translations/fr.json
index a4010c1477..3880451c95 100644
--- a/src/translations/fr.json
+++ b/src/translations/fr.json
@@ -63,8 +63,8 @@
"deletingFile": "Une erreur s'est produite lors de la suppression du fichier.",
"deletingTitle": "Une erreur s'est produite lors de la suppression de {{title}}. Veuillez vérifier votre connexion puis réessayer.",
"emailOrPasswordIncorrect": "L'adresse e-mail ou le mot de passe fourni est incorrect.",
- "followingFieldsInvalid_other": "Les champs suivants ne sont pas valides :",
"followingFieldsInvalid_one": "Le champ suivant n'est pas valide :",
+ "followingFieldsInvalid_other": "Les champs suivants ne sont pas valides :",
"incorrectCollection": "Collection incorrecte",
"invalidFileType": "Type de fichier invalide",
"invalidFileTypeValue": "Type de fichier invalide : {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Supprimé(e) avec succès.",
"deleting": "Suppression en cours...",
"descending": "Descendant(e)",
+ "deselectAllRows": "Désélectionner toutes les lignes",
"duplicate": "Dupliquer",
"duplicateWithoutSaving": "Dupliquer sans enregistrer les modifications",
"edit": "Éditer",
@@ -203,6 +204,7 @@
"newPassword": "Nouveau mot de passe",
"noFiltersSet": "Aucun filtre défini",
"noLabel": "",
+ "noOptions": "Aucune option",
"noResults": "Aucun(e) {{label}} trouvé(e). Soit aucun(e) {{label}} n'existe encore, soit aucun(e) ne correspond aux filtres que vous avez spécifiés ci-dessus",
"noValue": "Aucune valeur",
"none": "Aucun(e)",
@@ -222,10 +224,13 @@
"saving": "Sauvegarde en cours...",
"searchBy": "Rechercher par {{label}}",
"selectAll": "Tout sélectionner {{count}} {{label}}",
+ "selectAllRows": "Sélectionnez toutes les lignes",
"selectValue": "Sélectionnez une valeur",
"selectedCount": "{{count}} {{label}} sélectionné",
+ "showAllLabel": "Afficher tous les {{label}}",
"sorryNotFound": "Désolé, rien ne correspond à votre demande.",
"sort": "Trier",
+ "sortByLabelDirection": "Trier par {{label}} {{direction}}",
"stayOnThisPage": "Rester sur cette page",
"submissionSuccessful": "Soumission réussie.",
"submit": "Soumettre",
@@ -282,6 +287,7 @@
"invalidSelection": "Ce champ a une sélection invalide.",
"invalidSelections": "Ce champ contient des sélections invalides suivantes :",
"lessThanMin": "{{value}} est inférieur au min autorisé {{label}} de {{min}}.",
+ "limitReached": "Limite atteinte, seulement {{max}} éléments peuvent être ajoutés.",
"longerThanMin": "Cette valeur doit être supérieure à la longueur minimale de {{minLength}} caractères.",
"notValidDate": "\"{{value}}\" n'est pas une date valide.",
"required": "Ce champ est requis.",
diff --git a/src/translations/hr.json b/src/translations/hr.json
index 39c4317e35..6e640bcb67 100644
--- a/src/translations/hr.json
+++ b/src/translations/hr.json
@@ -63,8 +63,8 @@
"deletingFile": "Dogodila se pogreška pri brisanju datoteke.",
"deletingTitle": "Dogodila se pogreška pri brisanju {{title}}. Molim provjerite svoju internetsku vezu i pokušajte ponovno.",
"emailOrPasswordIncorrect": "Email ili lozinka netočni.",
- "followingFieldsInvalid_other": "Ova polja su nevaljana:",
"followingFieldsInvalid_one": " Ovo polje je nevaljano:",
+ "followingFieldsInvalid_other": "Ova polja su nevaljana:",
"incorrectCollection": "Nevaljana kolekcija",
"invalidFileType": "Nevaljan tip datoteke",
"invalidFileTypeValue": "Nevaljan tip datoteke: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Uspješno obrisano.",
"deleting": "Brisanje...",
"descending": "Silazno",
+ "deselectAllRows": "Odznači sve redove",
"duplicate": "Duplikat",
"duplicateWithoutSaving": "Dupliciraj bez spremanja promjena",
"edit": "Uredi",
@@ -203,6 +204,7 @@
"newPassword": "Nova lozinka",
"noFiltersSet": "Nema postavljenih filtera",
"noLabel": "",
+ "noOptions": "Nema opcija",
"noResults": "Nema pronađenih {{label}}. Ili {{label}} još uvijek ne postoji ili nijedan od odgovara postavljenim filterima.",
"noValue": "Bez vrijednosti",
"none": "Nijedan",
@@ -222,10 +224,13 @@
"saving": "Spremanje...",
"searchBy": "Traži po {{label}}",
"selectAll": "Odaberite sve {{count}} {{label}}",
+ "selectAllRows": "Odaberite sve redove",
"selectValue": "Odaberi vrijednost",
"selectedCount": "{{count}} {{label}} odabrano",
+ "showAllLabel": "Prikaži sve {{label}}",
"sorryNotFound": "Nažalost, ne postoji ništa što odgovara vašem zahtjevu.",
"sort": "Sortiraj",
+ "sortByLabelDirection": "Sortiraj prema {{label}} {{direction}}",
"stayOnThisPage": "Ostani na ovoj stranici",
"submissionSuccessful": "Uspješno slanje",
"submit": "Podnesi",
@@ -282,6 +287,7 @@
"invalidSelection": "Ovo polje ima nevaljan odabir.",
"invalidSelections": "Ovo polje ima sljedeće nevaljane odabire:",
"lessThanMin": "{{value}} is below the minimum allowable {{label}} limit of {{min}}.",
+ "limitReached": "Dosegnut je limit, može se dodati samo {{max}} stavki.",
"longerThanMin": "Ova vrijednost mora biti duža od minimalne dužine od {{minLength}} znakova",
"notValidDate": "\"{{value}}\" nije valjan datum.",
"required": "Ovo polje je obvezno.",
diff --git a/src/translations/hu.json b/src/translations/hu.json
index 4d7f41ddd5..2e5fa1a077 100644
--- a/src/translations/hu.json
+++ b/src/translations/hu.json
@@ -63,8 +63,8 @@
"deletingFile": "Hiba történt a fájl törlésekor.",
"deletingTitle": "Hiba történt a {{title}} törlése közben. Kérjük, ellenőrizze a kapcsolatot, és próbálja meg újra.",
"emailOrPasswordIncorrect": "A megadott e-mail-cím vagy jelszó helytelen.",
- "followingFieldsInvalid_other": "A következő mezők érvénytelenek:",
"followingFieldsInvalid_one": "A következő mező érvénytelen:",
+ "followingFieldsInvalid_other": "A következő mezők érvénytelenek:",
"incorrectCollection": "Helytelen gyűjtemény",
"invalidFileType": "Érvénytelen fájltípus",
"invalidFileTypeValue": "Érvénytelen fájltípus: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Sikeresen törölve.",
"deleting": "Törlés...",
"descending": "Csökkenő",
+ "deselectAllRows": "Jelölje ki az összes sort",
"duplicate": "Duplikálás",
"duplicateWithoutSaving": "Duplikálás a módosítások mentése nélkül",
"edit": "Szerkesztés",
@@ -203,6 +204,7 @@
"newPassword": "Új jelszó",
"noFiltersSet": "Nincs beállítva szűrő",
"noLabel": "",
+ "noOptions": "Nincs lehetőség",
"noResults": "Nem találtunk {{label}}. Vagy még nem létezik {{label}}, vagy egyik sem felel meg a fent megadott szűrőknek.",
"noValue": "Nincs érték",
"none": "Semmi",
@@ -222,10 +224,13 @@
"saving": "Mentés...",
"searchBy": "Keresés a következő szerint: {{label}}",
"selectAll": "Az összes kijelölése: {{count}} {{label}}",
+ "selectAllRows": "Válassza ki az összes sort",
"selectValue": "Válasszon ki egy értéket",
"selectedCount": "{{count}} {{label}} kiválasztva",
+ "showAllLabel": "Mutasd az összes {{címke}}",
"sorryNotFound": "Sajnáljuk – nincs semmi, ami megfelelne a kérésének.",
"sort": "Rendezés",
+ "sortByLabelDirection": "Rendezés {{label}} {{direction}} szerint",
"stayOnThisPage": "Maradjon ezen az oldalon",
"submissionSuccessful": "Beküldés sikeres.",
"submit": "Beküldés",
@@ -247,20 +252,21 @@
"welcome": "Üdvözöljük"
},
"operators": {
+ "contains": "tartalmaz",
"equals": "egyenlő",
- "isNotEqualTo": "nem egyenlő",
- "isIn": "benne van",
- "isNotIn": "nincs benne",
"exists": "létezik",
"isGreaterThan": "nagyobb, mint",
+ "isGreaterThanOrEqualTo": "nagyobb vagy egyenlő, mint",
+ "isIn": "benne van",
"isLessThan": "kisebb, mint",
"isLessThanOrEqualTo": "kisebb vagy egyenlő, mint",
- "isGreaterThanOrEqualTo": "nagyobb vagy egyenlő, mint",
- "near": "közel",
"isLike": "olyan, mint",
- "contains": "tartalmaz"
+ "isNotEqualTo": "nem egyenlő",
+ "isNotIn": "nincs benne",
+ "near": "közel"
},
"upload": {
+ "dragAndDrop": "Húzzon ide egy fájlt",
"dragAndDropHere": "vagy húzzon ide egy fájlt",
"fileName": "Fájlnév",
"fileSize": "Fájl mérete",
@@ -269,7 +275,6 @@
"moreInfo": "További információ",
"selectCollectionToBrowse": "Válassza ki a böngészni kívánt gyűjteményt",
"selectFile": "Válasszon ki egy fájlt",
- "dragAndDrop": "Húzzon ide egy fájlt",
"sizes": "Méretek",
"width": "Szélesség"
},
@@ -282,6 +287,7 @@
"invalidSelection": "Ez a mező érvénytelen kijelöléssel rendelkezik.",
"invalidSelections": "Ez a mező a következő érvénytelen kijelöléseket tartalmazza:",
"lessThanMin": "{{value}} kisebb, mint a megengedett minimum {{label}} érték, ami {{min}}.",
+ "limitReached": "Elérte a korlátot, csak {{max}} elem adható hozzá.",
"longerThanMin": "Ennek az értéknek hosszabbnak kell lennie, mint a minimális {{minLength}} karakter hosszúság.",
"notValidDate": "\" {{value}} \" nem érvényes dátum.",
"required": "Ez a mező kötelező.",
diff --git a/src/translations/it.json b/src/translations/it.json
index 6c938f4151..d280f12109 100644
--- a/src/translations/it.json
+++ b/src/translations/it.json
@@ -63,8 +63,8 @@
"deletingFile": "Si è verificato un errore durante l'eleminazione del file.",
"deletingTitle": "Si è verificato un errore durante l'eliminazione di {{title}}. Per favore controlla la tua connessione e riprova.",
"emailOrPasswordIncorrect": "L'email o la password fornita non è corretta.",
- "followingFieldsInvalid_other": "I seguenti campi non sono validi:",
"followingFieldsInvalid_one": "Il seguente campo non è valido:",
+ "followingFieldsInvalid_other": "I seguenti campi non sono validi:",
"incorrectCollection": "Collezione non corretta",
"invalidFileType": "Tipo di file non valido",
"invalidFileTypeValue": "Tipo di file non valido: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Eliminato con successo.",
"deleting": "Sto eliminando...",
"descending": "Decrescente",
+ "deselectAllRows": "Deseleziona tutte le righe",
"duplicate": "Duplica",
"duplicateWithoutSaving": "Duplica senza salvare le modifiche",
"edit": "Modificare",
@@ -203,6 +204,7 @@
"newPassword": "Nuova Password",
"noFiltersSet": "Nessun filtro impostato",
"noLabel": "",
+ "noOptions": "Nessuna opzione",
"noResults": "Nessun {{label}} trovato. Non esiste ancora nessun {{label}} oppure nessuno corrisponde ai filtri che hai specificato sopra.",
"noValue": "Nessun valore",
"none": "Nessuno",
@@ -222,10 +224,13 @@
"saving": "Salvo...",
"searchBy": "Cerca per {{label}}",
"selectAll": "Seleziona tutto {{count}} {{label}}",
+ "selectAllRows": "Seleziona tutte le righe",
"selectValue": "Seleziona un valore",
"selectedCount": "{{count}} {{label}} selezionato",
+ "showAllLabel": "Mostra tutti {{label}}",
"sorryNotFound": "Siamo spiacenti, non c'è nulla che corrisponda alla tua richiesta.",
"sort": "Ordina",
+ "sortByLabelDirection": "Ordina per {{label}} {{direction}}",
"stayOnThisPage": "Rimani su questa pagina",
"submissionSuccessful": "Invio riuscito.",
"submit": "Invia",
@@ -282,6 +287,7 @@
"invalidSelection": "Questo campo ha una selezione non valida.",
"invalidSelections": "'In questo campo sono presenti le seguenti selezioni non valide:'",
"lessThanMin": "{{value}} è inferiore al minimo consentito {{label}} di {{min}}.",
+ "limitReached": "Raggiunto il limite, possono essere aggiunti solo {{max}} elementi.",
"longerThanMin": "Questo valore deve essere più lungo della lunghezza minima di {{minLength}} caratteri.",
"notValidDate": "\"{{value}}\" non è una data valida.",
"required": "Questo campo è obbligatorio.",
diff --git a/src/translations/ja.json b/src/translations/ja.json
index 17a7ebb628..7f39eb076c 100644
--- a/src/translations/ja.json
+++ b/src/translations/ja.json
@@ -63,8 +63,8 @@
"deletingFile": "ファイルの削除中にエラーが発生しました。",
"deletingTitle": "{{title}} を削除する際にエラーが発生しました。接続を確認してからもう一度お試しください。",
"emailOrPasswordIncorrect": "メールアドレス、または、パスワードが正しくありません。",
- "followingFieldsInvalid_other": "次のフィールドは無効です:",
"followingFieldsInvalid_one": "次のフィールドは無効です:",
+ "followingFieldsInvalid_other": "次のフィールドは無効です:",
"incorrectCollection": "不正なコレクション",
"invalidFileType": "無効なファイル形式",
"invalidFileTypeValue": "無効なファイル形式: {{value}}",
@@ -173,14 +173,15 @@
"deletedSuccessfully": "正常に削除されました。",
"deleting": "削除しています...",
"descending": "降順",
+ "deselectAllRows": "すべての行の選択を解除します",
"duplicate": "複製",
"duplicateWithoutSaving": "変更を保存せずに複製",
"edit": "編集",
"editLabel": "{{label}} を編集",
+ "editing": "編集",
"editingLabel_many": "{{count}}つの{{label}}を編集しています",
"editingLabel_one": "{{count}}つの{{label}}を編集しています",
"editingLabel_other": "{{count}}つの{{label}}を編集しています",
- "editing": "編集",
"email": "メールアドレス",
"emailAddress": "メールアドレス",
"enterAValue": "値を入力",
@@ -203,6 +204,7 @@
"newPassword": "新しいパスワード",
"noFiltersSet": "絞り込みが未設定です。",
"noLabel": "",
+ "noOptions": "選択肢なし",
"noResults": "{{label}} データが見つかりませんでした。データが存在しない、または、絞り込みに一致するものがありません。",
"noValue": "未設定",
"none": "なし",
@@ -222,10 +224,13 @@
"saving": "保存しています...",
"searchBy": "{{label}} で検索",
"selectAll": "すべての{{count}}つの{{label}}を選択",
+ "selectAllRows": "すべての行を選択します",
"selectValue": "値を選択",
"selectedCount": "{{count}}つの{{label}}を選択中",
+ "showAllLabel": "すべての{{label}}を表示する",
"sorryNotFound": "申し訳ありません。リクエストに対応する内容が見つかりませんでした。",
"sort": "並び替え",
+ "sortByLabelDirection": "{{label}}により並べ替え {{direction}}",
"stayOnThisPage": "この画面にとどまる",
"submissionSuccessful": "送信が成功しました。",
"submit": "送信",
@@ -247,20 +252,21 @@
"welcome": "ようこそ"
},
"operators": {
+ "contains": "含む",
"equals": "等しい",
- "isNotEqualTo": "等しくない",
- "isIn": "あります",
- "isNotIn": "入っていません",
"exists": "存在す",
"isGreaterThan": "より大きい",
+ "isGreaterThanOrEqualTo": "以上",
+ "isIn": "あります",
"isLessThan": "より小さい",
"isLessThanOrEqualTo": "以下",
- "isGreaterThanOrEqualTo": "以上",
- "near": "近く",
"isLike": "のような",
- "contains": "含む"
+ "isNotEqualTo": "等しくない",
+ "isNotIn": "入っていません",
+ "near": "近く"
},
"upload": {
+ "dragAndDrop": "ファイルをドラッグ アンド ドロップする",
"dragAndDropHere": "または、このエリアにファイルをドラッグ & ドロップ",
"fileName": "ファイル名",
"fileSize": "ファイル容量",
@@ -269,7 +275,6 @@
"moreInfo": "詳細を表示",
"selectCollectionToBrowse": "閲覧するコレクションを選択",
"selectFile": "ファイルを選択",
- "dragAndDrop": "ファイルをドラッグ アンド ドロップする",
"sizes": "容量",
"width": "横幅"
},
@@ -282,6 +287,7 @@
"invalidSelection": "無効な選択です。",
"invalidSelections": "次の無効な選択があります: ",
"lessThanMin": "{{value}}は許容最小{{label}}の{{min}}未満です。",
+ "limitReached": "制限に達しました、{{max}}個以上のアイテムを追加することはできません。",
"longerThanMin": "{{minLength}} 文字以上にする必要があります。",
"notValidDate": "\"{{value}}\" は有効な日付ではありません。",
"required": "必須フィールドです。",
@@ -293,15 +299,18 @@
"validUploadID": "有効なアップロードIDではありません。"
},
"version": {
+ "aboutToPublishSelection": "選択中のすべての{{label}}を公開しようとしています。よろしいですか?",
"aboutToRestore": "この {{label}} データを {{versionDate}} 時点のバージョンに復元しようとしています。",
"aboutToRestoreGlobal": "グローバルな {{label}} データを {{versionDate}} 時点のバージョンに復元しようとしています。",
"aboutToRevertToPublished": "このデータの変更を公開時の状態に戻そうとしています。よろしいですか?",
"aboutToUnpublish": "このデータを非公開にしようとしています。よろしいですか?",
+ "aboutToUnpublishSelection": "選択したすべての{{label}}の公開を取り消そうとしています。よろしいですか?",
"autosave": "自動保存",
"autosavedSuccessfully": "自動保存に成功しました。",
"autosavedVersion": "自動保存されたバージョン",
"changed": "変更済み",
"compareVersion": "バージョンを比較:",
+ "confirmPublish": "公開を確認する",
"confirmRevertToSaved": "保存された状態に戻す確認",
"confirmUnpublish": "非公開の確認",
"confirmVersionRestoration": "バージョン復元の確認",
@@ -313,6 +322,7 @@
"noRowsFound": "{{label}} は未設定です",
"preview": "プレビュー",
"problemRestoringVersion": "このバージョンの復元に問題がありました。",
+ "publish": "公開する",
"publishChanges": "変更内容を公開",
"published": "公開済み",
"restoreThisVersion": "このバージョンを復元",
@@ -324,6 +334,7 @@
"selectLocales": "表示するロケールを選択",
"selectVersionToCompare": "比較するバージョンを選択",
"showLocales": "ロケールを表示:",
+ "showingVersionsFor": "次のバージョンを表示します:",
"status": "ステータス",
"type": "タイプ",
"unpublish": "非公開",
@@ -332,6 +343,7 @@
"versionCount_many": "{{count}} バージョンがあります",
"versionCount_none": "バージョンがありません",
"versionCount_one": "{{count}} バージョンがあります",
+ "versionCount_other": "{{count}}バージョンが見つかりました",
"versionCreatedOn": "{{version}} 作成日時:",
"versionID": "バージョンID",
"versions": "バージョン",
diff --git a/src/translations/my.json b/src/translations/my.json
index 7b1d54156f..99fb976ee1 100644
--- a/src/translations/my.json
+++ b/src/translations/my.json
@@ -63,8 +63,8 @@
"deletingFile": "ဖိုင်ကိုဖျက်ရာတွင် အမှားအယွင်းရှိနေသည်။",
"deletingTitle": "{{title}} ကို ဖျက်ရာတွင် အမှားအယွင်းရှိခဲ့သည်။ သင့် အင်တာနက်လိုင်းအား စစ်ဆေးပြီး ထပ်မံကြို့စားကြည့်ပါ။",
"emailOrPasswordIncorrect": "ထည့်သွင်းထားသော အီးမေးလ် သို့မဟုတ် စကားဝှက်သည် မမှန်ပါ။",
- "followingFieldsInvalid_other": "ထည့်သွင်းထားသော အချက်အလက်များသည် မမှန်ကန်ပါ။",
"followingFieldsInvalid_one": "ထည့်သွင်းထားသော အချက်အလက်သည် မမှန်ကန်ပါ။",
+ "followingFieldsInvalid_other": "ထည့်သွင်းထားသော အချက်အလက်များသည် မမှန်ကန်ပါ။",
"incorrectCollection": "မှားယွင်းသော စုစည်းမှု",
"invalidFileType": "မမှန်ကန်သော ဖိုင်အမျိုးအစား",
"invalidFileTypeValue": "မမှန်ကန်သော ဖိုင်အမျိုးအစား: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "အောင်မြင်စွာ ဖျက်လိုက်ပါပြီ။",
"deleting": "ဖျက်နေဆဲ ...",
"descending": "ဆင်းသက်လာသည်။",
+ "deselectAllRows": "အားလုံးကို မရွေးနိုင်ပါ",
"duplicate": "ပုံတူပွားမည်။",
"duplicateWithoutSaving": "သေချာပါပြီ။",
"edit": "တည်းဖြတ်ပါ။",
@@ -203,6 +204,7 @@
"newPassword": "စကားဝှက် အသစ်",
"noFiltersSet": "စစ်ထုတ်မှုများ မသတ်မှတ်ထားပါ။",
"noLabel": "<မရှိ {{label}}>",
+ "noOptions": "ရွေးချယ်မှုမရှိပါ",
"noResults": "{{label}} မတွေ့ပါ။ {{label}} မရှိသေးသည်ဖြစ်စေ အထက်တွင်ဖော်ပြထားသော စစ်ထုတ်မှုများနှင့် ကိုက်ညီမှုမရှိပါ။",
"noValue": "တန်ဖိုး မရှိပါ။",
"none": "တစ်ခုမှ",
@@ -222,10 +224,13 @@
"saving": "သိမ်းနေဆဲ ...",
"searchBy": "ရှာဖွေပါ။",
"selectAll": "{{count}} {{label}} အားလုံးကို ရွေးပါ",
+ "selectAllRows": "အားလုံးကိုရွေးချယ်ပါ",
"selectValue": "တစ်ခုခုကို ရွေးချယ်ပါ။",
"selectedCount": "{{count}} {{label}} ကို ရွေးထားသည်။",
+ "showAllLabel": "Tunjukkan semua {{label}}",
"sorryNotFound": "ဝမ်းနည်းပါသည်။ သင်ရှာနေတဲ့ဟာ ဒီမှာမရှိပါ။",
"sort": "အစဉ်လိုက်",
+ "sortByLabelDirection": "အစဉ်အလိုက် စီမံခန့်ခွဲထားသည် {{label}} {{direction}}",
"stayOnThisPage": "ဒီမှာပဲ ဆက်နေမည်။",
"submissionSuccessful": "သိမ်းဆည်းမှု အောင်မြင်ပါသည်။",
"submit": "သိမ်းဆည်းမည်။",
@@ -282,6 +287,7 @@
"invalidSelection": "ဤအကွက်တွင် မမှန်ကန်သော ရွေးချယ်မှုတစ်ခုရှိသည်။",
"invalidSelections": "ဤအကွက်တွင် အောက်ပါ မမှန်ကန်သော ရွေးချယ်မှုများ ရှိသည်",
"lessThanMin": "{{value}} သည် {{min}} ထက် ပိုမိုနိမ့်သည်။ ဤသည်ဖြင့် {{label}} အနည်းဆုံးခွင့်ပြုထားသော တန်ဖိုးထက် နိမ့်သည်။",
+ "limitReached": "Had yang dibenarkan telah dicapai, hanya {{max}} item sahaja yang boleh ditambah.",
"longerThanMin": "ဤတန်ဖိုးသည် အနိမ့်ဆုံးအရှည် {{minLength}} စာလုံးထက် ပိုရှည်ရမည်။",
"notValidDate": "\"{{value}}\" သည် တရားဝင်ရက်စွဲမဟုတ်ပါ။",
"required": "ဤအကွက်ကို လိုအပ်သည်။",
diff --git a/src/translations/nb.json b/src/translations/nb.json
index 3493db1de1..17b23706c5 100644
--- a/src/translations/nb.json
+++ b/src/translations/nb.json
@@ -63,8 +63,8 @@
"deletingFile": "Det oppstod en feil under sletting av filen.",
"deletingTitle": "Det oppstod en feil under sletting av {{title}}. Sjekk tilkoblingen og prøv igjen.",
"emailOrPasswordIncorrect": "E-postadressen eller passordet er feil.",
- "followingFieldsInvalid_other": "Følgende felter er ugyldige:",
"followingFieldsInvalid_one": "Følgende felt er ugyldig:",
+ "followingFieldsInvalid_other": "Følgende felter er ugyldige:",
"incorrectCollection": "Ugyldig samling",
"invalidFileType": "Ugyldig filtype",
"invalidFileTypeValue": "Ugyldig filtype: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Slettet.",
"deleting": "Sletter...",
"descending": "Synkende",
+ "deselectAllRows": "Fjern markeringen fra alle rader",
"duplicate": "Dupliser",
"duplicateWithoutSaving": "Dupliser uten å lagre endringer",
"edit": "Redigere",
@@ -203,6 +204,7 @@
"newPassword": "Nytt passord",
"noFiltersSet": "Ingen filtre satt",
"noLabel": "",
+ "noOptions": "Ingen alternativer",
"noResults": "Ingen {{label}} funnet. Enten finnes det ingen {{label}} enda eller ingen matcher filterne du har spesifisert ovenfor.",
"noValue": "Ingen verdi",
"none": "Ingen",
@@ -222,10 +224,13 @@
"saving": "Lagrer...",
"searchBy": "Søk etter {{label}}",
"selectAll": "Velg alle {{count}} {{label}}",
+ "selectAllRows": "Velg alle rader",
"selectValue": "Velg en verdi",
"selectedCount": "{{count}} {{label}} valgt",
+ "showAllLabel": "Vis alle {{label}}",
"sorryNotFound": "Beklager, det er ingenting som samsvarer med forespørselen din.",
"sort": "Sortér",
+ "sortByLabelDirection": "Sorter etter {{label}} {{direction}}",
"stayOnThisPage": "Bli på denne siden",
"submissionSuccessful": "Innsending vellykket.",
"submit": "Send inn",
@@ -282,6 +287,7 @@
"invalidSelection": "Dette feltet har en ugyldig utvalg.",
"invalidSelections": "Dette feltet har følgende ugyldige utvalg:",
"lessThanMin": "{{value}} er mindre enn den tillatte minimale {{label}} på {{min}}.",
+ "limitReached": "Begrensning nådd, bare {{max}} elementer kan legges til.",
"longerThanMin": "Denne verdien må være lengre enn minimumslengden på {{minLength}} tegn.",
"notValidDate": "\"{{value}}\" er ikke en gyldig dato.",
"required": "Dette feltet er påkrevd.",
diff --git a/src/translations/nl.json b/src/translations/nl.json
index f7ccc9895b..147aa0e2f3 100644
--- a/src/translations/nl.json
+++ b/src/translations/nl.json
@@ -63,8 +63,8 @@
"deletingFile": "Er is een fout opgetreden bij het verwijderen van dit bestand.",
"deletingTitle": "Er is een fout opgetreden tijdens het verwijderen van {{title}}. Controleer uw verbinding en probeer het opnieuw.",
"emailOrPasswordIncorrect": "Het opgegeven e-mailadres of wachtwoord is onjuist.",
- "followingFieldsInvalid_other": "De volgende velden zijn ongeldig:",
"followingFieldsInvalid_one": "Het volgende veld is ongeldig:",
+ "followingFieldsInvalid_other": "De volgende velden zijn ongeldig:",
"incorrectCollection": "Ongeldige collectie",
"invalidFileType": "Ongeldig bestandstype",
"invalidFileTypeValue": "Ongeldig bestandstype: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Succesvol verwijderd.",
"deleting": "Verwijderen...",
"descending": "Aflopend",
+ "deselectAllRows": "Deselecteer alle rijen",
"duplicate": "Dupliceren",
"duplicateWithoutSaving": "Dupliceren zonder wijzigingen te bewaren",
"edit": "Bewerk",
@@ -203,6 +204,7 @@
"newPassword": "Nieuw wachtwoord",
"noFiltersSet": "Geen filters ingesteld",
"noLabel": "Geen \"{{label}}\"",
+ "noOptions": "Geen opties",
"noResults": "Geen {{label}} gevonden. Of er bestaat nog geen {{label}}, of niets komt overeen met de hierboven gespecifieerde filters.",
"noValue": "Geen waarde",
"none": "Niets",
@@ -222,10 +224,13 @@
"saving": "Bewaren...",
"searchBy": "Zoeken op {{label}}",
"selectAll": "Alles selecteren {{count}} {{label}}",
+ "selectAllRows": "Selecteer alle rijen",
"selectValue": "Selecteer een waarde",
"selectedCount": "{{count}} {{label}} geselecteerd",
+ "showAllLabel": "Toon alle {{label}}",
"sorryNotFound": "Sorry, er is niets dat overeen komt met uw verzoek.",
"sort": "Sorteer",
+ "sortByLabelDirection": "Sorteer op {{label}} {{direction}}",
"stayOnThisPage": "Blijf op deze pagina",
"submissionSuccessful": "Indiening succesvol.",
"submit": "Indienen",
@@ -282,6 +287,7 @@
"invalidSelection": "Dit veld heeft een ongeldige selectie.",
"invalidSelections": "Dit veld heeft de volgende ongeldige selecties:",
"lessThanMin": "{{value}} is kleiner dan de minimaal toegestane {{label}} van {{min}}.",
+ "limitReached": "Limiet bereikt, er kunnen slechts {{max}} items worden toegevoegd.",
"longerThanMin": "Deze waarde moet langer zijn dan de minimale lengte van {{minLength}} tekens.",
"notValidDate": "\"{{value}}\" is geen geldige datum.",
"required": "Dit veld is verplicht.",
diff --git a/src/translations/pl.json b/src/translations/pl.json
index 99613d87c4..c2e681f639 100644
--- a/src/translations/pl.json
+++ b/src/translations/pl.json
@@ -63,8 +63,8 @@
"deletingFile": "",
"deletingTitle": "Wystąpił błąd podczas usuwania {{title}}. Proszę, sprawdź swoje połączenie i spróbuj ponownie.",
"emailOrPasswordIncorrect": "Podany adres e-mail lub hasło jest nieprawidłowe.",
- "followingFieldsInvalid_other": "Następujące pola są nieprawidłowe:",
"followingFieldsInvalid_one": "To pole jest nieprawidłowe:",
+ "followingFieldsInvalid_other": "Następujące pola są nieprawidłowe:",
"incorrectCollection": "Nieprawidłowa kolekcja",
"invalidFileType": "Nieprawidłowy typ pliku",
"invalidFileTypeValue": "Nieprawidłowy typ pliku: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Skutecznie usunięte.",
"deleting": "Usuwanie...",
"descending": "Malejąco",
+ "deselectAllRows": "Odznacz wszystkie wiersze",
"duplicate": "Zduplikuj",
"duplicateWithoutSaving": "Zduplikuj bez zapisywania zmian",
"edit": "Edytować",
@@ -203,6 +204,7 @@
"newPassword": "Nowe hasło",
"noFiltersSet": "Brak ustawionych filtrów",
"noLabel": "",
+ "noOptions": "Brak opcji",
"noResults": "Nie znaleziono {{label}}. Być może {{label}} jeszcze nie istnieje, albo żaden nie pasuje do filtrów określonych powyżej.",
"noValue": "Brak wartości",
"none": "Nic",
@@ -222,10 +224,13 @@
"saving": "Zapisywanie...",
"searchBy": "Szukaj według",
"selectAll": "Wybierz wszystkie {{liczba}} {{etykieta}}",
+ "selectAllRows": "Wybierz wszystkie wiersze",
"selectValue": "Wybierz wartość",
"selectedCount": "Wybrano {{count}} {{label}}",
+ "showAllLabel": "Pokaż wszystkie {{label}}",
"sorryNotFound": "Przepraszamy — nie ma nic, co odpowiadałoby twojej prośbie.",
"sort": "Sortuj",
+ "sortByLabelDirection": "Sortuj według {{label}} {{direction}}",
"stayOnThisPage": "Pozostań na stronie",
"submissionSuccessful": "Zgłoszenie zakończone powodzeniem.",
"submit": "Zatwierdź",
@@ -282,6 +287,7 @@
"invalidSelection": "To pole ma nieprawidłowy wybór.",
"invalidSelections": "To pole zawiera następujące, nieprawidłowe wybory:",
"lessThanMin": "{{value}} jest mniejsze niż minimalnie dozwolony {{label}} wynoszący {{min}}.",
+ "limitReached": "Osiągnięto limit, można dodać tylko {{max}} elementów.",
"longerThanMin": "Ta wartość musi być dłuższa niż minimalna długość znaków: {{minLength}}.",
"notValidDate": "\"{{value}}\" nie jest prawidłową datą.",
"required": "To pole jest wymagane.",
diff --git a/src/translations/pt.json b/src/translations/pt.json
index 9166a65738..2ea860d7e2 100644
--- a/src/translations/pt.json
+++ b/src/translations/pt.json
@@ -63,8 +63,8 @@
"deletingFile": "Ocorreu um erro ao excluir o arquivo.",
"deletingTitle": "Ocorreu um erro ao excluir {{title}}. Por favor, verifique sua conexão e tente novamente.",
"emailOrPasswordIncorrect": "O email ou senha fornecido está incorreto.",
- "followingFieldsInvalid_other": "Os campos a seguir estão inválidos:",
"followingFieldsInvalid_one": "O campo a seguir está inválido:",
+ "followingFieldsInvalid_other": "Os campos a seguir estão inválidos:",
"incorrectCollection": "Coleção Incorreta",
"invalidFileType": "Tipo de arquivo inválido",
"invalidFileTypeValue": "Tipo de arquivo inválido: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Apagado com sucesso.",
"deleting": "Excluindo...",
"descending": "Decrescente",
+ "deselectAllRows": "Desmarcar todas as linhas",
"duplicate": "Duplicar",
"duplicateWithoutSaving": "Duplicar sem salvar alterações",
"edit": "Editar",
@@ -203,6 +204,7 @@
"newPassword": "Nova Senha",
"noFiltersSet": "Nenhum filtro definido",
"noLabel": "",
+ "noOptions": "Sem opções",
"noResults": "Nenhum {{label}} encontrado. Ou nenhum(a) {{label}} existe ainda, ou nenhum(a) corresponde aos filtros que você especificou acima.",
"noValue": "Nenhum valor",
"none": "Nenhum",
@@ -222,10 +224,13 @@
"saving": "Salvando...",
"searchBy": "Buscar por {{label}}",
"selectAll": "Selecione tudo {{count}} {{label}}",
+ "selectAllRows": "Selecione todas as linhas",
"selectValue": "Selecione um valor",
"selectedCount": "{{count}} {{label}} selecionado",
+ "showAllLabel": "Mostre todos {{label}}",
"sorryNotFound": "Desculpe—não há nada que corresponda à sua requisição.",
"sort": "Ordenar",
+ "sortByLabelDirection": "Ordenar por {{label}} {{direction}}",
"stayOnThisPage": "Permanecer nessa página",
"submissionSuccessful": "Envio bem-sucedido.",
"submit": "Enviar",
@@ -282,6 +287,7 @@
"invalidSelection": "Esse campo tem uma seleção inválida.",
"invalidSelections": "'Esse campo tem as seguintes seleções inválidas:'",
"lessThanMin": "{{value}} é menor que o mínimo permitido de {{label}} que é {{min}}.",
+ "limitReached": "Limite atingido, apenas {{max}} itens podem ser adicionados.",
"longerThanMin": "Esse valor deve ser maior do que o mínimo de {{minLength}} characters.",
"notValidDate": "\"{{value}}\" não é uma data válida.",
"required": "Esse campo é obrigatório.",
diff --git a/src/translations/ro.json b/src/translations/ro.json
index 56298aa24a..b7e17c5b05 100644
--- a/src/translations/ro.json
+++ b/src/translations/ro.json
@@ -63,8 +63,8 @@
"deletingFile": "S-a produs o eroare la ștergerea fișierului.",
"deletingTitle": "S-a produs o eroare în timpul ștergerii {{title}}. Vă rugăm să verificați conexiunea și să încercați din nou.",
"emailOrPasswordIncorrect": "Adresa de e-mail sau parola este incorectă.",
- "followingFieldsInvalid_other": "Următoarele câmpuri nu sunt valabile:",
"followingFieldsInvalid_one": "Următorul câmp nu este valid:",
+ "followingFieldsInvalid_other": "Următoarele câmpuri nu sunt valabile:",
"incorrectCollection": "Colecție incorectă",
"invalidFileType": "Tip de fișier invalid",
"invalidFileTypeValue": "Tip de fișier invalid: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Șters cu succes.",
"deleting": "Deleting...",
"descending": "Descendentă",
+ "deselectAllRows": "Deselectează toate rândurile",
"duplicate": "Duplicați",
"duplicateWithoutSaving": "Duplicați fără salvarea modificărilor",
"edit": "Editează",
@@ -203,6 +204,7 @@
"newPassword": "Parolă nouă",
"noFiltersSet": "Nici un filtru setat",
"noLabel": "",
+ "noOptions": "Fără opțiuni",
"noResults": "Nici un {{label}} găsit. Fie nu există încă niciun {{label}}, fie niciunul nu se potrivește cu filtrele pe care le-ați specificat mai sus..",
"noValue": "Nici o valoare",
"none": "Nici unul",
@@ -222,10 +224,13 @@
"saving": "Salvare...",
"searchBy": "Căutați după {{label}}",
"selectAll": "Selectați toate {{count}} {{label}}",
+ "selectAllRows": "Selectează toate rândurile",
"selectValue": "Selectați o valoare",
"selectedCount": "{{count}} {{label}} selectate",
+ "showAllLabel": "Afișează toate {{eticheta}}",
"sorryNotFound": "Ne pare rău - nu există nimic care să corespundă cu cererea dvs.",
"sort": "Sortează",
+ "sortByLabelDirection": "Sortează după {{etichetă}} {{direcţie}}",
"stayOnThisPage": "Rămâneți pe această pagină",
"submissionSuccessful": "Trimitere cu succes.",
"submit": "Trimite",
@@ -282,6 +287,7 @@
"invalidSelection": "Acest câmp are o selecție invalidă.",
"invalidSelections": "Acest câmp are următoarele selecții invalide:",
"lessThanMin": "{{value}} este mai mic decât valoarea minimă permisă pentru {{label}} de {{min}}.",
+ "limitReached": "Limita atinsă, doar {{max}} elemente pot fi adăugate.",
"longerThanMin": "Această valoare trebuie să fie mai mare decât lungimea minimă de {{minLength}} caractere.",
"notValidDate": "\"{{value}}\" nu este o dată valabilă.",
"required": "Acest câmp este obligatoriu.",
diff --git a/src/translations/ru.json b/src/translations/ru.json
index 04e82d0bfc..f553c90d68 100644
--- a/src/translations/ru.json
+++ b/src/translations/ru.json
@@ -63,8 +63,8 @@
"deletingFile": "Произошла ошибка при удалении файла.",
"deletingTitle": "При удалении {{title}} произошла ошибка. Пожалуйста, проверьте соединение и повторите попытку.",
"emailOrPasswordIncorrect": "Указанный email или пароль неверен.",
- "followingFieldsInvalid_other": "Следующие поля недействительны:",
"followingFieldsInvalid_one": "Следующее поле недействительно:",
+ "followingFieldsInvalid_other": "Следующие поля недействительны:",
"incorrectCollection": "Неправильная Коллекция",
"invalidFileType": "Недопустимый тип файла",
"invalidFileTypeValue": "Недопустимый тип файла: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Удален успешно.",
"deleting": "Удаление...",
"descending": "Уменьшение",
+ "deselectAllRows": "Снять выделение со всех строк",
"duplicate": "Дублировать",
"duplicateWithoutSaving": "Дублирование без сохранения изменений",
"edit": "Редактировать",
@@ -203,6 +204,7 @@
"newPassword": "Новый пароль",
"noFiltersSet": "Фильтры не установлены",
"noLabel": "Без метки",
+ "noOptions": "Нет вариантов",
"noResults": "Не найдено ни одной {{label}}. Либо {{label}} еще не существует, либо ни одна из них не соответствует фильтрам, которые вы указали выше.",
"noValue": "Нет значения",
"none": "Никто",
@@ -222,10 +224,13 @@
"saving": "Сохранение...",
"searchBy": "Искать по",
"selectAll": "Выбрать все {{count}} {{label}}",
+ "selectAllRows": "Выбрать все строки",
"selectValue": "Выбрать значение",
"selectedCount": "{{count}} {{label}} выбрано",
+ "showAllLabel": "Показать все {{label}}",
"sorryNotFound": "К сожалению, ничего подходящего под ваш запрос нет.",
"sort": "Сортировать",
+ "sortByLabelDirection": "Сортировать по {{label}} {{direction}}",
"stayOnThisPage": "Остаться на этой странице",
"submissionSuccessful": "Успешно отправлено.",
"submit": "Отправить",
@@ -282,6 +287,7 @@
"invalidSelection": "В этом поле выбран недопустимый вариант.",
"invalidSelections": "'Это поле содержит следующие неправильные варианты:'",
"lessThanMin": "{{value}} меньше минимально допустимого значения {{label}} {{min}}.",
+ "limitReached": "Достигнут лимит, можно добавить только {{max}} элементов.",
"longerThanMin": "Это значение должно быть больше минимальной длины символов: {{minLength}}.",
"notValidDate": "\"{{value}}\" это не действительная дата.",
"required": "Это обязательное поле.",
diff --git a/src/translations/sv.json b/src/translations/sv.json
index 4fd209c5b2..ad8539c2f5 100644
--- a/src/translations/sv.json
+++ b/src/translations/sv.json
@@ -63,8 +63,8 @@
"deletingFile": "Det gick inte att ta bort filen.",
"deletingTitle": "Det uppstod ett fel vid borttagningen av {{title}}. Vänligen kontrollera din anslutning och försök igen.",
"emailOrPasswordIncorrect": "E-postadressen eller lösenordet som angivits är felaktigt.",
- "followingFieldsInvalid_other": "Följande fält är ogiltiga:",
"followingFieldsInvalid_one": "Följande fält är ogiltigt:",
+ "followingFieldsInvalid_other": "Följande fält är ogiltiga:",
"incorrectCollection": "Felaktig Samling",
"invalidFileType": "Ogiltig filtyp",
"invalidFileTypeValue": "Ogiltig filtyp: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Togs bort framgångsrikt.",
"deleting": "Tar bort...",
"descending": "Fallande",
+ "deselectAllRows": "Avmarkera alla rader",
"duplicate": "Duplicera",
"duplicateWithoutSaving": "Duplicera utan att spara ändringar",
"edit": "Redigera",
@@ -203,6 +204,7 @@
"newPassword": "Nytt Lösenord",
"noFiltersSet": "Inga filter inställda",
"noLabel": "",
+ "noOptions": "Inga alternativ",
"noResults": "Inga {{label}} hittades. Antingen finns inga {{label}} ännu eller matchar ingen filtren du har angett ovan.",
"noValue": "Inget värde",
"none": "Ingen",
@@ -222,10 +224,13 @@
"saving": "Sparar...",
"searchBy": "Sök efter {{label}}",
"selectAll": "Välj alla {{count}} {{label}}",
+ "selectAllRows": "Välj alla rader",
"selectValue": "Välj ett värde",
"selectedCount": "{{count}} {{label}} har valts",
+ "showAllLabel": "Visa alla {{label}}",
"sorryNotFound": "Tyvärr–det finns inget som motsvarar din begäran.",
"sort": "Sortera",
+ "sortByLabelDirection": "Sortera efter {{label}} {{direction}}",
"stayOnThisPage": "Stanna på denna sida",
"submissionSuccessful": "Inlämningen Lyckades.",
"submit": "Lämna in",
@@ -282,6 +287,7 @@
"invalidSelection": "Det här fältet har ett ogiltigt urval.",
"invalidSelections": "Det här fältet har följande ogiltiga val:",
"lessThanMin": "{{value}} är mindre än den minst tillåtna {{label}} av {{min}}.",
+ "limitReached": "Gränsen nådd, endast {{max}} objekt kan läggas till.",
"longerThanMin": "Detta värde måste vara längre än minimilängden på {{minLength}} tecken.",
"notValidDate": "\"{{value}}\" är inte ett giltigt datum.",
"required": "Detta fält är obligatoriskt.",
diff --git a/src/translations/th.json b/src/translations/th.json
index 09968e06e2..6140c3134c 100644
--- a/src/translations/th.json
+++ b/src/translations/th.json
@@ -63,8 +63,8 @@
"deletingFile": "เกิดปัญหาระหว่างการลบไฟล์",
"deletingTitle": "เกิดปัญหาระหว่างการลบ {{title}} โปรดตรวจสอบการเชื่อมต่อของคุณแล้วลองอีกครั้ง",
"emailOrPasswordIncorrect": "อีเมลหรือรหัสผ่านไม่ถูกต้อง",
- "followingFieldsInvalid_other": "ช่องต่อไปนี้ไม่ถูกต้อง:",
"followingFieldsInvalid_one": "ช่องต่อไปนี้ไม่ถูกต้อง:",
+ "followingFieldsInvalid_other": "ช่องต่อไปนี้ไม่ถูกต้อง:",
"incorrectCollection": "Collection ไม่ถูกต้อง",
"invalidFileType": "ประเภทของไฟล์ไม่ถูกต้อง",
"invalidFileTypeValue": "ประเภทของไฟล์ไม่ถูกต้อง: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "ลบสำเร็จ",
"deleting": "กำลังลบ...",
"descending": "มากไปน้อย",
+ "deselectAllRows": "ยกเลิกการเลือกทุกแถว",
"duplicate": "สำเนา",
"duplicateWithoutSaving": "สำเนาโดยไม่บันทึกการแก้ไข",
"edit": "แก้ไข",
@@ -203,6 +204,7 @@
"newPassword": "รหัสผ่านใหม่",
"noFiltersSet": "ไม่มีการกรอง",
"noLabel": "<ไม่มี {{label}}>",
+ "noOptions": "ไม่มีตัวเลือก",
"noResults": "ไม่พบ {{label}} เนื่องจากยังไม่มี {{label}} หรือไม่มี {{label}} ใดตรงกับการกรองด้านบน",
"noValue": "ไม่มีค่า",
"none": "ไม่มี",
@@ -222,10 +224,13 @@
"saving": "กำลังบันทึก...",
"searchBy": "ค้นหาด้วย {{label}}",
"selectAll": "เลือกทั้งหมด {{count}} {{label}}",
+ "selectAllRows": "เลือกทุกแถว",
"selectValue": "เลือกค่า",
"selectedCount": "เลือก {{count}} {{label}} แล้ว",
+ "showAllLabel": "แสดง {{label}} ทั้งหมด",
"sorryNotFound": "ขออภัย ไม่สามารถทำตามคำขอของคุณได้",
"sort": "เรียง",
+ "sortByLabelDirection": "เรียงลำดับตาม {{label}} {{direction}}",
"stayOnThisPage": "อยู่หน้านี้ต่อ",
"submissionSuccessful": "ส่งสำเร็จ",
"submit": "ส่ง",
@@ -282,6 +287,7 @@
"invalidSelection": "ค่าที่เลือกไม่ถูกต้อง",
"invalidSelections": "ค่าที่เลือกไม่ถูกต้องดังนี้:",
"lessThanMin": "{{value}} น้อยกว่าค่าต่ำสุดที่อนุญาตของ {{label}} ซึ่งคือ {{min}}.",
+ "limitReached": "ถึงขีดจำกัดแล้ว, สามารถเพิ่มไอเทมได้เพียง {{max}} ไอเทมเท่านั้น",
"longerThanMin": "ค่าต้องมีความยาวมากกว่า {{minLength}} ตัวอักษร",
"notValidDate": "วันที่ \"{{value}}\" ไม่ถูกต้อง",
"required": "จำเป็นต้องระบุค่า",
diff --git a/src/translations/tr.json b/src/translations/tr.json
index 4a5c10468d..181f0e736f 100644
--- a/src/translations/tr.json
+++ b/src/translations/tr.json
@@ -63,8 +63,8 @@
"deletingFile": "Dosya silinirken bir hatayla karşılaşıldı.",
"deletingTitle": "{{title}} silinirken bir sorun yaşandı. Lütfen internet bağlantınızı kontrol edip tekrar deneyin.",
"emailOrPasswordIncorrect": "Girilen e-posta veya parola hatalı",
- "followingFieldsInvalid_other": "Lütfen geçersiz alanları düzeltin:",
"followingFieldsInvalid_one": "Lütfen geçersiz alanı düzeltin:",
+ "followingFieldsInvalid_other": "Lütfen geçersiz alanları düzeltin:",
"incorrectCollection": "Hatalı koleksiyon",
"invalidFileType": "Geçersiz dosya türü",
"invalidFileTypeValue": "Geçersiz dosya türü: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Başarıyla silindi.",
"deleting": "Siliniyor...",
"descending": "Azalan",
+ "deselectAllRows": "Tüm satırların seçimini kaldır",
"duplicate": "Çoğalt",
"duplicateWithoutSaving": "Ayarları kaydetmeden çoğalt",
"edit": "Düzenle",
@@ -203,6 +204,7 @@
"newPassword": "Yeni parola",
"noFiltersSet": "Tanımlı filtre yok",
"noLabel": "<{{label}} yok>",
+ "noOptions": "Seçenek yok",
"noResults": "{{label}} bulunamadı. Henüz bir {{label}} eklenmemiş olabilir veya seçtiğiniz filtrelerle eşleşen bir sonuç bulunamamış olabilir.",
"noValue": "Değer yok",
"none": "Hiç",
@@ -222,10 +224,13 @@
"saving": "Kaydediliyor...",
"searchBy": "Şuna göre sırala: {{label}}",
"selectAll": "Tüm {{count}} {{label}}'ı seçin",
+ "selectAllRows": "Tüm satırları seçin",
"selectValue": "Bir değer seçin",
"selectedCount": "{{count}} {{label}} seçildi",
+ "showAllLabel": "Tüm {{label}} göster",
"sorryNotFound": "Üzgünüz, isteğinizle eşleşen bir sonuç bulunamadı.",
"sort": "Sırala",
+ "sortByLabelDirection": "{{label}} göre sırala {{direction}}",
"stayOnThisPage": "Bu sayfada kal",
"submissionSuccessful": "Gönderme başarılı",
"submit": "Gönder",
@@ -282,6 +287,7 @@
"invalidSelection": "Bu alanda geçersiz bir seçim mevcut.",
"invalidSelections": "'Bu alan şu geçersiz seçimlere sahip:'",
"lessThanMin": "{{value}} izin verilen minimum {{label}} değerinden daha küçük.",
+ "limitReached": "Sınır aşıldı, yalnızca {{max}} öğe eklenebilir.",
"longerThanMin": "Bu değer minimum {{minLength}} karakterden uzun olmalıdır.",
"notValidDate": "\"{{value}}\" geçerli bir tarih değil.",
"required": "Bu alan gereklidir.",
diff --git a/src/translations/translation-schema.json b/src/translations/translation-schema.json
index 86f6c1687c..804b35cbeb 100644
--- a/src/translations/translation-schema.json
+++ b/src/translations/translation-schema.json
@@ -663,6 +663,9 @@
"descending": {
"type": "string"
},
+ "deselectAllRows": {
+ "type": "string"
+ },
"duplicate": {
"type": "string"
},
@@ -753,6 +756,9 @@
"noLabel": {
"type": "string"
},
+ "noOptions": {
+ "type": "string"
+ },
"noResults": {
"type": "string"
},
@@ -810,18 +816,27 @@
"selectAll": {
"type": "string"
},
+ "selectAllRows": {
+ "type": "string"
+ },
"selectValue": {
"type": "string"
},
"selectedCount": {
"type": "string"
},
+ "showAllLabel": {
+ "type": "string"
+ },
"sorryNotFound": {
"type": "string"
},
"sort": {
"type": "string"
},
+ "sortByLabelDirection": {
+ "type": "string"
+ },
"stayOnThisPage": {
"type": "string"
},
@@ -1108,6 +1123,9 @@
"enterNumber": {
"type": "string"
},
+ "limitReached": {
+ "type": "string"
+ },
"fieldHasNo": {
"type": "string"
},
diff --git a/src/translations/ua.json b/src/translations/ua.json
index 257f37362f..ea6fae68db 100644
--- a/src/translations/ua.json
+++ b/src/translations/ua.json
@@ -63,8 +63,8 @@
"deletingFile": "Виникла помилка під час видалення файлу",
"deletingTitle": "Виникла помилка під час видалення {{title}}, Будь ласка, перевірте ваше з'єднання та спробуйте ще раз.",
"emailOrPasswordIncorrect": "Вказаний email або пароль не є вірними",
- "followingFieldsInvalid_other": "Наступні поля не є вірними",
"followingFieldsInvalid_one": "Наступне поле не є вірним:",
+ "followingFieldsInvalid_other": "Наступні поля не є вірними",
"incorrectCollection": "Неправильна колекція",
"invalidFileType": "Невіртий тип файлу",
"invalidFileTypeValue": "Невірний тип файлу: {{value}}",
@@ -131,8 +131,8 @@
"selectExistingLabel": "Вибрати існуючий {{label}}",
"selectFieldsToEdit": "Виберіть поля для редагування",
"showAll": "Показати все",
- "swapUpload": "Замінити завантаження",
"swapRelationship": "Замінити зв'язок",
+ "swapUpload": "Замінити завантаження",
"textToDisplay": "Текст для відображення",
"toggleBlock": "Перемкнути блок",
"uploadNewLabel": "Завантажити новий {{label}}"
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Успішно видалено.",
"deleting": "Видалення...",
"descending": "В порядку спадання",
+ "deselectAllRows": "Скасувати вибір всіх рядків",
"duplicate": "Дублювати",
"duplicateWithoutSaving": "Дублювання без збереження змін",
"edit": "Редагувати",
@@ -203,6 +204,7 @@
"newPassword": "Новий пароль",
"noFiltersSet": "Відсусті фільтри",
"noLabel": "<без {{label}}>",
+ "noOptions": "Немає варіантів",
"noResults": "Жодного {{label}} не знайдено. Або {{label}} ще не існує, або жодна з них не відповідає фільтрам, які ви задали више.",
"noValue": "Немає значення",
"none": "Ніхто",
@@ -222,10 +224,13 @@
"saving": "Збереження...",
"searchBy": "Шукати по {{label}}",
"selectAll": "Вибрати всі {{count}} {{label}}",
+ "selectAllRows": "Вибрати всі рядки",
"selectValue": "Вибрати значення",
"selectedCount": "Вибрано {{count}} {{label}}",
+ "showAllLabel": "Показати всі {{label}}",
"sorryNotFound": "Вибачте - немає нічого, що відповідало б Вашому запиту.",
"sort": "Сортувати",
+ "sortByLabelDirection": "Сортувати за {{label}} {{direction}}",
"stayOnThisPage": "Залишитись на цій сторінці",
"submissionSuccessful": "Успішно відправлено.",
"submit": "Відправити",
@@ -282,6 +287,7 @@
"invalidSelection": "Це поле має некоректний вибір.",
"invalidSelections": "Це поле має наступні невірні варіанти вибору:",
"lessThanMin": "{{value}} менше, ніж дозволено мінімуму {{label}} в {{min}}.",
+ "limitReached": "Досягнуто межі, можна додати лише {{max}} елементів.",
"longerThanMin": "Це значення має бути більше, ніж мінімальна довжина {{minLength}} characters.",
"notValidDate": "\"{{value}}\" - некоректна дата.",
"required": "Це поле є обов'язковим.",
diff --git a/src/translations/vi.json b/src/translations/vi.json
index ded98ed594..b152604fc2 100644
--- a/src/translations/vi.json
+++ b/src/translations/vi.json
@@ -63,8 +63,8 @@
"deletingFile": "Lỗi - Đã xảy ra vấn đề khi xóa tệp này.",
"deletingTitle": "Lỗi - Đã xảy ra vấn đề khi xóa {{title}}. Hãy kiểm tra kết nối mạng và thử lại.",
"emailOrPasswordIncorrect": "Lỗi - Email hoặc mật khẩu không chính xác.",
- "followingFieldsInvalid_other": "Lỗi - Những fields sau không hợp lệ:",
"followingFieldsInvalid_one": "Lỗi - Field sau không hợp lệ:",
+ "followingFieldsInvalid_other": "Lỗi - Những fields sau không hợp lệ:",
"incorrectCollection": "Lỗi - Collection không hợp lệ.",
"invalidFileType": "Lỗi - Định dạng tệp không hợp lệ.",
"invalidFileTypeValue": "Lỗi - Định dạng tệp không hợp lệ: {{value}}.",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "Đã xoá thành công.",
"deleting": "Đang xóa...",
"descending": "Xếp theo thứ tự giảm dần",
+ "deselectAllRows": "Bỏ chọn tất cả các hàng",
"duplicate": "Tạo bản sao",
"duplicateWithoutSaving": "Không lưu dữ liệu và tạo bản sao",
"edit": "Chỉnh sửa",
@@ -203,6 +204,7 @@
"newPassword": "Mật khảu mới",
"noFiltersSet": "Không có bộ lọc nào được áp dụng",
"noLabel": "",
+ "noOptions": "Không có lựa chọn",
"noResults": "Danh sách rỗng: {{label}}. Có thể {{label}} chưa tồn tại hoặc không có dữ kiện trùng với bộ lọc hiện tại.",
"noValue": "Không có giá trị",
"none": "Không có",
@@ -222,10 +224,13 @@
"saving": "Đang lưu...",
"searchBy": "Tìm với {{label}}",
"selectAll": "Chọn tất cả {{count}} {{label}}",
+ "selectAllRows": "Chọn tất cả các hàng",
"selectValue": "Chọn một giá trị",
"selectedCount": "Đã chọn {{count}} {{label}}",
+ "showAllLabel": "Hiển thị tất cả {{label}}",
"sorryNotFound": "Xin lỗi, không có kết quả nào tương ứng với request của bạn.",
"sort": "Sắp xếp",
+ "sortByLabelDirection": "Sắp xếp theo {{label}} {{direction}}",
"stayOnThisPage": "Ở lại trang này",
"submissionSuccessful": "Gửi thành công.",
"submit": "Gửi",
@@ -282,6 +287,7 @@
"invalidSelection": "Lựa chọn ở field này không hợp lệ.",
"invalidSelections": "'Field này có những lựa chọn không hợp lệ sau:'",
"lessThanMin": "{{value}} nhỏ hơn giá trị tối thiểu cho phép của {{label}} là {{min}}.",
+ "limitReached": "Đã đạt giới hạn, chỉ có thể thêm {{max}} mục.",
"longerThanMin": "Giá trị này cần có độ dài tối thiểu {{minLength}} ký tự.",
"notValidDate": "\"{{value}}\" không phải là một ngày (date) hợp lệ.",
"required": "Field này cần được diền.",
diff --git a/src/translations/zh.json b/src/translations/zh.json
index a37a48d4b7..98e595a938 100644
--- a/src/translations/zh.json
+++ b/src/translations/zh.json
@@ -63,8 +63,8 @@
"deletingFile": "删除文件时出现了错误。",
"deletingTitle": "删除{{title}}时出现了错误。请检查您的连接并重试。",
"emailOrPasswordIncorrect": "提供的电子邮件或密码不正确。",
- "followingFieldsInvalid_other": "以下字段是无效的:",
"followingFieldsInvalid_one": "下面的字段是无效的:",
+ "followingFieldsInvalid_other": "以下字段是无效的:",
"incorrectCollection": "不正确的集合",
"invalidFileType": "无效的文件类型",
"invalidFileTypeValue": "无效的文件类型: {{value}}",
@@ -173,6 +173,7 @@
"deletedSuccessfully": "已成功删除。",
"deleting": "删除中...",
"descending": "降序",
+ "deselectAllRows": "取消选择所有行",
"duplicate": "重复",
"duplicateWithoutSaving": "重复而不保存更改。",
"edit": "编辑",
@@ -203,6 +204,7 @@
"newPassword": "新密码",
"noFiltersSet": "没有设置过滤器",
"noLabel": "<没有{{label}}>",
+ "noOptions": "没有选项",
"noResults": "没有找到{{label}}。{{label}}并不存在或没有符合您上面所指定的过滤器。",
"noValue": "没有值",
"none": "无",
@@ -222,10 +224,13 @@
"saving": "保存中...",
"searchBy": "搜索{{label}}",
"selectAll": "选择所有 {{count}} {{label}}",
+ "selectAllRows": "选择所有行",
"selectValue": "选择一个值",
"selectedCount": "已选择 {{count}} {{label}}",
+ "showAllLabel": "显示所有{{label}}",
"sorryNotFound": "对不起,没有与您的请求相对应的东西。",
"sort": "排序",
+ "sortByLabelDirection": "按{{label}} {{direction}}排序",
"stayOnThisPage": "停留在此页面",
"submissionSuccessful": "提交成功。",
"submit": "提交",
@@ -282,6 +287,7 @@
"invalidSelection": "这个字段有一个无效的选择。",
"invalidSelections": "这个字段有以下无效的选择:",
"lessThanMin": "{{value}}小于允许的最小{{label}},该最小值为{{min}}。",
+ "limitReached": "已达限制,只能添加{{max}}个项目。",
"longerThanMin": "该值必须大于{{minLength}}字符的最小长度",
"notValidDate": "\"{{value}}\"不是一个有效的日期。",
"required": "该字段为必填项目。",
diff --git a/src/types/constants.ts b/src/types/constants.ts
new file mode 100644
index 0000000000..cac6ee6fe3
--- /dev/null
+++ b/src/types/constants.ts
@@ -0,0 +1,17 @@
+export const validOperators = [
+ 'equals',
+ 'contains',
+ 'not_equals',
+ 'in',
+ 'all',
+ 'not_in',
+ 'exists',
+ 'greater_than',
+ 'greater_than_equal',
+ 'less_than',
+ 'less_than_equal',
+ 'like',
+ 'within',
+ 'intersects',
+ 'near',
+] as const;
diff --git a/src/types/index.ts b/src/types/index.ts
index 7397ec4af7..afd0f34bf7 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,21 +1,9 @@
import { TypeWithTimestamps } from '../collections/config/types';
+import { validOperators } from './constants';
export { PayloadRequest } from '../express/types';
-export type Operator =
- | 'equals'
- | 'contains'
- | 'not_equals'
- | 'in'
- | 'all'
- | 'not_in'
- | 'exists'
- | 'greater_than'
- | 'greater_than_equal'
- | 'less_than'
- | 'less_than_equal'
- | 'like'
- | 'near';
+export type Operator = typeof validOperators[number];
export type WhereField = {
[key in Operator]?: unknown;
diff --git a/src/uploads/generateFileData.ts b/src/uploads/generateFileData.ts
index 46cc535fb2..33586505c1 100644
--- a/src/uploads/generateFileData.ts
+++ b/src/uploads/generateFileData.ts
@@ -114,6 +114,7 @@ export const generateFileData = async ({
fileBuffer = await sharpFile.toBuffer({ resolveWithObject: true });
({ mime, ext } = await fromBuffer(fileBuffer.data)); // This is getting an incorrect gif height back.
fileData.width = fileBuffer.info.width;
+ fileData.height = fileBuffer.info.height;
fileData.filesize = fileBuffer.info.size;
// Animated GIFs + WebP aggregate the height from every frame, so we need to use divide by number of pages
diff --git a/src/uploads/imageResizer.ts b/src/uploads/imageResizer.ts
index 7da8f0a313..d28d85a7d7 100644
--- a/src/uploads/imageResizer.ts
+++ b/src/uploads/imageResizer.ts
@@ -106,7 +106,7 @@ const createResult = (
* @returns true if the image needs to be resized, false otherwise
*/
const needsResize = (
- { width: desiredWidth, height: desiredHeigth, withoutEnlargement, withoutReduction }: ImageSize,
+ { width: desiredWidth, height: desiredHeight, withoutEnlargement, withoutReduction }: ImageSize,
original: ProbedImageSize,
): boolean => {
// allow enlargement or prevent reduction (our default is to prevent
@@ -115,7 +115,8 @@ const needsResize = (
return true; // needs resize
}
- const isWidthOrHeightNotDefined = !desiredHeigth || !desiredWidth;
+ const isWidthOrHeightNotDefined = !desiredHeight || !desiredWidth;
+
if (isWidthOrHeightNotDefined) {
// If with and height are not defined, it means there is a format conversion
// and the image needs to be "resized" (transformed).
@@ -123,7 +124,7 @@ const needsResize = (
}
const hasInsufficientWidth = original.width < desiredWidth;
- const hasInsufficientHeight = original.height < desiredHeigth;
+ const hasInsufficientHeight = original.height < desiredHeight;
if (hasInsufficientWidth && hasInsufficientHeight) {
// doesn't need resize - prevent enlargement. This should only happen if both width and height are insufficient.
// if only one dimension is insufficient and the other is sufficient, resizing needs to happen, as the image
diff --git a/src/utilities/checkDuplicateCollections.ts b/src/utilities/checkDuplicateCollections.ts
index 2a34f67c68..03c1146c8e 100644
--- a/src/utilities/checkDuplicateCollections.ts
+++ b/src/utilities/checkDuplicateCollections.ts
@@ -1,9 +1,9 @@
import { DuplicateCollection } from '../errors';
-import { CollectionConfig } from '../collections/config/types';
+import { SanitizedCollectionConfig } from '../collections/config/types';
const getDuplicates = (arr: string[]) => arr.filter((item, index) => arr.indexOf(item) !== index);
-const checkDuplicateCollections = (collections: CollectionConfig[]): void => {
+const checkDuplicateCollections = (collections: SanitizedCollectionConfig[]): void => {
const duplicateSlugs = getDuplicates(collections.map((c) => c.slug));
if (duplicateSlugs.length > 0) {
throw new DuplicateCollection('slug', duplicateSlugs);
diff --git a/templates/ecommerce/.env.example b/templates/ecommerce/.env.example
index 7c16f9e3f2..ecbd588c90 100644
--- a/templates/ecommerce/.env.example
+++ b/templates/ecommerce/.env.example
@@ -6,8 +6,12 @@ PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
STRIPE_SECRET_KEY=
PAYLOAD_PUBLIC_STRIPE_IS_TEST_KEY=true
STRIPE_WEBHOOKS_SIGNING_SECRET=
+PAYLOAD_PUBLIC_DRAFT_SECRET=demo-draft-secret
+REVALIDATION_KEY=demo-revalation-key
# Next.js vars
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
NEXT_PUBLIC_IS_LIVE=
+NEXT_PRIVATE_DRAFT_SECRET=demo-draft-secret
+NEXT_PRIVATE_REVALIDATION_KEY=demo-revalation-key
diff --git a/templates/ecommerce/next.config.js b/templates/ecommerce/next.config.js
index 8947ed2680..4866b830ce 100644
--- a/templates/ecommerce/next.config.js
+++ b/templates/ecommerce/next.config.js
@@ -15,7 +15,7 @@ const nextConfig = {
reactStrictMode: true,
swcMinify: true,
images: {
- domains: ['localhost', process.env.NEXT_PUBLIC_SERVER_URL],
+ domains: ['localhost', process.env.NEXT_PUBLIC_SERVER_URL].filter(Boolean),
// remotePatterns: [
// {
// protocol: 'https',
diff --git a/templates/ecommerce/src/app/(pages)/[slug]/page.tsx b/templates/ecommerce/src/app/(pages)/[slug]/page.tsx
index f3b3f5e156..fe10c1a0cf 100644
--- a/templates/ecommerce/src/app/(pages)/[slug]/page.tsx
+++ b/templates/ecommerce/src/app/(pages)/[slug]/page.tsx
@@ -1,9 +1,10 @@
import React from 'react'
import { Metadata } from 'next'
+import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import { Page } from '../../../payload/payload-types'
-import { staticHome } from '../../../payload/seed/static-home'
+import { staticHome } from '../../../payload/seed/home-static'
import { fetchDoc } from '../../_api/fetchDoc'
import { fetchDocs } from '../../_api/fetchDocs'
import { Blocks } from '../../_components/Blocks'
@@ -11,19 +12,33 @@ import { Hero } from '../../_components/Hero'
import { generateMeta } from '../../_utilities/generateMeta'
export default async function Page({ params: { slug = 'home' } }) {
- let page = await fetchDoc({
- collection: 'pages',
- slug,
- })
+ const { isEnabled: isDraftMode } = draftMode()
- // If no `home` page exists, render a static one using dummy content
- // You should delete this code once you have created a home page in the CMS
- // This is really only useful for those who are demoing this template
+ let page: Page | null = null
+
+ try {
+ page = await fetchDoc({
+ collection: 'pages',
+ slug,
+ draft: isDraftMode,
+ })
+ } catch (error) {
+ // when deploying this template on Payload Cloud, this page needs to build before the APIs are live
+ // so swallow the error here and simply render the page with fallback data where necessary
+ // in production you may want to redirect to a 404 page or at least log the error somewhere
+ // console.error(error)
+ }
+
+ // if no `home` page exists, render a static one using dummy content
+ // you should delete this code once you have a home page in the CMS
+ // this is really only useful for those who are demoing this template
if (!page && slug === 'home') {
page = staticHome
}
- if (!page) return notFound()
+ if (!page) {
+ return notFound()
+ }
const { hero, layout } = page
@@ -39,16 +54,31 @@ export default async function Page({ params: { slug = 'home' } }) {
}
export async function generateStaticParams() {
- const pages = await fetchDocs('pages')
-
- return pages?.map(({ slug }) => slug)
+ try {
+ const pages = await fetchDocs('pages')
+ return pages?.map(({ slug }) => slug)
+ } catch (error) {
+ return []
+ }
}
export async function generateMetadata({ params: { slug = 'home' } }): Promise {
- let page = await fetchDoc({
- collection: 'pages',
- slug,
- })
+ const { isEnabled: isDraftMode } = draftMode()
+
+ let page: Page | null = null
+
+ try {
+ page = await fetchDoc({
+ collection: 'pages',
+ slug,
+ draft: isDraftMode,
+ })
+ } catch (error) {
+ // don't throw an error if the fetch fails
+ // this is so that we can render a static home page for the demo
+ // when deploying this template on Payload Cloud, this page needs to build before the APIs are live
+ // in production you may want to redirect to a 404 page or at least log the error somewhere
+ }
if (!page && slug === 'home') {
page = staticHome
diff --git a/templates/ecommerce/src/app/(pages)/cart/CartPage/index.tsx b/templates/ecommerce/src/app/(pages)/cart/CartPage/index.tsx
index 96844a2540..602fcd29cd 100644
--- a/templates/ecommerce/src/app/(pages)/cart/CartPage/index.tsx
+++ b/templates/ecommerce/src/app/(pages)/cart/CartPage/index.tsx
@@ -19,9 +19,8 @@ export const CartPage: React.FC<{
settings: Settings
page: Page
}> = props => {
- const {
- settings: { productsPage },
- } = props
+ const { settings } = props
+ const { productsPage } = settings || {}
const { user } = useAuth()
@@ -103,7 +102,7 @@ export const CartPage: React.FC<{
- navigate to the admin dashboard
+ edit this product in the admin panel
{'.'}
diff --git a/templates/ecommerce/src/app/(pages)/cart/page.tsx b/templates/ecommerce/src/app/(pages)/cart/page.tsx
index 482b7acf7b..5abfa63cc0 100644
--- a/templates/ecommerce/src/app/(pages)/cart/page.tsx
+++ b/templates/ecommerce/src/app/(pages)/cart/page.tsx
@@ -1,9 +1,11 @@
import React, { Fragment } from 'react'
import { Metadata } from 'next'
+import { notFound } from 'next/navigation'
-import { Page } from '../../../payload/payload-types'
+import { Page, Settings } from '../../../payload/payload-types'
+import { staticCart } from '../../../payload/seed/cart-static'
import { fetchDoc } from '../../_api/fetchDoc'
-import { fetchGlobals } from '../../_api/fetchGlobals'
+import { fetchSettings } from '../../_api/fetchGlobals'
import { Blocks } from '../../_components/Blocks'
import { Gutter } from '../../_components/Gutter'
import { Hero } from '../../_components/Hero'
@@ -14,12 +16,41 @@ import { CartPage } from './CartPage'
import classes from './index.module.scss'
export default async function Cart() {
- const { settings } = await fetchGlobals()
+ let page: Page | null = null
- const page = await fetchDoc({
- collection: 'pages',
- slug: 'cart',
- })
+ try {
+ page = await fetchDoc({
+ collection: 'pages',
+ slug: 'cart',
+ })
+ } catch (error) {
+ // when deploying this template on Payload Cloud, this page needs to build before the APIs are live
+ // so swallow the error here and simply render the page with fallback data where necessary
+ // in production you may want to redirect to a 404 page or at least log the error somewhere
+ // console.error(error)
+ }
+
+ // if no `cart` page exists, render a static one using dummy content
+ // you should delete this code once you have a cart page in the CMS
+ // this is really only useful for those who are demoing this template
+ if (!page) {
+ page = staticCart
+ }
+
+ if (!page) {
+ return notFound()
+ }
+
+ let settings: Settings | null = null
+
+ try {
+ settings = await fetchSettings()
+ } catch (error) {
+ // when deploying this template on Payload Cloud, this page needs to build before the APIs are live
+ // so swallow the error here and simply render the page with fallback data where necessary
+ // in production you may want to redirect to a 404 page or at least log the error somewhere
+ // console.error(error)
+ }
return (
@@ -61,10 +92,23 @@ export default async function Cart() {
}
export async function generateMetadata(): Promise {
- const page = await fetchDoc({
- collection: 'pages',
- slug: 'cart',
- })
+ let page: Page | null = null
+
+ try {
+ page = await fetchDoc({
+ collection: 'pages',
+ slug: 'cart',
+ })
+ } catch (error) {
+ // don't throw an error if the fetch fails
+ // this is so that we can render a static cart page for the demo
+ // when deploying this template on Payload Cloud, this page needs to build before the APIs are live
+ // in production you may want to redirect to a 404 page or at least log the error somewhere
+ }
+
+ if (!page) {
+ page = staticCart
+ }
return generateMeta({ doc: page })
}
diff --git a/templates/ecommerce/src/app/(pages)/checkout/CheckoutPage/index.tsx b/templates/ecommerce/src/app/(pages)/checkout/CheckoutPage/index.tsx
index 35fb78cdfe..50b4026ab1 100644
--- a/templates/ecommerce/src/app/(pages)/checkout/CheckoutPage/index.tsx
+++ b/templates/ecommerce/src/app/(pages)/checkout/CheckoutPage/index.tsx
@@ -130,7 +130,7 @@ export const CheckoutPage: React.FC<{
- navigate to the admin dashboard
+ edit this product in the admin panel
{'.'}
diff --git a/templates/ecommerce/src/app/(pages)/checkout/page.tsx b/templates/ecommerce/src/app/(pages)/checkout/page.tsx
index 2a1170b416..cecc57b63a 100644
--- a/templates/ecommerce/src/app/(pages)/checkout/page.tsx
+++ b/templates/ecommerce/src/app/(pages)/checkout/page.tsx
@@ -1,7 +1,8 @@
import React, { Fragment } from 'react'
import { Metadata } from 'next'
-import { fetchGlobals } from '../../_api/fetchGlobals'
+import { Settings } from '../../../payload/payload-types'
+import { fetchSettings } from '../../_api/fetchGlobals'
import { Gutter } from '../../_components/Gutter'
import { Message } from '../../_components/Message'
import { LowImpactHero } from '../../_heros/LowImpact'
@@ -18,7 +19,14 @@ export default async function Checkout() {
)}&redirect=${encodeURIComponent('/checkout')}`,
})
- const { settings } = await fetchGlobals()
+ let settings: Settings | null = null
+
+ try {
+ settings = await fetchSettings()
+ } catch (error) {
+ // no need to redirect to 404 here, just simply render the page with fallback data where necessary
+ console.error(error) // eslint-disable-line no-console
+ }
return (
diff --git a/templates/ecommerce/src/app/(pages)/logout/LogoutPage/index.tsx b/templates/ecommerce/src/app/(pages)/logout/LogoutPage/index.tsx
index a88d007ad1..177ecc146f 100644
--- a/templates/ecommerce/src/app/(pages)/logout/LogoutPage/index.tsx
+++ b/templates/ecommerce/src/app/(pages)/logout/LogoutPage/index.tsx
@@ -9,9 +9,8 @@ import { useAuth } from '../../../_providers/Auth'
export const LogoutPage: React.FC<{
settings: Settings
}> = props => {
- const {
- settings: { productsPage },
- } = props
+ const { settings } = props
+ const { productsPage } = settings || {}
const { logout } = useAuth()
const [success, setSuccess] = useState('')
const [error, setError] = useState('')
diff --git a/templates/ecommerce/src/app/(pages)/logout/page.tsx b/templates/ecommerce/src/app/(pages)/logout/page.tsx
index 302c7cfab8..681a99207b 100644
--- a/templates/ecommerce/src/app/(pages)/logout/page.tsx
+++ b/templates/ecommerce/src/app/(pages)/logout/page.tsx
@@ -1,7 +1,8 @@
import React from 'react'
import { Metadata } from 'next'
-import { fetchGlobals } from '../../_api/fetchGlobals'
+import { Settings } from '../../../payload/payload-types'
+import { fetchSettings } from '../../_api/fetchGlobals'
import { Gutter } from '../../_components/Gutter'
import { mergeOpenGraph } from '../../_utilities/mergeOpenGraph'
import { LogoutPage } from './LogoutPage'
@@ -9,7 +10,16 @@ import { LogoutPage } from './LogoutPage'
import classes from './index.module.scss'
export default async function Logout() {
- const { settings } = await fetchGlobals()
+ let settings: Settings | null = null
+
+ try {
+ settings = await fetchSettings()
+ } catch (error) {
+ // when deploying this template on Payload Cloud, this page needs to build before the APIs are live
+ // so swallow the error here and simply render the page with fallback data where necessary
+ // in production you may want to redirect to a 404 page or at least log the error somewhere
+ // console.error(error)
+ }
return (
diff --git a/templates/ecommerce/src/app/(pages)/orders/[id]/page.tsx b/templates/ecommerce/src/app/(pages)/orders/[id]/page.tsx
index 80a2859201..48bbedc140 100644
--- a/templates/ecommerce/src/app/(pages)/orders/[id]/page.tsx
+++ b/templates/ecommerce/src/app/(pages)/orders/[id]/page.tsx
@@ -22,17 +22,24 @@ export default async function Order({ params: { id } }) {
)}&redirect=${encodeURIComponent(`/order/${id}`)}`,
})
- const order: Order = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/orders/${id}`, {
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `JWT ${token}`,
- },
- })?.then(async res => {
- const json = await res.json()
- if ('error' in json && json.error) notFound()
- if ('errors' in json && json.errors) notFound()
- return json
- })
+ let order: Order | null = null
+
+ try {
+ order = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/orders/${id}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `JWT ${token}`,
+ },
+ })?.then(async res => {
+ if (!res.ok) notFound()
+ const json = await res.json()
+ if ('error' in json && json.error) notFound()
+ if ('errors' in json && json.errors) notFound()
+ return json
+ })
+ } catch (error) {
+ console.error(error) // eslint-disable-line no-console
+ }
if (!order) {
notFound()
@@ -92,7 +99,7 @@ export default async function Order({ params: { id } }) {
- navigate to the admin dashboard
+ edit this product in the admin panel
{'.'}
diff --git a/templates/ecommerce/src/app/(pages)/orders/page.tsx b/templates/ecommerce/src/app/(pages)/orders/page.tsx
index 6f6a184c1d..75a261d75c 100644
--- a/templates/ecommerce/src/app/(pages)/orders/page.tsx
+++ b/templates/ecommerce/src/app/(pages)/orders/page.tsx
@@ -21,20 +21,30 @@ export default async function Orders() {
)}&redirect=${encodeURIComponent('/orders')}`,
})
- const orders: Order[] = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/orders`, {
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `JWT ${token}`,
- },
- cache: 'no-store',
- })
- ?.then(async res => {
- const json = await res.json()
- if ('error' in json && json.error) notFound()
- if ('errors' in json && json.errors) notFound()
- return json
+ let orders: Order[] | null = null
+
+ try {
+ orders = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/orders`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `JWT ${token}`,
+ },
+ cache: 'no-store',
})
- ?.then(json => json.docs)
+ ?.then(async res => {
+ if (!res.ok) notFound()
+ const json = await res.json()
+ if ('error' in json && json.error) notFound()
+ if ('errors' in json && json.errors) notFound()
+ return json
+ })
+ ?.then(json => json.docs)
+ } catch (error) {
+ // when deploying this template on Payload Cloud, this page needs to build before the APIs are live
+ // so swallow the error here and simply render the page with fallback data where necessary
+ // in production you may want to redirect to a 404 page or at least log the error somewhere
+ // console.error(error)
+ }
return (
diff --git a/templates/ecommerce/src/app/(pages)/products/[slug]/page.tsx b/templates/ecommerce/src/app/(pages)/products/[slug]/page.tsx
index 2325c79291..358a0adf36 100644
--- a/templates/ecommerce/src/app/(pages)/products/[slug]/page.tsx
+++ b/templates/ecommerce/src/app/(pages)/products/[slug]/page.tsx
@@ -1,5 +1,6 @@
import React from 'react'
import { Metadata } from 'next'
+import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import { Product, Product as ProductType } from '../../../../payload/payload-types'
@@ -11,12 +12,23 @@ import { ProductHero } from '../../../_heros/Product'
import { generateMeta } from '../../../_utilities/generateMeta'
export default async function Product({ params: { slug } }) {
- const product = await fetchDoc({
- collection: 'products',
- slug,
- })
+ const { isEnabled: isDraftMode } = draftMode()
- if (!product) return notFound()
+ let product: Product | null = null
+
+ try {
+ product = await fetchDoc({
+ collection: 'products',
+ slug,
+ draft: isDraftMode,
+ })
+ } catch (error) {
+ console.error(error) // eslint-disable-line no-console
+ }
+
+ if (!product) {
+ notFound()
+ }
const { layout } = product
@@ -30,16 +42,26 @@ export default async function Product({ params: { slug } }) {
}
export async function generateStaticParams() {
- const products = await fetchDocs('products')
-
- return products?.map(({ slug }) => slug)
+ try {
+ const products = await fetchDocs('products')
+ return products?.map(({ slug }) => slug)
+ } catch (error) {
+ return []
+ }
}
export async function generateMetadata({ params: { slug } }): Promise {
- const product = await fetchDoc({
- collection: 'products',
- slug,
- })
+ const { isEnabled: isDraftMode } = draftMode()
+
+ let product: Product | null = null
+
+ try {
+ product = await fetchDoc({
+ collection: 'products',
+ slug,
+ draft: isDraftMode,
+ })
+ } catch (error) {}
return generateMeta({ doc: product })
}
diff --git a/templates/ecommerce/src/app/_api/fetchDoc.ts b/templates/ecommerce/src/app/_api/fetchDoc.ts
index c39ced3922..126dcc2be6 100644
--- a/templates/ecommerce/src/app/_api/fetchDoc.ts
+++ b/templates/ecommerce/src/app/_api/fetchDoc.ts
@@ -1,7 +1,10 @@
+import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies'
+
import type { Config } from '../../payload/payload-types'
import { ORDER } from '../_graphql/orders'
import { PAGE } from '../_graphql/pages'
import { PRODUCT } from '../_graphql/products'
+import { payloadToken } from './token'
const queryMap = {
pages: {
@@ -22,22 +25,31 @@ export const fetchDoc = async (args: {
collection: keyof Config['collections']
slug?: string
id?: string
- token?: string
+ draft?: boolean
}): Promise => {
- const { collection, slug, token } = args || {}
+ const { collection, slug, draft } = args || {}
if (!queryMap[collection]) throw new Error(`Collection ${collection} not found`)
+ let token: RequestCookie | undefined
+
+ if (draft) {
+ const { cookies } = await import('next/headers')
+ token = cookies().get(payloadToken)
+ }
+
const doc: T = await fetch(`${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
- ...(token ? { Authorization: `JWT ${token}` } : {}),
+ ...(token?.value && draft ? { Authorization: `JWT ${token.value}` } : {}),
},
+ next: { tags: [`${collection}_${slug}`] },
body: JSON.stringify({
query: queryMap[collection].query,
variables: {
slug,
+ draft,
},
}),
})
diff --git a/templates/ecommerce/src/app/_api/fetchDocs.ts b/templates/ecommerce/src/app/_api/fetchDocs.ts
index 34ffdf3681..2198cb7645 100644
--- a/templates/ecommerce/src/app/_api/fetchDocs.ts
+++ b/templates/ecommerce/src/app/_api/fetchDocs.ts
@@ -1,7 +1,10 @@
+import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies'
+
import type { Config } from '../../payload/payload-types'
import { ORDERS } from '../_graphql/orders'
import { PAGES } from '../_graphql/pages'
import { PRODUCTS } from '../_graphql/products'
+import { payloadToken } from './token'
const queryMap = {
pages: {
@@ -18,14 +21,26 @@ const queryMap = {
},
}
-export const fetchDocs = async (collection: keyof Config['collections']): Promise => {
+export const fetchDocs = async (
+ collection: keyof Config['collections'],
+ draft?: boolean,
+): Promise => {
if (!queryMap[collection]) throw new Error(`Collection ${collection} not found`)
+ let token: RequestCookie | undefined
+
+ if (draft) {
+ const { cookies } = await import('next/headers')
+ token = cookies().get(payloadToken)
+ }
+
const docs: T[] = await fetch(`${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+ ...(token?.value && draft ? { Authorization: `JWT ${token.value}` } : {}),
},
+ next: { tags: [collection] },
body: JSON.stringify({
query: queryMap[collection].query,
}),
diff --git a/templates/ecommerce/src/app/_api/fetchGlobals.ts b/templates/ecommerce/src/app/_api/fetchGlobals.ts
index 876c929f63..c518d8d436 100644
--- a/templates/ecommerce/src/app/_api/fetchGlobals.ts
+++ b/templates/ecommerce/src/app/_api/fetchGlobals.ts
@@ -1,8 +1,10 @@
import type { Footer, Header, Settings } from '../../payload/payload-types'
import { FOOTER_QUERY, HEADER_QUERY, SETTINGS_QUERY } from '../_graphql/globals'
-async function getSettings(): Promise {
- const settings = await fetch(`${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/graphql`, {
+export async function fetchSettings(): Promise {
+ if (!process.env.NEXT_PUBLIC_SERVER_URL) throw new Error('NEXT_PUBLIC_SERVER_URL not found')
+
+ const settings = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -11,7 +13,10 @@ async function getSettings(): Promise {
query: SETTINGS_QUERY,
}),
})
- ?.then(res => res.json())
+ ?.then(res => {
+ if (!res.ok) throw new Error('Error fetching doc')
+ return res.json()
+ })
?.then(res => {
if (res?.errors) throw new Error(res?.errors[0]?.message || 'Error fetching settings')
return res.data?.Settings
@@ -20,8 +25,10 @@ async function getSettings(): Promise {
return settings
}
-async function getHeader(): Promise {
- const header = await fetch(`${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/graphql`, {
+export async function fetchHeader(): Promise {
+ if (!process.env.NEXT_PUBLIC_SERVER_URL) throw new Error('NEXT_PUBLIC_SERVER_URL not found')
+
+ const header = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -30,7 +37,10 @@ async function getHeader(): Promise {
query: HEADER_QUERY,
}),
})
- ?.then(res => res.json())
+ ?.then(res => {
+ if (!res.ok) throw new Error('Error fetching doc')
+ return res.json()
+ })
?.then(res => {
if (res?.errors) throw new Error(res?.errors[0]?.message || 'Error fetching header')
return res.data?.Header
@@ -39,8 +49,10 @@ async function getHeader(): Promise {
return header
}
-async function getFooter(): Promise
diff --git a/templates/ecommerce/src/app/api/exit-preview/route.ts b/templates/ecommerce/src/app/api/exit-preview/route.ts
new file mode 100644
index 0000000000..0c15caea1e
--- /dev/null
+++ b/templates/ecommerce/src/app/api/exit-preview/route.ts
@@ -0,0 +1,6 @@
+import { draftMode } from 'next/headers'
+
+export async function GET(): Promise {
+ draftMode().disable()
+ return new Response('Draft mode is disabled')
+}
diff --git a/templates/ecommerce/src/app/api/preview/route.ts b/templates/ecommerce/src/app/api/preview/route.ts
new file mode 100644
index 0000000000..fdc9c698bf
--- /dev/null
+++ b/templates/ecommerce/src/app/api/preview/route.ts
@@ -0,0 +1,49 @@
+import { draftMode } from 'next/headers'
+import { redirect } from 'next/navigation'
+
+import { payloadToken } from '../../_api/token'
+
+export async function GET(
+ req: Request & {
+ cookies: {
+ get: (name: string) => {
+ value: string
+ }
+ }
+ },
+): Promise {
+ const token = req.cookies.get(payloadToken)?.value
+ const { searchParams } = new URL(req.url)
+ const url = searchParams.get('url')
+ const secret = searchParams.get('secret')
+
+ if (!url) {
+ return new Response('No URL provided', { status: 404 })
+ }
+
+ if (!token) {
+ new Response('You are not allowed to preview this page', { status: 403 })
+ }
+
+ // validate the Payload token
+ const userReq = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/me`, {
+ headers: {
+ Authorization: `JWT ${token}`,
+ },
+ })
+
+ const userRes = await userReq.json()
+
+ if (!userReq.ok || !userRes?.user) {
+ draftMode().disable()
+ return new Response('You are not allowed to preview this page', { status: 403 })
+ }
+
+ if (secret !== process.env.NEXT_PRIVATE_DRAFT_SECRET) {
+ return new Response('Invalid token', { status: 401 })
+ }
+
+ draftMode().enable()
+
+ redirect(url)
+}
diff --git a/templates/ecommerce/src/app/api/revalidate/route.ts b/templates/ecommerce/src/app/api/revalidate/route.ts
new file mode 100644
index 0000000000..01045d812c
--- /dev/null
+++ b/templates/ecommerce/src/app/api/revalidate/route.ts
@@ -0,0 +1,20 @@
+import { revalidateTag } from 'next/cache'
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+
+export async function GET(request: NextRequest): Promise {
+ const collection = request.nextUrl.searchParams.get('collection')
+ const slug = request.nextUrl.searchParams.get('slug')
+ const secret = request.nextUrl.searchParams.get('secret')
+
+ if (secret !== process.env.NEXT_PRIVATE_REVALIDATION_KEY) {
+ return NextResponse.json({ revalidated: false, now: Date.now() })
+ }
+
+ if (typeof collection === 'string' && typeof slug === 'string') {
+ revalidateTag(`${collection}_${slug}`)
+ return NextResponse.json({ revalidated: true, now: Date.now() })
+ }
+
+ return NextResponse.json({ revalidated: false, now: Date.now() })
+}
diff --git a/templates/ecommerce/src/payload/collections/Pages/hooks/revalidatePage.ts b/templates/ecommerce/src/payload/collections/Pages/hooks/revalidatePage.ts
new file mode 100644
index 0000000000..ba2096690f
--- /dev/null
+++ b/templates/ecommerce/src/payload/collections/Pages/hooks/revalidatePage.ts
@@ -0,0 +1,15 @@
+import type { AfterChangeHook } from 'payload/dist/collections/config/types'
+
+import { revalidate } from '../../../utilities/revalidate'
+
+// Revalidate the page in the background, so the user doesn't have to wait
+// Notice that the hook itself is not async and we are not awaiting `revalidate`
+// Only revalidate existing docs that are published
+// Don't scope to `operation` in order to purge static demo pages
+export const revalidatePage: AfterChangeHook = ({ doc, req: { payload } }) => {
+ if (doc._status === 'published') {
+ revalidate({ payload, collection: 'pages', slug: doc.slug })
+ }
+
+ return doc
+}
diff --git a/templates/ecommerce/src/payload/collections/Pages/index.ts b/templates/ecommerce/src/payload/collections/Pages/index.ts
index 416af30e4b..d05ca745fd 100644
--- a/templates/ecommerce/src/payload/collections/Pages/index.ts
+++ b/templates/ecommerce/src/payload/collections/Pages/index.ts
@@ -1,3 +1,5 @@
+import dotenv from 'dotenv'
+import path from 'path'
import type { CollectionConfig } from 'payload/types'
import { admins } from '../../access/admins'
@@ -10,17 +12,26 @@ import { slugField } from '../../fields/slug'
import { populateArchiveBlock } from '../../hooks/populateArchiveBlock'
import { populatePublishedDate } from '../../hooks/populatePublishedDate'
import { adminsOrPublished } from './access/adminsOrPublished'
+import { revalidatePage } from './hooks/revalidatePage'
+
+dotenv.config({
+ path: path.resolve(__dirname, '../.env'),
+})
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'updatedAt'],
- preview: doc =>
- `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/${doc.slug !== 'home' ? doc.slug : ''}`,
+ preview: doc => {
+ return `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/preview?url=${encodeURIComponent(
+ `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/${doc.slug !== 'home' ? doc.slug : ''}`,
+ )}&secret=${process.env.PAYLOAD_PUBLIC_DRAFT_SECRET}`
+ },
},
hooks: {
beforeChange: [populatePublishedDate],
+ afterChange: [revalidatePage],
afterRead: [populateArchiveBlock],
},
versions: {
diff --git a/templates/ecommerce/src/payload/collections/Products/hooks/revalidateProduct.ts b/templates/ecommerce/src/payload/collections/Products/hooks/revalidateProduct.ts
new file mode 100644
index 0000000000..c769aff440
--- /dev/null
+++ b/templates/ecommerce/src/payload/collections/Products/hooks/revalidateProduct.ts
@@ -0,0 +1,15 @@
+import type { AfterChangeHook } from 'payload/dist/collections/config/types'
+
+import { revalidate } from '../../../utilities/revalidate'
+
+// Revalidate the page in the background, so the user doesn't have to wait
+// Notice that the hook itself is not async and we are not awaiting `revalidate`
+// Only revalidate existing docs that are published
+// Don't scope to `operation` in order to purge static demo pages
+export const revalidateProduct: AfterChangeHook = ({ doc, req: { payload } }) => {
+ if (doc._status === 'published') {
+ revalidate({ payload, collection: 'products', slug: doc.slug })
+ }
+
+ return doc
+}
diff --git a/templates/ecommerce/src/payload/collections/Products/index.ts b/templates/ecommerce/src/payload/collections/Products/index.ts
index fb44af8d4b..e43df3d003 100644
--- a/templates/ecommerce/src/payload/collections/Products/index.ts
+++ b/templates/ecommerce/src/payload/collections/Products/index.ts
@@ -11,6 +11,7 @@ import { populatePublishedDate } from '../../hooks/populatePublishedDate'
import { checkUserPurchases } from './access/checkUserPurchases'
import { beforeProductChange } from './hooks/beforeChange'
import { deleteProductFromCarts } from './hooks/deleteProductFromCarts'
+import { revalidateProduct } from './hooks/revalidateProduct'
import { ProductSelect } from './ui/ProductSelect'
const Products: CollectionConfig = {
@@ -18,11 +19,15 @@ const Products: CollectionConfig = {
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'stripeProductID', '_status'],
- preview: doc =>
- `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/products/${doc.slug !== 'home' ? doc.slug : ''}`,
+ preview: doc => {
+ return `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/preview?url=${encodeURIComponent(
+ `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/products/${doc.slug}`,
+ )}&secret=${process.env.PAYLOAD_PUBLIC_DRAFT_SECRET}`
+ },
},
hooks: {
beforeChange: [populatePublishedDate, beforeProductChange],
+ afterChange: [revalidateProduct],
afterRead: [populateArchiveBlock],
afterDelete: [deleteProductFromCarts],
},
diff --git a/templates/ecommerce/src/payload/fields/richText/label/plugin.ts b/templates/ecommerce/src/payload/fields/richText/label/plugin.ts
index 926b10ace1..7a43c73a9d 100644
--- a/templates/ecommerce/src/payload/fields/richText/label/plugin.ts
+++ b/templates/ecommerce/src/payload/fields/richText/label/plugin.ts
@@ -5,7 +5,7 @@ type RichTextPlugin = Exclude[0]
const withLabel: RichTextPlugin = incomingEditor => {
const editor: BaseEditor & {
- shouldBreakOutOnEnter?: (element: any) => boolean
+ shouldBreakOutOnEnter?: (element: any) => boolean // eslint-disable-line @typescript-eslint/no-explicit-any
} = incomingEditor
const { shouldBreakOutOnEnter } = editor
diff --git a/templates/ecommerce/src/payload/fields/richText/largeBody/plugin.ts b/templates/ecommerce/src/payload/fields/richText/largeBody/plugin.ts
index 991b9cdcda..029745817b 100644
--- a/templates/ecommerce/src/payload/fields/richText/largeBody/plugin.ts
+++ b/templates/ecommerce/src/payload/fields/richText/largeBody/plugin.ts
@@ -5,7 +5,7 @@ type RichTextPlugin = Exclude[0]
const withLargeBody: RichTextPlugin = incomingEditor => {
const editor: BaseEditor & {
- shouldBreakOutOnEnter?: (element: any) => boolean
+ shouldBreakOutOnEnter?: (element: any) => boolean // eslint-disable-line @typescript-eslint/no-explicit-any
} = incomingEditor
const { shouldBreakOutOnEnter } = editor
diff --git a/templates/ecommerce/src/payload/seed/cart-static.ts b/templates/ecommerce/src/payload/seed/cart-static.ts
new file mode 100644
index 0000000000..c6134e75da
--- /dev/null
+++ b/templates/ecommerce/src/payload/seed/cart-static.ts
@@ -0,0 +1,109 @@
+import type { Page } from '../payload-types'
+
+export const staticCart: Page = {
+ id: '',
+ title: 'Cart',
+ slug: 'cart',
+ createdAt: '',
+ updatedAt: '',
+ _status: 'published',
+ meta: {
+ title: 'Cart',
+ description:
+ 'Your cart will sync to your user profile so you can continue shopping from any device.',
+ },
+ hero: {
+ type: 'lowImpact',
+ links: [],
+ media: '',
+ richText: [
+ {
+ type: 'h1',
+ children: [
+ {
+ text: 'Cart',
+ },
+ ],
+ },
+ {
+ type: 'p',
+ children: [
+ {
+ text: 'This cart saves to local storage so you can continue shopping later. Once you authenticate with Payload, your cart will sync to your user profile so you can continue shopping from any device. ',
+ },
+ {
+ text: 'Your database does not have a cart page yet.',
+ bold: true,
+ },
+ {
+ text: " You are currently seeing a demo page. To manage this page's content, ",
+ },
+ {
+ type: 'link',
+ linkType: 'custom',
+ url: '/admin',
+ children: [
+ {
+ text: 'log in to the admin dashboard',
+ },
+ ],
+ },
+ {
+ text: ' and click "seed your database". If you have already seeded your database, ',
+ },
+ {
+ text: 'you may need to hard refresh this page to clear the cached request.',
+ bold: true,
+ },
+ ],
+ },
+ ],
+ },
+ layout: [
+ {
+ richText: [
+ {
+ children: [
+ {
+ text: 'Create a cart page',
+ },
+ ],
+ type: 'h4',
+ },
+ {
+ children: [
+ {
+ text: 'Your database is does not have a cart page yet. To seed your database with a cart page, ',
+ },
+ {
+ type: 'link',
+ linkType: 'custom',
+ url: '/admin',
+ children: [
+ {
+ text: 'log in to the admin dashboard',
+ },
+ ],
+ },
+ {
+ text: ' and click "seed your database".',
+ },
+ ],
+ },
+ ],
+ links: [
+ {
+ link: {
+ type: 'custom',
+ url: '/admin',
+ label: 'Go to dashboard',
+ appearance: 'primary',
+ reference: null,
+ },
+ },
+ ],
+ blockName: 'CTA',
+ blockType: 'cta',
+ },
+ ],
+}
diff --git a/templates/ecommerce/src/payload/seed/static-home.ts b/templates/ecommerce/src/payload/seed/home-static.ts
similarity index 100%
rename from templates/ecommerce/src/payload/seed/static-home.ts
rename to templates/ecommerce/src/payload/seed/home-static.ts
index 804b4cf090..4c8576c213 100644
--- a/templates/ecommerce/src/payload/seed/static-home.ts
+++ b/templates/ecommerce/src/payload/seed/home-static.ts
@@ -3,6 +3,9 @@ import type { Page } from '../payload-types'
export const staticHome: Page = {
id: '',
title: 'Home',
+ slug: 'home',
+ createdAt: '',
+ updatedAt: '',
meta: {
title: 'Payload E-Commerce Template',
description: 'An open-source e-commerce store built with Payload and Next.js.',
@@ -73,9 +76,6 @@ export const staticHome: Page = {
],
media: '',
},
- createdAt: '',
- updatedAt: '',
- slug: 'home',
layout: [
{
richText: [
diff --git a/templates/ecommerce/src/payload/utilities/revalidate.ts b/templates/ecommerce/src/payload/utilities/revalidate.ts
new file mode 100644
index 0000000000..d47e9f3d2b
--- /dev/null
+++ b/templates/ecommerce/src/payload/utilities/revalidate.ts
@@ -0,0 +1,29 @@
+// ensure that the home page is revalidated at '/' instead of '/home'
+// Revalidate the page in the background, so the user doesn't have to wait
+// Notice that the function itself is not async and we are not awaiting `revalidate`
+
+import type { Payload } from 'payload'
+
+export const revalidate = async (args: {
+ collection: string
+ slug: string
+ payload: Payload
+}): Promise => {
+ const { collection, slug, payload } = args
+
+ try {
+ const res = await fetch(
+ `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/revalidate?secret=${process.env.REVALIDATION_KEY}&collection=${collection}&slug=${slug}`,
+ )
+
+ if (res.ok) {
+ payload.logger.info(`Revalidated page '${slug}' in collection '${collection}'`)
+ } else {
+ payload.logger.error(`Error revalidating page '${slug}' in collection '${collection}'`)
+ }
+ } catch (err: unknown) {
+ payload.logger.error(
+ `Error hitting revalidate route for page '${slug}' in collection '${collection}'`,
+ )
+ }
+}
diff --git a/templates/website/src/server.ts b/templates/website/src/server.ts
index 0afd8b93d1..a67b0b6ac9 100644
--- a/templates/website/src/server.ts
+++ b/templates/website/src/server.ts
@@ -29,7 +29,7 @@ const start = async (): Promise => {
payload.logger.info('Done.')
}
- app.listen(process.env.PORT)
+ app.listen(process.env.PORT || 3000)
}
start()
diff --git a/test/access-control/e2e.spec.ts b/test/access-control/e2e.spec.ts
index 7017cc1704..38dd6bf2a0 100644
--- a/test/access-control/e2e.spec.ts
+++ b/test/access-control/e2e.spec.ts
@@ -206,7 +206,7 @@ describe('access control', () => {
const duplicateAction = page.locator('.collection-edit__collection-actions >> li').last();
await expect(duplicateAction).toContainText('Duplicate');
- await page.locator('#field-approvedForRemoval + button').click();
+ await page.locator('#field-approvedForRemoval').check();
await page.locator('#action-save').click();
const deleteAction = page.locator('.collection-edit__collection-actions >> li').last();
diff --git a/test/admin/config.ts b/test/admin/config.ts
index 018115f1d2..ed32a2483c 100644
--- a/test/admin/config.ts
+++ b/test/admin/config.ts
@@ -186,6 +186,15 @@ export default buildConfigWithDefaults({
},
],
},
+ {
+ slug: 'geo',
+ fields: [
+ {
+ name: 'point',
+ type: 'point',
+ },
+ ],
+ },
],
globals: [
{
@@ -259,5 +268,19 @@ export default buildConfigWithDefaults({
},
});
});
+
+ await payload.create({
+ collection: 'geo',
+ data: {
+ point: [7, -7],
+ },
+ });
+
+ await payload.create({
+ collection: 'geo',
+ data: {
+ point: [5, -5],
+ },
+ });
},
});
diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts
index fb6ac90b28..3566a84d26 100644
--- a/test/admin/e2e.spec.ts
+++ b/test/admin/e2e.spec.ts
@@ -1,5 +1,6 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
+import qs from 'qs';
import payload from '../../src';
import { AdminUrlUtil } from '../helpers/adminUrlUtil';
import { initPayloadE2E } from '../helpers/configHelpers';
@@ -15,12 +16,13 @@ const title = 'title';
const description = 'description';
let url: AdminUrlUtil;
+let serverURL: string;
describe('admin', () => {
let page: Page;
beforeAll(async ({ browser }) => {
- const { serverURL } = await initPayloadE2E(__dirname);
+ serverURL = (await initPayloadE2E(__dirname)).serverURL;
await clearDocs(); // Clear any seeded data from onInit
url = new AdminUrlUtil(serverURL, slug);
@@ -198,7 +200,7 @@ describe('admin', () => {
await page.goto(url.list);
- await page.locator('.select-all__input').click();
+ await page.locator('input#select-all').check();
await page.locator('.delete-documents__toggle').click();
@@ -216,7 +218,7 @@ describe('admin', () => {
const bulkTitle = 'Bulk update title';
await page.goto(url.list);
- await page.locator('.select-all__input').click();
+ await page.locator('input#select-all').check();
await page.locator('.edit-many__toggle').click();
await page.locator('.field-select .rs__control').click();
const options = page.locator('.rs__option');
@@ -394,6 +396,73 @@ describe('admin', () => {
await page.locator('.condition__actions-remove').click();
await expect(page.locator(tableRowLocator)).toHaveCount(2);
});
+
+ test('should accept where query from valid URL where parameter', async () => {
+ await createPost({ title: 'post1' });
+ await createPost({ title: 'post2' });
+ await page.goto(`${url.list}?limit=10&page=1&where[or][0][and][0][title][equals]=post1`);
+
+ await expect(page.locator('.react-select--single-value').first()).toContainText('Title en');
+ await expect(page.locator(tableRowLocator)).toHaveCount(1);
+ });
+
+ test('should accept transformed where query from invalid URL where parameter', async () => {
+ await createPost({ title: 'post1' });
+ await createPost({ title: 'post2' });
+ // [title][equals]=post1 should be getting transformed into a valid where[or][0][and][0][title][equals]=post1
+ await page.goto(`${url.list}?limit=10&page=1&where[title][equals]=post1`);
+
+ await expect(page.locator('.react-select--single-value').first()).toContainText('Title en');
+ await expect(page.locator(tableRowLocator)).toHaveCount(1);
+ });
+
+ test('should accept where query from complex, valid URL where parameter using the near operator', async () => {
+ // We have one point collection with the point [5,-5] and one with [7,-7]. This where query should kick out the [5,-5] point
+ await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}?limit=10&page=1&where[or][0][and][0][point][near]=6,-7,200000`);
+
+ await expect(page.getByPlaceholder('Enter a value')).toHaveValue('6,-7,200000');
+ await expect(page.locator(tableRowLocator)).toHaveCount(1);
+ });
+
+ test('should accept transformed where query from complex, invalid URL where parameter using the near operator', async () => {
+ // We have one point collection with the point [5,-5] and one with [7,-7]. This where query should kick out the [5,-5] point
+ await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}?limit=10&page=1&where[point][near]=6,-7,200000`);
+
+ await expect(page.getByPlaceholder('Enter a value')).toHaveValue('6,-7,200000');
+ await expect(page.locator(tableRowLocator)).toHaveCount(1);
+ });
+
+ test('should accept where query from complex, valid URL where parameter using the within operator', async () => {
+ type Point = [number, number];
+ const polygon: Point[] = [
+ [3.5, -3.5], // bottom-left
+ [3.5, -6.5], // top-left
+ [6.5, -6.5], // top-right
+ [6.5, -3.5], // bottom-right
+ [3.5, -3.5], // back to starting point to close the polygon
+ ];
+
+ const whereQueryJSON = {
+ point: {
+ within: {
+ type: 'Polygon',
+ coordinates: [polygon],
+ },
+ },
+ };
+
+ const whereQuery = qs.stringify({
+ ...({ where: whereQueryJSON }),
+ }, {
+ addQueryPrefix: false,
+ });
+
+ // We have one point collection with the point [5,-5] and one with [7,-7]. This where query should kick out the [7,-7] point, as it's not within the polygon
+ await page.goto(`${new AdminUrlUtil(serverURL, 'geo').list}?limit=10&page=1&${whereQuery}`);
+
+ await expect(page.getByPlaceholder('Enter a value')).toHaveValue('[object Object]');
+ await expect(page.locator(tableRowLocator)).toHaveCount(1);
+ });
});
describe('table columns', () => {
@@ -523,18 +592,18 @@ describe('admin', () => {
});
test('should select multiple rows', async () => {
- const selectAll = page.locator('.select-all');
- await page.locator('.row-1 .select-row button').click();
+ const selectAll = page.locator('.custom-checkbox:has(#select-all)');
+ await page.locator('.row-1 .cell-_select input').check();
- const indeterminateSelectAll = selectAll.locator('.icon--line');
+ const indeterminateSelectAll = selectAll.locator('.custom-checkbox__icon.partial');
expect(indeterminateSelectAll).toBeDefined();
- await selectAll.locator('button').click();
- const emptySelectAll = selectAll.locator('.icon');
+ await selectAll.locator('input').click();
+ const emptySelectAll = selectAll.locator('.custom-checkbox__icon:not(.check):not(.partial)');
await expect(emptySelectAll).toHaveCount(0);
- await selectAll.locator('button').click();
- const checkSelectAll = selectAll.locator('.icon .icon--check');
+ await selectAll.locator('input').click();
+ const checkSelectAll = selectAll.locator('.custom-checkbox__icon.check');
expect(checkSelectAll).toBeDefined();
});
@@ -542,16 +611,16 @@ describe('admin', () => {
// delete should not appear without selection
await expect(page.locator('#confirm-delete')).toHaveCount(0);
// select one row
- await page.locator('.row-1 .select-row button').click();
+ await page.locator('.row-1 .cell-_select input').check();
// delete button should be present
await expect(page.locator('#confirm-delete')).toHaveCount(1);
- await page.locator('.row-2 .select-row button').click();
+ await page.locator('.row-2 .cell-_select input').check();
await page.locator('.delete-documents__toggle').click();
await page.locator('#confirm-delete').click();
- await expect(page.locator('.select-row')).toHaveCount(1);
+ await expect(await page.locator('.cell-_select')).toHaveCount(1);
});
});
diff --git a/test/collections-graphql/config.ts b/test/collections-graphql/config.ts
index 9ef801927d..88b177ae88 100644
--- a/test/collections-graphql/config.ts
+++ b/test/collections-graphql/config.ts
@@ -30,6 +30,9 @@ const collectionWithName = (collectionSlug: string): CollectionConfig => {
export const slug = 'posts';
export const relationSlug = 'relation';
+
+export const pointSlug = 'point';
+
export default buildConfigWithDefaults({
graphQL: {
schemaOutputFile: path.resolve(__dirname, 'schema.graphql'),
@@ -41,6 +44,16 @@ export default buildConfigWithDefaults({
access: openAccess,
fields: [],
},
+ {
+ slug: pointSlug,
+ access: openAccess,
+ fields: [
+ {
+ type: 'point',
+ name: 'point',
+ },
+ ],
+ },
{
slug,
access: openAccess,
@@ -414,5 +427,12 @@ export default buildConfigWithDefaults({
relation: payloadAPITest1.id,
},
});
+
+ await payload.create({
+ collection: pointSlug,
+ data: {
+ point: [10, 20],
+ },
+ });
},
});
diff --git a/test/collections-graphql/int.spec.ts b/test/collections-graphql/int.spec.ts
index 3ae0e8dc6f..c894cf0638 100644
--- a/test/collections-graphql/int.spec.ts
+++ b/test/collections-graphql/int.spec.ts
@@ -1,8 +1,9 @@
import { GraphQLClient } from 'graphql-request';
import { initPayloadTest } from '../helpers/configHelpers';
-import configPromise, { slug } from './config';
+import configPromise, { pointSlug, slug } from './config';
import payload from '../../src';
import type { Post } from './payload-types';
+import { mapAsync } from '../../src/utilities/mapAsync';
const title = 'title';
@@ -383,6 +384,228 @@ describe('collections-graphql', () => {
expect(docs).toContainEqual(expect.objectContaining({ id: specialPost.id }));
});
+ describe('near', () => {
+ const point = [10, 20];
+ const [lat, lng] = point;
+
+ it('should return a document near a point', async () => {
+ const nearQuery = `
+ query {
+ Points(
+ where: {
+ point: {
+ near: [${lat + 0.01}, ${lng + 0.01}, 10000]
+ }
+ }
+ ) {
+ docs {
+ id
+ point
+ }
+ }
+ }`;
+
+ const response = await client.request(nearQuery);
+ const { docs } = response.Points;
+
+ expect(docs).toHaveLength(1);
+ });
+
+ it('should not return a point far away', async () => {
+ const nearQuery = `
+ query {
+ Points(
+ where: {
+ point: {
+ near: [${lng + 1}, ${lat - 1}, 5000]
+ }
+ }
+ ) {
+ docs {
+ id
+ point
+ }
+ }
+ }`;
+
+ const response = await client.request(nearQuery);
+ const { docs } = response.Points;
+
+ expect(docs).toHaveLength(0);
+ });
+
+ it('should sort find results by nearest distance', async () => {
+ // creating twice as many records as we are querying to get a random sample
+ await mapAsync([...Array(10)], async () => {
+ // setTimeout used to randomize the creation timestamp
+ setTimeout(async () => {
+ await payload.create({
+ collection: pointSlug,
+ data: {
+ // only randomize longitude to make distance comparison easy
+ point: [Math.random(), 0],
+ },
+ });
+ }, Math.random());
+ });
+
+ const nearQuery = `
+ query {
+ Points(
+ where: {
+ point: {
+ near: [0, 0, 100000, 0]
+ }
+ },
+ limit: 5
+ ) {
+ docs {
+ id
+ point
+ }
+ }
+ }`;
+
+ const response = await client.request(nearQuery);
+ const { docs } = response.Points;
+
+ let previous = 0;
+ docs.forEach(({ point: coordinates }) => {
+ // The next document point should always be greater than the one before
+ expect(previous).toBeLessThanOrEqual(coordinates[0]);
+ [previous] = coordinates;
+ });
+ });
+ });
+
+
+ describe('within', () => {
+ type Point = [number, number];
+ const polygon: Point[] = [
+ [9.0, 19.0], // bottom-left
+ [9.0, 21.0], // top-left
+ [11.0, 21.0], // top-right
+ [11.0, 19.0], // bottom-right
+ [9.0, 19.0], // back to starting point to close the polygon
+ ];
+
+ it('should return a document with the point inside the polygon', async () => {
+ const query = `
+ query {
+ Points(
+ where: {
+ point: {
+ within: {
+ type: "Polygon",
+ coordinates: ${JSON.stringify([polygon])}
+ }
+ }
+ }) {
+ docs {
+ id
+ point
+ }
+ }
+ }`;
+
+ const response = await client.request(query);
+ const { docs } = response.Points;
+
+ expect(docs).toHaveLength(1);
+ expect(docs[0].point).toEqual([10, 20]);
+ });
+
+ it('should not return a document with the point outside the polygon', async () => {
+ const reducedPolygon = polygon.map((vertex) => vertex.map((coord) => coord * 0.1));
+ const query = `
+ query {
+ Points(
+ where: {
+ point: {
+ within: {
+ type: "Polygon",
+ coordinates: ${JSON.stringify([reducedPolygon])}
+ }
+ }
+ }) {
+ docs {
+ id
+ point
+ }
+ }
+ }`;
+
+ const response = await client.request(query);
+ const { docs } = response.Points;
+
+ expect(docs).toHaveLength(0);
+ });
+ });
+
+ describe('intersects', () => {
+ type Point = [number, number];
+ const polygon: Point[] = [
+ [9.0, 19.0], // bottom-left
+ [9.0, 21.0], // top-left
+ [11.0, 21.0], // top-right
+ [11.0, 19.0], // bottom-right
+ [9.0, 19.0], // back to starting point to close the polygon
+ ];
+
+ it('should return a document with the point intersecting the polygon', async () => {
+ const query = `
+ query {
+ Points(
+ where: {
+ point: {
+ intersects: {
+ type: "Polygon",
+ coordinates: ${JSON.stringify([polygon])}
+ }
+ }
+ }) {
+ docs {
+ id
+ point
+ }
+ }
+ }`;
+
+ const response = await client.request(query);
+ const { docs } = response.Points;
+
+ expect(docs).toHaveLength(1);
+ expect(docs[0].point).toEqual([10, 20]);
+ });
+
+ it('should not return a document with the point not intersecting a smaller polygon', async () => {
+ const reducedPolygon = polygon.map((vertex) => vertex.map((coord) => coord * 0.1));
+ const query = `
+ query {
+ Points(
+ where: {
+ point: {
+ within: {
+ type: "Polygon",
+ coordinates: ${JSON.stringify([reducedPolygon])}
+ }
+ }
+ }) {
+ docs {
+ id
+ point
+ }
+ }
+ }`;
+
+ const response = await client.request(query);
+ const { docs } = response.Points;
+
+ expect(docs).toHaveLength(0);
+ });
+ });
+
+
it('can query deeply nested fields within rows, tabs, collapsibles', async () => {
const withNestedField = await createPost({ D1: { D2: { D3: { D4: 'nested message' } } } });
const query = `{
diff --git a/test/collections-rest/int.spec.ts b/test/collections-rest/int.spec.ts
index e886bfcc8a..0949d68a50 100644
--- a/test/collections-rest/int.spec.ts
+++ b/test/collections-rest/int.spec.ts
@@ -1,4 +1,5 @@
import { randomBytes } from 'crypto';
+import mongoose from 'mongoose';
import { initPayloadTest } from '../helpers/configHelpers';
import type { Relation } from './config';
import config, { customIdNumberSlug, customIdSlug, errorOnHookSlug, pointSlug, relationSlug, slug } from './config';
@@ -862,6 +863,98 @@ describe('collections-rest', () => {
});
});
+ describe('within', () => {
+ type Point = [number, number];
+ const polygon: Point[] = [
+ [9.0, 19.0], // bottom-left
+ [9.0, 21.0], // top-left
+ [11.0, 21.0], // top-right
+ [11.0, 19.0], // bottom-right
+ [9.0, 19.0], // back to starting point to close the polygon
+ ];
+ it('should return a document with the point inside the polygon', async () => {
+ // There should be 1 total points document populated by default with the point [10, 20]
+ const { status, result } = await client.find({
+ slug: pointSlug,
+ query: {
+ point: {
+ within: {
+ type: 'Polygon',
+ coordinates: [polygon],
+ },
+ },
+ },
+ });
+
+ expect(status).toEqual(200);
+ expect(result.docs).toHaveLength(1);
+ });
+
+ it('should not return a document with the point outside a smaller polygon', async () => {
+ const { status, result } = await client.find({
+ slug: pointSlug,
+ query: {
+ point: {
+ within: {
+ type: 'Polygon',
+ coordinates: [polygon.map((vertex) => vertex.map((coord) => coord * 0.1))], // Reduce polygon to 10% of its size
+ },
+ },
+ },
+ });
+
+ expect(status).toEqual(200);
+ expect(result.docs).toHaveLength(0);
+ });
+ });
+
+ describe('intersects', () => {
+ type Point = [number, number];
+ const polygon: Point[] = [
+ [9.0, 19.0], // bottom-left
+ [9.0, 21.0], // top-left
+ [11.0, 21.0], // top-right
+ [11.0, 19.0], // bottom-right
+ [9.0, 19.0], // back to starting point to close the polygon
+ ];
+
+ it('should return a document with the point intersecting the polygon', async () => {
+ // There should be 1 total points document populated by default with the point [10, 20]
+ const { status, result } = await client.find({
+ slug: pointSlug,
+ query: {
+ point: {
+ intersects: {
+ type: 'Polygon',
+ coordinates: [polygon],
+ },
+ },
+ },
+ });
+
+ expect(status).toEqual(200);
+ expect(result.docs).toHaveLength(1);
+ });
+
+ it('should not return a document with the point not intersecting a smaller polygon', async () => {
+ const { status, result } = await client.find({
+ slug: pointSlug,
+ query: {
+ point: {
+ intersects: {
+ type: 'Polygon',
+ coordinates: [polygon.map((vertex) => vertex.map((coord) => coord * 0.1))], // Reduce polygon to 10% of its size
+ },
+ },
+ },
+ });
+
+ expect(status).toEqual(200);
+ expect(result.docs).toHaveLength(0);
+ });
+ });
+
+
it('or', async () => {
const post1 = await createPost({ title: 'post1' });
const post2 = await createPost({ title: 'post2' });
diff --git a/test/fields/collections/Text/index.ts b/test/fields/collections/Text/index.ts
index 56a7220ebd..fd92475e38 100644
--- a/test/fields/collections/Text/index.ts
+++ b/test/fields/collections/Text/index.ts
@@ -57,6 +57,26 @@ const TextFields: CollectionConfig = {
type: 'text',
maxLength: 50000,
},
+ {
+ name: 'fieldWithDefaultValue',
+ type: 'text',
+ defaultValue: async () => {
+ const defaultValue = new Promise((resolve) => setTimeout(() => resolve('some-value'), 1000));
+
+ return defaultValue;
+ },
+ },
+ {
+ name: 'dependentOnFieldWithDefaultValue',
+ type: 'text',
+ hooks: {
+ beforeChange: [
+ ({ data }) => {
+ return data?.fieldWithDefaultValue || '';
+ },
+ ],
+ },
+ },
],
};
diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts
index 3a60234403..63a10e6914 100644
--- a/test/fields/int.spec.ts
+++ b/test/fields/int.spec.ts
@@ -48,6 +48,15 @@ describe('Fields', () => {
expect(doc.defaultFunction).toEqual(defaultText);
expect(doc.defaultAsync).toEqual(defaultText);
});
+
+ it('should populate default values in beforeValidate hook', async () => {
+ const { fieldWithDefaultValue, dependentOnFieldWithDefaultValue } = await payload.create({
+ collection: 'text-fields',
+ data: { text },
+ });
+
+ await expect(fieldWithDefaultValue).toEqual(dependentOnFieldWithDefaultValue);
+ });
});
describe('timestamps', () => {
diff --git a/test/hooks/collections/AfterOperation/index.ts b/test/hooks/collections/AfterOperation/index.ts
new file mode 100644
index 0000000000..cad742b32c
--- /dev/null
+++ b/test/hooks/collections/AfterOperation/index.ts
@@ -0,0 +1,71 @@
+import { AfterOperationHook, CollectionConfig } from '../../../../src/collections/config/types';
+import { AfterOperation } from '../../payload-types';
+
+export const afterOperationSlug = 'afterOperation';
+
+const AfterOperation: CollectionConfig = {
+ slug: afterOperationSlug,
+ hooks: {
+ // beforeRead: [(operation) => operation.doc],
+ afterOperation: [
+ async ({ result, operation }) => {
+ if (operation === 'create') {
+ if ('docs' in result) {
+ return {
+ ...result,
+ docs: result.docs?.map((doc) => ({
+ ...doc,
+ title: 'Title created',
+ })),
+ };
+ }
+
+ return { ...result, title: 'Title created' };
+ }
+
+ if (operation === 'find') {
+ // only modify the first doc for `find` operations
+ // this is so we can test against the other operations
+ return {
+ ...result,
+ docs: result.docs?.map((doc, index) => (index === 0 ? {
+ ...doc,
+ title: 'Title read',
+ } : doc)),
+ };
+ }
+
+ if (operation === 'findByID') {
+ return { ...result, title: 'Title read' };
+ }
+
+ if (operation === 'update') {
+ if ('docs' in result) {
+ return {
+ ...result,
+ docs: result.docs?.map((doc) => ({
+ ...doc,
+ title: 'Title updated',
+ })),
+ };
+ }
+ }
+
+ if (operation === 'updateByID') {
+ return { ...result, title: 'Title updated' };
+ }
+
+ return result;
+ },
+ ] as AfterOperationHook[],
+ },
+ fields: [
+ {
+ name: 'title',
+ type: 'text',
+ required: true,
+ },
+ ],
+};
+
+export default AfterOperation;
diff --git a/test/hooks/config.ts b/test/hooks/config.ts
index d36e9ce364..cca0e4b3b4 100644
--- a/test/hooks/config.ts
+++ b/test/hooks/config.ts
@@ -4,11 +4,13 @@ import Hooks, { hooksSlug } from './collections/Hook';
import NestedAfterReadHooks from './collections/NestedAfterReadHooks';
import ChainingHooks from './collections/ChainingHooks';
import Relations from './collections/Relations';
+import AfterOperation from './collections/AfterOperation';
import Users, { seedHooksUsers } from './collections/Users';
import ContextHooks from './collections/ContextHooks';
export default buildConfigWithDefaults({
collections: [
+ AfterOperation,
ContextHooks,
TransformHooks,
Hooks,
diff --git a/test/hooks/int.spec.ts b/test/hooks/int.spec.ts
index dac7319856..fda8da3e07 100644
--- a/test/hooks/int.spec.ts
+++ b/test/hooks/int.spec.ts
@@ -1,5 +1,5 @@
import { initPayloadTest } from '../helpers/configHelpers';
-import config from './config';
+import configPromise from './config';
import payload from '../../src';
import { RESTClient } from '../helpers/rest';
import { transformSlug } from './collections/Transform';
@@ -7,10 +7,10 @@ import { hooksSlug } from './collections/Hook';
import { chainingHooksSlug } from './collections/ChainingHooks';
import { generatedAfterReadText, nestedAfterReadHooksSlug } from './collections/NestedAfterReadHooks';
import { relationsSlug } from './collections/Relations';
-import type { NestedAfterReadHook } from './payload-types';
import { hooksUsersSlug } from './collections/Users';
import { devUser, regularUser } from '../credentials';
import { AuthenticationError } from '../../src/errors';
+import { afterOperationSlug } from './collections/AfterOperation';
import { contextHooksSlug } from './collections/ContextHooks';
let client: RESTClient;
@@ -19,6 +19,7 @@ let apiUrl;
describe('Hooks', () => {
beforeAll(async () => {
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
+ const config = await configPromise;
client = new RESTClient(config, { serverURL, defaultSlug: transformSlug });
apiUrl = `${serverURL}/api`;
});
@@ -73,7 +74,7 @@ describe('Hooks', () => {
});
it('should save data generated with afterRead hooks in nested field structures', async () => {
- const document = await payload.create({
+ const document = await payload.create({
collection: nestedAfterReadHooksSlug,
data: {
text: 'ok',
@@ -154,6 +155,62 @@ describe('Hooks', () => {
expect(retrievedDocs[0].text).toEqual('ok!!');
});
+ it('should execute collection afterOperation hook', async () => {
+ const [doc1, doc2] = await Promise.all([
+ await payload.create({
+ collection: afterOperationSlug,
+ data: {
+ title: 'Title',
+ },
+ }),
+ await payload.create({
+ collection: afterOperationSlug,
+ data: {
+ title: 'Title',
+ },
+ }),
+ ]);
+
+ expect(doc1.title === 'Title created').toBeTruthy();
+ expect(doc2.title === 'Title created').toBeTruthy();
+
+ const findResult = await payload.find({
+ collection: afterOperationSlug,
+ });
+
+ expect(findResult.docs).toHaveLength(2);
+ expect(findResult.docs[0].title === 'Title read').toBeTruthy();
+ expect(findResult.docs[1].title === 'Title').toBeTruthy();
+
+ const [updatedDoc1, updatedDoc2] = await Promise.all([
+ await payload.update({
+ collection: afterOperationSlug,
+ id: doc1.id,
+ data: {
+ title: 'Title',
+ },
+ }),
+ await payload.update({
+ collection: afterOperationSlug,
+ id: doc2.id,
+ data: {
+ title: 'Title',
+ },
+ }),
+ ]);
+
+ expect(updatedDoc1.title === 'Title updated').toBeTruthy();
+ expect(updatedDoc2.title === 'Title updated').toBeTruthy();
+
+ const findResult2 = await payload.find({
+ collection: afterOperationSlug,
+ });
+
+ expect(findResult2.docs).toHaveLength(2);
+ expect(findResult2.docs[0].title === 'Title read').toBeTruthy();
+ expect(findResult2.docs[1].title === 'Title').toBeTruthy();
+ });
+
it('should pass context from beforeChange to afterChange', async () => {
const document = await payload.create({
collection: contextHooksSlug,
diff --git a/test/hooks/payload-types.ts b/test/hooks/payload-types.ts
index f5582c89f3..4d9a73c487 100644
--- a/test/hooks/payload-types.ts
+++ b/test/hooks/payload-types.ts
@@ -5,11 +5,24 @@
* and re-run `payload generate:types` to regenerate this file.
*/
-export interface Config {}
-/**
- * This interface was referenced by `Config`'s JSON-Schema
- * via the `definition` "transforms".
- */
+export interface Config {
+ collections: {
+ afterOperation: AfterOperation;
+ transforms: Transform;
+ hooks: Hook;
+ 'nested-after-read-hooks': NestedAfterReadHook;
+ 'chaining-hooks': ChainingHook;
+ relations: Relation;
+ 'hooks-users': HooksUser;
+ };
+ globals: {};
+}
+export interface AfterOperation {
+ id: string;
+ title: string;
+ updatedAt: string;
+ createdAt: string;
+}
export interface Transform {
id: string;
/**
@@ -22,13 +35,9 @@ export interface Transform {
* @maxItems 2
*/
localizedTransform?: [number, number];
- createdAt: string;
updatedAt: string;
+ createdAt: string;
}
-/**
- * This interface was referenced by `Config`'s JSON-Schema
- * via the `definition` "hooks".
- */
export interface Hook {
id: string;
fieldBeforeValidate?: boolean;
@@ -40,53 +49,48 @@ export interface Hook {
collectionAfterChange?: boolean;
collectionBeforeRead?: boolean;
collectionAfterRead?: boolean;
- createdAt: string;
updatedAt: string;
+ createdAt: string;
}
-/**
- * This interface was referenced by `Config`'s JSON-Schema
- * via the `definition` "nested-after-read-hooks".
- */
export interface NestedAfterReadHook {
id: string;
text?: string;
- group: {
- array: {
+ group?: {
+ array?: {
input?: string;
afterRead?: string;
shouldPopulate?: string | Relation;
id?: string;
}[];
- subGroup: {
+ subGroup?: {
afterRead?: string;
shouldPopulate?: string | Relation;
};
};
- createdAt: string;
updatedAt: string;
+ createdAt: string;
}
-/**
- * This interface was referenced by `Config`'s JSON-Schema
- * via the `definition` "relations".
- */
export interface Relation {
id: string;
title: string;
- createdAt: string;
updatedAt: string;
+ createdAt: string;
+}
+export interface ChainingHook {
+ id: string;
+ text?: string;
+ updatedAt: string;
+ createdAt: string;
}
-/**
- * This interface was referenced by `Config`'s JSON-Schema
- * via the `definition` "hooks-users".
- */
export interface HooksUser {
id: string;
roles: ('admin' | 'user')[];
+ updatedAt: string;
+ createdAt: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
- createdAt: string;
- updatedAt: string;
+ password?: string;
}
diff --git a/test/refresh-permissions/e2e.spec.ts b/test/refresh-permissions/e2e.spec.ts
index aa52f99c65..92dd2da8f5 100644
--- a/test/refresh-permissions/e2e.spec.ts
+++ b/test/refresh-permissions/e2e.spec.ts
@@ -24,7 +24,7 @@ describe('refresh-permissions', () => {
await expect(page.locator('#nav-global-test')).toBeHidden();
// Allow access to test global.
- await page.locator('.custom-checkbox:has(#field-test) button').click();
+ await page.locator('.custom-checkbox:has(#field-test) input').check();
await page.locator('#action-save').click();
// Now test collection should appear in the menu.
diff --git a/test/relationships/config.ts b/test/relationships/config.ts
index 231d342118..556516ffd7 100644
--- a/test/relationships/config.ts
+++ b/test/relationships/config.ts
@@ -190,6 +190,12 @@ export default buildConfigWithDefaults({
name: 'name',
type: 'text',
},
+ {
+ name: 'movies',
+ type: 'relationship',
+ relationTo: 'movies',
+ hasMany: true,
+ },
],
},
],
diff --git a/test/relationships/int.spec.ts b/test/relationships/int.spec.ts
index 4bea0c901d..a41bfbd9cc 100644
--- a/test/relationships/int.spec.ts
+++ b/test/relationships/int.spec.ts
@@ -1,4 +1,5 @@
import { randomBytes } from 'crypto';
+import mongoose from 'mongoose';
import { initPayloadTest } from '../helpers/configHelpers';
import config, {
chainedRelSlug,
@@ -358,6 +359,64 @@ describe('Relationships', () => {
expect(query.docs).toHaveLength(1);
});
});
+ describe('Multiple Docs', () => {
+ const movieList = [
+ 'Pulp Fiction',
+ 'Reservoir Dogs',
+ 'Once Upon a Time in Hollywood',
+ 'Shrek',
+ 'Shrek 2',
+ 'Shrek 3',
+ 'Scream',
+ 'The Matrix',
+ 'The Matrix Reloaded',
+ 'The Matrix Revolutions',
+ 'The Matrix Resurrections',
+ 'The Haunting',
+ 'The Haunting of Hill House',
+ 'The Haunting of Bly Manor',
+ 'Insidious',
+ ];
+
+ beforeAll(async () => {
+ await Promise.all(movieList.map((movie) => {
+ return payload.create({
+ collection: 'movies',
+ data: {
+ name: movie,
+ },
+ });
+ }));
+ });
+
+ it('should return more than 10 docs in relationship', async () => {
+ const allMovies = await payload.find({
+ collection: 'movies',
+ limit: 20,
+ });
+
+ const movieIDs = allMovies.docs.map((doc) => doc.id);
+
+ await payload.create({
+ collection: 'directors',
+ data: {
+ name: 'Quentin Tarantino',
+ movies: movieIDs,
+ },
+ });
+
+ const director = await payload.find({
+ collection: 'directors',
+ where: {
+ name: {
+ equals: 'Quentin Tarantino',
+ },
+ },
+ });
+
+ expect(director.docs[0].movies.length).toBeGreaterThan(10);
+ });
+ });
});
});
diff --git a/test/relationships/payload-types.ts b/test/relationships/payload-types.ts
index de4c1310a6..3ba0925a93 100644
--- a/test/relationships/payload-types.ts
+++ b/test/relationships/payload-types.ts
@@ -85,6 +85,7 @@ export interface Movie {
export interface Director {
id: string;
name?: string;
+ movies?: Array;
updatedAt: string;
createdAt: string;
}
diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts
index 787fa065f9..58b8a4e4d3 100644
--- a/test/versions/e2e.spec.ts
+++ b/test/versions/e2e.spec.ts
@@ -54,7 +54,7 @@ describe('versions', () => {
test('should bulk publish', async () => {
await page.goto(url.list);
- await page.locator('.select-all__input').click();
+ await page.locator('.custom-checkbox:has(#select-all) input').check();
await page.locator('.publish-many__toggle').click();
@@ -67,7 +67,7 @@ describe('versions', () => {
test('should bulk unpublish', async () => {
await page.goto(url.list);
- await page.locator('.select-all__input').click();
+ await page.locator('.custom-checkbox:has(#select-all) input').check();
await page.locator('.unpublish-many__toggle').click();
@@ -80,7 +80,7 @@ describe('versions', () => {
test('should publish while editing many', async () => {
const description = 'published document';
await page.goto(url.list);
- await page.locator('.select-all__input').click();
+ await page.locator('.custom-checkbox:has(#select-all) input').check();
await page.locator('.edit-many__toggle').click();
await page.locator('.field-select .rs__control').click();
const options = page.locator('.rs__option');
@@ -97,7 +97,7 @@ describe('versions', () => {
test('should save as draft while editing many', async () => {
const description = 'draft document';
await page.goto(url.list);
- await page.locator('.select-all__input').click();
+ await page.locator('.custom-checkbox:has(#select-all) input').check();
await page.locator('.edit-many__toggle').click();
await page.locator('.field-select .rs__control').click();
const options = page.locator('.rs__option');
diff --git a/types.d.ts b/types.d.ts
index 379e9567c9..9d55ac729a 100644
--- a/types.d.ts
+++ b/types.d.ts
@@ -10,6 +10,7 @@ export { DatabaseAdapter } from './dist/database/types';
export {
CollectionConfig,
SanitizedCollectionConfig,
+ AfterOperationHook as CollectionAfterOperationHook,
BeforeOperationHook as CollectionBeforeOperationHook,
BeforeValidateHook as CollectionBeforeValidateHook,
BeforeChangeHook as CollectionBeforeChangeHook,