Merge branch 'feat/1.0' of github.com:payloadcms/payload into feat/1.0
This commit is contained in:
@@ -4,7 +4,8 @@
|
|||||||
"node_modules",
|
"node_modules",
|
||||||
"node_modules/**/node_modules",
|
"node_modules/**/node_modules",
|
||||||
"src/admin",
|
"src/admin",
|
||||||
"src/**/*.spec.ts"
|
"src/**/*.spec.ts",
|
||||||
|
"test/**/payload-types.ts"
|
||||||
],
|
],
|
||||||
"watch": [
|
"watch": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"demo:generate:graphqlschema": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts node dist/bin/generateGraphQLSchema",
|
"demo:generate:graphqlschema": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts node dist/bin/generateGraphQLSchema",
|
||||||
"demo:serve": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts NODE_ENV=production nodemon",
|
"demo:serve": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts NODE_ENV=production nodemon",
|
||||||
"dev": "nodemon",
|
"dev": "nodemon",
|
||||||
|
"dev:generatetypes": "node ./test/dev/generateTypes.js",
|
||||||
"test": "yarn test:int && yarn test:components",
|
"test": "yarn test:int && yarn test:components",
|
||||||
"test:int": "cross-env NODE_ENV=test DISABLE_LOGGING=true jest --forceExit --detectOpenHandles",
|
"test:int": "cross-env NODE_ENV=test DISABLE_LOGGING=true jest --forceExit --detectOpenHandles",
|
||||||
"test:e2e": "cross-env NODE_ENV=test DISABLE_LOGGING=true playwright test",
|
"test:e2e": "cross-env NODE_ENV=test DISABLE_LOGGING=true playwright test",
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ const config: PlaywrightTestConfig = {
|
|||||||
// Look for test files in the "tests" directory, relative to this configuration file
|
// Look for test files in the "tests" directory, relative to this configuration file
|
||||||
testDir: 'test/e2e',
|
testDir: 'test/e2e',
|
||||||
workers: 999,
|
workers: 999,
|
||||||
|
timeout: 600000,
|
||||||
};
|
};
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ const ButtonContents = ({ children, icon, tooltip }) => {
|
|||||||
const BuiltInIcon = icons[icon];
|
const BuiltInIcon = icons[icon];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`${baseClass}__content`}>
|
<span
|
||||||
|
className={`${baseClass}__content`}
|
||||||
|
>
|
||||||
{tooltip && (
|
{tooltip && (
|
||||||
<Tooltip className={`${baseClass}__tooltip`}>
|
<Tooltip className={`${baseClass}__tooltip`}>
|
||||||
{tooltip}
|
{tooltip}
|
||||||
@@ -49,6 +51,7 @@ const ButtonContents = ({ children, icon, tooltip }) => {
|
|||||||
const Button: React.FC<Props> = (props) => {
|
const Button: React.FC<Props> = (props) => {
|
||||||
const {
|
const {
|
||||||
className,
|
className,
|
||||||
|
id,
|
||||||
type = 'button',
|
type = 'button',
|
||||||
el,
|
el,
|
||||||
to,
|
to,
|
||||||
@@ -86,6 +89,7 @@ const Button: React.FC<Props> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buttonProps = {
|
const buttonProps = {
|
||||||
|
id,
|
||||||
type,
|
type,
|
||||||
className: classes,
|
className: classes,
|
||||||
disabled,
|
disabled,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { MouseEvent } from 'react';
|
|||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
className?: string,
|
className?: string,
|
||||||
|
id?: string,
|
||||||
type?: 'submit' | 'button',
|
type?: 'submit' | 'button',
|
||||||
el?: 'link' | 'anchor' | undefined,
|
el?: 'link' | 'anchor' | undefined,
|
||||||
to?: string,
|
to?: string,
|
||||||
@@ -12,6 +13,7 @@ export type Props = {
|
|||||||
icon?: React.ReactNode | ['chevron' | 'x' | 'plus' | 'edit'],
|
icon?: React.ReactNode | ['chevron' | 'x' | 'plus' | 'edit'],
|
||||||
iconStyle?: 'with-border' | 'without-border' | 'none',
|
iconStyle?: 'with-border' | 'without-border' | 'none',
|
||||||
buttonStyle?: 'primary' | 'secondary' | 'transparent' | 'error' | 'none' | 'icon-label',
|
buttonStyle?: 'primary' | 'secondary' | 'transparent' | 'error' | 'none' | 'icon-label',
|
||||||
|
buttonId?: string,
|
||||||
round?: boolean,
|
round?: boolean,
|
||||||
size?: 'small' | 'medium',
|
size?: 'small' | 'medium',
|
||||||
iconPosition?: 'left' | 'right',
|
iconPosition?: 'left' | 'right',
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ import './index.scss';
|
|||||||
const baseClass = 'card';
|
const baseClass = 'card';
|
||||||
|
|
||||||
const Card: React.FC<Props> = (props) => {
|
const Card: React.FC<Props> = (props) => {
|
||||||
const { title, actions, onClick } = props;
|
const { id, title, actions, onClick } = props;
|
||||||
|
|
||||||
const classes = [
|
const classes = [
|
||||||
baseClass,
|
baseClass,
|
||||||
|
id,
|
||||||
onClick && `${baseClass}--has-onclick`,
|
onClick && `${baseClass}--has-onclick`,
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes}>
|
<div
|
||||||
|
className={classes}
|
||||||
|
id={id}
|
||||||
|
>
|
||||||
<h5>
|
<h5>
|
||||||
{title}
|
{title}
|
||||||
</h5>
|
</h5>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type Props = {
|
export type Props = {
|
||||||
|
id?: string,
|
||||||
title: string,
|
title: string,
|
||||||
actions?: React.ReactNode,
|
actions?: React.ReactNode,
|
||||||
onClick?: () => void,
|
onClick?: () => void,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
|||||||
const {
|
const {
|
||||||
title: titleFromProps,
|
title: titleFromProps,
|
||||||
id,
|
id,
|
||||||
|
buttonId,
|
||||||
collection: {
|
collection: {
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle,
|
useAsTitle,
|
||||||
@@ -78,6 +79,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
|||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
id={buttonId}
|
||||||
className={`${baseClass}__toggle`}
|
className={`${baseClass}__toggle`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -105,6 +107,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
|||||||
". Are you sure?
|
". Are you sure?
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
|
id="confirm-cancel"
|
||||||
buttonStyle="secondary"
|
buttonStyle="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={deleting ? undefined : () => toggle(modalSlug)}
|
onClick={deleting ? undefined : () => toggle(modalSlug)}
|
||||||
@@ -113,6 +116,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={deleting ? undefined : handleDelete}
|
onClick={deleting ? undefined : handleDelete}
|
||||||
|
id="confirm-delete"
|
||||||
>
|
>
|
||||||
{deleting ? 'Deleting...' : 'Confirm'}
|
{deleting ? 'Deleting...' : 'Confirm'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ import { SanitizedCollectionConfig } from '../../../../collections/config/types'
|
|||||||
export type Props = {
|
export type Props = {
|
||||||
collection?: SanitizedCollectionConfig,
|
collection?: SanitizedCollectionConfig,
|
||||||
id?: string,
|
id?: string,
|
||||||
|
buttonId?: string,
|
||||||
title?: string,
|
title?: string,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const Duplicate: React.FC<Props> = ({ slug }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
id="action-duplicate"
|
||||||
buttonStyle="none"
|
buttonStyle="none"
|
||||||
className={baseClass}
|
className={baseClass}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ const Table: React.FC<Props> = ({ columns, data }) => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((col, i) => (
|
{columns.map((col, i) => (
|
||||||
<th key={i}>
|
<th
|
||||||
|
key={i}
|
||||||
|
id={`heading-${col.accessor}`}
|
||||||
|
>
|
||||||
{col.components.Heading}
|
{col.components.Heading}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -24,9 +27,15 @@ const Table: React.FC<Props> = ({ columns, data }) => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data && data.map((row, rowIndex) => (
|
{data && data.map((row, rowIndex) => (
|
||||||
<tr key={rowIndex}>
|
<tr
|
||||||
|
key={rowIndex}
|
||||||
|
className={`row-${rowIndex + 1}`}
|
||||||
|
>
|
||||||
{columns.map((col, colIndex) => (
|
{columns.map((col, colIndex) => (
|
||||||
<td key={colIndex}>
|
<td
|
||||||
|
key={colIndex}
|
||||||
|
className={`cell-${col.accessor}`}
|
||||||
|
>
|
||||||
{col.components.renderCell(row, row[col.accessor])}
|
{col.components.renderCell(row, row[col.accessor])}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ const Condition: React.FC<Props> = (props) => {
|
|||||||
<div className={`${baseClass}__actions`}>
|
<div className={`${baseClass}__actions`}>
|
||||||
<Button
|
<Button
|
||||||
icon="x"
|
icon="x"
|
||||||
|
className={`${baseClass}__actions-remove`}
|
||||||
round
|
round
|
||||||
buttonStyle="icon-label"
|
buttonStyle="icon-label"
|
||||||
iconStyle="with-border"
|
iconStyle="with-border"
|
||||||
@@ -116,6 +117,7 @@ const Condition: React.FC<Props> = (props) => {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon="plus"
|
icon="plus"
|
||||||
|
className={`${baseClass}__actions-add`}
|
||||||
round
|
round
|
||||||
buttonStyle="icon-label"
|
buttonStyle="icon-label"
|
||||||
iconStyle="with-border"
|
iconStyle="with-border"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import './index.scss';
|
|||||||
const baseClass = 'form-submit';
|
const baseClass = 'form-submit';
|
||||||
|
|
||||||
const FormSubmit: React.FC<Props> = (props) => {
|
const FormSubmit: React.FC<Props> = (props) => {
|
||||||
const { children, disabled: disabledFromProps, type = 'submit' } = props;
|
const { children, buttonId: id, disabled: disabledFromProps, type = 'submit' } = props;
|
||||||
const processing = useFormProcessing();
|
const processing = useFormProcessing();
|
||||||
const { disabled } = useForm();
|
const { disabled } = useForm();
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ const FormSubmit: React.FC<Props> = (props) => {
|
|||||||
<div className={baseClass}>
|
<div className={baseClass}>
|
||||||
<Button
|
<Button
|
||||||
{...props}
|
{...props}
|
||||||
|
id={id}
|
||||||
type={type}
|
type={type}
|
||||||
disabled={disabledFromProps || processing || disabled ? true : undefined}
|
disabled={disabledFromProps || processing || disabled ? true : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<div
|
<div
|
||||||
|
id={`field-${path}`}
|
||||||
className={classes}
|
className={classes}
|
||||||
>
|
>
|
||||||
<div className={`${baseClass}__error-wrap`}>
|
<div className={`${baseClass}__error-wrap`}>
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ const Blocks: React.FC<Props> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<div
|
<div
|
||||||
|
id={`field-${path}`}
|
||||||
className={classes}
|
className={classes}
|
||||||
>
|
>
|
||||||
<div className={`${baseClass}__error-wrap`}>
|
<div className={`${baseClass}__error-wrap`}>
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ const Checkbox: React.FC<Props> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
id={`field-${path}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name={path}
|
name={path}
|
||||||
id={path}
|
|
||||||
checked={Boolean(value)}
|
checked={Boolean(value)}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -78,11 +78,12 @@ const Code: React.FC<Props> = (props) => {
|
|||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={path}
|
htmlFor={`field-${path}`}
|
||||||
label={label}
|
label={label}
|
||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
<Editor
|
<Editor
|
||||||
|
id={`field-${path}`}
|
||||||
value={value as string || ''}
|
value={value as string || ''}
|
||||||
onValueChange={readOnly ? () => null : setValue}
|
onValueChange={readOnly ? () => null : setValue}
|
||||||
highlight={highlighter}
|
highlight={highlighter}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const ConfirmPassword: React.FC = () => {
|
|||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor="confirm-password"
|
htmlFor="field-confirm-password"
|
||||||
label="Confirm Password"
|
label="Confirm Password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -52,7 +52,7 @@ const ConfirmPassword: React.FC = () => {
|
|||||||
onChange={setValue}
|
onChange={setValue}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
id="confirm-password"
|
id="field-confirm-password"
|
||||||
name="confirm-password"
|
name="confirm-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,7 +76,10 @@ const DateTime: React.FC<Props> = (props) => {
|
|||||||
label={label}
|
label={label}
|
||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
<div className={`${baseClass}__input-wrapper`}>
|
<div
|
||||||
|
className={`${baseClass}__input-wrapper`}
|
||||||
|
id={`field-${path}`}
|
||||||
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
{...date}
|
{...date}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ const Email: React.FC<Props> = (props) => {
|
|||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
id={`field-${path}`}
|
||||||
value={value as string || ''}
|
value={value as string || ''}
|
||||||
onChange={setValue}
|
onChange={setValue}
|
||||||
disabled={Boolean(readOnly)}
|
disabled={Boolean(readOnly)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
type="email"
|
type="email"
|
||||||
id={path}
|
|
||||||
name={path}
|
name={path}
|
||||||
autoComplete={autoComplete}
|
autoComplete={autoComplete}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const Group: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id={`field-${path}`}
|
||||||
className={[
|
className={[
|
||||||
'field-type',
|
'field-type',
|
||||||
baseClass,
|
baseClass,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const HiddenInput: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
|
id={`field-${path}`}
|
||||||
type="hidden"
|
type="hidden"
|
||||||
value={value as string || ''}
|
value={value as string || ''}
|
||||||
onChange={setValue}
|
onChange={setValue}
|
||||||
|
|||||||
@@ -79,17 +79,17 @@ const NumberField: React.FC<Props> = (props) => {
|
|||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={path}
|
htmlFor={`field-${path}`}
|
||||||
label={label}
|
label={label}
|
||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
id={`field-${path}`}
|
||||||
value={typeof value === 'number' ? value : ''}
|
value={typeof value === 'number' ? value : ''}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
type="number"
|
type="number"
|
||||||
id={path}
|
|
||||||
name={path}
|
name={path}
|
||||||
step={step}
|
step={step}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -60,17 +60,17 @@ const Password: React.FC<Props> = (props) => {
|
|||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={path}
|
htmlFor={`field-${path}`}
|
||||||
label={label}
|
label={label}
|
||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
id={`field-${path}`}
|
||||||
value={value as string || ''}
|
value={value as string || ''}
|
||||||
onChange={setValue}
|
onChange={setValue}
|
||||||
disabled={formProcessing}
|
disabled={formProcessing}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete={autoComplete}
|
autoComplete={autoComplete}
|
||||||
id={path}
|
|
||||||
name={path}
|
name={path}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,34 +81,34 @@ const PointField: React.FC<Props> = (props) => {
|
|||||||
<ul className={`${baseClass}__wrap`}>
|
<ul className={`${baseClass}__wrap`}>
|
||||||
<li>
|
<li>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={`${path}.longitude`}
|
htmlFor={`field-longitude-${path}`}
|
||||||
label={`${label} - Longitude`}
|
label={`${label} - Longitude`}
|
||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
id={`field-longitude-${path}`}
|
||||||
value={(value && typeof value[0] === 'number') ? value[0] : ''}
|
value={(value && typeof value[0] === 'number') ? value[0] : ''}
|
||||||
onChange={(e) => handleChange(e, 0)}
|
onChange={(e) => handleChange(e, 0)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
type="number"
|
type="number"
|
||||||
id={`${path}.longitude`}
|
|
||||||
name={`${path}.longitude`}
|
name={`${path}.longitude`}
|
||||||
step={step}
|
step={step}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={`${path}.latitude`}
|
htmlFor={`field-latitude-${path}`}
|
||||||
label={`${label} - Latitude`}
|
label={`${label} - Latitude`}
|
||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
id={`field-latitude-${path}`}
|
||||||
value={(value && typeof value[1] === 'number') ? value[1] : ''}
|
value={(value && typeof value[1] === 'number') ? value[1] : ''}
|
||||||
onChange={(e) => handleChange(e, 1)}
|
onChange={(e) => handleChange(e, 1)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
type="number"
|
type="number"
|
||||||
id={`${path}.latitude`}
|
|
||||||
name={`${path}.latitude`}
|
name={`${path}.latitude`}
|
||||||
step={step}
|
step={step}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -73,11 +73,14 @@ const RadioGroupInput: React.FC<RadioGroupInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={path}
|
htmlFor={`field-${path}`}
|
||||||
label={label}
|
label={label}
|
||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
<ul className={`${baseClass}--group`}>
|
<ul
|
||||||
|
id={`field-${path}`}
|
||||||
|
className={`${baseClass}--group`}
|
||||||
|
>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
let optionValue = '';
|
let optionValue = '';
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const RadioInput: React.FC<Props> = (props) => {
|
|||||||
isSelected && `${baseClass}--is-selected`,
|
isSelected && `${baseClass}--is-selected`,
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
const id = `${path}-${option.value}`;
|
const id = `field-${path}-${option.value}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ const Relationship: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id={`field-${path}`}
|
||||||
className={classes}
|
className={classes}
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ const RichText: React.FC<Props> = (props) => {
|
|||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={path}
|
htmlFor={`field-${path}`}
|
||||||
label={label}
|
label={label}
|
||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
@@ -270,6 +270,7 @@ const RichText: React.FC<Props> = (props) => {
|
|||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
>
|
>
|
||||||
<Editable
|
<Editable
|
||||||
|
id={`field-${path}`}
|
||||||
className={`${baseClass}__input`}
|
className={`${baseClass}__input`}
|
||||||
renderElement={renderElement}
|
renderElement={renderElement}
|
||||||
renderLeaf={renderLeaf}
|
renderLeaf={renderLeaf}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const SelectInput: React.FC<SelectInputProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id={`field-${path}`}
|
||||||
className={classes}
|
className={classes}
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
|
|||||||
@@ -61,17 +61,17 @@ const TextInput: React.FC<TextInputProps> = (props) => {
|
|||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={path}
|
htmlFor={`field-${path}`}
|
||||||
label={label}
|
label={label}
|
||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
id={`field-${path}`}
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
type="text"
|
type="text"
|
||||||
id={path}
|
|
||||||
name={path}
|
name={path}
|
||||||
/>
|
/>
|
||||||
<FieldDescription
|
<FieldDescription
|
||||||
|
|||||||
@@ -62,16 +62,16 @@ const TextareaInput: React.FC<TextAreaInputProps> = (props) => {
|
|||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={path}
|
htmlFor={`field-${path}`}
|
||||||
label={label}
|
label={label}
|
||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
|
id={`field-${path}`}
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
id={path}
|
|
||||||
name={path}
|
name={path}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const Dashboard: React.FC<Props> = (props) => {
|
|||||||
<li key={collection.slug}>
|
<li key={collection.slug}>
|
||||||
<Card
|
<Card
|
||||||
title={collection.labels.plural}
|
title={collection.labels.plural}
|
||||||
|
id={`card-${collection.slug}`}
|
||||||
onClick={() => push({ pathname: `${admin}/collections/${collection.slug}` })}
|
onClick={() => push({ pathname: `${admin}/collections/${collection.slug}` })}
|
||||||
actions={hasCreatePermission ? (
|
actions={hasCreatePermission ? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -134,7 +134,14 @@ const DefaultEditView: React.FC<Props> = (props) => {
|
|||||||
<ul className={`${baseClass}__collection-actions`}>
|
<ul className={`${baseClass}__collection-actions`}>
|
||||||
{(permissions?.create?.permission) && (
|
{(permissions?.create?.permission) && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<li><Link to={`${admin}/collections/${slug}/create`}>Create New</Link></li>
|
<li>
|
||||||
|
<Link
|
||||||
|
id="action-create"
|
||||||
|
to={`${admin}/collections/${slug}/create`}
|
||||||
|
>
|
||||||
|
Create New
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
{!disableDuplicate && (
|
{!disableDuplicate && (
|
||||||
<li><DuplicateDocument slug={slug} /></li>
|
<li><DuplicateDocument slug={slug} /></li>
|
||||||
)}
|
)}
|
||||||
@@ -145,6 +152,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
|
|||||||
<DeleteDocument
|
<DeleteDocument
|
||||||
collection={collection}
|
collection={collection}
|
||||||
id={id}
|
id={id}
|
||||||
|
buttonId="action-delete"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
@@ -167,7 +175,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
{!collection.versions?.drafts && (
|
{!collection.versions?.drafts && (
|
||||||
<FormSubmit>Save</FormSubmit>
|
<FormSubmit buttonId="action-save">Save</FormSubmit>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.relationship-cell {
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
@@ -1,55 +1,68 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useConfig } from '../../../../../../utilities/Config';
|
import { useConfig } from '../../../../../../utilities/Config';
|
||||||
|
import useIntersect from '../../../../../../../hooks/useIntersect';
|
||||||
|
import { useListRelationships } from '../../../RelationshipProvider';
|
||||||
|
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
type Value = { relationTo: string, value: number | string };
|
||||||
|
const baseClass = 'relationship-cell';
|
||||||
|
const totalToShow = 3;
|
||||||
|
|
||||||
const RelationshipCell = (props) => {
|
const RelationshipCell = (props) => {
|
||||||
const { field, data: cellData } = props;
|
const { field, data: cellData } = props;
|
||||||
const { relationTo } = field;
|
const { collections, routes } = useConfig();
|
||||||
const { collections } = useConfig();
|
const [intersectionRef, entry] = useIntersect();
|
||||||
const [data, setData] = useState();
|
const [values, setValues] = useState<Value[]>([]);
|
||||||
|
const { getRelationships, documents } = useListRelationships();
|
||||||
|
const [hasRequested, setHasRequested] = useState(false);
|
||||||
|
|
||||||
|
const isAboveViewport = entry?.boundingClientRect?.top > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasManyRelations = Array.isArray(relationTo);
|
if (cellData && isAboveViewport && !hasRequested) {
|
||||||
|
const formattedValues: Value[] = [];
|
||||||
|
|
||||||
if (cellData) {
|
const arrayCellData = Array.isArray(cellData) ? cellData : [cellData];
|
||||||
if (Array.isArray(cellData)) {
|
arrayCellData.slice(0, (arrayCellData.length < totalToShow ? arrayCellData.length : totalToShow)).forEach((cell) => {
|
||||||
setData(cellData.reduce((newData, value) => {
|
if (typeof cell === 'object' && 'relationTo' in cell && 'value' in cell) {
|
||||||
const relation = hasManyRelations ? value?.relationTo : relationTo;
|
formattedValues.push(cell);
|
||||||
const doc = hasManyRelations ? value.value : value;
|
|
||||||
|
|
||||||
const collection = collections.find((coll) => coll.slug === relation);
|
|
||||||
|
|
||||||
if (collection) {
|
|
||||||
const useAsTitle = collection.admin.useAsTitle ? collection.admin.useAsTitle : 'id';
|
|
||||||
let title: string;
|
|
||||||
if (typeof doc === 'string') {
|
|
||||||
title = doc;
|
|
||||||
} else {
|
|
||||||
title = doc?.[useAsTitle] ? doc[useAsTitle] : doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newData ? `${newData}, ${title}` : title;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newData;
|
|
||||||
}, ''));
|
|
||||||
} else {
|
|
||||||
const relation = hasManyRelations ? cellData?.relationTo : relationTo;
|
|
||||||
const doc = hasManyRelations ? cellData.value : cellData;
|
|
||||||
const collection = collections.find((coll) => coll.slug === relation);
|
|
||||||
|
|
||||||
if (collection && doc) {
|
|
||||||
const useAsTitle = collection.admin.useAsTitle ? collection.admin.useAsTitle : 'id';
|
|
||||||
|
|
||||||
setData(doc[useAsTitle] ? doc[useAsTitle] : doc);
|
|
||||||
}
|
}
|
||||||
}
|
if ((typeof cell === 'number' || typeof cell === 'string') && typeof field.relationTo === 'string') {
|
||||||
|
formattedValues.push({
|
||||||
|
value: cell,
|
||||||
|
relationTo: field.relationTo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
getRelationships(formattedValues);
|
||||||
|
setHasRequested(true);
|
||||||
|
setValues(formattedValues);
|
||||||
}
|
}
|
||||||
}, [cellData, relationTo, field, collections]);
|
}, [cellData, field, collections, isAboveViewport, routes.api, hasRequested, getRelationships]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<div
|
||||||
{data}
|
className={baseClass}
|
||||||
</React.Fragment>
|
ref={intersectionRef}
|
||||||
|
>
|
||||||
|
{values.map(({ relationTo, value }, i) => {
|
||||||
|
const document = documents[relationTo][value];
|
||||||
|
const relatedCollection = collections.find(({ slug }) => slug === relationTo);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{ document === false && `Untitled - ID: ${value}`}
|
||||||
|
{ document === null && 'Loading...'}
|
||||||
|
{ document && (
|
||||||
|
document[relatedCollection.admin.useAsTitle] ? document[relatedCollection.admin.useAsTitle] : `Untitled - ID: ${value}`
|
||||||
|
)}
|
||||||
|
{values.length > i + 1 && ', '}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{ Array.isArray(cellData) && cellData.length > totalToShow && ` and ${cellData.length - totalToShow} more` }
|
||||||
|
{ values.length === 0 && `No <${field.label}>`}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const UploadCell = ({ data }) => (
|
|
||||||
<React.Fragment>
|
|
||||||
<span>
|
|
||||||
{data?.filename}
|
|
||||||
</span>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default UploadCell;
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import relationship from './Relationship';
|
|||||||
import richText from './Richtext';
|
import richText from './Richtext';
|
||||||
import select from './Select';
|
import select from './Select';
|
||||||
import textarea from './Textarea';
|
import textarea from './Textarea';
|
||||||
import upload from './Upload';
|
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -20,5 +19,5 @@ export default {
|
|||||||
richText,
|
richText,
|
||||||
select,
|
select,
|
||||||
textarea,
|
textarea,
|
||||||
upload,
|
upload: relationship,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { Fragment } from 'react';
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { useConfig } from '../../../utilities/Config';
|
import { useConfig } from '../../../utilities/Config';
|
||||||
import UploadGallery from '../../../elements/UploadGallery';
|
import UploadGallery from '../../../elements/UploadGallery';
|
||||||
import Eyebrow from '../../../elements/Eyebrow';
|
import Eyebrow from '../../../elements/Eyebrow';
|
||||||
@@ -13,6 +13,7 @@ import { Props } from './types';
|
|||||||
import ViewDescription from '../../../elements/ViewDescription';
|
import ViewDescription from '../../../elements/ViewDescription';
|
||||||
import PerPage from '../../../elements/PerPage';
|
import PerPage from '../../../elements/PerPage';
|
||||||
import { Gutter } from '../../../elements/Gutter';
|
import { Gutter } from '../../../elements/Gutter';
|
||||||
|
import { RelationshipProvider } from './RelationshipProvider';
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
@@ -43,7 +44,6 @@ const DefaultList: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
const { routes: { admin } } = useConfig();
|
const { routes: { admin } } = useConfig();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { pathname, search } = useLocation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={baseClass}>
|
<div className={baseClass}>
|
||||||
@@ -73,14 +73,14 @@ const DefaultList: React.FC<Props> = (props) => {
|
|||||||
enableSort={Boolean(upload)}
|
enableSort={Boolean(upload)}
|
||||||
/>
|
/>
|
||||||
{(data.docs && data.docs.length > 0) && (
|
{(data.docs && data.docs.length > 0) && (
|
||||||
<React.Fragment
|
<React.Fragment>
|
||||||
key={`${pathname}${search}`}
|
|
||||||
>
|
|
||||||
{!upload && (
|
{!upload && (
|
||||||
|
<RelationshipProvider>
|
||||||
<Table
|
<Table
|
||||||
data={data.docs}
|
data={data.docs}
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
/>
|
/>
|
||||||
|
</RelationshipProvider>
|
||||||
)}
|
)}
|
||||||
{upload && (
|
{upload && (
|
||||||
<UploadGallery
|
<UploadGallery
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useReducer, useRef } from 'react';
|
||||||
|
import querystring from 'qs';
|
||||||
|
import { useConfig } from '../../../../utilities/Config';
|
||||||
|
import { TypeWithID } from '../../../../../../collections/config/types';
|
||||||
|
import { reducer } from './reducer';
|
||||||
|
import useDebounce from '../../../../../hooks/useDebounce';
|
||||||
|
|
||||||
|
// documents are first set to null when requested
|
||||||
|
// set to false when no doc is returned
|
||||||
|
// or set to the document returned
|
||||||
|
export type Documents = {
|
||||||
|
[slug: string]: {
|
||||||
|
[id: string | number]: TypeWithID | null | false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListRelationshipContext = {
|
||||||
|
getRelationships: (docs: {
|
||||||
|
relationTo: string,
|
||||||
|
value: number | string
|
||||||
|
}[]) => void;
|
||||||
|
documents: Documents
|
||||||
|
}
|
||||||
|
|
||||||
|
const Context = createContext({} as ListRelationshipContext);
|
||||||
|
|
||||||
|
export const RelationshipProvider: React.FC<{children?: React.ReactNode}> = ({ children }) => {
|
||||||
|
const [documents, dispatchDocuments] = useReducer(reducer, {});
|
||||||
|
const debouncedDocuments = useDebounce(documents, 100);
|
||||||
|
const config = useConfig();
|
||||||
|
const {
|
||||||
|
serverURL,
|
||||||
|
routes: { api },
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Object.entries(debouncedDocuments).forEach(async ([slug, docs]) => {
|
||||||
|
const idsToLoad: (string | number)[] = [];
|
||||||
|
|
||||||
|
Object.entries(docs).forEach(([id, value]) => {
|
||||||
|
if (value === null) {
|
||||||
|
idsToLoad.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (idsToLoad.length > 0) {
|
||||||
|
const url = `${serverURL}${api}/${slug}`;
|
||||||
|
const params = {
|
||||||
|
depth: 0,
|
||||||
|
'where[id][in]': idsToLoad,
|
||||||
|
pagination: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = querystring.stringify(params, { addQueryPrefix: true });
|
||||||
|
const result = await fetch(`${url}${query}`);
|
||||||
|
if (result.ok) {
|
||||||
|
const json = await result.json();
|
||||||
|
if (json.docs) {
|
||||||
|
dispatchDocuments({ type: 'ADD_LOADED', docs: json.docs, relationTo: slug, idsToLoad });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dispatchDocuments({ type: 'ADD_LOADED', docs: [], relationTo: slug, idsToLoad });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [serverURL, api, debouncedDocuments]);
|
||||||
|
|
||||||
|
const getRelationships = useCallback(async (relationships: { relationTo: string, value: number | string }[]) => {
|
||||||
|
dispatchDocuments({ type: 'REQUEST', docs: relationships });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={{ getRelationships, documents }}>
|
||||||
|
{children}
|
||||||
|
</Context.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useListRelationships = (): ListRelationshipContext => useContext(Context);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Documents } from './index';
|
||||||
|
import { TypeWithID } from '../../../../../../collections/config/types';
|
||||||
|
|
||||||
|
type RequestDocuments = {
|
||||||
|
type: 'REQUEST',
|
||||||
|
docs: { relationTo: string, value: number | string }[],
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddLoadedDocuments = {
|
||||||
|
type: 'ADD_LOADED',
|
||||||
|
relationTo: string,
|
||||||
|
docs: TypeWithID[],
|
||||||
|
idsToLoad: (string | number)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action = RequestDocuments | AddLoadedDocuments;
|
||||||
|
|
||||||
|
export function reducer(state: Documents, action: Action): Documents {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'REQUEST': {
|
||||||
|
const newState = { ...state };
|
||||||
|
|
||||||
|
action.docs.forEach(({ relationTo, value }) => {
|
||||||
|
if (typeof newState[relationTo] !== 'object') {
|
||||||
|
newState[relationTo] = {};
|
||||||
|
}
|
||||||
|
newState[relationTo][value] = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ADD_LOADED': {
|
||||||
|
const newState = { ...state };
|
||||||
|
if (typeof newState[action.relationTo] !== 'object') {
|
||||||
|
newState[action.relationTo] = {};
|
||||||
|
}
|
||||||
|
const unreturnedIDs = [...action.idsToLoad];
|
||||||
|
|
||||||
|
if (Array.isArray(action.docs)) {
|
||||||
|
action.docs.forEach((doc) => {
|
||||||
|
unreturnedIDs.splice(unreturnedIDs.indexOf(doc.id), 1);
|
||||||
|
newState[action.relationTo][doc.id] = doc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unreturnedIDs.forEach((id) => {
|
||||||
|
newState[action.relationTo][id] = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ const buildColumns = (collection: SanitizedCollectionConfig, columns: string[]):
|
|||||||
),
|
),
|
||||||
renderCell: (rowData, cellData) => (
|
renderCell: (rowData, cellData) => (
|
||||||
<Cell
|
<Cell
|
||||||
|
key={JSON.stringify(cellData)}
|
||||||
field={field}
|
field={field}
|
||||||
colIndex={colIndex}
|
colIndex={colIndex}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = {
|
const params = {
|
||||||
depth: 1,
|
depth: 0,
|
||||||
draft: 'true',
|
draft: 'true',
|
||||||
page: undefined,
|
page: undefined,
|
||||||
sort: undefined,
|
sort: undefined,
|
||||||
@@ -107,7 +107,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
|||||||
setTableColumns(buildColumns(collection, currentPreferences?.columns));
|
setTableColumns(buildColumns(collection, currentPreferences?.columns));
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = queryString.parse(history.location.search, { ignoreQueryPrefix: true, depth: 10 });
|
const params = queryString.parse(history.location.search, { ignoreQueryPrefix: true, depth: 0 });
|
||||||
|
|
||||||
const search = {
|
const search = {
|
||||||
...params,
|
...params,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { UploadedFile } from 'express-fileupload';
|
|
||||||
import { Payload } from '../../..';
|
import { Payload } from '../../..';
|
||||||
import { PayloadRequest } from '../../../express/types';
|
import { PayloadRequest } from '../../../express/types';
|
||||||
import { Document } from '../../../types';
|
import { Document } from '../../../types';
|
||||||
|
|||||||
42
test/dev/generateTypes.js
Normal file
42
test/dev/generateTypes.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const babelConfig = require('../../babel.config');
|
||||||
|
|
||||||
|
require('@babel/register')({
|
||||||
|
...babelConfig,
|
||||||
|
extensions: ['.ts', '.tsx', '.js', '.jsx'],
|
||||||
|
env: {
|
||||||
|
development: {
|
||||||
|
sourceMaps: 'inline',
|
||||||
|
retainLines: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { generateTypes } = require('../../src/bin/generateTypes');
|
||||||
|
|
||||||
|
const [testConfigDir] = process.argv.slice(2);
|
||||||
|
const testDir = path.resolve(__dirname, '../', testConfigDir);
|
||||||
|
|
||||||
|
// Generate types for entire directory
|
||||||
|
if (testConfigDir === 'int' || testConfigDir === 'e2e') {
|
||||||
|
fs.readdirSync(testDir, { withFileTypes: true })
|
||||||
|
.filter((f) => f.isDirectory())
|
||||||
|
.forEach((dir) => {
|
||||||
|
const suiteDir = path.resolve(testDir, dir.name);
|
||||||
|
setPaths(suiteDir);
|
||||||
|
generateTypes();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate for specific test suite directory
|
||||||
|
setPaths(testDir);
|
||||||
|
generateTypes();
|
||||||
|
|
||||||
|
// Set config path and TS output path using test dir
|
||||||
|
function setPaths(dir) {
|
||||||
|
const configPath = path.resolve(dir, 'config.ts');
|
||||||
|
process.env.PAYLOAD_CONFIG_PATH = configPath;
|
||||||
|
process.env.PAYLOAD_TS_OUTPUT_PATH = path.resolve(dir, 'payload-types.ts');
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
import payload from '../../src';
|
import payload from '../../src';
|
||||||
|
|
||||||
const expressApp = express();
|
const expressApp = express();
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
await payload.init({
|
await payload.init({
|
||||||
secret: 'SECRET_KEY',
|
secret: uuid(),
|
||||||
mongoURL: process.env.MONGO_URL || 'mongodb://localhost/payload',
|
mongoURL: process.env.MONGO_URL || 'mongodb://localhost/payload',
|
||||||
express: expressApp,
|
express: expressApp,
|
||||||
email: {
|
email: {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { devUser } from '../../credentials';
|
||||||
import { buildConfig } from '../buildConfig';
|
import { buildConfig } from '../buildConfig';
|
||||||
|
import type { ReadOnlyCollection } from './payload-types';
|
||||||
|
|
||||||
export const slug = 'access-controls';
|
export const slug = 'access-controls';
|
||||||
|
export const readOnlySlug = 'read-only-collection';
|
||||||
|
export const restrictedSlug = 'restricted';
|
||||||
|
|
||||||
export default buildConfig({
|
export default buildConfig({
|
||||||
collections: [
|
collections: [
|
||||||
@@ -17,20 +21,52 @@ export default buildConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'restricted',
|
slug: restrictedSlug,
|
||||||
fields: [],
|
fields: [],
|
||||||
access: {
|
access: {
|
||||||
|
create: () => false,
|
||||||
read: () => false,
|
read: () => false,
|
||||||
|
update: () => false,
|
||||||
|
delete: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: readOnlySlug,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
access: {
|
||||||
|
create: () => false,
|
||||||
|
read: () => true,
|
||||||
|
update: () => false,
|
||||||
|
delete: () => false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onInit: async (payload) => {
|
onInit: async (payload) => {
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await payload.create({
|
await payload.create({
|
||||||
collection: slug,
|
collection: slug,
|
||||||
data: {
|
data: {
|
||||||
restrictedField: 'restricted',
|
restrictedField: 'restricted',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
|
||||||
|
|
||||||
|
await payload.create<ReadOnlyCollection>({
|
||||||
|
collection: readOnlySlug,
|
||||||
|
data: {
|
||||||
|
name: 'read-only',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,21 +3,13 @@ import { expect, test } from '@playwright/test';
|
|||||||
import payload from '../../../src';
|
import payload from '../../../src';
|
||||||
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
||||||
import { initPayloadE2E } from '../../helpers/configHelpers';
|
import { initPayloadE2E } from '../../helpers/configHelpers';
|
||||||
import { firstRegister } from '../helpers';
|
import { login } from '../helpers';
|
||||||
import { slug } from './config';
|
import { readOnlySlug, restrictedSlug, slug } from './config';
|
||||||
|
import type { ReadOnlyCollection } from './payload-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Access Control
|
* TODO: Access Control
|
||||||
* - [x] restricted collections not shown
|
|
||||||
* - no sidebar link
|
|
||||||
* - no route
|
|
||||||
* - no card
|
|
||||||
* [x] field without read access should not show
|
|
||||||
* prevent user from logging in (canAccessAdmin)
|
* prevent user from logging in (canAccessAdmin)
|
||||||
* no create controls if no access
|
|
||||||
* no update control if no update
|
|
||||||
* - check fields are rendered as readonly
|
|
||||||
* no delete control if no access
|
|
||||||
* no version controls is no access
|
* no version controls is no access
|
||||||
*
|
*
|
||||||
* FSK: 'should properly prevent / allow public users from reading a restricted field'
|
* FSK: 'should properly prevent / allow public users from reading a restricted field'
|
||||||
@@ -26,26 +18,26 @@ import { slug } from './config';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { beforeAll, describe } = test;
|
const { beforeAll, describe } = test;
|
||||||
let url: AdminUrlUtil;
|
|
||||||
|
|
||||||
describe('access control', () => {
|
describe('access control', () => {
|
||||||
let page: Page;
|
let page: Page;
|
||||||
|
let url: AdminUrlUtil;
|
||||||
|
let restrictedUrl: AdminUrlUtil;
|
||||||
|
let readoOnlyUrl: AdminUrlUtil;
|
||||||
|
|
||||||
beforeAll(async ({ browser }) => {
|
beforeAll(async ({ browser }) => {
|
||||||
const { serverURL } = await initPayloadE2E(__dirname);
|
const { serverURL } = await initPayloadE2E(__dirname);
|
||||||
// await clearDocs(); // Clear any seeded data from onInit
|
|
||||||
|
|
||||||
url = new AdminUrlUtil(serverURL, slug);
|
url = new AdminUrlUtil(serverURL, slug);
|
||||||
|
restrictedUrl = new AdminUrlUtil(serverURL, restrictedSlug);
|
||||||
|
readoOnlyUrl = new AdminUrlUtil(serverURL, readOnlySlug);
|
||||||
|
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
page = await context.newPage();
|
page = await context.newPage();
|
||||||
|
|
||||||
await firstRegister({ page, serverURL });
|
await login({ page, serverURL });
|
||||||
});
|
});
|
||||||
|
|
||||||
// afterEach(async () => {
|
|
||||||
// });
|
|
||||||
|
|
||||||
test('field without read access should not show', async () => {
|
test('field without read access should not show', async () => {
|
||||||
const { id } = await createDoc({ restrictedField: 'restricted' });
|
const { id } = await createDoc({ restrictedField: 'restricted' });
|
||||||
|
|
||||||
@@ -55,9 +47,20 @@ describe('access control', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('restricted collection', () => {
|
describe('restricted collection', () => {
|
||||||
|
let existingDoc: ReadOnlyCollection;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
existingDoc = await payload.create<ReadOnlyCollection>({
|
||||||
|
collection: readOnlySlug,
|
||||||
|
data: {
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('should not show in card list', async () => {
|
test('should not show in card list', async () => {
|
||||||
await page.goto(url.admin);
|
await page.goto(url.admin);
|
||||||
await expect(page.locator('.dashboard__card-list >> text=Restricteds')).toHaveCount(0);
|
await expect(page.locator(`#card-${restrictedSlug}`)).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not show in nav', async () => {
|
test('should not show in nav', async () => {
|
||||||
@@ -65,11 +68,67 @@ describe('access control', () => {
|
|||||||
await expect(page.locator('.nav >> a:has-text("Restricteds")')).toHaveCount(0);
|
await expect(page.locator('.nav >> a:has-text("Restricteds")')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not have collection url', async () => {
|
test('should not have list url', async () => {
|
||||||
await page.goto(url.list);
|
await page.goto(restrictedUrl.list);
|
||||||
await page.locator('text=Nothing found').click();
|
await expect(page.locator('.unauthorized')).toBeVisible();
|
||||||
await page.locator('a:has-text("Back to Dashboard")').click();
|
});
|
||||||
await expect(page).toHaveURL(url.admin);
|
|
||||||
|
test('should not have create url', async () => {
|
||||||
|
await page.goto(restrictedUrl.create);
|
||||||
|
await expect(page.locator('.unauthorized')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not have access to existing doc', async () => {
|
||||||
|
await page.goto(restrictedUrl.edit(existingDoc.id));
|
||||||
|
await expect(page.locator('.unauthorized')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('read-only collection', () => {
|
||||||
|
let existingDoc: ReadOnlyCollection;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
existingDoc = await payload.create<ReadOnlyCollection>({
|
||||||
|
collection: readOnlySlug,
|
||||||
|
data: {
|
||||||
|
name: 'name',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show in card list', async () => {
|
||||||
|
await page.goto(url.admin);
|
||||||
|
await expect(page.locator(`#card-${readOnlySlug}`)).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show in nav', async () => {
|
||||||
|
await page.goto(url.admin);
|
||||||
|
await expect(page.locator(`.nav a[href="/admin/collections/${readOnlySlug}"]`)).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have collection url', async () => {
|
||||||
|
await page.goto(readoOnlyUrl.list);
|
||||||
|
await expect(page).toHaveURL(readoOnlyUrl.list); // no redirect
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not have "Create New" button', async () => {
|
||||||
|
await page.goto(readoOnlyUrl.create);
|
||||||
|
await expect(page.locator('.collection-list__header a')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not have quick create button', async () => {
|
||||||
|
await page.goto(url.admin);
|
||||||
|
await expect(page.locator(`#card-${readOnlySlug}`)).not.toHaveClass('card__actions');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit view should not have buttons', async () => {
|
||||||
|
await page.goto(readoOnlyUrl.edit(existingDoc.id));
|
||||||
|
await expect(page.locator('.collection-edit__collection-actions li')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fields should be read-only', async () => {
|
||||||
|
await page.goto(readoOnlyUrl.edit(existingDoc.id));
|
||||||
|
await expect(page.locator('#field-name')).toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
51
test/e2e/access-control/payload-types.ts
Normal file
51
test/e2e/access-control/payload-types.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by Payload CMS.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "access-controls".
|
||||||
|
*/
|
||||||
|
export interface AccessControl {
|
||||||
|
id: string;
|
||||||
|
restrictedField?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "restricted".
|
||||||
|
*/
|
||||||
|
export interface Restricted {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "read-only-collection".
|
||||||
|
*/
|
||||||
|
export interface ReadOnlyCollection {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
resetPasswordExpiration?: string;
|
||||||
|
loginAttempts?: number;
|
||||||
|
lockUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
27
test/e2e/auth/payload-types.ts
Normal file
27
test/e2e/auth/payload-types.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by Payload CMS.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
enableAPIKey?: boolean;
|
||||||
|
apiKey?: string;
|
||||||
|
apiKeyIndex?: string;
|
||||||
|
email?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
resetPasswordExpiration?: string;
|
||||||
|
_verified?: boolean;
|
||||||
|
_verificationToken?: string;
|
||||||
|
loginAttempts?: number;
|
||||||
|
lockUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@ import { buildConfig as buildPayloadConfig } from '../../src/config/build';
|
|||||||
import type { Config, SanitizedConfig } from '../../src/config/types';
|
import type { Config, SanitizedConfig } from '../../src/config/types';
|
||||||
|
|
||||||
export function buildConfig(overrides?: Partial<Config>): SanitizedConfig {
|
export function buildConfig(overrides?: Partial<Config>): SanitizedConfig {
|
||||||
const baseConfig: Config = {};
|
const baseConfig: Config = {
|
||||||
|
typescript: {
|
||||||
|
outputFile: process.env.PAYLOAD_TS_OUTPUT_PATH,
|
||||||
|
},
|
||||||
|
};
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
baseConfig.admin = {
|
baseConfig.admin = {
|
||||||
webpack: (config) => ({
|
webpack: (config) => ({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { mapAsync } from '../../../src/utilities/mapAsync';
|
import { mapAsync } from '../../../src/utilities/mapAsync';
|
||||||
|
import { devUser } from '../../credentials';
|
||||||
import { buildConfig } from '../buildConfig';
|
import { buildConfig } from '../buildConfig';
|
||||||
|
|
||||||
export const slug = 'posts';
|
export const slug = 'posts';
|
||||||
@@ -26,6 +27,14 @@ export default buildConfig({
|
|||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
onInit: async (payload) => {
|
onInit: async (payload) => {
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await mapAsync([...Array(11)], async () => {
|
await mapAsync([...Array(11)], async () => {
|
||||||
await payload.create({
|
await payload.create({
|
||||||
collection: slug,
|
collection: slug,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { Page } from '@playwright/test';
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import payload from '../../../src';
|
import payload from '../../../src';
|
||||||
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
||||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
import { initPayloadE2E } from '../../helpers/configHelpers';
|
||||||
import { firstRegister, saveDocAndAssert } from '../helpers';
|
import { login, saveDocAndAssert } from '../helpers';
|
||||||
import type { Post } from './config';
|
import type { Post } from './config';
|
||||||
import { slug } from './config';
|
import { slug } from './config';
|
||||||
import { mapAsync } from '../../../src/utilities/mapAsync';
|
import { mapAsync } from '../../../src/utilities/mapAsync';
|
||||||
@@ -20,19 +20,14 @@ describe('collections', () => {
|
|||||||
let page: Page;
|
let page: Page;
|
||||||
|
|
||||||
beforeAll(async ({ browser }) => {
|
beforeAll(async ({ browser }) => {
|
||||||
const { serverURL } = await initPayloadTest({
|
const { serverURL } = await initPayloadE2E(__dirname);
|
||||||
__dirname,
|
|
||||||
init: {
|
|
||||||
local: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await clearDocs(); // Clear any seeded data from onInit
|
await clearDocs(); // Clear any seeded data from onInit
|
||||||
url = new AdminUrlUtil(serverURL, slug);
|
url = new AdminUrlUtil(serverURL, slug);
|
||||||
|
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
page = await context.newPage();
|
page = await context.newPage();
|
||||||
|
|
||||||
await firstRegister({ page, serverURL });
|
await login({ page, serverURL });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -50,13 +45,13 @@ describe('collections', () => {
|
|||||||
|
|
||||||
test('should navigate to collection - card', async () => {
|
test('should navigate to collection - card', async () => {
|
||||||
await page.goto(url.admin);
|
await page.goto(url.admin);
|
||||||
await page.locator('a:has-text("Posts")').click();
|
await page.locator(`#card-${slug}`).click();
|
||||||
expect(page.url()).toContain(url.list);
|
expect(page.url()).toContain(url.list);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('breadcrumbs - from card to dashboard', async () => {
|
test('breadcrumbs - from list to dashboard', async () => {
|
||||||
await page.goto(url.list);
|
await page.goto(url.list);
|
||||||
await page.locator('a:has-text("Dashboard")').click();
|
await page.locator('.step-nav a[href="/admin"]').click();
|
||||||
expect(page.url()).toContain(url.admin);
|
expect(page.url()).toContain(url.admin);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +59,7 @@ describe('collections', () => {
|
|||||||
const { id } = await createPost();
|
const { id } = await createPost();
|
||||||
|
|
||||||
await page.goto(url.edit(id));
|
await page.goto(url.edit(id));
|
||||||
await page.locator('nav >> text=Posts').click();
|
await page.locator(`.step-nav >> text=${slug}`).click();
|
||||||
expect(page.url()).toContain(url.list);
|
expect(page.url()).toContain(url.list);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -73,14 +68,14 @@ describe('collections', () => {
|
|||||||
describe('CRUD', () => {
|
describe('CRUD', () => {
|
||||||
test('should create', async () => {
|
test('should create', async () => {
|
||||||
await page.goto(url.create);
|
await page.goto(url.create);
|
||||||
await page.locator('#title').fill(title);
|
await page.locator('#field-title').fill(title);
|
||||||
await page.locator('#description').fill(description);
|
await page.locator('#field-description').fill(description);
|
||||||
await page.click('text=Save', { delay: 100 });
|
await page.click('#action-save', { delay: 100 });
|
||||||
|
|
||||||
await saveDocAndAssert(page);
|
await saveDocAndAssert(page);
|
||||||
|
|
||||||
await expect(page.locator('#title')).toHaveValue(title);
|
await expect(page.locator('#field-title')).toHaveValue(title);
|
||||||
await expect(page.locator('#description')).toHaveValue(description);
|
await expect(page.locator('#field-description')).toHaveValue(description);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should read existing', async () => {
|
test('should read existing', async () => {
|
||||||
@@ -88,8 +83,8 @@ describe('collections', () => {
|
|||||||
|
|
||||||
await page.goto(url.edit(id));
|
await page.goto(url.edit(id));
|
||||||
|
|
||||||
await expect(page.locator('#title')).toHaveValue(title);
|
await expect(page.locator('#field-title')).toHaveValue(title);
|
||||||
await expect(page.locator('#description')).toHaveValue(description);
|
await expect(page.locator('#field-description')).toHaveValue(description);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should update existing', async () => {
|
test('should update existing', async () => {
|
||||||
@@ -99,21 +94,21 @@ describe('collections', () => {
|
|||||||
|
|
||||||
const newTitle = 'new title';
|
const newTitle = 'new title';
|
||||||
const newDesc = 'new description';
|
const newDesc = 'new description';
|
||||||
await page.locator('#title').fill(newTitle);
|
await page.locator('#field-title').fill(newTitle);
|
||||||
await page.locator('#description').fill(newDesc);
|
await page.locator('#field-description').fill(newDesc);
|
||||||
|
|
||||||
await saveDocAndAssert(page);
|
await saveDocAndAssert(page);
|
||||||
|
|
||||||
await expect(page.locator('#title')).toHaveValue(newTitle);
|
await expect(page.locator('#field-title')).toHaveValue(newTitle);
|
||||||
await expect(page.locator('#description')).toHaveValue(newDesc);
|
await expect(page.locator('#field-description')).toHaveValue(newDesc);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should delete existing', async () => {
|
test('should delete existing', async () => {
|
||||||
const { id } = await createPost();
|
const { id } = await createPost();
|
||||||
|
|
||||||
await page.goto(url.edit(id));
|
await page.goto(url.edit(id));
|
||||||
await page.locator('button:has-text("Delete")').click();
|
await page.locator('#action-delete').click();
|
||||||
await page.locator('button:has-text("Confirm")').click();
|
await page.locator('#confirm-delete').click();
|
||||||
|
|
||||||
await expect(page.locator(`text=Post "${id}" successfully deleted.`)).toBeVisible();
|
await expect(page.locator(`text=Post "${id}" successfully deleted.`)).toBeVisible();
|
||||||
expect(page.url()).toContain(url.list);
|
expect(page.url()).toContain(url.list);
|
||||||
@@ -123,10 +118,10 @@ describe('collections', () => {
|
|||||||
const { id } = await createPost();
|
const { id } = await createPost();
|
||||||
|
|
||||||
await page.goto(url.edit(id));
|
await page.goto(url.edit(id));
|
||||||
await page.locator('button:has-text("Duplicate")').click();
|
await page.locator('#action-duplicate').click();
|
||||||
|
|
||||||
expect(page.url()).toContain(url.create);
|
expect(page.url()).toContain(url.create);
|
||||||
await page.locator('button:has-text("Save")').click();
|
await page.locator('#action-save').click();
|
||||||
expect(page.url()).not.toContain(id); // new id
|
expect(page.url()).not.toContain(id); // new id
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -149,7 +144,7 @@ describe('collections', () => {
|
|||||||
test('toggle columns', async () => {
|
test('toggle columns', async () => {
|
||||||
const columnCountLocator = 'table >> thead >> tr >> th';
|
const columnCountLocator = 'table >> thead >> tr >> th';
|
||||||
await createPost();
|
await createPost();
|
||||||
await page.locator('button:has-text("Columns")').click();
|
await page.locator('.list-controls__toggle-columns').click();
|
||||||
await wait(1000); // Wait for column toggle UI, should probably use waitForSelector
|
await wait(1000); // Wait for column toggle UI, should probably use waitForSelector
|
||||||
|
|
||||||
const numberOfColumns = await page.locator(columnCountLocator).count();
|
const numberOfColumns = await page.locator(columnCountLocator).count();
|
||||||
@@ -170,13 +165,13 @@ describe('collections', () => {
|
|||||||
|
|
||||||
await expect(page.locator(tableRowLocator)).toHaveCount(2);
|
await expect(page.locator(tableRowLocator)).toHaveCount(2);
|
||||||
|
|
||||||
await page.locator('button:has-text("Filters")').click();
|
await page.locator('.list-controls__toggle-where').click();
|
||||||
await wait(1000); // Wait for column toggle UI, should probably use waitForSelector
|
await wait(1000); // Wait for column toggle UI, should probably use waitForSelector
|
||||||
|
|
||||||
await page.locator('text=Add filter').click();
|
await page.locator('.where-builder__add-first-filter').click();
|
||||||
|
|
||||||
const operatorField = page.locator('.condition >> .condition__operator');
|
const operatorField = page.locator('.condition__operator');
|
||||||
const valueField = page.locator('.condition >> .condition__value >> input');
|
const valueField = page.locator('.condition__value >> input');
|
||||||
|
|
||||||
await operatorField.click();
|
await operatorField.click();
|
||||||
|
|
||||||
@@ -192,7 +187,7 @@ describe('collections', () => {
|
|||||||
expect(firstId).toEqual(id);
|
expect(firstId).toEqual(id);
|
||||||
|
|
||||||
// Remove filter
|
// Remove filter
|
||||||
await page.locator('.condition >> .icon--x').click();
|
await page.locator('.condition__actions-remove').click();
|
||||||
await wait(1000);
|
await wait(1000);
|
||||||
await expect(page.locator(tableRowLocator)).toHaveCount(2);
|
await expect(page.locator(tableRowLocator)).toHaveCount(2);
|
||||||
});
|
});
|
||||||
@@ -237,14 +232,11 @@ describe('collections', () => {
|
|||||||
|
|
||||||
await expect(getTableItems()).toHaveCount(2);
|
await expect(getTableItems()).toHaveCount(2);
|
||||||
|
|
||||||
const chevrons = page.locator('table >> thead >> th >> button');
|
const upChevron = page.locator('#heading-id .sort-column__asc');
|
||||||
const upChevron = chevrons.first();
|
const downChevron = page.locator('#heading-id .sort-column__desc');
|
||||||
const downChevron = chevrons.nth(1);
|
|
||||||
|
|
||||||
const getFirstId = async () => getTableItems().first().locator('td').first()
|
const getFirstId = async () => page.locator('.row-1 .cell-id').innerText();
|
||||||
.innerText();
|
const getSecondId = async () => page.locator('.row-2 .cell-id').innerText();
|
||||||
const getSecondId = async () => getTableItems().nth(1).locator('td').first()
|
|
||||||
.innerText();
|
|
||||||
|
|
||||||
const firstId = await getFirstId();
|
const firstId = await getFirstId();
|
||||||
const secondId = await getSecondId();
|
const secondId = await getSecondId();
|
||||||
|
|||||||
33
test/e2e/collections/payload-types.ts
Normal file
33
test/e2e/collections/payload-types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by Payload CMS.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "posts".
|
||||||
|
*/
|
||||||
|
export interface Post {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
resetPasswordExpiration?: string;
|
||||||
|
loginAttempts?: number;
|
||||||
|
lockUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
35
test/e2e/fields-array/payload-types.ts
Normal file
35
test/e2e/fields-array/payload-types.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by Payload CMS.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "fields-array".
|
||||||
|
*/
|
||||||
|
export interface FieldsArray {
|
||||||
|
id: string;
|
||||||
|
readOnlyArray?: {
|
||||||
|
text?: string;
|
||||||
|
id?: string;
|
||||||
|
}[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
resetPasswordExpiration?: string;
|
||||||
|
loginAttempts?: number;
|
||||||
|
lockUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { CollectionConfig } from '../../../src/collections/config/types';
|
import type { CollectionConfig } from '../../../src/collections/config/types';
|
||||||
import { buildConfig } from '../buildConfig';
|
import { buildConfig } from '../buildConfig';
|
||||||
|
import { devUser } from '../../credentials';
|
||||||
|
import { mapAsync } from '../../../src/utilities/mapAsync';
|
||||||
|
|
||||||
export const slug = 'fields-relationship';
|
export const slug = 'fields-relationship';
|
||||||
|
|
||||||
@@ -12,6 +14,7 @@ export interface FieldsRelationship {
|
|||||||
id: string;
|
id: string;
|
||||||
relationship: RelationOne;
|
relationship: RelationOne;
|
||||||
relationshipHasMany: RelationOne[];
|
relationshipHasMany: RelationOne[];
|
||||||
|
relationshipHasManyMultiple: Array<RelationOne | RelationTwo | { relationTo: string, value: string}>;
|
||||||
relationshipMultiple: Array<RelationOne | RelationTwo>;
|
relationshipMultiple: Array<RelationOne | RelationTwo>;
|
||||||
relationshipRestricted: RelationRestricted;
|
relationshipRestricted: RelationRestricted;
|
||||||
relationshipWithTitle: RelationWithTitle;
|
relationshipWithTitle: RelationWithTitle;
|
||||||
@@ -38,6 +41,9 @@ export default buildConfig({
|
|||||||
collections: [
|
collections: [
|
||||||
{
|
{
|
||||||
slug,
|
slug,
|
||||||
|
admin: {
|
||||||
|
defaultColumns: ['id', 'relationship', 'relationshipRestricted', 'relationshipHasManyMultiple', 'relationshipWithTitle'],
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
@@ -55,6 +61,12 @@ export default buildConfig({
|
|||||||
name: 'relationshipMultiple',
|
name: 'relationshipMultiple',
|
||||||
relationTo: [relationOneSlug, relationTwoSlug],
|
relationTo: [relationOneSlug, relationTwoSlug],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'relationship',
|
||||||
|
name: 'relationshipHasManyMultiple',
|
||||||
|
hasMany: true,
|
||||||
|
relationTo: [relationOneSlug, relationTwoSlug],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
name: 'relationshipRestricted',
|
name: 'relationshipRestricted',
|
||||||
@@ -94,6 +106,13 @@ export default buildConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
onInit: async (payload) => {
|
onInit: async (payload) => {
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
});
|
||||||
// Create docs to relate to
|
// Create docs to relate to
|
||||||
const { id: relationOneDocId } = await payload.create<RelationOne>({
|
const { id: relationOneDocId } = await payload.create<RelationOne>({
|
||||||
collection: relationOneSlug,
|
collection: relationOneSlug,
|
||||||
@@ -102,18 +121,26 @@ export default buildConfig({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await payload.create<RelationOne>({
|
const relationOneIDs = [];
|
||||||
collection: relationOneSlug,
|
await mapAsync([...Array(5)], async () => {
|
||||||
data: {
|
const doc = await payload.create<RelationOne>({
|
||||||
name: relationOneSlug,
|
collection: relationOneSlug,
|
||||||
},
|
data: {
|
||||||
|
name: relationOneSlug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
relationOneIDs.push(doc.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
await payload.create<RelationTwo>({
|
const relationTwoIDs = [];
|
||||||
collection: relationTwoSlug,
|
await mapAsync([...Array(11)], async () => {
|
||||||
data: {
|
const doc = await payload.create<RelationTwo>({
|
||||||
name: relationTwoSlug,
|
collection: relationTwoSlug,
|
||||||
},
|
data: {
|
||||||
|
name: relationTwoSlug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
relationTwoIDs.push(doc.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Existing relationships
|
// Existing relationships
|
||||||
@@ -129,14 +156,36 @@ export default buildConfig({
|
|||||||
name: 'relation-title',
|
name: 'relation-title',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await payload.create<RelationOne>({
|
await payload.create<FieldsRelationship>({
|
||||||
collection: slug,
|
collection: slug,
|
||||||
data: {
|
data: {
|
||||||
name: 'with-existing-relations',
|
|
||||||
relationship: relationOneDocId,
|
relationship: relationOneDocId,
|
||||||
relationshipRestricted: restrictedDocId,
|
relationshipRestricted: restrictedDocId,
|
||||||
relationshipWithTitle: relationWithTitleDocId,
|
relationshipWithTitle: relationWithTitleDocId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await mapAsync([...Array(11)], async () => {
|
||||||
|
await payload.create<FieldsRelationship>({
|
||||||
|
collection: slug,
|
||||||
|
data: {
|
||||||
|
relationship: relationOneDocId,
|
||||||
|
relationshipRestricted: restrictedDocId,
|
||||||
|
relationshipHasManyMultiple: relationOneIDs.map((id) => ({ relationTo: relationOneSlug, value: id })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await mapAsync([...Array(15)], async () => {
|
||||||
|
const relationOneID = relationOneIDs[Math.floor(Math.random() * 10)];
|
||||||
|
const relationTwoID = relationTwoIDs[Math.floor(Math.random() * 10)];
|
||||||
|
await payload.create<FieldsRelationship>({
|
||||||
|
collection: slug,
|
||||||
|
data: {
|
||||||
|
relationship: relationOneDocId,
|
||||||
|
relationshipRestricted: restrictedDocId,
|
||||||
|
relationshipHasMany: [relationOneID],
|
||||||
|
relationshipHasManyMultiple: [{ relationTo: relationTwoSlug, value: relationTwoID }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { expect, test } from '@playwright/test';
|
|||||||
import payload from '../../../src';
|
import payload from '../../../src';
|
||||||
import { mapAsync } from '../../../src/utilities/mapAsync';
|
import { mapAsync } from '../../../src/utilities/mapAsync';
|
||||||
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
||||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
import { initPayloadE2E, initPayloadTest } from '../../helpers/configHelpers';
|
||||||
import { firstRegister, saveDocAndAssert } from '../helpers';
|
import { login, saveDocAndAssert } from '../helpers';
|
||||||
import type {
|
import type {
|
||||||
FieldsRelationship as CollectionWithRelationships,
|
FieldsRelationship as CollectionWithRelationships,
|
||||||
RelationOne,
|
RelationOne,
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
relationWithTitleSlug,
|
relationWithTitleSlug,
|
||||||
slug,
|
slug,
|
||||||
} from './config';
|
} from './config';
|
||||||
|
import wait from '../../../src/utilities/wait';
|
||||||
|
|
||||||
const { beforeAll, describe } = test;
|
const { beforeAll, describe } = test;
|
||||||
|
|
||||||
@@ -35,12 +36,7 @@ describe('fields - relationship', () => {
|
|||||||
let relationWithTitle: RelationWithTitle;
|
let relationWithTitle: RelationWithTitle;
|
||||||
|
|
||||||
beforeAll(async ({ browser }) => {
|
beforeAll(async ({ browser }) => {
|
||||||
const { serverURL } = await initPayloadTest({
|
const { serverURL } = await initPayloadE2E(__dirname);
|
||||||
__dirname,
|
|
||||||
init: {
|
|
||||||
local: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await clearAllDocs();
|
await clearAllDocs();
|
||||||
|
|
||||||
url = new AdminUrlUtil(serverURL, slug);
|
url = new AdminUrlUtil(serverURL, slug);
|
||||||
@@ -97,16 +93,15 @@ describe('fields - relationship', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await firstRegister({ page, serverURL });
|
await login({ page, serverURL });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create relationship', async () => {
|
test('should create relationship', async () => {
|
||||||
await page.goto(url.create);
|
await page.goto(url.create);
|
||||||
|
|
||||||
const fields = page.locator('.render-fields >> .react-select');
|
const field = page.locator('#field-relationship');
|
||||||
const relationshipField = fields.nth(0);
|
|
||||||
|
|
||||||
await relationshipField.click({ delay: 100 });
|
await field.click({ delay: 100 });
|
||||||
|
|
||||||
const options = page.locator('.rs__option');
|
const options = page.locator('.rs__option');
|
||||||
|
|
||||||
@@ -114,7 +109,7 @@ describe('fields - relationship', () => {
|
|||||||
|
|
||||||
// Select a relationship
|
// Select a relationship
|
||||||
await options.nth(1).click();
|
await options.nth(1).click();
|
||||||
await expect(relationshipField).toContainText(relationOneDoc.id);
|
await expect(field).toContainText(relationOneDoc.id);
|
||||||
|
|
||||||
await saveDocAndAssert(page);
|
await saveDocAndAssert(page);
|
||||||
});
|
});
|
||||||
@@ -122,10 +117,9 @@ describe('fields - relationship', () => {
|
|||||||
test('should create hasMany relationship', async () => {
|
test('should create hasMany relationship', async () => {
|
||||||
await page.goto(url.create);
|
await page.goto(url.create);
|
||||||
|
|
||||||
const fields = page.locator('.render-fields >> .react-select');
|
const field = page.locator('.field-relationshipHasMany');
|
||||||
const relationshipHasManyField = fields.nth(1);
|
|
||||||
|
|
||||||
await relationshipHasManyField.click({ delay: 100 });
|
await field.click({ delay: 100 });
|
||||||
|
|
||||||
const options = page.locator('.rs__option');
|
const options = page.locator('.rs__option');
|
||||||
|
|
||||||
@@ -133,16 +127,16 @@ describe('fields - relationship', () => {
|
|||||||
|
|
||||||
// Add one relationship
|
// Add one relationship
|
||||||
await options.locator(`text=${relationOneDoc.id}`).click();
|
await options.locator(`text=${relationOneDoc.id}`).click();
|
||||||
await expect(relationshipHasManyField).toContainText(relationOneDoc.id);
|
await expect(field).toContainText(relationOneDoc.id);
|
||||||
await expect(relationshipHasManyField).not.toContainText(anotherRelationOneDoc.id);
|
await expect(field).not.toContainText(anotherRelationOneDoc.id);
|
||||||
|
|
||||||
// Add second relationship
|
// Add second relationship
|
||||||
await relationshipHasManyField.click({ delay: 100 });
|
await field.click({ delay: 100 });
|
||||||
await options.locator(`text=${anotherRelationOneDoc.id}`).click();
|
await options.locator(`text=${anotherRelationOneDoc.id}`).click();
|
||||||
await expect(relationshipHasManyField).toContainText(anotherRelationOneDoc.id);
|
await expect(field).toContainText(anotherRelationOneDoc.id);
|
||||||
|
|
||||||
// No options left
|
// No options left
|
||||||
await relationshipHasManyField.click({ delay: 100 });
|
await field.click({ delay: 100 });
|
||||||
await expect(page.locator('.rs__menu')).toHaveText('No options');
|
await expect(page.locator('.rs__menu')).toHaveText('No options');
|
||||||
|
|
||||||
await saveDocAndAssert(page);
|
await saveDocAndAssert(page);
|
||||||
@@ -151,10 +145,9 @@ describe('fields - relationship', () => {
|
|||||||
test('should create relations to multiple collections', async () => {
|
test('should create relations to multiple collections', async () => {
|
||||||
await page.goto(url.create);
|
await page.goto(url.create);
|
||||||
|
|
||||||
const fields = page.locator('.render-fields >> .react-select');
|
const field = page.locator('.field-relationshipMultiple');
|
||||||
const relationshipMultipleField = fields.nth(2);
|
|
||||||
|
|
||||||
await relationshipMultipleField.click({ delay: 100 });
|
await field.click({ delay: 100 });
|
||||||
|
|
||||||
const options = page.locator('.rs__option');
|
const options = page.locator('.rs__option');
|
||||||
|
|
||||||
@@ -162,12 +155,12 @@ describe('fields - relationship', () => {
|
|||||||
|
|
||||||
// Add one relationship
|
// Add one relationship
|
||||||
await options.locator(`text=${relationOneDoc.id}`).click();
|
await options.locator(`text=${relationOneDoc.id}`).click();
|
||||||
await expect(relationshipMultipleField).toContainText(relationOneDoc.id);
|
await expect(field).toContainText(relationOneDoc.id);
|
||||||
|
|
||||||
// Add relationship of different collection
|
// Add relationship of different collection
|
||||||
await relationshipMultipleField.click({ delay: 100 });
|
await field.click({ delay: 100 });
|
||||||
await options.locator(`text=${relationTwoDoc.id}`).click();
|
await options.locator(`text=${relationTwoDoc.id}`).click();
|
||||||
await expect(relationshipMultipleField).toContainText(relationTwoDoc.id);
|
await expect(field).toContainText(relationTwoDoc.id);
|
||||||
|
|
||||||
await saveDocAndAssert(page);
|
await saveDocAndAssert(page);
|
||||||
});
|
});
|
||||||
@@ -176,11 +169,10 @@ describe('fields - relationship', () => {
|
|||||||
test('should highlight existing relationship', async () => {
|
test('should highlight existing relationship', async () => {
|
||||||
await page.goto(url.edit(docWithExistingRelations.id));
|
await page.goto(url.edit(docWithExistingRelations.id));
|
||||||
|
|
||||||
const fields = page.locator('.render-fields >> .react-select');
|
const field = page.locator('#field-relationship');
|
||||||
const relationOneField = fields.nth(0);
|
|
||||||
|
|
||||||
// Check dropdown options
|
// Check dropdown options
|
||||||
await relationOneField.click({ delay: 100 });
|
await field.click({ delay: 100 });
|
||||||
|
|
||||||
await expect(page.locator('.rs__option--is-selected')).toHaveCount(1);
|
await expect(page.locator('.rs__option--is-selected')).toHaveCount(1);
|
||||||
await expect(page.locator('.rs__option--is-selected')).toHaveText(relationOneDoc.id);
|
await expect(page.locator('.rs__option--is-selected')).toHaveText(relationOneDoc.id);
|
||||||
@@ -189,33 +181,58 @@ describe('fields - relationship', () => {
|
|||||||
test('should show untitled ID on restricted relation', async () => {
|
test('should show untitled ID on restricted relation', async () => {
|
||||||
await page.goto(url.edit(docWithExistingRelations.id));
|
await page.goto(url.edit(docWithExistingRelations.id));
|
||||||
|
|
||||||
const fields = page.locator('.render-fields >> .react-select');
|
const field = page.locator('#field-relationshipRestricted');
|
||||||
const restrictedRelationField = fields.nth(3);
|
|
||||||
|
|
||||||
// Check existing relationship has untitled ID
|
// Check existing relationship has untitled ID
|
||||||
await expect(restrictedRelationField).toContainText(`Untitled - ID: ${restrictedRelation.id}`);
|
await expect(field).toContainText(`Untitled - ID: ${restrictedRelation.id}`);
|
||||||
|
|
||||||
// Check dropdown options
|
// Check dropdown options
|
||||||
await restrictedRelationField.click({ delay: 100 });
|
await field.click({ delay: 100 });
|
||||||
const options = page.locator('.rs__option');
|
const options = page.locator('.rs__option');
|
||||||
|
|
||||||
await expect(options).toHaveCount(2); // None + 1 Unitled ID
|
await expect(options).toHaveCount(2); // None + 1 Unitled ID
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// test.todo('should paginate within the dropdown');
|
||||||
|
// test.todo('should search within the relationship field');
|
||||||
|
|
||||||
test('should show useAsTitle on relation', async () => {
|
test('should show useAsTitle on relation', async () => {
|
||||||
await page.goto(url.edit(docWithExistingRelations.id));
|
await page.goto(url.edit(docWithExistingRelations.id));
|
||||||
|
|
||||||
const fields = page.locator('.render-fields >> .react-select');
|
const field = page.locator('#field-relationshipWithTitle .react-select');
|
||||||
const relationWithTitleField = fields.nth(4);
|
|
||||||
|
|
||||||
// Check existing relationship for correct title
|
// Check existing relationship for correct title
|
||||||
await expect(relationWithTitleField).toHaveText(relationWithTitle.name);
|
await expect(field).toHaveText(relationWithTitle.name);
|
||||||
|
|
||||||
await relationWithTitleField.click({ delay: 100 });
|
await field.click({ delay: 100 });
|
||||||
const options = page.locator('.rs__option');
|
const options = page.locator('.rs__option');
|
||||||
|
|
||||||
await expect(options).toHaveCount(2); // None + 1 Doc
|
await expect(options).toHaveCount(2); // None + 1 Doc
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should show id on relation in list view', async () => {
|
||||||
|
await page.goto(url.list);
|
||||||
|
await wait(110);
|
||||||
|
const cells = page.locator('.cell-relationship');
|
||||||
|
const relationship = cells.nth(0);
|
||||||
|
await expect(relationship).toHaveText(relationOneDoc.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show Untitled ID on restricted relation in list view', async () => {
|
||||||
|
await page.goto(url.list);
|
||||||
|
await wait(110);
|
||||||
|
const cells = page.locator('.cell-relationshipRestricted');
|
||||||
|
const relationship = cells.nth(0);
|
||||||
|
await expect(relationship).toContainText('Untitled - ID: ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show useAsTitle on relation in list view', async () => {
|
||||||
|
await page.goto(url.list);
|
||||||
|
await wait(110);
|
||||||
|
const cells = page.locator('.cell-relationshipWithTitle');
|
||||||
|
const relationship = cells.nth(0);
|
||||||
|
await expect(relationship).toHaveText(relationWithTitle.name);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
94
test/e2e/fields-relationship/payload-types.ts
Normal file
94
test/e2e/fields-relationship/payload-types.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by Payload CMS.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "fields-relationship".
|
||||||
|
*/
|
||||||
|
export interface FieldsRelationship {
|
||||||
|
id: string;
|
||||||
|
relationship?: string | RelationOne;
|
||||||
|
relationshipHasMany?: (string | RelationOne)[];
|
||||||
|
relationshipMultiple?:
|
||||||
|
| {
|
||||||
|
value: string | RelationOne;
|
||||||
|
relationTo: 'relation-one';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
value: string | RelationTwo;
|
||||||
|
relationTo: 'relation-two';
|
||||||
|
};
|
||||||
|
relationshipHasManyMultiple?: (
|
||||||
|
| {
|
||||||
|
value: string | RelationOne;
|
||||||
|
relationTo: 'relation-one';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
value: string | RelationTwo;
|
||||||
|
relationTo: 'relation-two';
|
||||||
|
}
|
||||||
|
)[];
|
||||||
|
relationshipRestricted?: string | RelationRestricted;
|
||||||
|
relationshipWithTitle?: string | RelationWithTitle;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "relation-one".
|
||||||
|
*/
|
||||||
|
export interface RelationOne {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "relation-two".
|
||||||
|
*/
|
||||||
|
export interface RelationTwo {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "relation-restricted".
|
||||||
|
*/
|
||||||
|
export interface RelationRestricted {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "relation-with-title".
|
||||||
|
*/
|
||||||
|
export interface RelationWithTitle {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
resetPasswordExpiration?: string;
|
||||||
|
loginAttempts?: number;
|
||||||
|
lockUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
32
test/e2e/fields/payload-types.ts
Normal file
32
test/e2e/fields/payload-types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by Payload CMS.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "text-fields".
|
||||||
|
*/
|
||||||
|
export interface TextField {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
resetPasswordExpiration?: string;
|
||||||
|
loginAttempts?: number;
|
||||||
|
lockUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -17,9 +17,9 @@ export async function firstRegister(args: FirstRegisterArgs): Promise<void> {
|
|||||||
const { page, serverURL } = args;
|
const { page, serverURL } = args;
|
||||||
|
|
||||||
await page.goto(`${serverURL}/admin`);
|
await page.goto(`${serverURL}/admin`);
|
||||||
await page.fill('#email', devUser.email);
|
await page.fill('#field-email', devUser.email);
|
||||||
await page.fill('#password', devUser.password);
|
await page.fill('#field-password', devUser.password);
|
||||||
await page.fill('#confirm-password', devUser.password);
|
await page.fill('#field-confirm-password', devUser.password);
|
||||||
await wait(500);
|
await wait(500);
|
||||||
await page.click('[type=submit]');
|
await page.click('[type=submit]');
|
||||||
await page.waitForURL(`${serverURL}/admin`);
|
await page.waitForURL(`${serverURL}/admin`);
|
||||||
@@ -29,15 +29,15 @@ export async function login(args: LoginArgs): Promise<void> {
|
|||||||
const { page, serverURL } = args;
|
const { page, serverURL } = args;
|
||||||
|
|
||||||
await page.goto(`${serverURL}/admin`);
|
await page.goto(`${serverURL}/admin`);
|
||||||
await page.fill('#email', devUser.email);
|
await page.fill('#field-email', devUser.email);
|
||||||
await page.fill('#password', devUser.password);
|
await page.fill('#field-password', devUser.password);
|
||||||
await wait(500);
|
await wait(500);
|
||||||
await page.click('[type=submit]');
|
await page.click('[type=submit]');
|
||||||
await page.waitForURL(`${serverURL}/admin`);
|
await page.waitForURL(`${serverURL}/admin`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveDocAndAssert(page: Page): Promise<void> {
|
export async function saveDocAndAssert(page: Page): Promise<void> {
|
||||||
await page.click('text=Save', { delay: 100 });
|
await page.click('#action-save', { delay: 100 });
|
||||||
await expect(page.locator('.Toastify')).toContainText('successfully');
|
await expect(page.locator('.Toastify')).toContainText('successfully');
|
||||||
expect(page.url()).not.toContain('create');
|
expect(page.url()).not.toContain('create');
|
||||||
}
|
}
|
||||||
|
|||||||
33
test/e2e/localization/payload-types.ts
Normal file
33
test/e2e/localization/payload-types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by Payload CMS.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "localized-posts".
|
||||||
|
*/
|
||||||
|
export interface LocalizedPost {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
resetPasswordExpiration?: string;
|
||||||
|
loginAttempts?: number;
|
||||||
|
lockUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
1
test/e2e/uploads/.gitignore
vendored
Normal file
1
test/e2e/uploads/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/media
|
||||||
102
test/e2e/uploads/config.ts
Normal file
102
test/e2e/uploads/config.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { buildConfig } from '../buildConfig';
|
||||||
|
import { devUser } from '../../credentials';
|
||||||
|
import getFileByPath from '../../../src/uploads/getFileByPath';
|
||||||
|
|
||||||
|
export const mediaSlug = 'media';
|
||||||
|
|
||||||
|
export const relationSlug = 'relation';
|
||||||
|
|
||||||
|
const mockModulePath = path.resolve(__dirname, './mocks/mockFSModule.js');
|
||||||
|
|
||||||
|
export default buildConfig({
|
||||||
|
admin: {
|
||||||
|
webpack: (config) => ({
|
||||||
|
...config,
|
||||||
|
resolve: {
|
||||||
|
...config.resolve,
|
||||||
|
alias: {
|
||||||
|
...config.resolve.alias,
|
||||||
|
fs: mockModulePath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// upload: {},
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
slug: relationSlug,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'image',
|
||||||
|
type: 'upload',
|
||||||
|
relationTo: 'media',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: mediaSlug,
|
||||||
|
upload: {
|
||||||
|
staticURL: '/media',
|
||||||
|
staticDir: './media',
|
||||||
|
imageSizes: [
|
||||||
|
{
|
||||||
|
name: 'maintainedAspectRatio',
|
||||||
|
width: 1024,
|
||||||
|
height: null,
|
||||||
|
crop: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tablet',
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
crop: 'left top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mobile',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
crop: 'left top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'icon',
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onInit: async (payload) => {
|
||||||
|
// delete files in /media
|
||||||
|
const mediaDir = path.resolve(__dirname, './media');
|
||||||
|
fs.readdirSync(mediaDir).forEach((f) => fs.rmSync(`${mediaDir}/${f}`));
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Create image
|
||||||
|
const filePath = path.resolve(__dirname, './image.png');
|
||||||
|
const file = getFileByPath(filePath);
|
||||||
|
|
||||||
|
const { id: uploadedImage } = await payload.create({
|
||||||
|
collection: mediaSlug,
|
||||||
|
data: {},
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: relationSlug,
|
||||||
|
data: {
|
||||||
|
image: uploadedImage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
88
test/e2e/uploads/index.spec.ts
Normal file
88
test/e2e/uploads/index.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
||||||
|
import { initPayloadE2E } from '../../helpers/configHelpers';
|
||||||
|
import { login, saveDocAndAssert } from '../helpers';
|
||||||
|
import { relationSlug, mediaSlug } from './config';
|
||||||
|
import type { Media } from './payload-types';
|
||||||
|
import wait from '../../../src/utilities/wait';
|
||||||
|
import payload from '../../../src';
|
||||||
|
|
||||||
|
const { beforeAll, describe } = test;
|
||||||
|
|
||||||
|
let mediaURL: AdminUrlUtil;
|
||||||
|
let relationURL: AdminUrlUtil;
|
||||||
|
|
||||||
|
describe('uploads', () => {
|
||||||
|
let page: Page;
|
||||||
|
let mediaDoc: Media;
|
||||||
|
|
||||||
|
beforeAll(async ({ browser }) => {
|
||||||
|
const { serverURL } = await initPayloadE2E(__dirname);
|
||||||
|
|
||||||
|
mediaURL = new AdminUrlUtil(serverURL, mediaSlug);
|
||||||
|
relationURL = new AdminUrlUtil(serverURL, relationSlug);
|
||||||
|
|
||||||
|
const context = await browser.newContext();
|
||||||
|
page = await context.newPage();
|
||||||
|
|
||||||
|
const findMedia = await payload.find({
|
||||||
|
collection: mediaSlug,
|
||||||
|
depth: 0,
|
||||||
|
pagination: false,
|
||||||
|
});
|
||||||
|
mediaDoc = findMedia.docs[0] as Media;
|
||||||
|
|
||||||
|
await login({ page, serverURL });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should see upload filename in relation list', async () => {
|
||||||
|
await page.goto(relationURL.list);
|
||||||
|
|
||||||
|
await wait(110);
|
||||||
|
const field = page.locator('.cell-image');
|
||||||
|
|
||||||
|
await expect(field).toContainText('image.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show upload filename in upload collection list', async () => {
|
||||||
|
await page.goto(mediaURL.list);
|
||||||
|
|
||||||
|
const media = page.locator('.upload-card__filename');
|
||||||
|
await wait(110);
|
||||||
|
|
||||||
|
await expect(media).toHaveText('image.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create file upload', async () => {
|
||||||
|
await page.goto(mediaURL.create);
|
||||||
|
|
||||||
|
await page.setInputFiles('input[type="file"]', './image.png');
|
||||||
|
|
||||||
|
const filename = page.locator('.file-field__filename');
|
||||||
|
|
||||||
|
await expect(filename).toContainText('.png');
|
||||||
|
|
||||||
|
await page.locator('.form-submit button').click();
|
||||||
|
|
||||||
|
await saveDocAndAssert(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show resized images', async () => {
|
||||||
|
await page.goto(mediaURL.edit(mediaDoc.id));
|
||||||
|
|
||||||
|
await page.locator('.btn.file-details__toggle-more-info').click();
|
||||||
|
|
||||||
|
const maintainedAspectRatioMeta = page.locator('.file-details__sizes .file-meta').nth(0);
|
||||||
|
await expect(maintainedAspectRatioMeta).toContainText('1024x1024');
|
||||||
|
|
||||||
|
const tabletMeta = page.locator('.file-details__sizes .file-meta').nth(1);
|
||||||
|
await expect(tabletMeta).toContainText('640x480');
|
||||||
|
|
||||||
|
const mobileMeta = page.locator('.file-details__sizes .file-meta').nth(2);
|
||||||
|
await expect(mobileMeta).toContainText('320x240');
|
||||||
|
|
||||||
|
const iconMeta = page.locator('.file-details__sizes .file-meta').nth(3);
|
||||||
|
await expect(iconMeta).toContainText('16x16');
|
||||||
|
});
|
||||||
|
});
|
||||||
4
test/e2e/uploads/mocks/mockFSModule.js
Normal file
4
test/e2e/uploads/mocks/mockFSModule.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export default {
|
||||||
|
readdirSync: () => {},
|
||||||
|
rmSync: () => {},
|
||||||
|
};
|
||||||
81
test/e2e/uploads/payload-types.ts
Normal file
81
test/e2e/uploads/payload-types.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by Payload CMS.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "relation".
|
||||||
|
*/
|
||||||
|
export interface Relation {
|
||||||
|
id: string;
|
||||||
|
image?: string | Media;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "media".
|
||||||
|
*/
|
||||||
|
export interface Media {
|
||||||
|
id: string;
|
||||||
|
url?: string;
|
||||||
|
filename?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
filesize?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
sizes?: {
|
||||||
|
maintainedAspectRatio?: {
|
||||||
|
url?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
filesize?: number;
|
||||||
|
filename?: string;
|
||||||
|
};
|
||||||
|
tablet?: {
|
||||||
|
url?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
filesize?: number;
|
||||||
|
filename?: string;
|
||||||
|
};
|
||||||
|
mobile?: {
|
||||||
|
url?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
filesize?: number;
|
||||||
|
filename?: string;
|
||||||
|
};
|
||||||
|
icon?: {
|
||||||
|
url?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
filesize?: number;
|
||||||
|
filename?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
resetPasswordExpiration?: string;
|
||||||
|
loginAttempts?: number;
|
||||||
|
lockUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
31
test/e2e/versions/payload-types.ts
Normal file
31
test/e2e/versions/payload-types.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by Payload CMS.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "slugname".
|
||||||
|
*/
|
||||||
|
export interface Slugname {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
resetPasswordExpiration?: string;
|
||||||
|
loginAttempts?: number;
|
||||||
|
lockUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import qs from 'qs';
|
|||||||
import type { Config } from '../../src/config/types';
|
import type { Config } from '../../src/config/types';
|
||||||
import type { PaginatedDocs } from '../../src/mongoose/types';
|
import type { PaginatedDocs } from '../../src/mongoose/types';
|
||||||
import type { Where } from '../../src/types';
|
import type { Where } from '../../src/types';
|
||||||
|
import { devUser } from '../credentials';
|
||||||
|
|
||||||
require('isomorphic-fetch');
|
require('isomorphic-fetch');
|
||||||
|
|
||||||
@@ -68,8 +69,8 @@ export class RESTClient {
|
|||||||
|
|
||||||
async login(incomingArgs?: LoginArgs): Promise<string> {
|
async login(incomingArgs?: LoginArgs): Promise<string> {
|
||||||
const args = incomingArgs ?? {
|
const args = incomingArgs ?? {
|
||||||
email: 'dev@payloadcms.com',
|
email: devUser.email,
|
||||||
password: 'test',
|
password: devUser.password,
|
||||||
collection: 'users',
|
collection: 'users',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
|
|||||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
import { initPayloadTest } from '../../helpers/configHelpers';
|
||||||
import payload from '../../../src';
|
import payload from '../../../src';
|
||||||
import config from './config';
|
import config from './config';
|
||||||
|
import type { Array as ArrayCollection } from './payload-types';
|
||||||
|
|
||||||
const collection = config.collections[0]?.slug;
|
const collection = config.collections[0]?.slug;
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ describe('array-update', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const updatedDoc = await payload.update({
|
const updatedDoc = await payload.update<ArrayCollection>({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
collection,
|
collection,
|
||||||
data: {
|
data: {
|
||||||
@@ -55,7 +56,7 @@ describe('array-update', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(updatedDoc.array[0]).toMatchObject({
|
expect(updatedDoc.array?.[0]).toMatchObject({
|
||||||
required: updatedText,
|
required: updatedText,
|
||||||
optional: originalText,
|
optional: originalText,
|
||||||
});
|
});
|
||||||
@@ -69,7 +70,7 @@ describe('array-update', () => {
|
|||||||
optional: 'optional test',
|
optional: 'optional test',
|
||||||
};
|
};
|
||||||
|
|
||||||
const doc = await payload.create({
|
const doc = await payload.create<ArrayCollection>({
|
||||||
collection,
|
collection,
|
||||||
data: {
|
data: {
|
||||||
array: [
|
array: [
|
||||||
@@ -82,7 +83,7 @@ describe('array-update', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedDoc = await payload.update<any>({
|
const updatedDoc = await payload.update<ArrayCollection>({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
collection,
|
collection,
|
||||||
data: {
|
data: {
|
||||||
@@ -91,8 +92,8 @@ describe('array-update', () => {
|
|||||||
required: updatedText,
|
required: updatedText,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: doc.array[1].id,
|
id: doc.array?.[1].id,
|
||||||
required: doc.array[1].required,
|
required: doc.array?.[1].required as string,
|
||||||
// NOTE - not passing optional field. It should persist
|
// NOTE - not passing optional field. It should persist
|
||||||
// because we're passing ID
|
// because we're passing ID
|
||||||
},
|
},
|
||||||
@@ -100,9 +101,9 @@ describe('array-update', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(updatedDoc.array[0].required).toStrictEqual(updatedText);
|
expect(updatedDoc.array?.[0].required).toStrictEqual(updatedText);
|
||||||
expect(updatedDoc.array[0].optional).toBeUndefined();
|
expect(updatedDoc.array?.[0].optional).toBeUndefined();
|
||||||
|
|
||||||
expect(updatedDoc.array[1]).toMatchObject(secondArrayItem);
|
expect(updatedDoc.array?.[1]).toMatchObject(secondArrayItem);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
36
test/int/array-update/payload-types.ts
Normal file
36
test/int/array-update/payload-types.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by Payload CMS.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "arrays".
|
||||||
|
*/
|
||||||
|
export interface Array {
|
||||||
|
id: string;
|
||||||
|
array?: {
|
||||||
|
required: string;
|
||||||
|
optional?: string;
|
||||||
|
id?: string;
|
||||||
|
}[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
resetPasswordExpiration?: string;
|
||||||
|
loginAttempts?: number;
|
||||||
|
lockUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@ import { buildConfig as buildPayloadConfig } from '../../src/config/build';
|
|||||||
import type { Config, SanitizedConfig } from '../../src/config/types';
|
import type { Config, SanitizedConfig } from '../../src/config/types';
|
||||||
|
|
||||||
export function buildConfig(overrides?: Partial<Config>): SanitizedConfig {
|
export function buildConfig(overrides?: Partial<Config>): SanitizedConfig {
|
||||||
const baseConfig: Config = {};
|
const baseConfig: Config = {
|
||||||
|
typescript: {
|
||||||
|
outputFile: process.env.PAYLOAD_TS_OUTPUT_PATH,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
baseConfig.admin = {
|
baseConfig.admin = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { initPayloadTest } from '../../helpers/configHelpers';
|
|||||||
import config from './config';
|
import config from './config';
|
||||||
import payload from '../../../src';
|
import payload from '../../../src';
|
||||||
import { RESTClient } from '../../helpers/rest';
|
import { RESTClient } from '../../helpers/rest';
|
||||||
|
import type { Post } from './payload-types';
|
||||||
|
|
||||||
const collection = config.collections[0]?.slug;
|
const collection = config.collections[0]?.slug;
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ let client: RESTClient;
|
|||||||
describe('collections-graphql', () => {
|
describe('collections-graphql', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
|
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
|
||||||
client = new RESTClient(config, { serverURL });
|
client = new RESTClient(config, { serverURL, defaultSlug: collection });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -23,7 +24,7 @@ describe('collections-graphql', () => {
|
|||||||
it('should create', async () => {
|
it('should create', async () => {
|
||||||
const title = 'hello';
|
const title = 'hello';
|
||||||
|
|
||||||
const { doc } = await client.create({
|
const { doc } = await client.create<Post>({
|
||||||
slug: collection,
|
slug: collection,
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
|
|||||||
32
test/int/collections-graphql/payload-types.ts
Normal file
32
test/int/collections-graphql/payload-types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by Payload CMS.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "posts".
|
||||||
|
*/
|
||||||
|
export interface Post {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
resetPasswordExpiration?: string;
|
||||||
|
loginAttempts?: number;
|
||||||
|
lockUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -1,23 +1,13 @@
|
|||||||
import type { CollectionConfig } from '../../../src/collections/config/types';
|
import type { CollectionConfig } from '../../../src/collections/config/types';
|
||||||
|
import { devUser } from '../../credentials';
|
||||||
import { buildConfig } from '../buildConfig';
|
import { buildConfig } from '../buildConfig';
|
||||||
|
import type { Post } from './payload-types';
|
||||||
export interface Post {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
number?: number;
|
|
||||||
relationField?: Relation | string
|
|
||||||
relationHasManyField?: RelationHasMany[] | string[]
|
|
||||||
relationMultiRelationTo?: Relation[] | string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Relation {
|
export interface Relation {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RelationHasMany = Relation
|
|
||||||
|
|
||||||
const openAccess = {
|
const openAccess = {
|
||||||
create: () => true,
|
create: () => true,
|
||||||
read: () => true,
|
read: () => true,
|
||||||
@@ -25,9 +15,9 @@ const openAccess = {
|
|||||||
delete: () => true,
|
delete: () => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const collectionWithName = (slug: string): CollectionConfig => {
|
const collectionWithName = (collectionSlug: string): CollectionConfig => {
|
||||||
return {
|
return {
|
||||||
slug,
|
slug: collectionSlug,
|
||||||
access: openAccess,
|
access: openAccess,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -39,9 +29,7 @@ const collectionWithName = (slug: string): CollectionConfig => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const slug = 'posts';
|
export const slug = 'posts';
|
||||||
export const relationSlug = 'relation-normal';
|
export const relationSlug = 'relation';
|
||||||
export const relationHasManySlug = 'relation-has-many';
|
|
||||||
export const relationMultipleRelationToSlug = 'relation-multi-relation-to';
|
|
||||||
export default buildConfig({
|
export default buildConfig({
|
||||||
collections: [
|
collections: [
|
||||||
{
|
{
|
||||||
@@ -70,7 +58,7 @@ export default buildConfig({
|
|||||||
{
|
{
|
||||||
name: 'relationHasManyField',
|
name: 'relationHasManyField',
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
relationTo: relationHasManySlug,
|
relationTo: relationSlug,
|
||||||
hasMany: true,
|
hasMany: true,
|
||||||
},
|
},
|
||||||
// Relation multiple relationTo
|
// Relation multiple relationTo
|
||||||
@@ -79,27 +67,84 @@ export default buildConfig({
|
|||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
relationTo: [relationSlug, 'dummy'],
|
relationTo: [relationSlug, 'dummy'],
|
||||||
},
|
},
|
||||||
|
// Relation multiple relationTo hasMany
|
||||||
|
{
|
||||||
|
name: 'relationMultiRelationToHasMany',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: [relationSlug, 'dummy'],
|
||||||
|
hasMany: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
collectionWithName(relationSlug),
|
collectionWithName(relationSlug),
|
||||||
collectionWithName(relationHasManySlug),
|
|
||||||
collectionWithName('dummy'),
|
collectionWithName('dummy'),
|
||||||
],
|
],
|
||||||
onInit: async (payload) => {
|
onInit: async (payload) => {
|
||||||
const rel1 = await payload.create<RelationHasMany>({
|
await payload.create({
|
||||||
collection: relationHasManySlug,
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rel1 = await payload.create<Relation>({
|
||||||
|
collection: relationSlug,
|
||||||
data: {
|
data: {
|
||||||
name: 'name',
|
name: 'name',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const rel2 = await payload.create<Relation>({
|
||||||
|
collection: relationSlug,
|
||||||
|
data: {
|
||||||
|
name: 'name2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await payload.create({
|
// Relation - hasMany
|
||||||
|
await payload.create<Post>({
|
||||||
collection: slug,
|
collection: slug,
|
||||||
data: {
|
data: {
|
||||||
title: 'title',
|
title: 'rel to hasMany',
|
||||||
relationHasManyField: rel1.id,
|
relationHasManyField: rel1.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
await payload.create<Post>({
|
||||||
|
collection: slug,
|
||||||
|
data: {
|
||||||
|
title: 'rel to hasMany 2',
|
||||||
|
relationHasManyField: rel2.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Relation - relationTo multi
|
||||||
|
await payload.create<Post>({
|
||||||
|
collection: slug,
|
||||||
|
data: {
|
||||||
|
title: 'rel to multi',
|
||||||
|
relationMultiRelationTo: {
|
||||||
|
relationTo: relationSlug,
|
||||||
|
value: rel2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Relation - relationTo multi hasMany
|
||||||
|
await payload.create<Post>({
|
||||||
|
collection: slug,
|
||||||
|
data: {
|
||||||
|
title: 'rel to multi hasMany',
|
||||||
|
relationMultiRelationToHasMany: [
|
||||||
|
{
|
||||||
|
relationTo: relationSlug,
|
||||||
|
value: rel1.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
relationTo: relationSlug,
|
||||||
|
value: rel2.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
import { initPayloadTest } from '../../helpers/configHelpers';
|
||||||
import type { Relation, Post, RelationHasMany } from './config';
|
import type { Relation } from './config';
|
||||||
import config, { relationHasManySlug, slug, relationSlug } from './config';
|
import config, { slug, relationSlug } from './config';
|
||||||
import payload from '../../../src';
|
import payload from '../../../src';
|
||||||
import { RESTClient } from '../../helpers/rest';
|
import { RESTClient } from '../../helpers/rest';
|
||||||
import { mapAsync } from '../../../src/utilities/mapAsync';
|
import { mapAsync } from '../../../src/utilities/mapAsync';
|
||||||
|
import type { Post } from './payload-types';
|
||||||
|
|
||||||
let client: RESTClient;
|
let client: RESTClient;
|
||||||
|
|
||||||
@@ -85,54 +86,75 @@ describe('collections-rest', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe('Querying', () => {
|
describe('Querying', () => {
|
||||||
describe('Relationships', () => {
|
describe('Relationships', () => {
|
||||||
it('should query nested relationship', async () => {
|
let post: Post;
|
||||||
const nameToQuery = 'name';
|
let relation: Relation;
|
||||||
const { doc: relation } = await client.create<Relation>({
|
let relation2: Relation;
|
||||||
|
const nameToQuery = 'name';
|
||||||
|
const nameToQuery2 = 'name';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
({ doc: relation } = await client.create<Relation>({
|
||||||
slug: relationSlug,
|
slug: relationSlug,
|
||||||
data: {
|
data: {
|
||||||
name: nameToQuery,
|
name: nameToQuery,
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
const post1 = await createPost({
|
({ doc: relation2 } = await client.create<Relation>({
|
||||||
|
slug: relationSlug,
|
||||||
|
data: {
|
||||||
|
name: nameToQuery2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
post = await createPost({
|
||||||
relationField: relation.id,
|
relationField: relation.id,
|
||||||
});
|
});
|
||||||
await createPost();
|
|
||||||
|
|
||||||
const { status, result } = await client.find<Post>({
|
await createPost(); // Extra post to allow asserting totalDoc count
|
||||||
query: {
|
});
|
||||||
'relationField.name': {
|
|
||||||
equals: nameToQuery,
|
describe('regular relationship', () => {
|
||||||
|
it('query by property value', async () => {
|
||||||
|
const { status, result } = await client.find<Post>({
|
||||||
|
query: {
|
||||||
|
'relationField.name': {
|
||||||
|
equals: relation.name,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
|
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(result.docs).toEqual([post]);
|
||||||
|
expect(result.totalDocs).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toEqual(200);
|
it('query by id', async () => {
|
||||||
expect(result.docs).toEqual([post1]);
|
const { status, result } = await client.find<Post>({
|
||||||
expect(result.totalDocs).toEqual(1);
|
query: {
|
||||||
|
relationField: {
|
||||||
|
equals: relation.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(result.docs).toEqual([post]);
|
||||||
|
expect(result.totalDocs).toEqual(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should query nested relationship - hasMany', async () => {
|
it('should query nested relationship - hasMany', async () => {
|
||||||
const nameToQuery = 'name';
|
|
||||||
const { doc: relation } = await client.create<RelationHasMany>({
|
|
||||||
slug: relationHasManySlug,
|
|
||||||
data: {
|
|
||||||
name: nameToQuery,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const post1 = await createPost({
|
const post1 = await createPost({
|
||||||
relationHasManyField: [relation.id],
|
relationHasManyField: [relation.id, relation2.id],
|
||||||
});
|
});
|
||||||
await createPost();
|
|
||||||
|
|
||||||
const { status, result } = await client.find<Post>({
|
const { status, result } = await client.find<Post>({
|
||||||
query: {
|
query: {
|
||||||
'relationHasManyField.name': {
|
'relationHasManyField.name': {
|
||||||
equals: nameToQuery,
|
equals: relation.name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -140,6 +162,81 @@ describe('collections-rest', () => {
|
|||||||
expect(status).toEqual(200);
|
expect(status).toEqual(200);
|
||||||
expect(result.docs).toEqual([post1]);
|
expect(result.docs).toEqual([post1]);
|
||||||
expect(result.totalDocs).toEqual(1);
|
expect(result.totalDocs).toEqual(1);
|
||||||
|
|
||||||
|
// Query second relationship
|
||||||
|
const { status: status2, result: result2 } = await client.find<Post>({
|
||||||
|
query: {
|
||||||
|
'relationHasManyField.name': {
|
||||||
|
equals: relation2.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status2).toEqual(200);
|
||||||
|
expect(result2.docs).toEqual([post1]);
|
||||||
|
expect(result2.totalDocs).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('relationTo multi', () => {
|
||||||
|
it('nested by id', async () => {
|
||||||
|
const post1 = await createPost({
|
||||||
|
relationMultiRelationTo: { relationTo: relationSlug, value: relation.id },
|
||||||
|
});
|
||||||
|
await createPost();
|
||||||
|
|
||||||
|
const { status, result } = await client.find<Post>({
|
||||||
|
query: {
|
||||||
|
'relationMultiRelationTo.value': {
|
||||||
|
equals: relation.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(result.docs).toEqual([post1]);
|
||||||
|
expect(result.totalDocs).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.todo('nested by property value');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('relationTo multi hasMany', () => {
|
||||||
|
it('nested by id', async () => {
|
||||||
|
const post1 = await createPost({
|
||||||
|
relationMultiRelationToHasMany: [
|
||||||
|
{ relationTo: relationSlug, value: relation.id },
|
||||||
|
{ relationTo: relationSlug, value: relation2.id },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await createPost();
|
||||||
|
|
||||||
|
const { status, result } = await client.find<Post>({
|
||||||
|
query: {
|
||||||
|
'relationMultiRelationToHasMany.value': {
|
||||||
|
equals: relation.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(result.docs).toEqual([post1]);
|
||||||
|
expect(result.totalDocs).toEqual(1);
|
||||||
|
|
||||||
|
// Query second relation
|
||||||
|
const { status: status2, result: result2 } = await client.find<Post>({
|
||||||
|
query: {
|
||||||
|
'relationMultiRelationToHasMany.value': {
|
||||||
|
equals: relation.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status2).toEqual(200);
|
||||||
|
expect(result2.docs).toEqual([post1]);
|
||||||
|
expect(result2.totalDocs).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.todo('nested by property value');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,7 +312,7 @@ describe('collections-rest', () => {
|
|||||||
const { status, result } = await client.find<Post>({
|
const { status, result } = await client.find<Post>({
|
||||||
query: {
|
query: {
|
||||||
title: {
|
title: {
|
||||||
like: post1.title.substring(0, 6),
|
like: post1.title?.substring(0, 6),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -412,7 +509,6 @@ describe('collections-rest', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
async function createPost(overrides?: Partial<Post>) {
|
async function createPost(overrides?: Partial<Post>) {
|
||||||
const { doc } = await client.create<Post>({ data: { title: 'title', ...overrides } });
|
const { doc } = await client.create<Post>({ data: { title: 'title', ...overrides } });
|
||||||
return doc;
|
return doc;
|
||||||
|
|||||||
75
test/int/collections-rest/payload-types.ts
Normal file
75
test/int/collections-rest/payload-types.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by Payload CMS.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "posts".
|
||||||
|
*/
|
||||||
|
export interface Post {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
number?: number;
|
||||||
|
relationField?: string | Relation;
|
||||||
|
relationHasManyField?: (string | Relation)[];
|
||||||
|
relationMultiRelationTo?:
|
||||||
|
| {
|
||||||
|
value: string | Relation;
|
||||||
|
relationTo: 'relation';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
value: string | Dummy;
|
||||||
|
relationTo: 'dummy';
|
||||||
|
};
|
||||||
|
relationMultiRelationToHasMany?: (
|
||||||
|
| {
|
||||||
|
value: string | Relation;
|
||||||
|
relationTo: 'relation';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
value: string | Dummy;
|
||||||
|
relationTo: 'dummy';
|
||||||
|
}
|
||||||
|
)[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "relation".
|
||||||
|
*/
|
||||||
|
export interface Relation {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "dummy".
|
||||||
|
*/
|
||||||
|
export interface Dummy {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
resetPasswordToken?: string;
|
||||||
|
resetPasswordExpiration?: string;
|
||||||
|
loginAttempts?: number;
|
||||||
|
lockUntil?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user