feat: initial drafts and versions merge

This commit is contained in:
James
2022-02-06 12:13:52 -05:00
199 changed files with 6182 additions and 834 deletions

View File

@@ -9,6 +9,9 @@ import { requests } from '../api';
import Loading from './elements/Loading';
import StayLoggedIn from './modals/StayLoggedIn';
import Unlicensed from './views/Unlicensed';
import Versions from './views/Versions';
import Version from './views/Version';
import { DocumentInfoProvider } from './utilities/DocumentInfo';
const Dashboard = lazy(() => import('./views/Dashboard'));
const ForgotPassword = lazy(() => import('./views/ForgotPassword'));
@@ -142,89 +145,190 @@ const Routes = () => {
</Route>
<Route path={`${match.url}/account`}>
<Account />
<DocumentInfoProvider
collection={collections.find(({ slug }) => slug === userSlug)}
id={user.id}
>
<Account />
</DocumentInfoProvider>
</Route>
{collections.map((collection) => (
<Route
key={`${collection.slug}-list`}
path={`${match.url}/collections/${collection.slug}`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<List
{...routeProps}
collection={collection}
/>
);
}
{collections.reduce((collectionRoutes, collection) => {
const routesToReturn = [
...collectionRoutes,
<Route
key={`${collection.slug}-list`}
path={`${match.url}/collections/${collection.slug}`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<List
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>
))}
return <Unauthorized />;
}}
/>,
<Route
key={`${collection.slug}-create`}
path={`${match.url}/collections/${collection.slug}/create`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.create?.permission) {
return (
<DocumentInfoProvider collection={collection}>
<Edit
{...routeProps}
collection={collection}
/>
</DocumentInfoProvider>
);
}
{collections.map((collection) => (
<Route
key={`${collection.slug}-create`}
path={`${match.url}/collections/${collection.slug}/create`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.create?.permission) {
return (
<Edit
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>,
<Route
key={`${collection.slug}-edit`}
path={`${match.url}/collections/${collection.slug}/:id`}
exact
render={(routeProps) => {
const { match: { params: { id } } } = routeProps;
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<DocumentInfoProvider
collection={collection}
id={id}
>
<Edit
isEditing
{...routeProps}
collection={collection}
/>
</DocumentInfoProvider>
);
}
return <Unauthorized />;
}}
/>
))}
return <Unauthorized />;
}}
/>,
];
{collections.map((collection) => (
<Route
key={`${collection.slug}-edit`}
path={`${match.url}/collections/${collection.slug}/:id`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<Edit
isEditing
{...routeProps}
collection={collection}
/>
);
}
if (collection.versions) {
routesToReturn.push(
<Route
key={`${collection.slug}-versions`}
path={`${match.url}/collections/${collection.slug}/:id/versions`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.readVersions?.permission) {
return (
<Versions
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>
))}
return <Unauthorized />;
}}
/>,
);
{globals && globals.map((global) => (
<Route
key={`${global.slug}`}
path={`${match.url}/globals/${global.slug}`}
exact
render={(routeProps) => {
if (permissions?.globals?.[global.slug]?.read?.permission) {
return (
<EditGlobal
{...routeProps}
global={global}
/>
);
}
routesToReturn.push(
<Route
key={`${collection.slug}-view-version`}
path={`${match.url}/collections/${collection.slug}/:id/versions/:versionID`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.readVersions?.permission) {
return (
<Version
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>
))}
return <Unauthorized />;
}}
/>,
);
}
return routesToReturn;
}, [])}
{globals && globals.reduce((globalRoutes, global) => {
const routesToReturn = [
...globalRoutes,
<Route
key={`${global.slug}`}
path={`${match.url}/globals/${global.slug}`}
exact
render={(routeProps) => {
if (permissions?.globals?.[global.slug]?.read?.permission) {
return (
<DocumentInfoProvider global={global}>
<EditGlobal
{...routeProps}
global={global}
/>
</DocumentInfoProvider>
);
}
return <Unauthorized />;
}}
/>,
];
if (global.versions) {
routesToReturn.push(
<Route
key={`${global.slug}-versions`}
path={`${match.url}/globals/${global.slug}/versions`}
exact
render={(routeProps) => {
if (permissions?.globals?.[global.slug]?.readVersions?.permission) {
return (
<Versions
{...routeProps}
global={global}
/>
);
}
return <Unauthorized />;
}}
/>,
);
routesToReturn.push(
<Route
key={`${global.slug}-view-version`}
path={`${match.url}/globals/${global.slug}/versions/:versionID`}
exact
render={(routeProps) => {
if (permissions?.globals?.[global.slug]?.readVersions?.permission) {
return (
<Version
{...routeProps}
global={global}
/>
);
}
return <Unauthorized />;
}}
/>,
);
}
return routesToReturn;
}, [])}
<Route path={`${match.url}*`}>
<NotFound />

View File

@@ -0,0 +1,5 @@
@import '../../../scss/styles.scss';
.autosave {
min-height: $baseline;
}

View File

@@ -0,0 +1,136 @@
import { useConfig } from '@payloadcms/config-provider';
import { formatDistance } from 'date-fns';
import { useHistory } from 'react-router-dom';
import { toast } from 'react-toastify';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useWatchForm, useFormModified } from '../../forms/Form/context';
import { useLocale } from '../../utilities/Locale';
import { Props } from './types';
import reduceFieldsToValues from '../../forms/Form/reduceFieldsToValues';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import './index.scss';
const baseClass = 'autosave';
const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdatedAt }) => {
const { serverURL, routes: { api, admin } } = useConfig();
const { versions, getVersions } = useDocumentInfo();
const { fields, dispatchFields } = useWatchForm();
const modified = useFormModified();
const locale = useLocale();
const { replace } = useHistory();
const fieldRef = useRef(fields);
const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<number>();
// Store fields in ref so the autosave func
// can always retrieve the most to date copies
// after the timeout has executed
fieldRef.current = fields;
const interval = collection.versions.drafts && collection.versions.drafts.autosave ? collection.versions.drafts.autosave.interval : 5;
const createDoc = useCallback(async () => {
const res = await fetch(`${serverURL}${api}/${collection.slug}?locale=${locale}&fallback-locale=null&depth=0&draft=true`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (res.status === 201) {
const json = await res.json();
replace(`${admin}/collections/${collection.slug}/${json.doc.id}`);
} else {
toast.error('There was a problem while autosaving this document.');
}
}, [collection, serverURL, api, admin, locale, replace]);
useEffect(() => {
// If no ID, but this is used for a collection doc,
// Immediately save it and set lastSaved
if (!id && collection) {
createDoc();
}
}, [id, collection, global, createDoc]);
// When fields change, autosave
useEffect(() => {
const autosave = async () => {
if (lastSaved && modified && !saving) {
const lastSavedDate = new Date(lastSaved);
lastSavedDate.setSeconds(lastSavedDate.getSeconds() + interval);
const timeToSaveAgain = lastSavedDate.getTime();
if (Date.now() >= timeToSaveAgain) {
setSaving(true);
setTimeout(async () => {
let url: string;
let method: string;
if (collection && id) {
url = `${serverURL}${api}/${collection.slug}/${id}?draft=true&autosave=true`;
method = 'PUT';
}
if (global) {
url = `${serverURL}${api}/globals/${global.slug}?draft=true&autosave=true`;
method = 'POST';
}
if (url) {
const body = {
...reduceFieldsToValues(fieldRef.current),
_status: 'draft',
};
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (res.status === 200) {
setLastSaved(new Date().getTime());
getVersions();
}
setSaving(false);
}
}, 2000);
}
}
};
autosave();
}, [fields, modified, interval, lastSaved, serverURL, api, collection, global, id, saving, dispatchFields, getVersions]);
useEffect(() => {
if (versions?.docs?.[0]) {
setLastSaved(new Date(versions.docs[0].updatedAt).getTime());
} else if (publishedDocUpdatedAt) {
setLastSaved(new Date(publishedDocUpdatedAt).getTime());
}
}, [publishedDocUpdatedAt, versions]);
return (
<div className={baseClass}>
{saving && 'Saving...'}
{(!saving && lastSaved) && (
<React.Fragment>
Last saved&nbsp;
{formatDistance(new Date(), new Date(lastSaved))}
&nbsp;ago
</React.Fragment>
)}
</div>
);
};
export default Autosave;

View File

@@ -0,0 +1,9 @@
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
export type Props = {
collection?: SanitizedCollectionConfig,
global?: SanitizedGlobalConfig,
id?: string | number
publishedDocUpdatedAt: string
}

View File

@@ -72,8 +72,18 @@
background-color: $color-dark-gray;
color: white;
&:hover {
background: lighten($color-dark-gray, 5%);
&.btn--disabled {
background-color: rgba($color-dark-gray, .6);
}
&:not(.btn--disabled) {
&:hover {
background: lighten($color-dark-gray, 5%);
}
&:active {
background: lighten($color-dark-gray, 10%);
}
}
&:focus {
@@ -81,9 +91,6 @@
outline: none;
}
&:active {
background: lighten($color-dark-gray, 10%);
}
}
&--style-secondary {
@@ -99,14 +106,20 @@
box-shadow: $hover-box-shadow;
}
&:active {
background: lighten($color-light-gray, 7%);
}
&.btn--disabled {
color: rgba($color-dark-gray, .6);
background: none;
box-shadow: inset 0 0 0 $style-stroke-width rgba($color-dark-gray, .4);
}
&:focus {
outline: none;
box-shadow: $hover-box-shadow, $focus-box-shadow;
}
&:active {
background: lighten($color-light-gray, 7%);
}
}
&--style-none {
@@ -172,6 +185,10 @@
}
}
&--disabled {
cursor: default;
}
&:hover {
.btn__icon {
@include color-svg(white);

View File

@@ -88,7 +88,8 @@ const Button: React.FC<Props> = (props) => {
const buttonProps = {
type,
className: classes,
onClick: handleClick,
disabled,
onClick: !disabled ? handleClick : undefined,
rel: newTab ? 'noopener noreferrer' : undefined,
target: newTab ? '_blank' : undefined,
};

View File

@@ -55,14 +55,8 @@ const DeleteDocument: React.FC<Props> = (props) => {
const json = await res.json();
if (res.status < 400) {
closeAll();
return history.push({
pathname: `${admin}/collections/${slug}`,
state: {
status: {
message: `${singular} "${title}" successfully deleted.`,
},
},
});
toast.success(`${singular} "${title}" successfully deleted.`);
return history.push(`${admin}/collections/${slug}`);
}
closeAll();

View File

@@ -0,0 +1,11 @@
@import '../../../scss/styles';
.id-label {
font-size: base(.75);
font-weight: normal;
color: $color-gray;
background: $color-background-gray;
padding: base(.25) base(.5);
border-radius: $style-radius-m;
display: inline-flex;
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
import './index.scss';
const baseClass = 'id-label';
const IDLabel: React.FC<{ id: string, prefix?: string }> = ({ id, prefix = 'ID:' }) => (
<div className={baseClass}>
{prefix}
&nbsp;&nbsp;
{id}
</div>
);
export default IDLabel;

View File

@@ -4,27 +4,22 @@ import { useHistory } from 'react-router-dom';
import { useSearchParams } from '../../utilities/SearchParams';
import Popup from '../Popup';
import Chevron from '../../icons/Chevron';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { defaults } from '../../../../collections/config/defaults';
import './index.scss';
const baseClass = 'per-page';
const defaultLimits = defaults.admin.pagination.limits;
type Props = {
collection: SanitizedCollectionConfig
limits: number[]
limit: number
handleChange?: (limit: number) => void
modifySearchParams?: boolean
}
const PerPage: React.FC<Props> = ({ collection, limit, handleChange, modifySearchParams = true }) => {
const {
admin: {
pagination: {
limits,
},
},
} = collection;
const PerPage: React.FC<Props> = ({ limits = defaultLimits, limit, handleChange, modifySearchParams = true }) => {
const params = useSearchParams();
const history = useHistory();

View File

@@ -11,6 +11,7 @@
border-radius: $style-radius-s;
padding: 0 base(.25);
padding-left: base(.0875 + .25);
cursor: default;
&:active,
&:focus {
@@ -37,12 +38,14 @@
}
&--style-light {
&:hover {
background: lighten($color-light-gray, 3%);
}
&.pill--has-action {
&:hover {
background: lighten($color-light-gray, 3%);
}
&:active {
background: lighten($color-light-gray, 5%);
&:active {
background: lighten($color-light-gray, 5%);
}
}
}
@@ -51,6 +54,14 @@
color: $color-dark-gray;
}
&--style-warning {
background: $color-yellow;
}
&--style-success {
background: $color-green;
}
&--style-dark {
background: $color-dark-gray;
color: white;
@@ -59,12 +70,14 @@
@include color-svg(white);
}
&:hover {
background: lighten($color-dark-gray, 3%);
}
&.pill--has-action {
&:hover {
background: lighten($color-dark-gray, 3%);
}
&:active {
background: lighten($color-dark-gray, 5%);
&:active {
background: lighten($color-dark-gray, 5%);
}
}
}
}

View File

@@ -4,7 +4,7 @@ export type Props = {
icon?: React.ReactNode,
alignIcon?: 'left' | 'right',
onClick?: () => void,
pillStyle?: 'light' | 'dark' | 'light-gray',
pillStyle?: 'light' | 'dark' | 'light-gray' | 'warning' | 'success',
}
export type RenderedTypeProps = {

View File

@@ -0,0 +1 @@
@import '../../../scss/styles.scss';

View File

@@ -4,12 +4,14 @@ import Button from '../Button';
import { Props } from './types';
import { useLocale } from '../../utilities/Locale';
import './index.scss';
const baseClass = 'preview-btn';
const PreviewButton: React.FC<Props> = (props) => {
const {
generatePreviewURL,
data
data,
} = props;
const [url, setUrl] = useState<string | undefined>(undefined);
@@ -22,7 +24,7 @@ const PreviewButton: React.FC<Props> = (props) => {
const makeRequest = async () => {
const previewURL = await generatePreviewURL(data, { locale, token });
setUrl(previewURL);
}
};
makeRequest();
}
@@ -30,7 +32,7 @@ const PreviewButton: React.FC<Props> = (props) => {
generatePreviewURL,
locale,
token,
data
data,
]);
if (url) {

View File

@@ -0,0 +1,34 @@
import React, { useCallback } from 'react';
import FormSubmit from '../../forms/Submit';
import { Props } from './types';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import { useForm, useFormModified } from '../../forms/Form/context';
const Publish: React.FC<Props> = () => {
const { unpublishedVersions, publishedDoc } = useDocumentInfo();
const { submit } = useForm();
const modified = useFormModified();
const hasNewerVersions = unpublishedVersions?.totalDocs > 0;
const canPublish = modified || hasNewerVersions || !publishedDoc;
const publish = useCallback(() => {
submit({
overrides: {
_status: 'published',
},
});
}, [submit]);
return (
<FormSubmit
type="button"
onClick={publish}
disabled={!canPublish}
>
Publish changes
</FormSubmit>
);
};
export default Publish;

View File

@@ -0,0 +1 @@
export type Props = {}

View File

@@ -9,7 +9,8 @@ div.react-select {
}
.rs__value-container {
padding: 0;
padding: base(.25) 0;
min-height: base(1.5);
> * {
margin-top: 0;
@@ -20,6 +21,8 @@ div.react-select {
&--is-multi {
margin-left: - base(.25);
padding-top: 0;
padding-bottom: 0;
}
}
@@ -40,9 +43,6 @@ div.react-select {
}
.rs__input {
margin-top: base(.25);
margin-bottom: base(.25);
input {
font-family: $font-body;
width: 100% !important;

View File

@@ -14,6 +14,7 @@ const ReactSelect: React.FC<Props> = (props) => {
value,
disabled = false,
placeholder,
isSearchable = true,
} = props;
const classes = [
@@ -33,6 +34,7 @@ const ReactSelect: React.FC<Props> = (props) => {
className={classes}
classNamePrefix="rs"
options={options}
isSearchable={isSearchable}
/>
);
};

View File

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

View File

@@ -1,12 +0,0 @@
@import '../../../scss/styles.scss';
.render-title {
&--id-as-title {
font-size: base(.75);
font-weight: normal;
color: $color-gray;
background: $color-background-gray;
padding: base(.25) base(.5);
border-radius: $style-radius-m;
}
}

View File

@@ -1,8 +1,7 @@
import React, { Fragment } from 'react';
import React from 'react';
import { Props } from './types';
import useTitle from '../../../hooks/useTitle';
import './index.scss';
import IDLabel from '../IDLabel';
const baseClass = 'render-title';
@@ -25,18 +24,14 @@ const RenderTitle : React.FC<Props> = (props) => {
const idAsTitle = title === data?.id;
const classes = [
baseClass,
idAsTitle && `${baseClass}--id-as-title`,
].filter(Boolean).join(' ');
if (idAsTitle) {
return (
<IDLabel id={data?.id} />
);
}
return (
<span className={classes}>
{idAsTitle && (
<Fragment>
ID:&nbsp;&nbsp;
</Fragment>
)}
<span className={baseClass}>
{title}
</span>
);

View File

@@ -0,0 +1,57 @@
import React, { useCallback } from 'react';
import { useConfig } from '@payloadcms/config-provider';
import FormSubmit from '../../forms/Submit';
import { useForm, useFormModified } from '../../forms/Form/context';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import { useLocale } from '../../utilities/Locale';
import './index.scss';
const baseClass = 'save-draft';
const SaveDraft: React.FC = () => {
const { serverURL, routes: { api } } = useConfig();
const { submit } = useForm();
const { collection, global, id } = useDocumentInfo();
const modified = useFormModified();
const locale = useLocale();
const canSaveDraft = modified;
const saveDraft = useCallback(() => {
const search = `?locale=${locale}&depth=0&fallback-locale=null&draft=true`;
let action;
let method = 'POST';
if (collection) {
action = `${serverURL}${api}/${collection.slug}${id ? `/${id}` : ''}${search}`;
if (id) method = 'PUT';
}
if (global) {
action = `${serverURL}${api}/globals/${global.slug}${search}`;
}
submit({
action,
method,
overrides: {
_status: 'draft',
},
});
}, [submit, collection, global, serverURL, api, locale, id]);
return (
<FormSubmit
className={baseClass}
type="button"
buttonStyle="secondary"
onClick={saveDraft}
disabled={!canSaveDraft}
>
Save draft
</FormSubmit>
);
};
export default SaveDraft;

View File

@@ -0,0 +1,11 @@
@import '../../../scss/styles.scss';
.status {
&__label {
color: gray;
}
&__value {
font-weight: 600;
}
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Props } from './types';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import './index.scss';
const baseClass = 'status';
const Status: React.FC<Props> = () => {
const { publishedDoc, unpublishedVersions } = useDocumentInfo();
let statusToRender;
if (unpublishedVersions?.docs?.length > 0 && publishedDoc) {
statusToRender = 'Changed';
} else if (!publishedDoc) {
statusToRender = 'Draft';
} else if (publishedDoc && unpublishedVersions?.docs?.length <= 1) {
statusToRender = 'Published';
}
if (statusToRender) {
return (
<div className={baseClass}>
<div className={`${baseClass}__value`}>
{statusToRender}
</div>
</div>
);
}
return null;
};
export default Status;

View File

@@ -0,0 +1,3 @@
export type Props = {
}

View File

@@ -0,0 +1,9 @@
@import '../../../scss/styles.scss';
.versions-count__button {
font-weight: 600;
&:hover {
text-decoration: underline;
}
}

View File

@@ -0,0 +1,56 @@
import { useConfig } from '@payloadcms/config-provider';
import React from 'react';
import Button from '../Button';
import { Props } from './types';
import './index.scss';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
const baseClass = 'versions-count';
const Versions: React.FC<Props> = ({ collection, global, id }) => {
const { routes: { admin } } = useConfig();
const { versions } = useDocumentInfo();
let versionsURL: string;
if (collection) {
versionsURL = `${admin}/collections/${collection.slug}/${id}/versions`;
}
if (global) {
versionsURL = `${admin}/globals/${global.slug}/versions`;
}
return (
<div className={baseClass}>
{versions?.docs && (
<React.Fragment>
{versions.docs.length === 0 && (
<React.Fragment>
No versions found
</React.Fragment>
)}
{versions?.docs?.length > 0 && (
<React.Fragment>
<Button
className={`${baseClass}__button`}
buttonStyle="none"
el="link"
to={versionsURL}
>
{versions.totalDocs}
{' '}
version
{versions.totalDocs > 1 && 's'}
{' '}
found
</Button>
</React.Fragment>
)}
</React.Fragment>
)}
</div>
);
};
export default Versions;

View File

@@ -0,0 +1,8 @@
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
export type Props = {
collection?: SanitizedCollectionConfig,
global?: SanitizedGlobalConfig
id?: string | number
}

View File

@@ -5,7 +5,7 @@ import optionsReducer from './optionsReducer';
import useDebounce from '../../../../../hooks/useDebounce';
import ReactSelect from '../../../ReactSelect';
import { Value } from '../../../ReactSelect/types';
import { PaginatedDocs } from '../../../../../../collections/config/types';
import { PaginatedDocs } from '../../../../../../mongoose/types';
import './index.scss';

View File

@@ -1,5 +1,6 @@
import { RelationshipField } from '../../../../../../fields/config/types';
import { PaginatedDocs, SanitizedCollectionConfig } from '../../../../../../collections/config/types';
import { SanitizedCollectionConfig } from '../../../../../../collections/config/types';
import { PaginatedDocs } from '../../../../../../mongoose/types';
export type Props = {
onChange: (val: unknown) => void,

View File

@@ -2,7 +2,7 @@ import { unflatten } from 'flatley';
import reduceFieldsToValues from './reduceFieldsToValues';
import { Fields } from './types';
const getDataByPath = (fields: Fields, path: string): unknown => {
const getDataByPath = <T = unknown>(fields: Fields, path: string): T => {
const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1);
const name = path.split('.').pop();

View File

@@ -1,3 +1,4 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, {
useReducer, useEffect, useRef, useState, useCallback,
} from 'react';
@@ -16,7 +17,7 @@ import getDataByPathFunc from './getDataByPath';
import wait from '../../../../utilities/wait';
import buildInitialState from './buildInitialState';
import errorMessages from './errorMessages';
import { Context as FormContextType, Props } from './types';
import { Context as FormContextType, Props, SubmitOptions } from './types';
import { SubmittedContext, ProcessingContext, ModifiedContext, FormContext, FormWatchContext } from './context';
@@ -49,6 +50,7 @@ const Form: React.FC<Props> = (props) => {
const [submitted, setSubmitted] = useState(false);
const [formattedInitialData, setFormattedInitialData] = useState(buildInitialState(initialData));
const formRef = useRef<HTMLFormElement>(null);
const contextRef = useRef({} as FormContextType);
let initialFieldState = {};
@@ -97,14 +99,24 @@ const Form: React.FC<Props> = (props) => {
return isValid;
}, [contextRef]);
const submit = useCallback(async (e): Promise<void> => {
const submit = useCallback(async (options: SubmitOptions = {}, e): Promise<void> => {
const {
overrides = {},
action: actionToUse = action,
method: methodToUse = method,
} = options;
if (disabled) {
e.preventDefault();
if (e) {
e.preventDefault();
}
return;
}
e.stopPropagation();
e.preventDefault();
if (e) {
e.stopPropagation();
e.preventDefault();
}
setProcessing(true);
@@ -124,14 +136,19 @@ const Form: React.FC<Props> = (props) => {
// If submit handler comes through via props, run that
if (onSubmit) {
onSubmit(fields, reduceFieldsToValues(fields));
const data = {
...reduceFieldsToValues(fields),
...overrides,
};
onSubmit(fields, data);
return;
}
const formData = contextRef.current.createFormData();
const formData = contextRef.current.createFormData(overrides);
try {
const res = await requests[method.toLowerCase()](action, {
const res = await requests[methodToUse.toLowerCase()](actionToUse, {
body: formData,
});
@@ -268,8 +285,8 @@ const Form: React.FC<Props> = (props) => {
const getDataByPath = useCallback((path: string) => getDataByPathFunc(contextRef.current.fields, path), [contextRef]);
const getUnflattenedValues = useCallback(() => reduceFieldsToValues(contextRef.current.fields), [contextRef]);
const createFormData = useCallback(() => {
const data = reduceFieldsToValues(contextRef.current.fields, true);
const createFormData = useCallback((overrides: any = {}) => {
const data = reduceFieldsToValues(contextRef.current.fields);
const file = data?.file;
@@ -277,8 +294,13 @@ const Form: React.FC<Props> = (props) => {
delete data.file;
}
const dataWithOverrides = {
...data,
...overrides,
};
const dataToSerialize = {
_payload: JSON.stringify(data),
_payload: JSON.stringify(dataWithOverrides),
file,
};
@@ -301,6 +323,7 @@ const Form: React.FC<Props> = (props) => {
contextRef.current.setProcessing = setProcessing;
contextRef.current.setSubmitted = setSubmitted;
contextRef.current.disabled = disabled;
contextRef.current.formRef = formRef;
useEffect(() => {
if (initialState) {
@@ -340,10 +363,11 @@ const Form: React.FC<Props> = (props) => {
return (
<form
noValidate
onSubmit={contextRef.current.submit}
onSubmit={(e) => contextRef.current.submit({}, e)}
method={method}
action={action}
className={classes}
ref={formRef}
>
<FormContext.Provider value={contextRef.current}>
<FormWatchContext.Provider value={{

View File

@@ -42,6 +42,7 @@ const initialContextState: Context = {
initialState: {},
fields: {},
disabled: false,
formRef: null,
};
export default initialContextState;

View File

@@ -40,16 +40,22 @@ export type Props = {
log?: boolean
}
export type SubmitOptions = {
action?: string
method?: string
overrides?: Record<string, unknown>
}
export type DispatchFields = React.Dispatch<any>
export type Submit = (e: React.FormEvent<HTMLFormElement>) => void;
export type Submit = (options?: SubmitOptions, e?: React.FormEvent<HTMLFormElement>) => void;
export type ValidateForm = () => Promise<boolean>;
export type CreateFormData = () => FormData;
export type CreateFormData = (overrides?: any) => FormData;
export type GetFields = () => Fields;
export type GetField = (path: string) => Field;
export type GetData = () => Data;
export type GetSiblingData = (path: string) => Data;
export type GetUnflattenedValues = () => Data;
export type GetDataByPath = (path: string) => unknown;
export type GetDataByPath = <T = unknown>(path: string) => T;
export type SetModified = (modified: boolean) => void;
export type SetSubmitted = (submitted: boolean) => void;
export type SetProcessing = (processing: boolean) => void;
@@ -71,4 +77,5 @@ export type Context = {
setModified: SetModified
setProcessing: SetProcessing
setSubmitted: SetSubmitted
formRef: React.MutableRefObject<HTMLFormElement>
}

View File

@@ -1,20 +1,23 @@
import React from 'react';
import { useForm, useFormProcessing } from '../Form/context';
import Button from '../../elements/Button';
import { Props } from '../../elements/Button/types';
import './index.scss';
const baseClass = 'form-submit';
const FormSubmit = ({ children }) => {
const FormSubmit: React.FC<Props> = (props) => {
const { children, disabled: disabledFromProps, type = 'submit' } = props;
const processing = useFormProcessing();
const { disabled } = useForm();
return (
<div className={baseClass}>
<Button
type="submit"
disabled={processing || disabled ? true : undefined}
{...props}
type={type}
disabled={disabledFromProps || processing || disabled ? true : undefined}
>
{children}
</Button>

View File

@@ -10,7 +10,7 @@ import Label from '../../Label';
import Error from '../../Error';
import FieldDescription from '../../FieldDescription';
import { relationship } from '../../../../../fields/validations';
import { PaginatedDocs } from '../../../../../collections/config/types';
import { PaginatedDocs } from '../../../../../mongoose/types';
import { useFormProcessing } from '../../Form/context';
import optionsReducer from './optionsReducer';
import { Props, Option, ValueWithRelation } from './types';

View File

@@ -1,4 +1,5 @@
import { PaginatedDocs, SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { PaginatedDocs } from '../../../../../mongoose/types';
import { RelationshipField } from '../../../../../fields/config/types';
export type Props = Omit<RelationshipField, 'type'> & {

View File

@@ -207,7 +207,7 @@ const UploadButton: React.FC<{path: string}> = ({ path }) => {
{data.totalDocs}
</div>
<PerPage
collection={modalCollection}
limits={modalCollection?.admin?.pagination?.limits}
limit={limit}
modifySearchParams={false}
handleChange={setLimit}

View File

@@ -9,9 +9,9 @@ import useThumbnail from '../../../../../../../hooks/useThumbnail';
import Button from '../../../../../../elements/Button';
import { SanitizedCollectionConfig } from '../../../../../../../../collections/config/types';
import { SwapUploadModal } from './SwapUploadModal';
import { EditModal } from './EditModal';
import './index.scss';
import { EditModal } from './EditModal';
const baseClass = 'rich-text-upload';

View File

@@ -136,7 +136,7 @@ const SelectExistingUploadModal: React.FC<Props> = (props) => {
{data.totalDocs}
</div>
<PerPage
collection={collection}
limits={collection?.admin?.pagination?.limits}
limit={limit}
modifySearchParams={false}
handleChange={setLimit}

View File

@@ -1,61 +1,146 @@
import React, {
createContext, useContext,
createContext, useCallback, useContext, useEffect, useState,
} from 'react';
type CollectionDoc = {
type: 'collection'
slug: string
id?: string
}
type GlobalDoc = {
type: 'global'
slug: string
}
type ContextType = (CollectionDoc | GlobalDoc) & {
preferencesKey?: string
}
import { useConfig } from '@payloadcms/config-provider';
import qs from 'qs';
import { PaginatedDocs } from '../../../../mongoose/types';
import { ContextType, Props, Version } from './types';
import { TypeWithID } from '../../../../globals/config/types';
import { TypeWithTimestamps } from '../../../../collections/config/types';
const Context = createContext({} as ContextType);
export const DocumentInfoProvider: React.FC<CollectionDoc | GlobalDoc> = (props) => {
const { children, type, slug } = props;
export const DocumentInfoProvider: React.FC<Props> = ({
children,
global,
collection,
id,
}) => {
const { serverURL, routes: { api } } = useConfig();
const [publishedDoc, setPublishedDoc] = useState<TypeWithID & TypeWithTimestamps>(null);
const [versions, setVersions] = useState<PaginatedDocs<Version>>(null);
const [unpublishedVersions, setUnpublishedVersions] = useState<PaginatedDocs<Version>>(null);
if (type === 'global') {
return (
<Context.Provider value={{
type,
slug: props.slug,
preferencesKey: `global-${slug}`,
}}
>
{children}
</Context.Provider>
);
const baseURL = `${serverURL}${api}`;
let slug;
let type;
let preferencesKey;
if (global) {
slug = global.slug;
type = 'global';
preferencesKey = `global-${slug}`;
}
if (type === 'collection') {
const { id } = props as CollectionDoc;
const value: ContextType = {
type,
slug,
};
if (collection) {
slug = collection.slug;
type = 'collection';
if (id) {
value.id = id;
value.preferencesKey = `collection-${slug}-${id}`;
preferencesKey = `collection-${slug}-${id}`;
}
return (
<Context.Provider value={value}>
{children}
</Context.Provider>
);
}
return null;
const getVersions = useCallback(async () => {
let versionFetchURL;
let publishedFetchURL;
let shouldFetchVersions = false;
let unpublishedVersionJSON = null;
let versionJSON = null;
let shouldFetch = true;
const params = {
where: {
and: [],
},
depth: 0,
};
if (global) {
shouldFetchVersions = Boolean(global?.versions);
versionFetchURL = `${baseURL}/globals/${global.slug}/versions?depth=0`;
publishedFetchURL = `${baseURL}/globals/${global.slug}?depth=0`;
}
if (collection) {
shouldFetchVersions = Boolean(collection?.versions);
versionFetchURL = `${baseURL}/${collection.slug}/versions?where[parent][equals]=${id}&depth=0`;
publishedFetchURL = `${baseURL}/${collection.slug}?where[id][equals]=${id}&depth=0`;
if (!id) {
shouldFetch = false;
}
params.where.and.push({
parent: {
equals: id,
},
});
}
if (shouldFetch) {
let publishedJSON = await fetch(publishedFetchURL).then((res) => res.json());
if (collection) {
publishedJSON = publishedJSON?.docs?.[0];
}
if (shouldFetchVersions) {
versionJSON = await fetch(versionFetchURL).then((res) => res.json());
if (publishedJSON?.updatedAt) {
const newerVersionParams = {
...params,
where: {
...params.where,
and: [
...params.where.and,
{
updatedAt: {
greater_than: publishedJSON?.updatedAt,
},
},
],
},
};
// Get any newer versions available
const newerVersionRes = await fetch(`${versionFetchURL}?${qs.stringify(newerVersionParams)}`);
if (newerVersionRes.status === 200) {
unpublishedVersionJSON = await newerVersionRes.json();
}
}
}
setPublishedDoc(publishedJSON);
setVersions(versionJSON);
setUnpublishedVersions(unpublishedVersionJSON);
}
}, [global, collection, id, baseURL]);
useEffect(() => {
getVersions();
}, [getVersions]);
const value = {
slug,
type,
preferencesKey,
global,
collection,
versions,
unpublishedVersions,
getVersions,
publishedDoc,
id,
};
return (
<Context.Provider value={value}>
{children}
</Context.Provider>
);
};
export const useDocumentInfo = (): ContextType => useContext(Context);

View File

@@ -0,0 +1,24 @@
import { SanitizedCollectionConfig, TypeWithID, TypeWithTimestamps } from '../../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
import { PaginatedDocs } from '../../../../mongoose/types';
import { TypeWithVersion } from '../../../../versions/types';
export type Version = TypeWithVersion<any>
export type ContextType = {
collection?: SanitizedCollectionConfig
global?: SanitizedGlobalConfig
type: 'global' | 'collection'
id?: string | number
preferencesKey?: string
versions?: PaginatedDocs<Version>
unpublishedVersions?: PaginatedDocs<Version>
publishedDoc?: TypeWithID & TypeWithTimestamps
getVersions: () => void
}
export type Props = {
collection?: SanitizedCollectionConfig
global?: SanitizedGlobalConfig
id?: string | number
}

View File

@@ -69,7 +69,9 @@ const DefaultAccount: React.FC<Props> = (props) => {
keywords="Account, Dashboard, Payload, CMS"
/>
<Eyebrow />
<LeaveWithoutSaving />
{!(collection.versions?.drafts && collection.versions?.drafts?.autosave) && (
<LeaveWithoutSaving />
)}
<div className={`${baseClass}__edit`}>
<header className={`${baseClass}__header`}>
<h1>

View File

@@ -5,7 +5,6 @@ import { useStepNav } from '../../elements/StepNav';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import { useLocale } from '../../utilities/Locale';
import { DocumentInfoProvider } from '../../utilities/DocumentInfo';
import DefaultAccount from './Default';
import buildStateFromSchema from '../../forms/Form/buildStateFromSchema';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
@@ -70,28 +69,22 @@ const AccountView: React.FC = () => {
}, [dataToRender, fields]);
return (
<DocumentInfoProvider
type="collection"
slug={collection?.slug}
id={user?.id}
>
<NegativeFieldGutterProvider allow>
<RenderCustomComponent
DefaultComponent={DefaultAccount}
CustomComponent={CustomAccount}
componentProps={{
action,
data,
collection,
permissions: collectionPermissions,
hasSavePermission,
initialState,
apiURL,
isLoading,
}}
/>
</NegativeFieldGutterProvider>
</DocumentInfoProvider>
<NegativeFieldGutterProvider allow>
<RenderCustomComponent
DefaultComponent={DefaultAccount}
CustomComponent={CustomAccount}
componentProps={{
action,
data,
collection,
permissions: collectionPermissions,
hasSavePermission,
initialState,
apiURL,
isLoading,
}}
/>
</NegativeFieldGutterProvider>
);
};

View File

@@ -10,6 +10,7 @@ import CopyToClipboard from '../../elements/CopyToClipboard';
import Meta from '../../utilities/Meta';
import fieldTypes from '../../forms/field-types';
import LeaveWithoutSaving from '../../modals/LeaveWithoutSaving';
import VersionsCount from '../../elements/VersionsCount';
import { Props } from './types';
import ViewDescription from '../../elements/ViewDescription';
@@ -28,9 +29,11 @@ const DefaultGlobalView: React.FC<Props> = (props) => {
const {
fields,
preview,
versions,
label,
admin: {
description,
hideAPIURL,
} = {},
} = global;
@@ -42,97 +45,103 @@ const DefaultGlobalView: React.FC<Props> = (props) => {
<Loading />
)}
{!isLoading && (
<Form
className={`${baseClass}__form`}
method="post"
action={action}
onSuccess={onSave}
disabled={!hasSavePermission}
initialState={initialState}
>
<div className={`${baseClass}__main`}>
<Meta
title={label}
description={label}
keywords={`${label}, Payload, CMS`}
/>
<Eyebrow />
<LeaveWithoutSaving />
<div className={`${baseClass}__edit`}>
<header className={`${baseClass}__header`}>
<h1>
Edit
{' '}
{label}
</h1>
{description && (
<div className={`${baseClass}__sub-header`}>
<ViewDescription description={description} />
</div>
)}
</header>
<RenderFields
operation="update"
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => (!field.admin.position || (field.admin.position && field.admin.position !== 'sidebar'))}
fieldTypes={fieldTypes}
fieldSchema={fields}
<Form
className={`${baseClass}__form`}
method="post"
action={action}
onSuccess={onSave}
disabled={!hasSavePermission}
initialState={initialState}
>
<div className={`${baseClass}__main`}>
<Meta
title={label}
description={label}
keywords={`${label}, Payload, CMS`}
/>
</div>
</div>
<div className={`${baseClass}__sidebar-wrap`}>
<div className={`${baseClass}__sidebar`}>
<div className={`${baseClass}__sidebar-sticky-wrap`}>
<div className={`${baseClass}__document-actions${preview ? ` ${baseClass}__document-actions--with-preview` : ''}`}>
<PreviewButton
generatePreviewURL={preview}
data={data}
/>
{hasSavePermission && (
<FormSubmit>Save</FormSubmit>
<Eyebrow />
{!(global.versions?.drafts && global.versions?.drafts?.autosave) && (
<LeaveWithoutSaving />
)}
<div className={`${baseClass}__edit`}>
<header className={`${baseClass}__header`}>
<h1>
Edit
{' '}
{label}
</h1>
{description && (
<div className={`${baseClass}__sub-header`}>
<ViewDescription description={description} />
</div>
)}
</div>
<div className={`${baseClass}__sidebar-fields`}>
<RenderFields
operation="update"
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => field.admin.position === 'sidebar'}
fieldTypes={fieldTypes}
fieldSchema={fields}
/>
</div>
{data && (
<ul className={`${baseClass}__meta`}>
{data && (
<li className={`${baseClass}__api-url`}>
<span className={`${baseClass}__label`}>
API URL
{' '}
<CopyToClipboard value={apiURL} />
</span>
<a
href={apiURL}
target="_blank"
rel="noopener noreferrer"
>
{apiURL}
</a>
</li>
)}
{data.updatedAt && (
<li>
<div className={`${baseClass}__label`}>Last Modified</div>
<div>{format(new Date(data.updatedAt as string), dateFormat)}</div>
</li>
)}
</ul>
)}
</header>
<RenderFields
operation="update"
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => (!field.admin.position || (field.admin.position && field.admin.position !== 'sidebar'))}
fieldTypes={fieldTypes}
fieldSchema={fields}
/>
</div>
</div>
</div>
</Form>
<div className={`${baseClass}__sidebar-wrap`}>
<div className={`${baseClass}__sidebar`}>
<div className={`${baseClass}__sidebar-sticky-wrap`}>
<div className={`${baseClass}__document-actions${preview ? ` ${baseClass}__document-actions--with-preview` : ''}`}>
<PreviewButton
generatePreviewURL={preview}
data={data}
/>
{hasSavePermission && (
<FormSubmit>Save</FormSubmit>
)}
</div>
<div className={`${baseClass}__sidebar-fields`}>
<RenderFields
operation="update"
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => field.admin.position === 'sidebar'}
fieldTypes={fieldTypes}
fieldSchema={fields}
/>
</div>
<ul className={`${baseClass}__meta`}>
{versions && (
<li>
<div className={`${baseClass}__label`}>Versions</div>
<VersionsCount global={global} />
</li>
)}
{(data && !hideAPIURL) && (
<li className={`${baseClass}__api-url`}>
<span className={`${baseClass}__label`}>
API URL
{' '}
<CopyToClipboard value={apiURL} />
</span>
<a
href={apiURL}
target="_blank"
rel="noopener noreferrer"
>
{apiURL}
</a>
</li>
)}
{data.updatedAt && (
<li>
<div className={`${baseClass}__label`}>Last Modified</div>
<div>{format(new Date(data.updatedAt as string), dateFormat)}</div>
</li>
)}
</ul>
</div>
</div>
</div>
</Form>
)}
</div>
);

View File

@@ -1,9 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { useConfig, useAuth } from '@payloadcms/config-provider';
import { useStepNav } from '../../elements/StepNav';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import { DocumentInfoProvider } from '../../utilities/DocumentInfo';
import { useLocale } from '../../utilities/Locale';
@@ -12,10 +11,10 @@ import DefaultGlobal from './Default';
import buildStateFromSchema from '../../forms/Form/buildStateFromSchema';
import { NegativeFieldGutterProvider } from '../../forms/FieldTypeGutter/context';
import { IndexProps } from './types';
import { DocumentInfoProvider } from '../../utilities/DocumentInfo';
const GlobalView: React.FC<IndexProps> = (props) => {
const { state: locationState } = useLocation<{data?: Record<string, unknown>}>();
const history = useHistory();
const locale = useLocale();
const { setStepNav } = useStepNav();
const { permissions } = useAuth();
@@ -24,7 +23,6 @@ const GlobalView: React.FC<IndexProps> = (props) => {
const {
serverURL,
routes: {
admin,
api,
},
} = useConfig();
@@ -44,14 +42,9 @@ const GlobalView: React.FC<IndexProps> = (props) => {
} = {},
} = global;
const onSave = (json) => {
history.push(`${admin}/globals/${global.slug}`, {
status: {
message: json.message,
type: 'success',
},
data: json.doc,
});
const onSave = async (json) => {
const state = await buildStateFromSchema(fields, json.doc);
setInitialState(state);
};
const [{ data, isLoading }] = usePayloadAPI(
@@ -82,8 +75,7 @@ const GlobalView: React.FC<IndexProps> = (props) => {
return (
<DocumentInfoProvider
slug={slug}
type="global"
global={global}
>
<NegativeFieldGutterProvider allow>
<RenderCustomComponent

View File

@@ -0,0 +1,10 @@
@import '../../../scss/styles';
.not-found {
&__wrap {
@include mid-break {
padding-left: $baseline;
padding-right: $baseline;
}
}
}

View File

@@ -5,6 +5,10 @@ import { useStepNav } from '../../elements/StepNav';
import Button from '../../elements/Button';
import Meta from '../../utilities/Meta';
import './index.scss';
const baseClass = 'not-found';
const NotFound: React.FC = () => {
const { setStepNav } = useStepNav();
const { routes: { admin } } = useConfig();
@@ -16,22 +20,23 @@ const NotFound: React.FC = () => {
}, [setStepNav]);
return (
<div className="not-found">
<div className={baseClass}>
<Meta
title="Not Found"
description="Page not found"
keywords="404, Not found, Payload, CMS"
/>
<Eyebrow />
<h1>Nothing found</h1>
<p>Sorry&mdash;there is nothing to correspond with your request.</p>
<br />
<Button
el="link"
to={`${admin}`}
>
Back to Dashboard
</Button>
<div className={`${baseClass}__wrap`}>
<h1>Nothing found</h1>
<p>Sorry&mdash;there is nothing to correspond with your request.</p>
<Button
el="link"
to={`${admin}`}
>
Back to Dashboard
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,15 @@
@import '../../../../scss/styles.scss';
.compare-version {
&__error-loading {
border: 1px solid $color-red;
min-height: base(2);
padding: base(.5) base(.75);
background-color: $color-red;
color: white;
}
&__label {
margin-bottom: base(.25);
}
}

View File

@@ -0,0 +1,108 @@
import React, { useState, useCallback, useEffect } from 'react';
import qs from 'qs';
import { useConfig } from '@payloadcms/config-provider';
import format from 'date-fns/format';
import { Props } from './types';
import ReactSelect from '../../../elements/ReactSelect';
import { PaginatedDocs } from '../../../../../mongoose/types';
import { mostRecentVersionOption, publishedVersionOption } from '../shared';
import './index.scss';
const baseClass = 'compare-version';
const maxResultsPerRequest = 10;
const baseOptions = [
publishedVersionOption,
mostRecentVersionOption,
];
const CompareVersion: React.FC<Props> = (props) => {
const { onChange, value, baseURL, parentID } = props;
const {
admin: {
dateFormat,
},
} = useConfig();
const [options, setOptions] = useState(baseOptions);
const [lastLoadedPage, setLastLoadedPage] = useState(1);
const [errorLoading, setErrorLoading] = useState('');
const getResults = useCallback(async ({
lastLoadedPage: lastLoadedPageArg,
} = {}) => {
const query = {
limit: maxResultsPerRequest,
page: lastLoadedPageArg,
depth: 0,
where: undefined,
};
if (parentID) {
query.where = {
parent: {
equals: parentID,
},
};
}
const search = qs.stringify(query);
const response = await fetch(`${baseURL}?${search}`);
if (response.ok) {
const data: PaginatedDocs<any> = await response.json();
if (data.docs.length > 0) {
setOptions((existingOptions) => [
...existingOptions,
...data.docs.map((doc) => ({
label: format(new Date(doc.createdAt), dateFormat),
value: doc.id,
})),
]);
setLastLoadedPage(data.page);
}
} else {
setErrorLoading('An error has occurred.');
}
}, [dateFormat, baseURL, parentID]);
const classes = [
'field-type',
baseClass,
errorLoading && 'error-loading',
].filter(Boolean).join(' ');
useEffect(() => {
getResults({ lastLoadedPage: 1 });
}, [getResults]);
return (
<div className={classes}>
<div className={`${baseClass}__label`}>
Compare version against:
</div>
{!errorLoading && (
<ReactSelect
isSearchable={false}
placeholder="Select a version to compare"
onChange={onChange}
onMenuScrollToBottom={() => {
getResults({ lastLoadedPage: lastLoadedPage + 1 });
}}
value={value}
options={options}
/>
)}
{errorLoading && (
<div className={`${baseClass}__error-loading`}>
{errorLoading}
</div>
)}
</div>
);
};
export default CompareVersion;

View File

@@ -0,0 +1,29 @@
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { PaginatedDocs } from '../../../../../mongoose/types';
import { CompareOption } from '../types';
export type Props = {
onChange: (val: CompareOption) => void,
value: CompareOption,
baseURL: string
parentID?: string
}
type CLEAR = {
type: 'CLEAR'
required: boolean
}
type ADD = {
type: 'ADD'
data: PaginatedDocs<any>
collection: SanitizedCollectionConfig
}
export type Action = CLEAR | ADD
export type ValueWithRelation = {
relationTo: string
value: string
}

View File

@@ -0,0 +1,6 @@
@import '../../../../../scss/styles.scss';
.field-diff-label {
margin-bottom: base(.25);
font-weight: 600;
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
import './index.scss';
const baseClass = 'field-diff-label';
const Label: React.FC = ({ children }) => (
<div className={baseClass}>
{children}
</div>
);
export default Label;

View File

@@ -0,0 +1,25 @@
@import '../../../../../../scss/styles.scss';
.iterable-diff {
margin-bottom: base(2);
&__locale-label {
margin-right: base(.25);
background: $color-background-gray;
padding: base(.25);
border-radius: $style-radius-m;
}
&__wrap {
margin: base(.5) 0;
padding-left: base(.5);
border-left: $style-stroke-width-s solid $color-light-gray;
}
&__no-rows {
font-family: monospace;
background-color: #fafbfc;
padding: base(.125) 0;
margin: base(.125) 0;
}
}

View File

@@ -0,0 +1,103 @@
import React from 'react';
import RenderFieldsToDiff from '../..';
import { Props } from '../types';
import Label from '../../Label';
import { ArrayField, BlockField, Field, fieldAffectsData } from '../../../../../../../fields/config/types';
import getUniqueListBy from '../../../../../../../utilities/getUniqueListBy';
import './index.scss';
const baseClass = 'iterable-diff';
const Iterable: React.FC<Props & { field: ArrayField | BlockField }> = ({
version,
comparison,
permissions,
field,
locale,
locales,
fieldComponents,
}) => {
const versionRowCount = Array.isArray(version) ? version.length : 0;
const comparisonRowCount = Array.isArray(comparison) ? comparison.length : 0;
const maxRows = Math.max(versionRowCount, comparisonRowCount);
return (
<div className={baseClass}>
{field.label && (
<Label>
{locale && (
<span className={`${baseClass}__locale-label`}>{locale}</span>
)}
{field.label}
</Label>
)}
{maxRows > 0 && (
<React.Fragment>
{Array.from(Array(maxRows).keys()).map((row, i) => {
const versionRow = version?.[i] || {};
const comparisonRow = comparison?.[i] || {};
let subFields: Field[] = [];
if (field.type === 'array') subFields = field.fields;
if (field.type === 'blocks') {
subFields = [
{
name: 'blockType',
label: 'Block Type',
type: 'text',
},
];
if (versionRow?.blockType === comparisonRow?.blockType) {
const matchedBlock = field.blocks.find((block) => block.slug === versionRow?.blockType) || { fields: [] };
subFields = [
...subFields,
...matchedBlock.fields,
];
} else {
const matchedVersionBlock = field.blocks.find((block) => block.slug === versionRow?.blockType) || { fields: [] };
const matchedComparisonBlock = field.blocks.find((block) => block.slug === comparisonRow?.blockType) || { fields: [] };
subFields = getUniqueListBy<Field>([
...subFields,
...matchedVersionBlock.fields,
...matchedComparisonBlock.fields,
], 'name');
}
}
return (
<div
className={`${baseClass}__wrap`}
key={i}
>
<RenderFieldsToDiff
locales={locales}
version={versionRow}
comparison={comparisonRow}
fieldPermissions={permissions}
fields={subFields.filter((subField) => !(fieldAffectsData(subField) && subField.name === 'id'))}
fieldComponents={fieldComponents}
/>
</div>
);
})}
</React.Fragment>
)}
{maxRows === 0 && (
<div className={`${baseClass}__no-rows`}>
No
{' '}
{field.labels.plural}
{' '}
found
</div>
)}
</div>
);
};
export default Iterable;

View File

@@ -0,0 +1,8 @@
@import '../../../../../../scss/styles.scss';
.nested-diff {
&__wrap--gutter {
padding-left: base(1);
border-left: $style-stroke-width-s solid $color-light-gray;
}
}

View File

@@ -0,0 +1,47 @@
import React from 'react';
import RenderFieldsToDiff from '../..';
import { Props } from '../types';
import Label from '../../Label';
import { FieldWithSubFields } from '../../../../../../../fields/config/types';
import './index.scss';
const baseClass = 'nested-diff';
const Nested: React.FC<Props & { field: FieldWithSubFields}> = ({
version,
comparison,
permissions,
field,
locale,
locales,
fieldComponents,
disableGutter = false,
}) => (
<div className={baseClass}>
{field.label && (
<Label>
{locale && (
<span className={`${baseClass}__locale-label`}>{locale}</span>
)}
{field.label}
</Label>
)}
<div className={[
`${baseClass}__wrap`,
!disableGutter && `${baseClass}__wrap--gutter`,
].filter(Boolean).join(' ')}
>
<RenderFieldsToDiff
locales={locales}
version={version}
comparison={comparison}
fieldPermissions={permissions}
fields={field.fields}
fieldComponents={fieldComponents}
/>
</div>
</div>
);
export default Nested;

View File

@@ -0,0 +1,10 @@
@import '../../../../../../scss/styles.scss';
.relationship-diff {
&__locale-label {
margin-right: base(.25);
background: $color-background-gray;
padding: base(.25);
border-radius: $style-radius-m;
}
}

View File

@@ -0,0 +1,100 @@
import { useConfig } from '@payloadcms/config-provider';
import React from 'react';
import ReactDiffViewer from 'react-diff-viewer';
import { useLocale } from '../../../../../utilities/Locale';
import { SanitizedCollectionConfig } from '../../../../../../../collections/config/types';
import { fieldAffectsData, fieldIsPresentationalOnly, RelationshipField } from '../../../../../../../fields/config/types';
import Label from '../../Label';
import { Props } from '../types';
import './index.scss';
const baseClass = 'relationship-diff';
type RelationshipValue = Record<string, any>;
const generateLabelFromValue = (
collections: SanitizedCollectionConfig[],
field: RelationshipField,
locale: string,
value: RelationshipValue | { relationTo: string, value: RelationshipValue },
): string => {
let relation: string;
let relatedDoc: RelationshipValue;
let valueToReturn = '';
if (Array.isArray(field.relationTo)) {
if (typeof value === 'object') {
relation = value.relationTo;
relatedDoc = value.value;
}
} else {
relation = field.relationTo;
relatedDoc = value;
}
const relatedCollection = collections.find((c) => c.slug === relation);
if (relatedCollection) {
const useAsTitle = relatedCollection?.admin?.useAsTitle;
const useAsTitleField = relatedCollection.fields.find((f) => (fieldAffectsData(f) && !fieldIsPresentationalOnly(f)) && f.name === useAsTitle);
let titleFieldIsLocalized = false;
if (useAsTitleField && fieldAffectsData(useAsTitleField)) titleFieldIsLocalized = useAsTitleField.localized;
if (typeof relatedDoc?.[useAsTitle] !== 'undefined') {
valueToReturn = relatedDoc[useAsTitle];
} else if (typeof relatedDoc?.id !== 'undefined') {
valueToReturn = relatedDoc.id;
}
if (typeof valueToReturn === 'object' && titleFieldIsLocalized) {
valueToReturn = valueToReturn[locale];
}
}
return valueToReturn;
};
const Relationship: React.FC<Props & { field: RelationshipField}> = ({ field, version, comparison }) => {
const { collections } = useConfig();
const locale = useLocale();
let placeholder = '';
if (version === comparison) placeholder = '[no value]';
let versionToRender = version;
let comparisonToRender = comparison;
if (field.hasMany) {
if (Array.isArray(version)) versionToRender = version.map((val) => generateLabelFromValue(collections, field, locale, val)).join(', ');
if (Array.isArray(comparison)) comparisonToRender = comparison.map((val) => generateLabelFromValue(collections, field, locale, val)).join(', ');
} else {
versionToRender = generateLabelFromValue(collections, field, locale, version);
comparisonToRender = generateLabelFromValue(collections, field, locale, comparison);
}
return (
<div className={baseClass}>
<Label>
{locale && (
<span className={`${baseClass}__locale-label`}>{locale}</span>
)}
{field.label}
</Label>
<ReactDiffViewer
oldValue={typeof versionToRender !== 'undefined' ? String(versionToRender) : placeholder}
newValue={typeof comparisonToRender !== 'undefined' ? String(comparisonToRender) : placeholder}
splitView
hideLineNumbers
showDiffOnly={false}
/>
</div>
);
return null;
};
export default Relationship;

View File

@@ -0,0 +1,10 @@
@import '../../../../../../scss/styles.scss';
.text-diff {
&__locale-label {
margin-right: base(.25);
background: $color-background-gray;
padding: base(.25);
border-radius: $style-radius-m;
}
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer';
import Label from '../../Label';
import { Props } from '../types';
import './index.scss';
const baseClass = 'text-diff';
const Text: React.FC<Props> = ({ field, locale, version, comparison, isRichText = false, diffMethod }) => {
let placeholder = '';
if (version === comparison) placeholder = '[no value]';
let versionToRender = version;
let comparisonToRender = comparison;
if (isRichText) {
if (typeof version === 'object') versionToRender = JSON.stringify(version, null, 2);
if (typeof comparison === 'object') comparisonToRender = JSON.stringify(comparison, null, 2);
}
return (
<div className={baseClass}>
<Label>
{locale && (
<span className={`${baseClass}__locale-label`}>{locale}</span>
)}
{field.label}
</Label>
<ReactDiffViewer
compareMethod={DiffMethod[diffMethod]}
oldValue={typeof versionToRender !== 'undefined' ? String(versionToRender) : placeholder}
newValue={typeof comparisonToRender !== 'undefined' ? String(comparisonToRender) : placeholder}
splitView
hideLineNumbers
showDiffOnly={false}
/>
</div>
);
return null;
};
export default Text;

View File

@@ -0,0 +1,115 @@
import { Text } from 'slate';
export const richTextToHTML = (content: unknown): string => {
if (Array.isArray(content)) {
return content.reduce((output, node) => {
const isTextNode = Text.isText(node);
const {
text,
bold,
code,
italic,
underline,
strikethrough,
} = node;
if (isTextNode) {
// convert straight single quotations to curly
// "\u201C" is starting double curly
// "\u201D" is ending double curly
let html = text?.replace(/'/g, '\u2019'); // single quotes
if (bold) {
html = `<strong>${html}</strong>`;
}
if (code) {
html = `<code>${html}</code>`;
}
if (italic) {
html = `<em>${html}</em>`;
}
if (underline) {
html = `<span style="text-decoration: underline;">${html}</span>`;
}
if (strikethrough) {
html = `<span style="text-decoration: line-through;">${html}</span>`;
}
return `${output}${html}`;
}
if (node) {
let nodeHTML;
switch (node.type) {
case 'h1':
nodeHTML = `<h1>${richTextToHTML(node.children)}</h1>`;
break;
case 'h2':
nodeHTML = `<h2>${richTextToHTML(node.children)}</h2>`;
break;
case 'h3':
nodeHTML = `<h3>${richTextToHTML(node.children)}</h3>`;
break;
case 'h4':
nodeHTML = `<h4>${richTextToHTML(node.children)}</h4>`;
break;
case 'h5':
nodeHTML = `<h5>${richTextToHTML(node.children)}</h5>`;
break;
case 'h6':
nodeHTML = `<h6>${richTextToHTML(node.children)}</h6>`;
break;
case 'ul':
nodeHTML = `<ul>${richTextToHTML(node.children)}</ul>`;
break;
case 'ol':
nodeHTML = `<ol>${richTextToHTML(node.children)}</ol>`;
break;
case 'li':
nodeHTML = `<li>${richTextToHTML(node.children)}</li>`;
break;
case 'link':
nodeHTML = `<a href="${node.url}">${richTextToHTML(node.children)}</a>`;
break;
case 'relationship':
nodeHTML = `<strong>Relationship to ${node.relationTo}: ${node.value}</strong><br/>`;
break;
case 'upload':
nodeHTML = `<strong>${node.relationTo} Upload: ${node.value}</strong><br/>`;
break;
case 'p':
case undefined:
nodeHTML = `<p>${richTextToHTML(node.children)}</p>`;
break;
default:
nodeHTML = `<strong>${node.type}</strong>:<br/>${JSON.stringify(node)}`;
break;
}
return `${output}${nodeHTML}\n`;
}
return output;
}, '');
}
return '';
};

View File

@@ -0,0 +1,65 @@
import { Text } from 'slate';
export const stringifyRichText = (content: unknown): string => {
if (Array.isArray(content)) {
return content.reduce((output, node) => {
const isTextNode = Text.isText(node);
const {
text,
} = node;
if (isTextNode) {
// convert straight single quotations to curly
// "\u201C" is starting double curly
// "\u201D" is ending double curly
const sanitizedText = text?.replace(/'/g, '\u2019'); // single quotes
return `${output}${sanitizedText}`;
}
if (node) {
let nodeHTML;
switch (node.type) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
case 'li':
case 'p':
case undefined:
nodeHTML = `${stringifyRichText(node.children)}\n`;
break;
case 'ul':
case 'ol':
nodeHTML = `${stringifyRichText(node.children)}\n\n`;
break;
case 'link':
nodeHTML = `${stringifyRichText(node.children)}`;
break;
case 'relationship':
nodeHTML = `Relationship to ${node.relationTo}: ${node?.value?.id}\n\n`;
break;
case 'upload':
nodeHTML = `${node.relationTo} Upload: ${node?.value?.id}\n\n`;
break;
default:
nodeHTML = `${node.type}: ${JSON.stringify(node)}\n\n`;
break;
}
return `${output}${nodeHTML}`;
}
return output;
}, '');
}
return '';
};

View File

@@ -0,0 +1,6 @@
export const diffMethods = {
select: 'WORDS_WITH_SPACE',
relationship: 'WORDS_WITH_SPACE',
upload: 'WORDS_WITH_SPACE',
radio: 'WORDS_WITH_SPACE',
};

View File

@@ -0,0 +1,24 @@
import Text from './Text';
import Nested from './Nested';
import Iterable from './Iterable';
import Relationship from './Relationship';
export default {
text: Text,
textarea: Text,
number: Text,
email: Text,
code: Text,
checkbox: Text,
radio: Text,
row: Nested,
group: Nested,
array: Iterable,
blocks: Iterable,
date: Text,
select: Text,
richText: Text,
relationship: Relationship,
upload: Relationship,
point: Text,
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { DiffMethod } from 'react-diff-viewer';
import { FieldPermissions } from '../../../../../../auth';
export type FieldComponents = Record<string, React.FC<Props>>
export type Props = {
diffMethod?: DiffMethod
fieldComponents: FieldComponents
version: any
comparison: any
field: any
permissions?: Record<string, FieldPermissions>
locale?: string
locales?: string[]
disableGutter?: boolean
isRichText?: boolean
}

View File

@@ -0,0 +1,11 @@
@import '../../../../scss/styles.scss';
.render-field-diffs {
&__field {
margin-bottom: $baseline;
}
&__locale {
margin-bottom: base(.5);
}
}

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { DiffMethod } from 'react-diff-viewer';
import { Props } from './types';
import { fieldAffectsData, fieldHasSubFields } from '../../../../../fields/config/types';
import Nested from './fields/Nested';
import './index.scss';
import { diffMethods } from './fields/diffMethods';
const baseClass = 'render-field-diffs';
const RenderFieldsToDiff: React.FC<Props> = ({
fields,
fieldComponents,
fieldPermissions,
version,
comparison,
locales,
}) => (
<div className={baseClass}>
{fields.map((field, i) => {
const Component = fieldComponents[field.type];
const isRichText = field.type === 'richText';
const diffMethod: DiffMethod = diffMethods[field.type] || 'CHARS';
if (Component) {
if (fieldAffectsData(field)) {
const versionValue = version?.[field.name];
const comparisonValue = comparison?.[field.name];
const hasPermission = fieldPermissions?.[field.name]?.read?.permission;
const subFieldPermissions = fieldPermissions?.[field.name]?.fields;
if (hasPermission === false) return null;
if (field.localized) {
return (
<div
className={`${baseClass}__field`}
key={i}
>
{locales.map((locale) => {
const versionLocaleValue = versionValue?.[locale];
const comparisonLocaleValue = comparisonValue?.[locale];
return (
<div
className={`${baseClass}__locale`}
key={locale}
>
<div className={`${baseClass}__locale-value`}>
<Component
diffMethod={diffMethod}
locale={locale}
locales={locales}
field={field}
fieldComponents={fieldComponents}
version={versionLocaleValue}
comparison={comparisonLocaleValue}
permissions={subFieldPermissions}
isRichText={isRichText}
/>
</div>
</div>
);
})}
</div>
);
}
return (
<div
className={`${baseClass}__field`}
key={i}
>
<Component
diffMethod={diffMethod}
locales={locales}
field={field}
fieldComponents={fieldComponents}
version={versionValue}
comparison={comparisonValue}
permissions={subFieldPermissions}
isRichText={isRichText}
/>
</div>
);
}
// At this point, we are dealing with a `row` or similar
if (fieldHasSubFields(field)) {
return (
<Nested
key={i}
locales={locales}
disableGutter
field={field}
fieldComponents={fieldComponents}
version={version}
comparison={comparison}
permissions={fieldPermissions}
/>
);
}
}
return null;
})}
</div>
);
export default RenderFieldsToDiff;

View File

@@ -0,0 +1,12 @@
import { FieldPermissions } from '../../../../../auth';
import { Field } from '../../../../../fields/config/types';
import { FieldComponents } from './fields/types';
export type Props = {
fields: Field[]
fieldComponents: FieldComponents,
fieldPermissions: Record<string, FieldPermissions>
version: Record<string, any>
comparison: Record<string, any>
locales: string[]
}

View File

@@ -0,0 +1,20 @@
@import '../../../../scss/styles.scss';
.restore-version {
cursor: pointer;
&__modal {
@include blur-bg;
display: flex;
align-items: center;
height: 100%;
&__toggle {
@extend %btn-reset;
}
.btn {
margin-right: $baseline;
}
}
}

View File

@@ -0,0 +1,84 @@
import React, { Fragment, useCallback, useState } from 'react';
import { toast } from 'react-toastify';
import { Modal, useModal } from '@faceless-ui/modal';
import { useConfig } from '@payloadcms/config-provider';
import { useHistory } from 'react-router-dom';
import { Button, MinimalTemplate, Pill } from '../../..';
import { Props } from './types';
import { requests } from '../../../../api';
import './index.scss';
const baseClass = 'restore-version';
const modalSlug = 'restore-version';
const Restore: React.FC<Props> = ({ collection, global, className, versionID, originalDocID, versionDate }) => {
const { serverURL, routes: { api, admin } } = useConfig();
const history = useHistory();
const { toggle } = useModal();
const [processing, setProcessing] = useState(false);
let fetchURL = `${serverURL}${api}`;
let redirectURL: string;
let restoreMessage: string;
if (collection) {
fetchURL = `${fetchURL}/${collection.slug}/versions/${versionID}`;
redirectURL = `${admin}/collections/${collection.slug}/${originalDocID}`;
restoreMessage = `You are about to restore this ${collection.labels.singular} document to the state that it was in on ${versionDate}.`;
}
if (global) {
fetchURL = `${fetchURL}/globals/${global.slug}/versions/${versionID}`;
redirectURL = `${admin}/globals/${global.slug}`;
restoreMessage = `You are about to restore the global ${global.label} to the state that it was in on ${versionDate}.`;
}
const handleRestore = useCallback(async () => {
setProcessing(true);
const res = await requests.post(fetchURL);
if (res.status === 200) {
const json = await res.json();
toast.success(json.message);
history.push(redirectURL);
} else {
toast.error('There was a problem while restoring this version.');
}
}, [history, fetchURL, redirectURL]);
return (
<Fragment>
<Pill
onClick={() => toggle(modalSlug)}
className={[baseClass, className].filter(Boolean).join(' ')}
>
Restore this version
</Pill>
<Modal
slug={modalSlug}
className={`${baseClass}__modal`}
>
<MinimalTemplate>
<h1>Confirm version restoration</h1>
<p>{restoreMessage}</p>
<Button
buttonStyle="secondary"
type="button"
onClick={processing ? undefined : () => toggle(modalSlug)}
>
Cancel
</Button>
<Button
onClick={processing ? undefined : handleRestore}
>
{processing ? 'Restoring...' : 'Confirm'}
</Button>
</MinimalTemplate>
</Modal>
</Fragment>
);
};
export default Restore;

View File

@@ -0,0 +1,11 @@
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../../../globals/config/types';
export type Props = {
collection?: SanitizedCollectionConfig
global?: SanitizedGlobalConfig
className?: string
versionID: string
originalDocID: string
versionDate: string
}

View File

@@ -0,0 +1,9 @@
@import '../../../../scss/styles.scss';
.select-version-locales {
flex-grow: 1;
&__label {
margin-bottom: base(.25);
}
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import ReactSelect from '../../../elements/ReactSelect';
import { Props } from './types';
import './index.scss';
const baseClass = 'select-version-locales';
const SelectLocales: React.FC<Props> = ({ onChange, value, options }) => (
<div className={baseClass}>
<div className={`${baseClass}__label`}>
Show locales:
</div>
<ReactSelect
isMulti
placeholder="Select locales to display"
onChange={onChange}
value={value}
options={options}
/>
</div>
);
export default SelectLocales;

View File

@@ -0,0 +1,7 @@
import { LocaleOption } from '../types';
export type Props = {
onChange: (options: LocaleOption[]) => void
value: LocaleOption[]
options: LocaleOption[]
}

View File

@@ -0,0 +1,222 @@
import React, { useEffect, useState } from 'react';
import { useAuth, useConfig } from '@payloadcms/config-provider';
import { useRouteMatch } from 'react-router-dom';
import format from 'date-fns/format';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import Eyebrow from '../../elements/Eyebrow';
import Loading from '../../elements/Loading';
import { useStepNav } from '../../elements/StepNav';
import { StepNavItem } from '../../elements/StepNav/types';
import Meta from '../../utilities/Meta';
import { LocaleOption, CompareOption, Props } from './types';
import CompareVersion from './Compare';
import { publishedVersionOption } from './shared';
import Restore from './Restore';
import SelectLocales from './SelectLocales';
import RenderFieldsToDiff from './RenderFieldsToDiff';
import fieldComponents from './RenderFieldsToDiff/fields';
import { Field, FieldAffectingData, fieldAffectsData } from '../../../../fields/config/types';
import { FieldPermissions } from '../../../../auth';
import { useLocale } from '../../utilities/Locale';
import './index.scss';
const baseClass = 'view-version';
const VersionView: React.FC<Props> = ({ collection, global }) => {
const { serverURL, routes: { admin, api }, admin: { dateFormat }, localization } = useConfig();
const { setStepNav } = useStepNav();
const { params: { id, versionID } } = useRouteMatch<{ id?: string, versionID: string }>();
const [compareValue, setCompareValue] = useState<CompareOption>(publishedVersionOption);
const [localeOptions] = useState<LocaleOption[]>(() => (localization?.locales ? localization.locales.map((locale) => ({ label: locale, value: locale })) : []));
const [locales, setLocales] = useState<LocaleOption[]>(localeOptions);
const { permissions } = useAuth();
const locale = useLocale();
let originalDocFetchURL: string;
let versionFetchURL: string;
let entityLabel: string;
let fields: Field[];
let fieldPermissions: Record<string, FieldPermissions>;
let compareBaseURL: string;
let slug: string;
let parentID: string;
if (collection) {
({ slug } = collection);
originalDocFetchURL = `${serverURL}${api}/${slug}/${id}`;
versionFetchURL = `${serverURL}${api}/${slug}/versions/${versionID}`;
compareBaseURL = `${serverURL}${api}/${slug}/versions`;
entityLabel = collection.labels.singular;
parentID = id;
fields = collection.fields;
fieldPermissions = permissions.collections[collection.slug].fields;
}
if (global) {
({ slug } = global);
originalDocFetchURL = `${serverURL}${api}/globals/${slug}`;
versionFetchURL = `${serverURL}${api}/globals/${slug}/versions/${versionID}`;
compareBaseURL = `${serverURL}${api}/globals/${slug}/versions`;
entityLabel = global.label;
fields = global.fields;
fieldPermissions = permissions.globals[global.slug].fields;
}
const compareFetchURL = compareValue?.value === 'mostRecent' || compareValue?.value === 'published' ? originalDocFetchURL : `${compareBaseURL}/${compareValue.value}`;
const [{ data: doc, isLoading }] = usePayloadAPI(versionFetchURL, { initialParams: { locale: '*', depth: 1 } });
const [{ data: publishedDoc }] = usePayloadAPI(originalDocFetchURL, { initialParams: { locale: '*', depth: 1 } });
const [{ data: mostRecentDoc }] = usePayloadAPI(originalDocFetchURL, { initialParams: { locale: '*', depth: 1, draft: true } });
const [{ data: compareDoc }] = usePayloadAPI(compareFetchURL, { initialParams: { locale: '*', depth: 1, draft: 'true' } });
useEffect(() => {
let nav: StepNavItem[] = [];
if (collection) {
let docLabel = '';
if (publishedDoc) {
const { useAsTitle } = collection.admin;
if (useAsTitle !== 'id') {
const titleField = collection.fields.find((field) => fieldAffectsData(field) && field.name === useAsTitle) as FieldAffectingData;
if (titleField && publishedDoc[useAsTitle]) {
if (titleField.localized) {
docLabel = publishedDoc[useAsTitle]?.[locale];
} else {
docLabel = publishedDoc[useAsTitle];
}
} else {
docLabel = '[Untitled]';
}
} else {
docLabel = publishedDoc.id;
}
}
nav = [
{
url: `${admin}/collections/${collection.slug}`,
label: collection.labels.plural,
},
{
label: docLabel,
url: `${admin}/collections/${collection.slug}/${id}`,
},
{
label: 'Versions',
url: `${admin}/collections/${collection.slug}/${id}/versions`,
},
{
label: doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '',
},
];
}
if (global) {
nav = [
{
url: `${admin}/globals/${global.slug}`,
label: global.label,
},
{
label: 'Versions',
url: `${admin}/globals/${global.slug}/versions`,
},
{
label: doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '',
},
];
}
setStepNav(nav);
}, [setStepNav, collection, global, dateFormat, doc, publishedDoc, admin, id, locale]);
let metaTitle: string;
let metaDesc: string;
const formattedCreatedAt = doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '';
if (collection) {
const useAsTitle = collection?.admin?.useAsTitle || 'id';
metaTitle = `Version - ${formattedCreatedAt} - ${doc[useAsTitle]} - ${entityLabel}`;
metaDesc = `Viewing version for the ${entityLabel} ${doc[useAsTitle]}`;
}
if (global) {
metaTitle = `Version - ${formattedCreatedAt} - ${entityLabel}`;
metaDesc = `Viewing version for the global ${entityLabel}`;
}
let comparison = compareDoc?.version;
if (compareValue?.value === 'mostRecent') {
comparison = mostRecentDoc;
}
if (compareValue?.value === 'published') {
comparison = publishedDoc;
}
return (
<div className={baseClass}>
<Meta
title={metaTitle}
description={metaDesc}
/>
<Eyebrow />
<div className={`${baseClass}__wrap`}>
<div className={`${baseClass}__intro`}>
{doc?.autosave ? 'Autosaved version ' : 'Version'}
{' '}
created on:
</div>
<header className={`${baseClass}__header`}>
<h2>
{formattedCreatedAt}
</h2>
<Restore
className={`${baseClass}__restore`}
collection={collection}
global={global}
originalDocID={id}
versionID={versionID}
versionDate={formattedCreatedAt}
/>
</header>
<div className={`${baseClass}__controls`}>
{localization && (
<SelectLocales
onChange={setLocales}
options={localeOptions}
value={locales}
/>
)}
<CompareVersion
baseURL={compareBaseURL}
parentID={parentID}
value={compareValue}
onChange={setCompareValue}
/>
</div>
{isLoading && (
<Loading />
)}
{doc?.version && (
<RenderFieldsToDiff
locales={locales.map((locale) => locale.value)}
fields={fields}
fieldComponents={fieldComponents}
fieldPermissions={fieldPermissions}
version={doc?.version}
comparison={comparison}
/>
)}
</div>
</div>
);
};
export default VersionView;

View File

@@ -0,0 +1,65 @@
@import '../../../scss/styles.scss';
.view-version {
width: 100%;
margin-bottom: base(2);
&__wrap {
padding: base(3);
margin-right: base(2);
background: white;
}
&__header {
margin-bottom: $baseline;
display: flex;
align-items: center;
flex-wrap: wrap;
h2 {
margin: 0;
}
}
&__controls {
display: flex;
margin-bottom: $baseline;
margin-left: base(-.5);
margin-right: base(-.5);
> * {
margin-left: base(.5);
margin-right: base(.5);
flex-basis: 100%;
}
}
&__restore {
margin: 0 0 0 $baseline;
}
@include mid-break {
&__wrap {
padding: $baseline;
margin-right: 0;
}
&__intro,
&__header {
display: block;
}
&__controls {
display: block;
margin: 0 base(-.5) base(2);
> * {
margin-bottom: base(.5);
}
}
&__restore {
margin: base(.5) 0 0 0;
}
}
}

View File

@@ -0,0 +1,13 @@
import React, { Suspense, lazy } from 'react';
import Loading from '../../elements/Loading';
import { Props } from './types';
const VersionView = lazy(() => import('./Version'));
const Version: React.FC<Props> = (props) => (
<Suspense fallback={<Loading />}>
<VersionView {...props} />
</Suspense>
);
export default Version;

View File

@@ -0,0 +1,9 @@
export const mostRecentVersionOption = {
label: 'Most recent draft',
value: 'mostRecent',
};
export const publishedVersionOption = {
label: 'Most recently published',
value: 'published',
};

View File

@@ -0,0 +1,19 @@
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
export type LocaleOption = {
label: string
value: string
}
export type CompareOption = {
label: string
value: string
relationTo?: string
options?: CompareOption[]
}
export type Props = {
collection?: SanitizedCollectionConfig
global?: SanitizedGlobalConfig
}

View File

@@ -0,0 +1,110 @@
import React from 'react';
import { Link, useRouteMatch } from 'react-router-dom';
import { useConfig } from '@payloadcms/config-provider';
import format from 'date-fns/format';
import { Column } from '../../elements/Table/types';
import SortColumn from '../../elements/SortColumn';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
import { Pill } from '../..';
type CreatedAtCellProps = {
id: string
date: string
collection?: SanitizedCollectionConfig
global?: SanitizedGlobalConfig
}
const CreatedAtCell: React.FC<CreatedAtCellProps> = ({ collection, global, id, date }) => {
const { routes: { admin }, admin: { dateFormat } } = useConfig();
const { params: { id: docID } } = useRouteMatch<{ id: string }>();
let to: string;
if (collection) to = `${admin}/collections/${collection.slug}/${docID}/versions/${id}`;
if (global) to = `${admin}/globals/${global.slug}/versions/${id}`;
return (
<Link to={to}>
{date && format(new Date(date), dateFormat)}
</Link>
);
};
const TextCell: React.FC = ({ children }) => (
<span>
{children}
</span>
);
export const getColumns = (collection: SanitizedCollectionConfig, global: SanitizedGlobalConfig): Column[] => [
{
accessor: 'updatedAt',
components: {
Heading: (
<SortColumn
label="Updated At"
name="updatedAt"
/>
),
renderCell: (row, data) => (
<CreatedAtCell
collection={collection}
global={global}
id={row?.id}
date={data}
/>
),
},
},
{
accessor: 'id',
components: {
Heading: (
<SortColumn
label="Version ID"
disable
name="id"
/>
),
renderCell: (row, data) => <TextCell>{data}</TextCell>,
},
},
{
accessor: 'autosave',
components: {
Heading: (
<SortColumn
label="Type"
name="autosave"
disable
/>
),
renderCell: (row) => (
<TextCell>
{row?.autosave && (
<React.Fragment>
<Pill>
Autosave
</Pill>
&nbsp;&nbsp;
</React.Fragment>
)}
{row?.version._status === 'published' && (
<React.Fragment>
<Pill pillStyle="success">
Published
</Pill>
&nbsp;&nbsp;
</React.Fragment>
)}
{row?.version._status === 'draft' && (
<Pill>
Draft
</Pill>
)}
</TextCell>
),
},
},
];

View File

@@ -0,0 +1,69 @@
@import '../../../scss/styles.scss';
.versions {
width: 100%;
margin-bottom: base(2);
&__wrap {
padding: base(3);
margin-right: base(2);
background: white;
}
&__header {
margin-bottom: $baseline;
}
&__intro {
margin-bottom: base(.5);
}
.table {
table {
width: 100%;
overflow: auto;
}
}
&__page-controls {
width: 100%;
display: flex;
align-items: center;
}
.paginator {
margin-bottom: 0;
}
&__page-info {
margin-right: base(1);
margin-left: auto;
}
@include mid-break {
&__wrap {
padding: $baseline 0;
margin-right: 0;
}
&__header,
.table,
&__page-controls {
padding-left: $baseline;
padding-right: $baseline;
}
&__page-controls {
flex-wrap: wrap;
}
&__page-info {
margin-left: 0;
}
.paginator {
width: 100%;
margin-bottom: $baseline;
}
}
}

View File

@@ -0,0 +1,216 @@
import { useConfig } from '@payloadcms/config-provider';
import React, { useEffect, useState } from 'react';
import { useRouteMatch } from 'react-router-dom';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import Eyebrow from '../../elements/Eyebrow';
import Loading from '../../elements/Loading';
import { useStepNav } from '../../elements/StepNav';
import { StepNavItem } from '../../elements/StepNav/types';
import Meta from '../../utilities/Meta';
import { Props } from './types';
import IDLabel from '../../elements/IDLabel';
import { getColumns } from './columns';
import Table from '../../elements/Table';
import Paginator from '../../elements/Paginator';
import PerPage from '../../elements/PerPage';
import { useSearchParams } from '../../utilities/SearchParams';
import './index.scss';
const baseClass = 'versions';
const Versions: React.FC<Props> = ({ collection, global }) => {
const { serverURL, routes: { admin, api } } = useConfig();
const { setStepNav } = useStepNav();
const { params: { id } } = useRouteMatch<{ id: string }>();
const [tableColumns] = useState(() => getColumns(collection, global));
const [fetchURL, setFetchURL] = useState('');
const { page, sort, limit } = useSearchParams();
let docURL: string;
let entityLabel: string;
let slug: string;
if (collection) {
({ slug } = collection);
docURL = `${serverURL}${api}/${slug}/${id}`;
entityLabel = collection.labels.singular;
}
if (global) {
({ slug } = global);
docURL = `${serverURL}${api}/globals/${slug}`;
entityLabel = global.label;
}
const useAsTitle = collection?.admin?.useAsTitle || 'id';
const [{ data: doc }] = usePayloadAPI(docURL, { initialParams: { draft: 'true' } });
const [{ data: versionsData, isLoading: isLoadingVersions }, { setParams }] = usePayloadAPI(fetchURL);
useEffect(() => {
let nav: StepNavItem[] = [];
if (collection) {
let docLabel = '';
if (doc) {
if (useAsTitle) {
if (doc[useAsTitle]) {
docLabel = doc[useAsTitle];
} else {
docLabel = '[Untitled]';
}
} else {
docLabel = doc.id;
}
}
nav = [
{
url: `${admin}/collections/${collection.slug}`,
label: collection.labels.plural,
},
{
label: docLabel,
url: `${admin}/collections/${collection.slug}/${id}`,
},
{
label: 'Versions',
},
];
}
if (global) {
nav = [
{
url: `${admin}/globals/${global.slug}`,
label: global.label,
},
{
label: 'Versions',
},
];
}
setStepNav(nav);
}, [setStepNav, collection, global, useAsTitle, doc, admin, id]);
useEffect(() => {
const params = {
depth: 1,
page: undefined,
sort: undefined,
limit,
where: {},
};
if (page) params.page = page;
if (sort) params.sort = sort;
let fetchURLToSet: string;
if (collection) {
fetchURLToSet = `${serverURL}${api}/${collection.slug}/versions`;
params.where = {
parent: {
equals: id,
},
};
}
if (global) {
fetchURLToSet = `${serverURL}${api}/globals/${global.slug}/versions`;
}
// Performance enhancement
// Setting the Fetch URL this way
// prevents a double-fetch
setFetchURL(fetchURLToSet);
setParams(params);
}, [setParams, page, sort, limit, serverURL, api, id, global, collection]);
let useIDLabel = doc[useAsTitle] === doc?.id;
let heading: string;
let metaDesc: string;
let metaTitle: string;
if (collection) {
metaTitle = `Versions - ${doc[useAsTitle]} - ${entityLabel}`;
metaDesc = `Viewing versions for the ${entityLabel} ${doc[useAsTitle]}`;
heading = doc?.[useAsTitle];
}
if (global) {
metaTitle = `Versions - ${entityLabel}`;
metaDesc = `Viewing versions for the global ${entityLabel}`;
heading = entityLabel;
useIDLabel = false;
}
return (
<div className={baseClass}>
<Meta
title={metaTitle}
description={metaDesc}
/>
<Eyebrow />
<div className={`${baseClass}__wrap`}>
<header className={`${baseClass}__header`}>
<div className={`${baseClass}__intro`}>Showing versions for:</div>
{useIDLabel && (
<IDLabel id={doc?.id} />
)}
{!useIDLabel && (
<h1>
{heading}
</h1>
)}
</header>
{isLoadingVersions && (
<Loading />
)}
{versionsData?.docs && (
<React.Fragment>
<Table
data={versionsData?.docs}
columns={tableColumns}
/>
<div className={`${baseClass}__page-controls`}>
<Paginator
limit={versionsData.limit}
totalPages={versionsData.totalPages}
page={versionsData.page}
hasPrevPage={versionsData.hasPrevPage}
hasNextPage={versionsData.hasNextPage}
prevPage={versionsData.prevPage}
nextPage={versionsData.nextPage}
numberOfNeighbors={1}
/>
{versionsData?.totalDocs > 0 && (
<React.Fragment>
<div className={`${baseClass}__page-info`}>
{(versionsData.page * versionsData.limit) - (versionsData.limit - 1)}
-
{versionsData.totalPages > 1 && versionsData.totalPages !== versionsData.page ? (versionsData.limit * versionsData.page) : versionsData.totalDocs}
{' '}
of
{' '}
{versionsData.totalDocs}
</div>
<PerPage
limits={collection?.admin?.pagination?.limits}
limit={limit ? Number(limit) : 10}
/>
</React.Fragment>
)}
</div>
</React.Fragment>
)}
</div>
</div>
);
};
export default Versions;

View File

@@ -0,0 +1,7 @@
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
export type Props = {
collection?: SanitizedCollectionConfig
global?: SanitizedGlobalConfig
}

View File

@@ -16,8 +16,15 @@ import fieldTypes from '../../../forms/field-types';
import RenderTitle from '../../../elements/RenderTitle';
import LeaveWithoutSaving from '../../../modals/LeaveWithoutSaving';
import Auth from './Auth';
import VersionsCount from '../../../elements/VersionsCount';
import Upload from './Upload';
import { Props } from './types';
import Autosave from '../../../elements/Autosave';
import Status from '../../../elements/Status';
import Publish from '../../../elements/Publish';
import SaveDraft from '../../../elements/SaveDraft';
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import './index.scss';
@@ -26,6 +33,7 @@ const baseClass = 'collection-edit';
const DefaultEditView: React.FC<Props> = (props) => {
const { params: { id } = {} } = useRouteMatch<Record<string, string>>();
const { admin: { dateFormat }, routes: { admin } } = useConfig();
const { publishedDoc } = useDocumentInfo();
const {
collection,
@@ -38,6 +46,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
apiURL,
action,
hasSavePermission,
autosaveEnabled,
} = props;
const {
@@ -47,7 +56,9 @@ const DefaultEditView: React.FC<Props> = (props) => {
useAsTitle,
disableDuplicate,
preview,
hideAPIURL,
},
versions,
timestamps,
auth,
upload,
@@ -81,15 +92,16 @@ const DefaultEditView: React.FC<Props> = (props) => {
keywords={`${collection.labels.singular}, Payload, CMS`}
/>
<Eyebrow />
<LeaveWithoutSaving />
{!(collection.versions?.drafts && collection.versions?.drafts?.autosave) && (
<LeaveWithoutSaving />
)}
<div className={`${baseClass}__edit`}>
<React.Fragment>
<header className={`${baseClass}__header`}>
<h1>
<RenderTitle {...{ data, useAsTitle, fallback: '[Untitled]' }} />
</h1>
</header>
{auth && (
<header className={`${baseClass}__header`}>
<h1>
<RenderTitle {...{ data, useAsTitle, fallback: '[Untitled]' }} />
</h1>
</header>
{auth && (
<Auth
useAPIKey={auth.useAPIKey}
requirePassword={!isEditing}
@@ -98,59 +110,86 @@ const DefaultEditView: React.FC<Props> = (props) => {
email={data?.email}
operation={operation}
/>
)}
{upload && (
)}
{upload && (
<Upload
data={data}
collection={collection}
/>
)}
<RenderFields
operation={operation}
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => (!field?.admin?.position || (field?.admin?.position !== 'sidebar'))}
fieldTypes={fieldTypes}
fieldSchema={fields}
/>
</React.Fragment>
)}
<RenderFields
operation={operation}
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => (!field?.admin?.position || (field?.admin?.position !== 'sidebar'))}
fieldTypes={fieldTypes}
fieldSchema={fields}
/>
</div>
</div>
<div className={`${baseClass}__sidebar-wrap`}>
<div className={`${baseClass}__sidebar`}>
<div className={`${baseClass}__sidebar-sticky-wrap`}>
{isEditing ? (
<ul className={`${baseClass}__collection-actions`}>
{(permissions?.create?.permission) && (
<ul className={`${baseClass}__collection-actions`}>
{(permissions?.create?.permission) && (
<React.Fragment>
<li><Link to={`${admin}/collections/${slug}/create`}>Create New</Link></li>
{!disableDuplicate && (
<li><DuplicateDocument slug={slug} /></li>
)}
</React.Fragment>
)}
{permissions?.delete?.permission && (
)}
{permissions?.delete?.permission && (
<li>
<DeleteDocument
collection={collection}
id={id}
/>
</li>
)}
</ul>
) : undefined}
<div className={`${baseClass}__document-actions${(preview && isEditing) ? ` ${baseClass}__document-actions--with-preview` : ''}`}>
{isEditing && (
<PreviewButton
generatePreviewURL={preview}
data={data}
/>
)}
</ul>
<div className={`${baseClass}__document-actions${(autosaveEnabled || (isEditing && preview)) ? ` ${baseClass}__document-actions--has-2` : ''}`}>
{(preview && !autosaveEnabled) && (
<PreviewButton
generatePreviewURL={preview}
data={data}
/>
)}
{hasSavePermission && (
<FormSubmit>Save</FormSubmit>
<React.Fragment>
{collection.versions.drafts && (
<React.Fragment>
{!collection.versions.drafts.autosave && (
<SaveDraft />
)}
<Publish />
</React.Fragment>
)}
{!collection.versions.drafts && (
<FormSubmit>Save</FormSubmit>
)}
</React.Fragment>
)}
</div>
<div className={`${baseClass}__sidebar-fields`}>
{(isEditing && preview && autosaveEnabled) && (
<PreviewButton
generatePreviewURL={preview}
data={data}
/>
)}
{collection.versions?.drafts && (
<React.Fragment>
<Status />
{(collection.versions.drafts.autosave && hasSavePermission) && (
<Autosave
publishedDocUpdatedAt={publishedDoc?.updatedAt || data?.createdAt}
collection={collection}
id={id}
/>
)}
</React.Fragment>
)}
<RenderFields
operation={isEditing ? 'update' : 'create'}
readOnly={!hasSavePermission}
@@ -161,7 +200,8 @@ const DefaultEditView: React.FC<Props> = (props) => {
/>
</div>
{isEditing && (
<ul className={`${baseClass}__meta`}>
<ul className={`${baseClass}__meta`}>
{!hideAPIURL && (
<li className={`${baseClass}__api-url`}>
<span className={`${baseClass}__label`}>
API URL
@@ -176,27 +216,37 @@ const DefaultEditView: React.FC<Props> = (props) => {
{apiURL}
</a>
</li>
)}
<li>
<div className={`${baseClass}__label`}>Created</div>
<div>{format(new Date(data.createdAt), dateFormat)}</div>
</li>
{versions && (
<li>
<div className={`${baseClass}__label`}>ID</div>
<div>{id}</div>
<div className={`${baseClass}__label`}>Versions</div>
<VersionsCount
collection={collection}
id={id}
/>
</li>
{timestamps && (
)}
{timestamps && (
<React.Fragment>
{data.updatedAt && (
<li>
<div className={`${baseClass}__label`}>Last Modified</div>
<div>{format(new Date(data.updatedAt), dateFormat)}</div>
</li>
<li>
<div className={`${baseClass}__label`}>Last Modified</div>
<div>{format(new Date(data.updatedAt), dateFormat)}</div>
</li>
)}
{data.createdAt && (
<li>
<div className={`${baseClass}__label`}>Created</div>
<div>{format(new Date(data.createdAt), dateFormat)}</div>
</li>
{(publishedDoc?.createdAt || data?.createdAt) && (
<li>
<div className={`${baseClass}__label`}>Created</div>
<div>{format(new Date(publishedDoc?.createdAt || data?.createdAt), dateFormat)}</div>
</li>
)}
</React.Fragment>
)}
</ul>
)}
</ul>
)}
</div>
</div>

View File

@@ -78,7 +78,7 @@
z-index: $z-nav;
}
&__document-actions--with-preview {
&__document-actions--has-2 {
display: flex;
> * {
@@ -96,8 +96,8 @@
.form-submit {
.btn {
width: 100%;
padding-left: base(2);
padding-right: base(2);
padding-left: base(.5);
padding-right: base(.5);
}
}
}
@@ -115,6 +115,12 @@
&__sidebar-fields {
padding-right: $baseline;
.preview-btn {
display: inline-block;
margin-top: 0;
width: calc(50% - #{base(.5)});
}
}
&__meta {
@@ -192,6 +198,12 @@
padding-left: $baseline;
}
&__sidebar-fields {
.preview-btn {
width: 100%;
}
}
&__collection-actions {
margin-top: base(.5);
padding-left: $baseline;

View File

@@ -1,11 +1,10 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { Redirect, useRouteMatch, useHistory, useLocation } from 'react-router-dom';
import { useConfig, useAuth } from '@payloadcms/config-provider';
import { useStepNav } from '../../../elements/StepNav';
import usePayloadAPI from '../../../../hooks/usePayloadAPI';
import RenderCustomComponent from '../../../utilities/RenderCustomComponent';
import { DocumentInfoProvider } from '../../../utilities/DocumentInfo';
import DefaultEdit from './Default';
import formatFields from './formatFields';
import buildStateFromSchema from '../../../forms/Form/buildStateFromSchema';
@@ -13,9 +12,10 @@ import { NegativeFieldGutterProvider } from '../../../forms/FieldTypeGutter/cont
import { useLocale } from '../../../utilities/Locale';
import { IndexProps } from './types';
import { StepNavItem } from '../../../elements/StepNav/types';
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
const EditView: React.FC<IndexProps> = (props) => {
const { collection, isEditing } = props;
const { collection: incomingCollection, isEditing } = props;
const {
slug,
@@ -30,8 +30,10 @@ const EditView: React.FC<IndexProps> = (props) => {
} = {},
} = {},
} = {},
} = collection;
const [fields] = useState(() => formatFields(collection, isEditing));
} = incomingCollection;
const [fields] = useState(() => formatFields(incomingCollection, isEditing));
const [collection] = useState(() => ({ ...incomingCollection, fields }));
const locale = useLocale();
const { serverURL, routes: { admin, api } } = useConfig();
@@ -41,8 +43,10 @@ const EditView: React.FC<IndexProps> = (props) => {
const { setStepNav } = useStepNav();
const [initialState, setInitialState] = useState({});
const { permissions } = useAuth();
const { getVersions } = useDocumentInfo();
const onSave = async (json) => {
const onSave = useCallback(async (json: any) => {
getVersions();
if (!isEditing) {
history.push(`${admin}/collections/${collection.slug}/${json?.doc?.id}`);
} else {
@@ -55,11 +59,11 @@ const EditView: React.FC<IndexProps> = (props) => {
},
});
}
};
}, [admin, collection, fields, history, isEditing, getVersions]);
const [{ data, isLoading, isError }] = usePayloadAPI(
(isEditing ? `${serverURL}${api}/${slug}/${id}` : null),
{ initialParams: { 'fallback-locale': 'null', depth: 0 } },
{ initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } },
);
const dataToRender = (locationState as Record<string, unknown>)?.data || data;
@@ -71,8 +75,22 @@ const EditView: React.FC<IndexProps> = (props) => {
}];
if (isEditing) {
let label = '';
if (dataToRender) {
if (useAsTitle) {
if (dataToRender[useAsTitle]) {
label = dataToRender[useAsTitle];
} else {
label = '[Untitled]';
}
} else {
label = dataToRender.id;
}
}
nav.push({
label: dataToRender ? dataToRender[useAsTitle || 'id'] : '',
label,
});
} else {
nav.push({
@@ -99,36 +117,31 @@ const EditView: React.FC<IndexProps> = (props) => {
}
const collectionPermissions = permissions?.collections?.[slug];
const apiURL = `${serverURL}${api}/${slug}/${id}`;
const apiURL = `${serverURL}${api}/${slug}/${id}${collection.versions.drafts ? '?draft=true' : ''}`;
const action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`;
const hasSavePermission = (isEditing && collectionPermissions?.update?.permission) || (!isEditing && collectionPermissions?.create?.permission);
const autosaveEnabled = collection.versions?.drafts && !collection.versions.drafts.autosave;
return (
<DocumentInfoProvider
id={id}
slug={collection.slug}
type="collection"
>
<NegativeFieldGutterProvider allow>
<RenderCustomComponent
DefaultComponent={DefaultEdit}
CustomComponent={CustomEdit}
componentProps={{
isLoading,
data: dataToRender,
collection: { ...collection, fields },
permissions: collectionPermissions,
isEditing,
onSave,
initialState,
hasSavePermission,
apiURL,
action,
}}
/>
</NegativeFieldGutterProvider>
</DocumentInfoProvider>
<NegativeFieldGutterProvider allow>
<RenderCustomComponent
DefaultComponent={DefaultEdit}
CustomComponent={CustomEdit}
componentProps={{
isLoading,
data: dataToRender,
collection,
permissions: collectionPermissions,
isEditing,
onSave,
initialState,
hasSavePermission,
apiURL,
action,
autosaveEnabled,
}}
/>
</NegativeFieldGutterProvider>
);
};
export default EditView;

View File

@@ -17,4 +17,5 @@ export type Props = IndexProps & {
apiURL: string
action: string
hasSavePermission: boolean
autosaveEnabled: boolean
}

View File

@@ -138,7 +138,7 @@ const DefaultList: React.FC<Props> = (props) => {
{data.totalDocs}
</div>
<PerPage
collection={collection}
limits={collection?.admin?.pagination?.limits}
limit={limit}
/>
</Fragment>

View File

@@ -77,6 +77,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
useEffect(() => {
const params = {
depth: 1,
draft: 'true',
page: undefined,
sort: undefined,
where: undefined,

View File

@@ -1,4 +1,5 @@
import { SanitizedCollectionConfig, PaginatedDocs } from '../../../../../collections/config/types';
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { PaginatedDocs } from '../../../../../mongoose/types';
import { Column } from '../../../elements/Table/types';
export type Props = {

View File

@@ -1,4 +1,4 @@
import { Field } from '../config/types';
import { Field } from '../../fields/config/types';
export default [
{

View File

@@ -1,5 +1,5 @@
import crypto from 'crypto';
import { Field, FieldHook } from '../config/types';
import { Field, FieldHook } from '../../fields/config/types';
const encryptKey: FieldHook = ({ req, value }) => (value ? req.payload.encrypt(value as string) : undefined);
const decryptKey: FieldHook = ({ req, value }) => (value ? req.payload.decrypt(value as string) : undefined);

View File

@@ -1,5 +1,5 @@
import { email } from '../validations';
import { Field } from '../config/types';
import { email } from '../../fields/validations';
import { Field } from '../../fields/config/types';
export default [
{

View File

@@ -1,4 +1,4 @@
import { Field, FieldHook } from '../config/types';
import { Field, FieldHook } from '../../fields/config/types';
const autoRemoveVerificationToken: FieldHook = ({ originalDoc, data, value, operation }) => {
// If a user manually sets `_verified` to true,

View File

@@ -1,3 +1,4 @@
import { Payload } from '../..';
import { PayloadRequest } from '../../express/types';
import { Permissions } from '../types';
@@ -7,7 +8,7 @@ type Arguments = {
req: PayloadRequest
}
async function accessOperation(args: Arguments): Promise<Permissions> {
async function accessOperation(this: Payload, args: Arguments): Promise<Permissions> {
const { config } = this;
const {
@@ -102,11 +103,26 @@ async function accessOperation(args: Arguments): Promise<Permissions> {
}
config.collections.forEach((collection) => {
executeEntityPolicies(collection, allOperations, 'collections');
const collectionOperations = [...allOperations];
if (collection.auth && (typeof collection.auth.maxLoginAttempts !== 'undefined' && collection.auth.maxLoginAttempts !== 0)) {
collectionOperations.push('unlock');
}
if (collection.versions) {
collectionOperations.push('readVersions');
}
executeEntityPolicies(collection, collectionOperations, 'collections');
});
config.globals.forEach((global) => {
executeEntityPolicies(global, ['read', 'update'], 'globals');
const globalOperations = ['read', 'update'];
if (global.versions) {
globalOperations.push('readVersions');
}
executeEntityPolicies(global, globalOperations, 'globals');
});
await Promise.all(promises);

View File

@@ -1,6 +1,7 @@
import { Response } from 'express';
import { Result } from '../login';
import { PayloadRequest } from '../../../express/types';
import { TypeWithID } from '../../../collections/config/types';
export type Options = {
collection: string
@@ -17,7 +18,7 @@ export type Options = {
showHiddenFields?: boolean
}
async function login(options: Options): Promise<Result> {
async function login<T extends TypeWithID = any>(options: Options): Promise<Result & { user: T}> {
const {
collection: collectionSlug,
req = {},

View File

@@ -8,6 +8,7 @@ import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
import { Field, fieldHasSubFields, fieldAffectsData } from '../../fields/config/types';
import { User } from '../types';
import { Collection } from '../../collections/config/types';
import { Payload } from '../..';
export type Result = {
user?: User,
@@ -28,7 +29,7 @@ export type Arguments = {
showHiddenFields?: boolean
}
async function login(incomingArgs: Arguments): Promise<Result> {
async function login(this: Payload, incomingArgs: Arguments): Promise<Result> {
const { config, operations, secret } = this;
let args = incomingArgs;
@@ -176,7 +177,7 @@ async function login(incomingArgs: Arguments): Promise<Result> {
id: user.id,
data: user,
hook: 'afterRead',
operation: 'login',
operation: 'read',
overrideAccess,
flattenLocales: true,
showHiddenFields,

View File

@@ -1,10 +1,16 @@
import { Document } from '../../types';
import { Forbidden } from '../../errors';
import { Payload } from '../..';
import { Collection } from '../../collections/config/types';
import { PayloadRequest } from '../../express/types';
import { Collection, TypeWithID } from '../../collections/config/types';
export type Arguments = {
collection: Collection
data: {
email: string
password: string
}
req: PayloadRequest
}
export type Result = {
@@ -23,6 +29,11 @@ async function registerFirstUser(this: Payload, args: Arguments): Promise<Result
},
},
},
req: {
payload,
},
req,
data,
} = args;
const count = await Model.countDocuments({});
@@ -33,14 +44,16 @@ async function registerFirstUser(this: Payload, args: Arguments): Promise<Result
// Register first user
// /////////////////////////////////////
let result = await this.operations.collections.create({
...args,
const result = await payload.create<TypeWithID>({
req,
collection: slug,
data,
overrideAccess: true,
});
// auto-verify (if applicable)
if (verify) {
await this.update({
await payload.update({
id: result.id,
collection: slug,
data: {
@@ -53,18 +66,19 @@ async function registerFirstUser(this: Payload, args: Arguments): Promise<Result
// Log in new user
// /////////////////////////////////////
const { token } = await this.operations.collections.auth.login({
const { token } = await payload.login({
...args,
collection: slug,
});
result = {
const resultToReturn = {
...result,
token,
};
return {
message: 'Registered and logged in successfully. Welcome!',
user: result,
user: resultToReturn,
};
}

Some files were not shown because too many files have changed in this diff Show More