Merge branch 'master' of github.com:payloadcms/payload into feat/revisions
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -17,4 +17,5 @@ export type Props = {
|
||||
isDisabled?: boolean
|
||||
onInputChange?: (val: string) => void
|
||||
onMenuScrollToBottom?: () => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -100,7 +100,7 @@ const fieldTypeConditions = {
|
||||
operators: [...base],
|
||||
},
|
||||
relationship: {
|
||||
component: 'Text',
|
||||
component: 'Relationship',
|
||||
operators: [...base],
|
||||
},
|
||||
select: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
.field-type.email {
|
||||
margin-bottom: $baseline;
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
@include formInput;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -113,7 +113,7 @@ async function update(incomingArgs: Arguments): Promise<Document> {
|
||||
data: docWithLocales,
|
||||
hook: 'afterRead',
|
||||
operation: 'update',
|
||||
overrideAccess,
|
||||
overrideAccess: true,
|
||||
flattenLocales: true,
|
||||
showHiddenFields,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,7 +44,7 @@ async function update(args) {
|
||||
data: globalJSON,
|
||||
hook: 'afterRead',
|
||||
operation: 'update',
|
||||
overrideAccess,
|
||||
overrideAccess: true,
|
||||
flattenLocales: true,
|
||||
showHiddenFields,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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') {
|
||||
|
||||
7
src/mongoose/createArrayFromCommaDelineated.ts
Normal file
7
src/mongoose/createArrayFromCommaDelineated.ts
Normal 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];
|
||||
}
|
||||
9
src/mongoose/getSchemaTypeOptions.ts
Normal file
9
src/mongoose/getSchemaTypeOptions.ts
Normal 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;
|
||||
};
|
||||
13
src/mongoose/operatorMap.ts
Normal file
13
src/mongoose/operatorMap.ts
Normal 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',
|
||||
};
|
||||
105
src/mongoose/sanitizeFormattedValue.ts
Normal file
105
src/mongoose/sanitizeFormattedValue.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user