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

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