Merge branch 'master' of github.com:payloadcms/payload into feat/revisions

This commit is contained in:
James
2021-11-23 14:32:26 -05:00
41 changed files with 1028 additions and 215 deletions

View File

@@ -13,6 +13,7 @@ const ReactSelect: React.FC<Props> = (props) => {
onChange,
value,
disabled = false,
placeholder,
} = props;
const classes = [
@@ -23,6 +24,7 @@ const ReactSelect: React.FC<Props> = (props) => {
return (
<Select
placeholder={placeholder}
{...props}
value={value}
onChange={onChange}

View File

@@ -17,4 +17,5 @@ export type Props = {
isDisabled?: boolean
onInputChange?: (val: string) => void
onMenuScrollToBottom?: () => void
placeholder?: string
}

View File

@@ -0,0 +1,11 @@
@import '../../../../../scss/styles.scss';
.condition-value-relationship {
&__error-loading {
border: 1px solid $color-red;
min-height: base(2);
padding: base(.5) base(.75);
background-color: $color-red;
color: white;
}
}

View File

@@ -0,0 +1,267 @@
import React, { useReducer, useState, useCallback, useEffect } from 'react';
import { useConfig } from '@payloadcms/config-provider';
import { Props, Option, ValueWithRelation } from './types';
import optionsReducer from './optionsReducer';
import useDebounce from '../../../../../hooks/useDebounce';
import ReactSelect from '../../../ReactSelect';
import { Value } from '../../../ReactSelect/types';
import { PaginatedDocs } from '../../../../../../collections/config/types';
import './index.scss';
const baseClass = 'condition-value-relationship';
const maxResultsPerRequest = 10;
const RelationshipField: React.FC<Props> = (props) => {
const { onChange, value, relationTo, hasMany } = props;
const {
serverURL,
routes: {
api,
},
collections,
} = useConfig();
const hasMultipleRelations = Array.isArray(relationTo);
const [options, dispatchOptions] = useReducer(optionsReducer, []);
const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1);
const [lastLoadedPage, setLastLoadedPage] = useState(1);
const [search, setSearch] = useState('');
const [errorLoading, setErrorLoading] = useState('');
const [hasLoadedFirstOptions, setHasLoadedFirstOptions] = useState(false);
const debouncedSearch = useDebounce(search, 300);
const addOptions = useCallback((data, relation) => {
const collection = collections.find((coll) => coll.slug === relation);
dispatchOptions({ type: 'ADD', data, relation, hasMultipleRelations, collection });
}, [collections, hasMultipleRelations]);
const getResults = useCallback(async ({
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
lastLoadedPage: lastLoadedPageArg,
search: searchArg,
}) => {
let lastLoadedPageToUse = typeof lastLoadedPageArg !== 'undefined' ? lastLoadedPageArg : 1;
const lastFullyLoadedRelationToUse = typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1;
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
const relationsToFetch = lastFullyLoadedRelationToUse === -1 ? relations : relations.slice(lastFullyLoadedRelationToUse + 1);
let resultsFetched = 0;
if (!errorLoading) {
relationsToFetch.reduce(async (priorRelation, relation) => {
await priorRelation;
if (resultsFetched < 10) {
const collection = collections.find((coll) => coll.slug === relation);
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
const searchParam = searchArg ? `&where[${fieldToSearch}][like]=${searchArg}` : '';
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageToUse}&depth=0${searchParam}`);
if (response.ok) {
const data: PaginatedDocs = await response.json();
if (data.docs.length > 0) {
resultsFetched += data.docs.length;
addOptions(data, relation);
setLastLoadedPage(data.page);
if (!data.nextPage) {
setLastFullyLoadedRelation(relations.indexOf(relation));
// If there are more relations to search, need to reset lastLoadedPage to 1
// both locally within function and state
if (relations.indexOf(relation) + 1 < relations.length) {
lastLoadedPageToUse = 1;
}
}
}
} else {
setErrorLoading('An error has occurred.');
}
}
}, Promise.resolve());
}
}, [addOptions, api, collections, serverURL, errorLoading, relationTo]);
const findOptionsByValue = useCallback((): Option | Option[] => {
if (value) {
if (hasMany) {
if (Array.isArray(value)) {
return value.map((val) => {
if (hasMultipleRelations) {
let matchedOption: Option;
options.forEach((opt) => {
if (opt.options) {
opt.options.some((subOpt) => {
if (subOpt?.value === val.value) {
matchedOption = subOpt;
return true;
}
return false;
});
}
});
return matchedOption;
}
return options.find((opt) => opt.value === val);
});
}
return undefined;
}
if (hasMultipleRelations) {
let matchedOption: Option;
const valueWithRelation = value as ValueWithRelation;
options.forEach((opt) => {
if (opt?.options) {
opt.options.some((subOpt) => {
if (subOpt?.value === valueWithRelation.value) {
matchedOption = subOpt;
return true;
}
return false;
});
}
});
return matchedOption;
}
return options.find((opt) => opt.value === value);
}
return undefined;
}, [hasMany, hasMultipleRelations, value, options]);
const handleInputChange = useCallback((newSearch) => {
if (search !== newSearch) {
setSearch(newSearch);
}
}, [search]);
const addOptionByID = useCallback(async (id, relation) => {
if (!errorLoading && id !== 'null') {
const response = await fetch(`${serverURL}${api}/${relation}/${id}?depth=0`);
if (response.ok) {
const data = await response.json();
addOptions({ docs: [data] }, relation);
} else {
console.error(`There was a problem loading the document with ID of ${id}.`);
}
}
}, [addOptions, api, errorLoading, serverURL]);
// ///////////////////////////
// Get results when search input changes
// ///////////////////////////
useEffect(() => {
dispatchOptions({
type: 'CLEAR',
required: true,
});
setHasLoadedFirstOptions(true);
setLastLoadedPage(1);
setLastFullyLoadedRelation(-1);
getResults({ search: debouncedSearch });
}, [getResults, debouncedSearch, relationTo]);
// ///////////////////////////
// Format options once first options have been retrieved
// ///////////////////////////
useEffect(() => {
if (value && hasLoadedFirstOptions) {
if (hasMany) {
const matchedOptions = findOptionsByValue();
(matchedOptions as Value[] || []).forEach((option, i) => {
if (!option) {
if (hasMultipleRelations) {
addOptionByID(value[i].value, value[i].relationTo);
} else {
addOptionByID(value[i], relationTo);
}
}
});
} else {
const matchedOption = findOptionsByValue();
if (!matchedOption) {
if (hasMultipleRelations) {
const valueWithRelation = value as ValueWithRelation;
addOptionByID(valueWithRelation.value, valueWithRelation.relationTo);
} else {
addOptionByID(value, relationTo);
}
}
}
}
}, [addOptionByID, findOptionsByValue, hasMany, hasMultipleRelations, relationTo, value, hasLoadedFirstOptions]);
const classes = [
'field-type',
baseClass,
errorLoading && 'error-loading',
].filter(Boolean).join(' ');
const valueToRender = (findOptionsByValue() || value) as Value;
return (
<div className={classes}>
{!errorLoading && (
<ReactSelect
placeholder="Select a value"
onInputChange={handleInputChange}
onChange={(selected) => {
if (hasMany) {
onChange(selected ? selected.map((option) => {
if (hasMultipleRelations) {
return {
relationTo: option.relationTo,
value: option.value,
};
}
return option.value;
}) : null);
} else if (hasMultipleRelations) {
onChange({
relationTo: selected.relationTo,
value: selected.value,
});
} else {
onChange(selected.value);
}
}}
onMenuScrollToBottom={() => {
getResults({ lastFullyLoadedRelation, lastLoadedPage: lastLoadedPage + 1 });
}}
value={valueToRender}
options={options}
isMulti={hasMany}
/>
)}
{errorLoading && (
<div className={`${baseClass}__error-loading`}>
{errorLoading}
</div>
)}
</div>
);
};
export default RelationshipField;

View File

@@ -0,0 +1,92 @@
import { Option, Action } from './types';
const reduceToIDs = (options) => options.reduce((ids, option) => {
if (option.options) {
return [
...ids,
...reduceToIDs(option.options),
];
}
return [
...ids,
option.id,
];
}, []);
const optionsReducer = (state: Option[], action: Action): Option[] => {
switch (action.type) {
case 'CLEAR': {
return action.required ? [] : [{ value: 'null', label: 'None' }];
}
case 'ADD': {
const { hasMultipleRelations, collection, relation, data } = action;
const labelKey = collection.admin.useAsTitle || 'id';
const loadedIDs = reduceToIDs(state);
if (!hasMultipleRelations) {
return [
...state,
...data.docs.reduce((docs, doc) => {
if (loadedIDs.indexOf(doc.id) === -1) {
loadedIDs.push(doc.id);
return [
...docs,
{
label: doc[labelKey],
value: doc.id,
},
];
}
return docs;
}, []),
];
}
const newOptions = [...state];
const optionsToAddTo = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural);
const newSubOptions = data.docs.reduce((docs, doc) => {
if (loadedIDs.indexOf(doc.id) === -1) {
loadedIDs.push(doc.id);
return [
...docs,
{
label: doc[labelKey],
relationTo: relation,
value: doc.id,
},
];
}
return docs;
}, []);
if (optionsToAddTo) {
optionsToAddTo.options = [
...optionsToAddTo.options,
...newSubOptions,
];
} else {
newOptions.push({
label: collection.labels.plural,
options: newSubOptions,
value: undefined,
});
}
return newOptions;
}
default: {
return state;
}
}
};
export default optionsReducer;

View File

@@ -0,0 +1,34 @@
import { RelationshipField } from '../../../../../../fields/config/types';
import { PaginatedDocs, SanitizedCollectionConfig } from '../../../../../../collections/config/types';
export type Props = {
onChange: (val: unknown) => void,
value: unknown,
} & RelationshipField
export type Option = {
label: string
value: string
relationTo?: string
options?: Option[]
}
type CLEAR = {
type: 'CLEAR'
required: boolean
}
type ADD = {
type: 'ADD'
data: PaginatedDocs
relation: string
hasMultipleRelations: boolean
collection: SanitizedCollectionConfig
}
export type Action = CLEAR | ADD
export type ValueWithRelation = {
relationTo: string
value: string
}

View File

@@ -6,6 +6,7 @@ import Button from '../../Button';
import Date from './Date';
import Number from './Number';
import Text from './Text';
import Relationship from './Relationship';
import useDebounce from '../../../../hooks/useDebounce';
import { FieldCondition } from '../types';
@@ -15,6 +16,7 @@ const valueFields = {
Date,
Number,
Text,
Relationship,
};
const baseClass = 'condition';
@@ -93,6 +95,7 @@ const Condition: React.FC<Props> = (props) => {
DefaultComponent={ValueComponent}
componentProps={{
...activeField?.props,
operator: operatorValue,
value: internalValue,
onChange: setInternalValue,
}}

View File

@@ -100,7 +100,7 @@ const fieldTypeConditions = {
operators: [...base],
},
relationship: {
component: 'Text',
component: 'Relationship',
operators: [...base],
},
select: {

View File

@@ -2,9 +2,9 @@ import React from 'react';
export type DescriptionFunction = (value: unknown) => string
export type DescriptionComponent = React.ComponentType<{value: unknown}>
export type DescriptionComponent = React.ComponentType<{ value: unknown }>
type Description = string | DescriptionFunction | DescriptionComponent
export type Description = string | DescriptionFunction | DescriptionComponent
export type Props = {
description?: Description

View File

@@ -1,7 +1,8 @@
import { Data } from '../../Form/types';
import { ArrayField, Labels, Field, Description } from '../../../../../fields/config/types';
import { ArrayField, Labels, Field } from '../../../../../fields/config/types';
import { FieldTypes } from '..';
import { FieldPermissions } from '../../../../../auth/types';
import { Description } from '../../FieldDescription/types';
export type Props = Omit<ArrayField, 'type'> & {
path?: string

View File

@@ -1,7 +1,8 @@
import { Data } from '../../Form/types';
import { BlockField, Labels, Block, Description } from '../../../../../fields/config/types';
import { BlockField, Labels, Block } from '../../../../../fields/config/types';
import { FieldTypes } from '..';
import { FieldPermissions } from '../../../../../auth/types';
import { Description } from '../../FieldDescription/types';
export type Props = Omit<BlockField, 'type'> & {
path?: string

View File

@@ -2,6 +2,7 @@
.field-type.email {
margin-bottom: $baseline;
position: relative;
input {
@include formInput;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Description, Validate } from '../../../../../fields/config/types';
import { Validate } from '../../../../../fields/config/types';
import { Description } from '../../FieldDescription/types';
export type Props = {
autoComplete?: string

View File

@@ -85,13 +85,12 @@ const Relationship: React.FC<Props> = (props) => {
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
lastLoadedPage: lastLoadedPageArg,
search: searchArg,
} = {
lastFullyLoadedRelation: -1,
lastLoadedPage: 1,
search: '',
}) => {
let lastLoadedPageToUse = typeof lastLoadedPageArg !== 'undefined' ? lastLoadedPageArg : 1;
const lastFullyLoadedRelationToUse = typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1;
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
const relationsToFetch = lastFullyLoadedRelationArg === -1 ? relations : relations.slice(lastFullyLoadedRelationArg);
const relationsToFetch = lastFullyLoadedRelationToUse === -1 ? relations : relations.slice(lastFullyLoadedRelationToUse + 1);
let resultsFetched = 0;
@@ -104,7 +103,7 @@ const Relationship: React.FC<Props> = (props) => {
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
const searchParam = searchArg ? `&where[${fieldToSearch}][like]=${searchArg}` : '';
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageArg}&depth=0${searchParam}`);
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageToUse}&depth=0${searchParam}`);
if (response.ok) {
const data: PaginatedDocs = await response.json();
@@ -115,6 +114,12 @@ const Relationship: React.FC<Props> = (props) => {
if (!data.nextPage) {
setLastFullyLoadedRelation(relations.indexOf(relation));
// If there are more relations to search, need to reset lastLoadedPage to 1
// both locally within function and state
if (relations.indexOf(relation) + 1 < relations.length) {
lastLoadedPageToUse = 1;
}
}
}
} else {
@@ -201,15 +206,6 @@ const Relationship: React.FC<Props> = (props) => {
}
}, [addOptions, api, errorLoading, serverURL]);
// ///////////////////////////
// Get first results
// ///////////////////////////
useEffect(() => {
getResults();
setHasLoadedFirstOptions(true);
}, [addOptions, api, required, relationTo, serverURL, getResults]);
// ///////////////////////////
// Get results when search input changes
// ///////////////////////////
@@ -220,6 +216,7 @@ const Relationship: React.FC<Props> = (props) => {
required,
});
setHasLoadedFirstOptions(true);
setLastLoadedPage(1);
setLastFullyLoadedRelation(-1);
getResults({ search: debouncedSearch });
@@ -311,7 +308,7 @@ const Relationship: React.FC<Props> = (props) => {
}
} : undefined}
onMenuScrollToBottom={() => {
getResults({ lastFullyLoadedRelation: lastFullyLoadedRelation + 1, lastLoadedPage });
getResults({ lastFullyLoadedRelation, lastLoadedPage: lastLoadedPage + 1 });
}}
value={valueToRender}
showError={showError}

View File

@@ -23,6 +23,11 @@
.rich-text-link__button {
@extend %btn-reset;
font-size: inherit;
font-weight: inherit;
color: inherit;
letter-spacing: inherit;
line-height: inherit;
position: relative;
z-index: 2;
text-decoration: underline;

View File

@@ -31,7 +31,7 @@ const RelationshipCell = (props) => {
const doc = hasManyRelations ? cellData.value : cellData;
const collection = collections.find((coll) => coll.slug === relation);
if (collection) {
if (collection && doc) {
const useAsTitle = collection.admin.useAsTitle ? collection.admin.useAsTitle : 'id';
setData(doc[useAsTitle]);

View File

@@ -28,8 +28,11 @@ async function logout(args: Arguments): Promise<string> {
httpOnly: true,
secure: collectionConfig.auth.cookies.secure,
sameSite: collectionConfig.auth.cookies.sameSite,
domain: undefined,
};
if (collectionConfig.auth.cookies.domain) cookieOptions.domain = collectionConfig.auth.cookies.domain;
res.clearCookie(`${config.cookiePrefix}-token`, cookieOptions);
return 'Logged out successfully.';

View File

@@ -68,7 +68,7 @@ describe('GrahpQL Resolvers', () => {
describe('Read', () => {
it('should be able to read localized post', async () => {
const title = 'gql read';
const title = 'gql read 1';
const description = 'description';
// language=graphQL
@@ -99,7 +99,7 @@ describe('GrahpQL Resolvers', () => {
});
it('should query exists - true', async () => {
const title = 'gql read';
const title = 'gql read 2';
const description = 'description';
const summary = 'summary';
@@ -136,7 +136,7 @@ describe('GrahpQL Resolvers', () => {
});
it('should query exists - false', async () => {
const title = 'gql read';
const title = 'gql read 3';
const description = 'description';
// language=graphQL

View File

@@ -113,7 +113,7 @@ async function update(incomingArgs: Arguments): Promise<Document> {
data: docWithLocales,
hook: 'afterRead',
operation: 'update',
overrideAccess,
overrideAccess: true,
flattenLocales: true,
showHiddenFields,
});

View File

@@ -5,11 +5,21 @@ import { PaginatedDocs } from '../config/types';
export default async function find(req: PayloadRequest, res: Response, next: NextFunction): Promise<Response<PaginatedDocs> | void> {
try {
let page;
if (typeof req.query.page === 'string') {
const parsedPage = parseInt(req.query.page, 10);
if (!Number.isNaN(parsedPage)) {
page = parsedPage;
}
}
const options = {
req,
collection: req.collection,
where: req.query.where,
page: req.query.page,
page,
limit: req.query.limit,
sort: req.query.sort,
depth: req.query.depth,

View File

@@ -10,6 +10,7 @@ let token = null;
let headers = null;
let localizedPostID;
const localizedPostTitle = 'title';
const englishPostDesc = 'english description';
const spanishPostDesc = 'spanish description';
@@ -44,7 +45,7 @@ describe('Collections - REST', () => {
beforeAll(async () => {
response = await fetch(`${url}/api/localized-posts`, {
body: JSON.stringify({
title: 'title',
title: localizedPostTitle,
description: englishPostDesc,
priority: 1,
nonLocalizedGroup: {
@@ -103,7 +104,7 @@ describe('Collections - REST', () => {
it('should allow updating an existing post', async () => {
const createResponse = await fetch(`${url}/api/localized-posts`, {
body: JSON.stringify({
title: 'title',
title: 'newTitle',
description: 'original description',
richText: [{
children: [{ text: 'english' }],
@@ -132,7 +133,7 @@ describe('Collections - REST', () => {
];
const response = await fetch(`${url}/api/localized-posts/${id}`, {
body: JSON.stringify({
title: 'title',
title: 'newTitle',
description: updatedDesc,
richText: updatedRichText,
nonLocalizedArray: updatedNonLocalizedArray,
@@ -161,7 +162,7 @@ describe('Collections - REST', () => {
it('should allow a Spanish locale to be added to an existing post', async () => {
const response = await fetch(`${url}/api/localized-posts/${localizedPostID}?locale=es`, {
body: JSON.stringify({
title: 'title',
title: 'title in spanish',
description: spanishPostDesc,
priority: 1,
nonLocalizedGroup: {
@@ -251,7 +252,7 @@ describe('Collections - REST', () => {
it('should allow querying by id', async () => {
const response = await fetch(`${url}/api/localized-posts`, {
body: JSON.stringify({
title: 'title',
title: 'another title',
description: 'description',
priority: 1,
}),
@@ -275,9 +276,12 @@ describe('Collections - REST', () => {
const desc = 'query test';
const response = await fetch(`${url}/api/localized-posts`, {
body: JSON.stringify({
title: 'title',
title: 'unique title here',
description: desc,
priority: 1,
nonLocalizedGroup: {
text: 'sample content',
},
}),
headers,
method: 'post',
@@ -290,6 +294,13 @@ describe('Collections - REST', () => {
expect(getResponse.status).toBe(200);
expect(data.docs[0].description).toBe(desc);
expect(data.docs).toHaveLength(1);
const getResponse2 = await fetch(`${url}/api/localized-posts?where[nonLocalizedGroup.text][equals]=sample content`);
const data2 = await getResponse2.json();
expect(getResponse2.status).toBe(200);
expect(data2.docs[0].description).toBe(desc);
expect(data2.docs).toHaveLength(1);
});
it('should allow querying with OR', async () => {
@@ -360,13 +371,110 @@ describe('Collections - REST', () => {
expect(data.docs).toHaveLength(1);
expect(data.docs[0].title).toBe(title1);
});
it('should allow querying by a non-localized nested relationship property', async () => {
const relationshipBTitle = 'test';
const relationshipBRes = await fetch(`${url}/api/relationship-b?depth=0`, {
body: JSON.stringify({
title: relationshipBTitle,
}),
headers,
method: 'post',
});
const relationshipBData = await relationshipBRes.json();
const res = await fetch(`${url}/api/relationship-a?depth=0`, {
body: JSON.stringify({
post: relationshipBData.doc.id,
}),
headers,
method: 'post',
});
const additionalRelationshipARes = await fetch(`${url}/api/relationship-a?depth=0`, {
body: JSON.stringify({
postLocalizedMultiple: [{
relationTo: 'localized-posts',
value: localizedPostID,
}],
}),
headers,
method: 'post',
});
const relationshipAData = await res.json();
expect(res.status).toBe(201);
expect(additionalRelationshipARes.status).toBe(201);
expect(relationshipAData.doc.post).toBe(relationshipBData.doc.id);
const queryRes = await fetch(`${url}/api/relationship-a?where[post.title][equals]=${relationshipBTitle}`);
const data = await queryRes.json();
expect(data.docs).toHaveLength(1);
});
it('should allow querying by a localized nested relationship property', async () => {
const res = await fetch(`${url}/api/relationship-a`, {
body: JSON.stringify({
LocalizedPost: [localizedPostID],
}),
headers,
method: 'post',
});
expect(res.status).toBe(201);
const queryRes1 = await fetch(`${url}/api/relationship-a?where[LocalizedPost.title][in]=${localizedPostTitle}`);
const data1 = await queryRes1.json();
expect(data1.docs).toHaveLength(1);
const queryRes2 = await fetch(`${url}/api/relationship-a?where[LocalizedPost.en.title][in]=${localizedPostTitle}`);
const data2 = await queryRes2.json();
expect(queryRes2.status).toBe(200);
expect(data2.docs).toHaveLength(1);
});
it('should allow querying by a field within a group', async () => {
const text = 'laiwjefliajwe';
await fetch(`${url}/api/localized-posts`, {
body: JSON.stringify({
title: 'super great title',
description: 'desc',
priority: 1,
nonLocalizedGroup: {
text,
},
localizedGroup: {
text,
},
}),
headers,
method: 'post',
});
const queryRes1 = await fetch(`${url}/api/localized-posts?where[nonLocalizedGroup.text][equals]=${text}`);
const data1 = await queryRes1.json();
expect(data1.docs).toHaveLength(1);
const queryRes2 = await fetch(`${url}/api/localized-posts?where[localizedGroup.text][equals]=${text}`);
const data2 = await queryRes2.json();
expect(queryRes2.status).toBe(200);
expect(data2.docs).toHaveLength(1);
});
});
describe('Delete', () => {
it('should allow a post to be deleted', async () => {
const response = await fetch(`${url}/api/localized-posts`, {
body: JSON.stringify({
title: 'title',
title: 'title to be deleted',
description: englishPostDesc,
priority: 1,
}),

View File

@@ -37,7 +37,7 @@ describe('Collections - REST', () => {
it('beforeChange', async () => {
const response = await fetch(`${url}/api/hooks`, {
body: JSON.stringify({
title: 'title',
title: 'title 1',
description: 'Original',
priority: 1,
}),
@@ -55,7 +55,7 @@ describe('Collections - REST', () => {
it('beforeDelete', async () => {
const createResponse = await fetch(`${url}/api/hooks`, {
body: JSON.stringify({
title: 'title',
title: 'title 2',
description: 'Original',
priority: 1,
}),
@@ -84,7 +84,7 @@ describe('Collections - REST', () => {
it('afterRead', async () => {
const response = await fetch(`${url}/api/hooks`, {
body: JSON.stringify({
title: 'title',
title: 'title 3',
description: 'afterRead',
priority: 1,
}),
@@ -104,7 +104,7 @@ describe('Collections - REST', () => {
it('afterChange', async () => {
const createResponse = await fetch(`${url}/api/hooks`, {
body: JSON.stringify({
title: 'title',
title: 'title 4',
description: 'Original',
priority: 1,
}),
@@ -133,7 +133,7 @@ describe('Collections - REST', () => {
it('afterDelete', async () => {
const createResponse = await fetch(`${url}/api/hooks`, {
body: JSON.stringify({
title: 'title',
title: 'title 5',
description: 'Original',
priority: 1,
}),

View File

@@ -4,6 +4,7 @@ import { Editor } from 'slate';
import { PayloadRequest } from '../../express/types';
import { Document } from '../../types';
import { ConditionalDateProps } from '../../admin/components/elements/DatePicker/types';
import { Description } from '../../admin/components/forms/FieldDescription/types';
export type FieldHook = (args: {
value?: unknown,
@@ -40,8 +41,6 @@ type Admin = {
hidden?: boolean
}
export type Description = string | ((value: Record<string, unknown>) => string);
export type Labels = {
singular: string;
plural: string;
@@ -302,22 +301,22 @@ export type FieldAffectingData =
| PointField
export type NonPresentationalField = TextField
| NumberField
| EmailField
| TextareaField
| CheckboxField
| DateField
| BlockField
| GroupField
| RadioField
| RelationshipField
| ArrayField
| RichTextField
| SelectField
| UploadField
| CodeField
| PointField
| RowField;
| NumberField
| EmailField
| TextareaField
| CheckboxField
| DateField
| BlockField
| GroupField
| RadioField
| RelationshipField
| ArrayField
| RichTextField
| SelectField
| UploadField
| CodeField
| PointField
| RowField;
export type FieldWithPath = Field & {
path?: string

View File

@@ -44,7 +44,7 @@ async function update(args) {
data: globalJSON,
hook: 'afterRead',
operation: 'update',
overrideAccess,
overrideAccess: true,
flattenLocales: true,
showHiddenFields,
});

View File

@@ -154,6 +154,8 @@ export class Payload {
initPreferences(this);
// Connect to database
connectMongoose(this.mongoURL, options.mongoOptions, options.local);
// If not initializing locally, set up HTTP routing

View File

@@ -1,21 +1,19 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable no-restricted-syntax */
import deepmerge from 'deepmerge';
import mongoose, { FilterQuery } from 'mongoose';
import { combineMerge } from '../utilities/combineMerge';
import { CollectionModel } from '../collections/config/types';
import { getSchemaTypeOptions } from './getSchemaTypeOptions';
import { operatorMap } from './operatorMap';
import { sanitizeQueryValue } from './sanitizeFormattedValue';
const validOperators = ['like', 'in', 'all', 'not_in', 'greater_than_equal', 'greater_than', 'less_than_equal', 'less_than', 'not_equals', 'equals', 'exists', 'near'];
function addSearchParam(key, value, searchParams) {
return {
...searchParams,
[key]: value,
};
}
function convertArrayFromCommaDelineated(input) {
if (Array.isArray(input)) return input;
if (input.indexOf(',') > -1) {
return input.split(',');
}
return [input];
}
const subQueryOptions = {
limit: 50,
lean: true,
};
type ParseType = {
searchParams?:
@@ -25,6 +23,17 @@ type ParseType = {
sort?: boolean;
};
type PathToQuery = {
complete: boolean
path: string
Model: CollectionModel
}
type SearchParam = {
path?: string,
value: unknown,
}
class ParamParser {
locale: string;
@@ -50,10 +59,6 @@ class ParamParser {
};
}
getLocalizedKey(key: string, schemaObject) {
return `${key}${(schemaObject && schemaObject.localized) ? `.${this.locale}` : ''}`;
}
// Entry point to the ParamParser class
async parse(): Promise<ParseType> {
@@ -91,9 +96,14 @@ class ParamParser {
for (const operator of Object.keys(pathOperators)) {
if (validOperators.includes(operator)) {
const searchParam = await this.buildSearchParam(this.model.schema, relationOrPath, pathOperators[operator], operator);
if (Array.isArray(searchParam)) {
const [key, value] = searchParam;
result = addSearchParam(key, value, result);
if (searchParam?.value && searchParam?.path) {
result = {
...result,
[searchParam.path]: searchParam.value,
};
} else if (typeof searchParam?.value === 'object') {
result = deepmerge(result, searchParam.value, { arrayMerge: combineMerge });
}
}
}
@@ -117,140 +127,219 @@ class ParamParser {
return completedConditions;
}
// Checks to see
async buildSearchParam(schema, key, val, operator) {
let schemaObject = schema.obj[key];
const sanitizedKey = key.replace(/__/gi, '.');
let localizedKey = this.getLocalizedKey(sanitizedKey, schemaObject);
// Build up an array of auto-localized paths to search on
// Multiple paths may be possible if searching on properties of relationship fields
if (key === '_id' || key === 'id') {
localizedKey = '_id';
schemaObject = schema.paths._id;
getLocalizedPaths(Model: CollectionModel, incomingPath: string, operator): PathToQuery[] {
const { schema } = Model;
const pathSegments = incomingPath.split('.');
if (schemaObject.instance === 'ObjectID') {
const isValid = mongoose.Types.ObjectId.isValid(val);
if (!isValid) {
return undefined;
}
}
let paths: PathToQuery[] = [
{
path: '',
complete: false,
Model,
},
];
if (schemaObject.instance === 'Number') {
const parsedNumber = parseFloat(val);
pathSegments.forEach((segment, i) => {
const lastIncompletePath = paths.find(({ complete }) => !complete);
const { path } = lastIncompletePath;
if (Number.isNaN(parsedNumber)) {
return undefined;
}
}
}
const currentPath = path ? `${path}.${segment}` : segment;
const currentSchemaType = schema.path(currentPath);
const currentSchemaPathType = schema.pathType(currentPath);
if (key.includes('.') || key.includes('__')) {
const paths = key.split('.');
schemaObject = schema.obj[paths[0]];
const localizedPath = this.getLocalizedKey(paths[0], schemaObject);
const path = schema.paths[localizedPath];
// If the schema object has a dot, split on the dot
// Check the path of the first index of the newly split array
// If it's an array OR an ObjectID, we need to recurse
if (path) {
// If the path is an ObjectId with a direct ref,
// Grab it
let { ref } = path.options;
// If the path is an Array, grab the ref of the first index type
if (path.instance === 'Array') {
ref = path.options && path.options.type && path.options.type[0].ref;
}
// //////////////////////////////////////////////////////////////////////////
// TODO:
//
// Need to handle relationships that have more than one type.
// Right now, this code only handles one ref. But there could be a
// refPath as well, which could allow for a relation to multiple types.
// In that case, we would need to get the allowed referenced models
// and run the subModel query on each - building up a list of $in IDs.
// //////////////////////////////////////////////////////////////////////////
if (ref) {
const subModel = mongoose.model(ref);
let subQuery = {};
const localizedSubKey = this.getLocalizedKey(paths[1], subModel.schema.obj[paths[1]]);
const [searchParamKey, searchParamValue] = await this.buildSearchParam(subModel.schema, localizedSubKey, val, operator);
subQuery = addSearchParam(searchParamKey, searchParamValue, subQuery);
const matchingSubDocuments = await subModel.find(subQuery);
return [localizedPath, {
$in: matchingSubDocuments.map((subDoc) => subDoc.id),
}];
}
}
}
let formattedValue = val;
if (currentSchemaType && currentSchemaPathType !== 'adhocOrUndefined') {
const currentSchemaTypeOptions = getSchemaTypeOptions(currentSchemaType);
const schemaObjectType = schemaObject?.localized ? schemaObject?.type[this.locale].type : schemaObject?.type;
if (currentSchemaTypeOptions.localized) {
const upcomingSegment = pathSegments[i + 1];
const upcomingPath = `${currentPath}.${upcomingSegment}`;
const upcomingSchemaType = schema.path(upcomingPath);
if (schemaObject && schemaObjectType === Boolean && typeof val === 'string') {
if (val.toLowerCase() === 'true') formattedValue = true;
if (val.toLowerCase() === 'false') formattedValue = false;
}
if (schemaObject && schemaObjectType === Number && typeof val === 'string') {
formattedValue = Number(val);
}
if (schemaObject && schemaObject.ref && val === 'null') {
formattedValue = null;
}
if (operator && validOperators.includes(operator)) {
switch (operator) {
case 'greater_than_equal':
formattedValue = { $gte: formattedValue };
break;
case 'less_than_equal':
formattedValue = { $lte: formattedValue };
break;
case 'less_than':
formattedValue = { $lt: formattedValue };
break;
case 'greater_than':
formattedValue = { $gt: formattedValue };
break;
case 'in':
case 'all':
formattedValue = { [`$${operator}`]: convertArrayFromCommaDelineated(formattedValue) };
break;
case 'not_in':
formattedValue = { $nin: convertArrayFromCommaDelineated(formattedValue) };
break;
case 'not_equals':
formattedValue = { $ne: formattedValue };
break;
case 'like':
if (localizedKey !== '_id') {
formattedValue = { $regex: formattedValue, $options: '-i' };
if (upcomingSchemaType) {
lastIncompletePath.path = currentPath;
return;
}
break;
case 'exists':
formattedValue = { $exists: (formattedValue === 'true' || formattedValue === true) };
break;
case 'near':
// eslint-disable-next-line no-case-declarations
const [x, y, maxDistance, minDistance] = convertArrayFromCommaDelineated(formattedValue);
if (!x || !y || (!maxDistance && !minDistance)) {
formattedValue = undefined;
break;
const localePath = `${currentPath}.${this.locale}`;
const localizedSchemaType = schema.path(localePath);
if (localizedSchemaType || operator === 'near') {
lastIncompletePath.path = localePath;
return;
}
formattedValue = {
$near: {
$geometry: { type: 'Point', coordinates: [parseFloat(x), parseFloat(y)] },
}
lastIncompletePath.path = currentPath;
return;
}
const priorSchemaType = schema.path(path);
if (priorSchemaType) {
const priorSchemaTypeOptions = getSchemaTypeOptions(priorSchemaType);
if (typeof priorSchemaTypeOptions.ref === 'string') {
const RefModel = mongoose.model(priorSchemaTypeOptions.ref) as any;
lastIncompletePath.complete = true;
const remainingPath = pathSegments.slice(i).join('.');
paths = [
...paths,
...this.getLocalizedPaths(RefModel, remainingPath, operator),
];
return;
}
}
if (operator === 'near') {
lastIncompletePath.path = currentPath;
}
});
return paths;
}
// Convert the Payload key / value / operator into a MongoDB query
async buildSearchParam(schema, incomingPath, val, operator): Promise<SearchParam> {
// Replace GraphQL nested field double underscore formatting
let sanitizedPath = incomingPath.replace(/__/gi, '.');
if (sanitizedPath === 'id') sanitizedPath = '_id';
const collectionPaths = this.getLocalizedPaths(this.model, sanitizedPath, operator);
const [{ path }] = collectionPaths;
if (path) {
const schemaType = schema.path(path);
const schemaOptions = getSchemaTypeOptions(schemaType);
const formattedValue = sanitizeQueryValue(schemaType, path, operator, val);
// If there are multiple collections to search through,
// Recursively build up a list of query constraints
if (collectionPaths.length > 1) {
// Remove top collection and reverse array
// to work backwards from top
const collectionPathsToSearch = collectionPaths.slice(1).reverse();
const initialRelationshipQuery = {
value: {},
} as SearchParam;
const relationshipQuery = await collectionPathsToSearch.reduce(async (priorQuery, { Model: SubModel, path: subPath }, i) => {
const priorQueryResult = await priorQuery;
// On the "deepest" collection,
// Search on the value passed through the query
if (i === 0) {
const subQuery = await SubModel.buildQuery({
where: {
[subPath]: {
[operator]: val,
},
},
}, this.locale);
const result = await SubModel.find(subQuery, subQueryOptions);
const $in = result.map((doc) => doc._id.toString());
if (collectionPathsToSearch.length === 1) return { path, value: { $in } };
return {
value: { _id: { $in } },
};
}
const subQuery = priorQueryResult.value;
const result = await SubModel.find(subQuery, subQueryOptions);
const $in = result.map((doc) => doc._id.toString());
// If it is the last recursion
// then pass through the search param
if (i + 1 === collectionPathsToSearch.length) {
return { path, value: { $in } };
}
return {
value: {
_id: { $in },
},
};
if (maxDistance) formattedValue.$near.$maxDistance = parseFloat(maxDistance);
if (minDistance) formattedValue.$near.$minDistance = parseFloat(minDistance);
break;
default:
break;
}, Promise.resolve(initialRelationshipQuery));
return relationshipQuery;
}
if (operator && validOperators.includes(operator)) {
const operatorKey = operatorMap[operator];
let overrideQuery = false;
let query;
// If there is a ref, this is a relationship or upload field
// IDs can be either string, number, or ObjectID
// So we need to build an `or` query for all these types
if (schemaOptions && (schemaOptions.ref || schemaOptions.refPath)) {
overrideQuery = true;
query = {
$or: [
{
[path]: {
[operatorKey]: formattedValue,
},
},
],
};
if (typeof formattedValue === 'number' || (typeof formattedValue === 'string' && mongoose.Types.ObjectId.isValid(formattedValue))) {
query.$or.push({
[path]: {
[operatorKey]: formattedValue.toString(),
},
});
}
if (typeof formattedValue === 'string') {
const parsedNumber = parseFloat(formattedValue);
if (!Number.isNaN(parsedNumber)) {
query.$or.push({
[path]: {
[operatorKey]: parsedNumber,
},
});
}
}
}
// If forced query
if (overrideQuery) {
return {
value: query,
};
}
// Some operators like 'near' need to define a full query
// so if there is no operator key, just return the value
if (!operatorKey) {
return {
path,
value: formattedValue,
};
}
return {
path,
value: { [operatorKey]: formattedValue },
};
}
}
return [localizedKey, formattedValue];
return undefined;
}
}
// This plugin asynchronously builds a list of Mongoose query constraints

View File

@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable class-methods-use-this */
/* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable no-use-before-define */
import { Schema, SchemaDefinition, SchemaOptions } from 'mongoose';

View File

@@ -1,19 +1,16 @@
import mongoose, { ConnectionOptions } from 'mongoose';
import mongoose, { ConnectOptions } from 'mongoose';
import Logger from '../utilities/logger';
import { connection } from './testCredentials';
const logger = Logger();
const connectMongoose = async (url: string, options: ConnectionOptions, local: boolean): Promise<void> => {
const connectMongoose = async (url: string, options: ConnectOptions, local: boolean): Promise<void> => {
let urlToConnect = url;
let successfulConnectionMessage = 'Connected to Mongo server successfully!';
const connectionOptions = {
...options,
useNewUrlParser: true,
useUnifiedTopology: true,
useCreateIndex: true,
autoIndex: true,
useFindAndModify: false,
};
if (process.env.NODE_ENV === 'test') {

View File

@@ -0,0 +1,7 @@
export function createArrayFromCommaDelineated(input: string): string[] {
if (Array.isArray(input)) return input;
if (input.indexOf(',') > -1) {
return input.split(',');
}
return [input];
}

View File

@@ -0,0 +1,9 @@
import { SchemaType, SchemaTypeOptions } from 'mongoose';
export const getSchemaTypeOptions = (schemaType: SchemaType): SchemaTypeOptions<{ localized: boolean }> => {
if (schemaType?.instance === 'Array') {
return schemaType.options.type[0];
}
return schemaType?.options;
};

View File

@@ -0,0 +1,13 @@
export const operatorMap = {
greater_than_equal: '$gte',
less_than_equal: '$lte',
less_than: '$lt',
greater_than: '$gt',
in: '$in',
all: '$all',
not_in: '$nin',
not_equals: '$ne',
exists: '$exists',
equals: '$eq',
near: '$near',
};

View File

@@ -0,0 +1,105 @@
import mongoose, { SchemaType } from 'mongoose';
import { createArrayFromCommaDelineated } from './createArrayFromCommaDelineated';
import { getSchemaTypeOptions } from './getSchemaTypeOptions';
export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operator: string, val: any): unknown => {
let formattedValue = val;
const schemaOptions = getSchemaTypeOptions(schemaType);
// Disregard invalid _ids
if (path === '_id' && typeof val === 'string') {
if (schemaType?.instance === 'ObjectID') {
const isValid = mongoose.Types.ObjectId.isValid(val);
if (!isValid) {
return undefined;
}
}
if (schemaType?.instance === 'Number') {
const parsedNumber = parseFloat(val);
if (Number.isNaN(parsedNumber)) {
return undefined;
}
}
}
// Cast incoming values as proper searchable types
if (schemaType?.instance === 'Boolean' && typeof val === 'string') {
if (val.toLowerCase() === 'true') formattedValue = true;
if (val.toLowerCase() === 'false') formattedValue = false;
}
if (schemaType?.instance === 'Number' && typeof val === 'string') {
formattedValue = Number(val);
}
if ((schemaOptions?.ref || schemaOptions?.refPath) && val === 'null') {
formattedValue = null;
}
// Set up specific formatting necessary by operators
if (operator === 'near') {
let x;
let y;
let maxDistance;
let minDistance;
if (Array.isArray(formattedValue)) {
[x, y, maxDistance, minDistance] = formattedValue;
}
if (typeof formattedValue === 'string') {
[x, y, maxDistance, minDistance] = createArrayFromCommaDelineated(formattedValue);
}
if (!x || !y || (!maxDistance && !minDistance)) {
formattedValue = undefined;
} else {
formattedValue = {
$geometry: { type: 'Point', coordinates: [parseFloat(x), parseFloat(y)] },
};
if (maxDistance) formattedValue.$maxDistance = parseFloat(maxDistance);
if (minDistance) formattedValue.$minDistance = parseFloat(minDistance);
}
}
if (['all', 'not_in'].includes(operator) && typeof formattedValue === 'string') {
formattedValue = createArrayFromCommaDelineated(formattedValue);
}
if (schemaOptions && (schemaOptions.ref || schemaOptions.refPath)) {
if (operator === 'in') {
if (typeof formattedValue === 'string') formattedValue = createArrayFromCommaDelineated(formattedValue);
if (Array.isArray(formattedValue)) {
formattedValue = formattedValue.reduce((formattedValues, inVal) => {
const newValues = [inVal];
if (mongoose.Types.ObjectId.isValid(inVal)) newValues.push(new mongoose.Types.ObjectId(inVal));
const parsedNumber = parseFloat(inVal);
if (!Number.isNaN(parsedNumber)) newValues.push(parsedNumber);
return [
...formattedValues,
...newValues,
];
}, []);
}
}
}
if (operator === 'like' && path !== '_id') {
formattedValue = { $regex: formattedValue, $options: '-i' };
}
if (operator === 'exists') {
formattedValue = (formattedValue === 'true' || formattedValue === true);
}
return formattedValue;
};

View File

@@ -1,3 +1,3 @@
export default function isImage(mimeType: string): boolean {
return ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'].indexOf(mimeType) > -1;
return ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp'].indexOf(mimeType) > -1;
}

View File

@@ -1,6 +1,6 @@
import merge from 'deepmerge';
const combineMerge = (target, source, options) => {
export const combineMerge = (target, source, options) => {
const destination = target.slice();
source.forEach((item, index) => {
@@ -14,5 +14,3 @@ const combineMerge = (target, source, options) => {
});
return destination;
};
export default combineMerge;