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

@@ -25,7 +25,9 @@ const ButtonContents = ({ children, icon, tooltip }) => {
const BuiltInIcon = icons[icon];
return (
<span className={`${baseClass}__content`}>
<span
className={`${baseClass}__content`}
>
{tooltip && (
<Tooltip className={`${baseClass}__tooltip`}>
{tooltip}
@@ -49,6 +51,7 @@ const ButtonContents = ({ children, icon, tooltip }) => {
const Button: React.FC<Props> = (props) => {
const {
className,
id,
type = 'button',
el,
to,
@@ -86,6 +89,7 @@ const Button: React.FC<Props> = (props) => {
}
const buttonProps = {
id,
type,
className: classes,
disabled,

View File

@@ -2,6 +2,7 @@ import React, { MouseEvent } from 'react';
export type Props = {
className?: string,
id?: string,
type?: 'submit' | 'button',
el?: 'link' | 'anchor' | undefined,
to?: string,
@@ -12,6 +13,7 @@ export type Props = {
icon?: React.ReactNode | ['chevron' | 'x' | 'plus' | 'edit'],
iconStyle?: 'with-border' | 'without-border' | 'none',
buttonStyle?: 'primary' | 'secondary' | 'transparent' | 'error' | 'none' | 'icon-label',
buttonId?: string,
round?: boolean,
size?: 'small' | 'medium',
iconPosition?: 'left' | 'right',

View File

@@ -7,15 +7,19 @@ import './index.scss';
const baseClass = 'card';
const Card: React.FC<Props> = (props) => {
const { title, actions, onClick } = props;
const { id, title, actions, onClick } = props;
const classes = [
baseClass,
id,
onClick && `${baseClass}--has-onclick`,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<div
className={classes}
id={id}
>
<h5>
{title}
</h5>

View File

@@ -1,4 +1,5 @@
export type Props = {
id?: string,
title: string,
actions?: React.ReactNode,
onClick?: () => void,

View File

@@ -18,6 +18,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
const {
title: titleFromProps,
id,
buttonId,
collection: {
admin: {
useAsTitle,
@@ -78,6 +79,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
<React.Fragment>
<button
type="button"
id={buttonId}
className={`${baseClass}__toggle`}
onClick={(e) => {
e.preventDefault();
@@ -105,6 +107,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
&quot;. Are you sure?
</p>
<Button
id="confirm-cancel"
buttonStyle="secondary"
type="button"
onClick={deleting ? undefined : () => toggle(modalSlug)}
@@ -113,6 +116,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
</Button>
<Button
onClick={deleting ? undefined : handleDelete}
id="confirm-delete"
>
{deleting ? 'Deleting...' : 'Confirm'}
</Button>

View File

@@ -3,5 +3,6 @@ import { SanitizedCollectionConfig } from '../../../../collections/config/types'
export type Props = {
collection?: SanitizedCollectionConfig,
id?: string,
buttonId?: string,
title?: string,
}

View File

@@ -27,6 +27,7 @@ const Duplicate: React.FC<Props> = ({ slug }) => {
return (
<Button
id="action-duplicate"
buttonStyle="none"
className={baseClass}
onClick={handleClick}

View File

@@ -16,7 +16,10 @@ const Table: React.FC<Props> = ({ columns, data }) => {
<thead>
<tr>
{columns.map((col, i) => (
<th key={i}>
<th
key={i}
id={`heading-${col.accessor}`}
>
{col.components.Heading}
</th>
))}
@@ -24,9 +27,15 @@ const Table: React.FC<Props> = ({ columns, data }) => {
</thead>
<tbody>
{data && data.map((row, rowIndex) => (
<tr key={rowIndex}>
<tr
key={rowIndex}
className={`row-${rowIndex + 1}`}
>
{columns.map((col, colIndex) => (
<td key={colIndex}>
<td
key={colIndex}
className={`cell-${col.accessor}`}
>
{col.components.renderCell(row, row[col.accessor])}
</td>
))}

View File

@@ -105,6 +105,7 @@ const Condition: React.FC<Props> = (props) => {
<div className={`${baseClass}__actions`}>
<Button
icon="x"
className={`${baseClass}__actions-remove`}
round
buttonStyle="icon-label"
iconStyle="with-border"
@@ -116,6 +117,7 @@ const Condition: React.FC<Props> = (props) => {
/>
<Button
icon="plus"
className={`${baseClass}__actions-add`}
round
buttonStyle="icon-label"
iconStyle="with-border"

View File

@@ -8,7 +8,7 @@ import './index.scss';
const baseClass = 'form-submit';
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 { disabled } = useForm();
@@ -16,6 +16,7 @@ const FormSubmit: React.FC<Props> = (props) => {
<div className={baseClass}>
<Button
{...props}
id={id}
type={type}
disabled={disabledFromProps || processing || disabled ? true : undefined}
>

View File

@@ -202,6 +202,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
return (
<DragDropContext onDragEnd={onDragEnd}>
<div
id={`field-${path}`}
className={classes}
>
<div className={`${baseClass}__error-wrap`}>

View File

@@ -212,6 +212,7 @@ const Blocks: React.FC<Props> = (props) => {
return (
<DragDropContext onDragEnd={onDragEnd}>
<div
id={`field-${path}`}
className={classes}
>
<div className={`${baseClass}__error-wrap`}>

View File

@@ -70,9 +70,9 @@ const Checkbox: React.FC<Props> = (props) => {
/>
</div>
<input
id={`field-${path}`}
type="checkbox"
name={path}
id={path}
checked={Boolean(value)}
readOnly
/>

View File

@@ -78,11 +78,12 @@ const Code: React.FC<Props> = (props) => {
message={errorMessage}
/>
<Label
htmlFor={path}
htmlFor={`field-${path}`}
label={label}
required={required}
/>
<Editor
id={`field-${path}`}
value={value as string || ''}
onValueChange={readOnly ? () => null : setValue}
highlight={highlighter}

View File

@@ -43,7 +43,7 @@ const ConfirmPassword: React.FC = () => {
message={errorMessage}
/>
<Label
htmlFor="confirm-password"
htmlFor="field-confirm-password"
label="Confirm Password"
required
/>
@@ -52,7 +52,7 @@ const ConfirmPassword: React.FC = () => {
onChange={setValue}
type="password"
autoComplete="off"
id="confirm-password"
id="field-confirm-password"
name="confirm-password"
/>
</div>

View File

@@ -76,7 +76,10 @@ const DateTime: React.FC<Props> = (props) => {
label={label}
required={required}
/>
<div className={`${baseClass}__input-wrapper`}>
<div
className={`${baseClass}__input-wrapper`}
id={`field-${path}`}
>
<DatePicker
{...date}
placeholder={placeholder}

View File

@@ -74,12 +74,12 @@ const Email: React.FC<Props> = (props) => {
required={required}
/>
<input
id={`field-${path}`}
value={value as string || ''}
onChange={setValue}
disabled={Boolean(readOnly)}
placeholder={placeholder}
type="email"
id={path}
name={path}
autoComplete={autoComplete}
/>

View File

@@ -33,6 +33,7 @@ const Group: React.FC<Props> = (props) => {
return (
<div
id={`field-${path}`}
className={[
'field-type',
baseClass,

View File

@@ -25,6 +25,7 @@ const HiddenInput: React.FC<Props> = (props) => {
return (
<input
id={`field-${path}`}
type="hidden"
value={value as string || ''}
onChange={setValue}

View File

@@ -79,17 +79,17 @@ const NumberField: React.FC<Props> = (props) => {
message={errorMessage}
/>
<Label
htmlFor={path}
htmlFor={`field-${path}`}
label={label}
required={required}
/>
<input
id={`field-${path}`}
value={typeof value === 'number' ? value : ''}
onChange={handleChange}
disabled={readOnly}
placeholder={placeholder}
type="number"
id={path}
name={path}
step={step}
/>

View File

@@ -60,17 +60,17 @@ const Password: React.FC<Props> = (props) => {
message={errorMessage}
/>
<Label
htmlFor={path}
htmlFor={`field-${path}`}
label={label}
required={required}
/>
<input
id={`field-${path}`}
value={value as string || ''}
onChange={setValue}
disabled={formProcessing}
type="password"
autoComplete={autoComplete}
id={path}
name={path}
/>
</div>

View File

@@ -81,34 +81,34 @@ const PointField: React.FC<Props> = (props) => {
<ul className={`${baseClass}__wrap`}>
<li>
<Label
htmlFor={`${path}.longitude`}
htmlFor={`field-longitude-${path}`}
label={`${label} - Longitude`}
required={required}
/>
<input
id={`field-longitude-${path}`}
value={(value && typeof value[0] === 'number') ? value[0] : ''}
onChange={(e) => handleChange(e, 0)}
disabled={readOnly}
placeholder={placeholder}
type="number"
id={`${path}.longitude`}
name={`${path}.longitude`}
step={step}
/>
</li>
<li>
<Label
htmlFor={`${path}.latitude`}
htmlFor={`field-latitude-${path}`}
label={`${label} - Latitude`}
required={required}
/>
<input
id={`field-latitude-${path}`}
value={(value && typeof value[1] === 'number') ? value[1] : ''}
onChange={(e) => handleChange(e, 1)}
disabled={readOnly}
placeholder={placeholder}
type="number"
id={`${path}.latitude`}
name={`${path}.latitude`}
step={step}
/>

View File

@@ -73,11 +73,14 @@ const RadioGroupInput: React.FC<RadioGroupInputProps> = (props) => {
/>
</div>
<Label
htmlFor={path}
htmlFor={`field-${path}`}
label={label}
required={required}
/>
<ul className={`${baseClass}--group`}>
<ul
id={`field-${path}`}
className={`${baseClass}--group`}
>
{options.map((option) => {
let optionValue = '';

View File

@@ -13,7 +13,7 @@ const RadioInput: React.FC<Props> = (props) => {
isSelected && `${baseClass}--is-selected`,
].filter(Boolean).join(' ');
const id = `${path}-${option.value}`;
const id = `field-${path}-${option.value}`;
return (
<label

View File

@@ -335,6 +335,7 @@ const Relationship: React.FC<Props> = (props) => {
return (
<div
id={`field-${path}`}
className={classes}
style={{
...style,

View File

@@ -209,7 +209,7 @@ const RichText: React.FC<Props> = (props) => {
message={errorMessage}
/>
<Label
htmlFor={path}
htmlFor={`field-${path}`}
label={label}
required={required}
/>
@@ -270,6 +270,7 @@ const RichText: React.FC<Props> = (props) => {
ref={editorRef}
>
<Editable
id={`field-${path}`}
className={`${baseClass}__input`}
renderElement={renderElement}
renderLeaf={renderLeaf}

View File

@@ -62,6 +62,7 @@ const SelectInput: React.FC<SelectInputProps> = (props) => {
return (
<div
id={`field-${path}`}
className={classes}
style={{
...style,

View File

@@ -61,17 +61,17 @@ const TextInput: React.FC<TextInputProps> = (props) => {
message={errorMessage}
/>
<Label
htmlFor={path}
htmlFor={`field-${path}`}
label={label}
required={required}
/>
<input
id={`field-${path}`}
value={value || ''}
onChange={onChange}
disabled={readOnly}
placeholder={placeholder}
type="text"
id={path}
name={path}
/>
<FieldDescription

View File

@@ -62,16 +62,16 @@ const TextareaInput: React.FC<TextAreaInputProps> = (props) => {
message={errorMessage}
/>
<Label
htmlFor={path}
htmlFor={`field-${path}`}
label={label}
required={required}
/>
<textarea
id={`field-${path}`}
value={value || ''}
onChange={onChange}
disabled={readOnly}
placeholder={placeholder}
id={path}
name={path}
rows={rows}
/>

View File

@@ -47,6 +47,7 @@ const Dashboard: React.FC<Props> = (props) => {
<li key={collection.slug}>
<Card
title={collection.labels.plural}
id={`card-${collection.slug}`}
onClick={() => push({ pathname: `${admin}/collections/${collection.slug}` })}
actions={hasCreatePermission ? (
<Button

View File

@@ -134,7 +134,14 @@ const DefaultEditView: React.FC<Props> = (props) => {
<ul className={`${baseClass}__collection-actions`}>
{(permissions?.create?.permission) && (
<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 && (
<li><DuplicateDocument slug={slug} /></li>
)}
@@ -145,6 +152,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
<DeleteDocument
collection={collection}
id={id}
buttonId="action-delete"
/>
</li>
)}
@@ -167,7 +175,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
</React.Fragment>
)}
{!collection.versions?.drafts && (
<FormSubmit>Save</FormSubmit>
<FormSubmit buttonId="action-save">Save</FormSubmit>
)}
</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 { 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 { field, data: cellData } = props;
const { relationTo } = field;
const { collections } = useConfig();
const [data, setData] = useState();
const { collections, routes } = useConfig();
const [intersectionRef, entry] = useIntersect();
const [values, setValues] = useState<Value[]>([]);
const { getRelationships, documents } = useListRelationships();
const [hasRequested, setHasRequested] = useState(false);
const isAboveViewport = entry?.boundingClientRect?.top > 0;
useEffect(() => {
const hasManyRelations = Array.isArray(relationTo);
if (cellData && isAboveViewport && !hasRequested) {
const formattedValues: Value[] = [];
if (cellData) {
if (Array.isArray(cellData)) {
setData(cellData.reduce((newData, value) => {
const relation = hasManyRelations ? value?.relationTo : relationTo;
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);
const arrayCellData = Array.isArray(cellData) ? cellData : [cellData];
arrayCellData.slice(0, (arrayCellData.length < totalToShow ? arrayCellData.length : totalToShow)).forEach((cell) => {
if (typeof cell === 'object' && 'relationTo' in cell && 'value' in cell) {
formattedValues.push(cell);
}
}
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 (
<React.Fragment>
{data}
</React.Fragment>
<div
className={baseClass}
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 select from './Select';
import textarea from './Textarea';
import upload from './Upload';
export default {
@@ -20,5 +19,5 @@ export default {
richText,
select,
textarea,
upload,
upload: relationship,
};

View File

@@ -1,5 +1,5 @@
import React, { Fragment } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { useConfig } from '../../../utilities/Config';
import UploadGallery from '../../../elements/UploadGallery';
import Eyebrow from '../../../elements/Eyebrow';
@@ -13,6 +13,7 @@ import { Props } from './types';
import ViewDescription from '../../../elements/ViewDescription';
import PerPage from '../../../elements/PerPage';
import { Gutter } from '../../../elements/Gutter';
import { RelationshipProvider } from './RelationshipProvider';
import './index.scss';
@@ -43,7 +44,6 @@ const DefaultList: React.FC<Props> = (props) => {
const { routes: { admin } } = useConfig();
const history = useHistory();
const { pathname, search } = useLocation();
return (
<div className={baseClass}>
@@ -73,14 +73,14 @@ const DefaultList: React.FC<Props> = (props) => {
enableSort={Boolean(upload)}
/>
{(data.docs && data.docs.length > 0) && (
<React.Fragment
key={`${pathname}${search}`}
>
<React.Fragment>
{!upload && (
<RelationshipProvider>
<Table
data={data.docs}
columns={tableColumns}
/>
</RelationshipProvider>
)}
{upload && (
<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) => (
<Cell
key={JSON.stringify(cellData)}
field={field}
colIndex={colIndex}
collection={collection}

View File

@@ -76,7 +76,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
useEffect(() => {
const params = {
depth: 1,
depth: 0,
draft: 'true',
page: undefined,
sort: undefined,
@@ -107,7 +107,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
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 = {
...params,

View File

@@ -1,4 +1,3 @@
import { UploadedFile } from 'express-fileupload';
import { Payload } from '../../..';
import { PayloadRequest } from '../../../express/types';
import { Document } from '../../../types';