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

This commit is contained in:
James
2022-07-14 16:35:58 -07:00
77 changed files with 1595 additions and 287 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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,

View File

@@ -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',

View File

@@ -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>

View File

@@ -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,

View File

@@ -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) => {
&quot;. Are you sure? &quot;. 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>

View File

@@ -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,
} }

View File

@@ -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}

View File

@@ -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>
))} ))}

View File

@@ -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"

View File

@@ -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}
> >

View File

@@ -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`}>

View File

@@ -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`}>

View File

@@ -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
/> />

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}
/> />

View File

@@ -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,

View File

@@ -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}

View File

@@ -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}
/> />

View File

@@ -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>

View File

@@ -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}
/> />

View File

@@ -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 = '';

View File

@@ -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

View File

@@ -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,

View File

@@ -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}

View File

@@ -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,

View File

@@ -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

View File

@@ -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}
/> />

View File

@@ -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

View File

@@ -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>
)} )}

View File

@@ -0,0 +1,3 @@
.relationship-cell {
min-width: 250px;
}

View File

@@ -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>
); );
}; };

View File

@@ -1,11 +0,0 @@
import React from 'react';
const UploadCell = ({ data }) => (
<React.Fragment>
<span>
{data?.filename}
</span>
</React.Fragment>
);
export default UploadCell;

View File

@@ -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,
}; };

View File

@@ -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

View File

@@ -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);

View File

@@ -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;
}
}
}

View File

@@ -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}

View File

@@ -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,

View File

@@ -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
View 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');
}

View File

@@ -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: {

View File

@@ -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',
},
});
},
}); });

View File

@@ -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();
}); });
}); });
}); });

View 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;
}

View 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;
}

View File

@@ -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) => ({

View File

@@ -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,

View File

@@ -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();

View 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;
}

View 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;
}

View File

@@ -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 }],
},
});
});
}, },
}); });

View File

@@ -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);
});
}); });
}); });

View 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;
}

View 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;
}

View File

@@ -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');
} }

View 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
View File

@@ -0,0 +1 @@
/media

102
test/e2e/uploads/config.ts Normal file
View 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,
},
});
},
});

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View 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');
});
});

View File

@@ -0,0 +1,4 @@
export default {
readdirSync: () => {},
rmSync: () => {},
};

View 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;
}

View 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;
}

View File

@@ -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',
}; };

View File

@@ -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);
}); });
}); });

View 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;
}

View File

@@ -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 = {

View File

@@ -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,

View 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;
}

View File

@@ -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,
},
],
},
});
},
}); });

View File

@@ -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;

View 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;
}