feat: progress to Autosave
This commit is contained in:
5
src/admin/components/elements/Autosave/index.scss
Normal file
5
src/admin/components/elements/Autosave/index.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
.autosave {
|
||||
min-height: $baseline;
|
||||
}
|
||||
156
src/admin/components/elements/Autosave/index.tsx
Normal file
156
src/admin/components/elements/Autosave/index.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
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, 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 buildStateFromSchema from '../../forms/Form/buildStateFromSchema';
|
||||
import { Field } from '../../../../fields/config/types';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'autosave';
|
||||
|
||||
const Autosave: React.FC<Props> = ({ collection, global, id, updatedAt }) => {
|
||||
const { serverURL, routes: { api, admin } } = useConfig();
|
||||
const { fields, dispatchFields } = useWatchForm();
|
||||
const modified = useFormModified();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<number>();
|
||||
const locale = useLocale();
|
||||
const { push } = useHistory();
|
||||
|
||||
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&autosave=true`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (res.status === 201) {
|
||||
const json = await res.json();
|
||||
push(`${admin}/collections/${collection.slug}/${json.doc.id}`);
|
||||
} else {
|
||||
toast.error('There was a problem while autosaving this document.');
|
||||
}
|
||||
}, [collection, serverURL, api, admin, locale, push]);
|
||||
|
||||
const getLastSaved = useCallback(async () => {
|
||||
let url: string;
|
||||
|
||||
if (collection && id) {
|
||||
url = `${serverURL}${api}/${collection.slug}/versions?where[parent][equals]=${id}&depth=0`;
|
||||
}
|
||||
|
||||
if (global) {
|
||||
url = `${serverURL}${api}/globals/${global.slug}/versions?depth=0`;
|
||||
}
|
||||
|
||||
if (url) {
|
||||
const res = await fetch(url);
|
||||
|
||||
if (res.status === 200) {
|
||||
const json = await res.json();
|
||||
|
||||
if (json.docs[0]) {
|
||||
return setLastSaved(new Date(json.docs[0].updatedAt).getTime());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedAt) return setLastSaved(new Date(updatedAt).getTime());
|
||||
|
||||
return null;
|
||||
}, [collection, global, id, api, serverURL, updatedAt]);
|
||||
|
||||
// On mount, check for a recent autosave
|
||||
// Need it to store the lastSaved date
|
||||
useEffect(() => {
|
||||
getLastSaved();
|
||||
}, [getLastSaved]);
|
||||
|
||||
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(() => {
|
||||
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;
|
||||
let entityFields: Field[] = [];
|
||||
|
||||
if (collection && id) {
|
||||
url = `${serverURL}${api}/${collection.slug}/${id}?autosave=true`;
|
||||
method = 'PUT';
|
||||
entityFields = collection.fields;
|
||||
}
|
||||
|
||||
if (global) {
|
||||
url = `${serverURL}${api}/globals/${global.slug}?autosave=true`;
|
||||
method = 'POST';
|
||||
entityFields = global.fields;
|
||||
}
|
||||
|
||||
if (url) {
|
||||
setTimeout(() => {
|
||||
setSaving(false);
|
||||
}, 1000);
|
||||
|
||||
const body = {
|
||||
...reduceFieldsToValues(fields),
|
||||
_status: 'draft',
|
||||
};
|
||||
|
||||
// TODO:
|
||||
// Determine why field values are not present
|
||||
// even though we are using useWatchForm
|
||||
console.log(body);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
const json = await res.json();
|
||||
const state = await buildStateFromSchema(entityFields, json.doc);
|
||||
dispatchFields({ type: 'REPLACE_STATE', state });
|
||||
setLastSaved(new Date().getTime());
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
}, [fields, modified, interval, lastSaved, serverURL, api, collection, global, id, saving, dispatchFields]);
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
{saving && 'Saving...'}
|
||||
{(!saving && lastSaved) && (
|
||||
<React.Fragment>
|
||||
Last saved
|
||||
{formatDistance(new Date(), new Date(lastSaved))}
|
||||
ago
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Autosave;
|
||||
9
src/admin/components/elements/Autosave/types.ts
Normal file
9
src/admin/components/elements/Autosave/types.ts
Normal 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
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -73,13 +73,27 @@ const VersionView: React.FC<Props> = ({ collection, global }) => {
|
||||
let nav: StepNavItem[] = [];
|
||||
|
||||
if (collection) {
|
||||
let docLabel = '';
|
||||
|
||||
if (originalDoc) {
|
||||
if (useAsTitle) {
|
||||
if (originalDoc[useAsTitle]) {
|
||||
docLabel = originalDoc[useAsTitle];
|
||||
} else {
|
||||
docLabel = '[Untitled]';
|
||||
}
|
||||
} else {
|
||||
docLabel = originalDoc.id;
|
||||
}
|
||||
}
|
||||
|
||||
nav = [
|
||||
{
|
||||
url: `${admin}/collections/${collection.slug}`,
|
||||
label: collection.labels.plural,
|
||||
},
|
||||
{
|
||||
label: originalDoc ? originalDoc[useAsTitle] : '',
|
||||
label: docLabel,
|
||||
url: `${admin}/collections/${collection.slug}/${id}`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -51,13 +51,27 @@ const Versions: React.FC<Props> = ({ collection, global }) => {
|
||||
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: doc ? doc[useAsTitle] : '',
|
||||
label: docLabel,
|
||||
url: `${admin}/collections/${collection.slug}/${id}`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { SanitizedCollectionConfig } from '../../../../../../collections/config/types';
|
||||
import { useWatchForm, useFormModified } from '../../../../forms/Form/context';
|
||||
|
||||
const Autosave: React.FC<{ collection: SanitizedCollectionConfig}> = ({ collection }) => {
|
||||
const { fields } = useWatchForm();
|
||||
const modified = useFormModified();
|
||||
const [lastSaved, setLastSaved] = useState(() => {
|
||||
const date = new Date();
|
||||
date.setSeconds(date.getSeconds() - 2);
|
||||
return date.getTime();
|
||||
});
|
||||
|
||||
const interval = collection.versions.drafts && collection.versions.drafts.autosave ? collection.versions.drafts.autosave.interval : 5;
|
||||
|
||||
useEffect(() => {
|
||||
const lastSavedDate = new Date(lastSaved);
|
||||
lastSavedDate.setSeconds(lastSavedDate.getSeconds() + interval);
|
||||
const timeToSaveAgain = lastSavedDate.getTime();
|
||||
|
||||
if (Date.now() >= timeToSaveAgain && modified) {
|
||||
setTimeout(() => {
|
||||
console.log('Autosaving');
|
||||
}, 1000);
|
||||
setLastSaved(new Date().getTime());
|
||||
}
|
||||
}, [modified, fields, interval, lastSaved]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Autosave;
|
||||
@@ -19,7 +19,7 @@ import Auth from './Auth';
|
||||
import VersionsCount from '../../../elements/VersionsCount';
|
||||
import Upload from './Upload';
|
||||
import { Props } from './types';
|
||||
import Autosave from './Autosave';
|
||||
import Autosave from '../../../elements/Autosave';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -159,14 +159,13 @@ const DefaultEditView: React.FC<Props> = (props) => {
|
||||
{!isLoading && (
|
||||
<React.Fragment>
|
||||
<div className={`${baseClass}__sidebar-fields`}>
|
||||
{/* {collection.versions?.drafts && (
|
||||
<Select
|
||||
label="Status"
|
||||
path="_status"
|
||||
name="_status"
|
||||
options={statuses}
|
||||
{(collection.versions?.drafts && collection.versions.drafts.autosave && hasSavePermission) && (
|
||||
<Autosave
|
||||
updatedAt={data.updatedAt}
|
||||
collection={collection}
|
||||
id={id}
|
||||
/>
|
||||
)} */}
|
||||
)}
|
||||
<RenderFields
|
||||
operation={isEditing ? 'update' : 'create'}
|
||||
readOnly={!hasSavePermission}
|
||||
@@ -232,9 +231,6 @@ const DefaultEditView: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(collection.versions?.drafts && collection.versions.drafts.autosave && hasSavePermission) && (
|
||||
<Autosave collection={collection} />
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { IndexProps } from './types';
|
||||
import { StepNavItem } from '../../../elements/StepNav/types';
|
||||
|
||||
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 [submissionCount, setSubmissionCount] = useState(0);
|
||||
|
||||
const locale = useLocale();
|
||||
@@ -73,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({
|
||||
@@ -120,7 +136,7 @@ const EditView: React.FC<IndexProps> = (props) => {
|
||||
submissionCount,
|
||||
isLoading,
|
||||
data: dataToRender,
|
||||
collection: { ...collection, fields },
|
||||
collection,
|
||||
permissions: collectionPermissions,
|
||||
isEditing,
|
||||
onSave,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { defaults, authDefaults } from './defaults';
|
||||
import { Config } from '../../config/types';
|
||||
import { versionCollectionDefaults } from '../../versions/defaults';
|
||||
import baseVersionFields from '../../versions/baseFields';
|
||||
import TimestampsRequired from '../../errors/TimestampsRequired';
|
||||
|
||||
const mergeBaseFields = (fields, baseFields) => {
|
||||
const mergedFields = [];
|
||||
@@ -69,16 +70,18 @@ const sanitizeCollection = (config: Config, collection: CollectionConfig): Sanit
|
||||
if (sanitized.versions) {
|
||||
if (sanitized.versions === true) sanitized.versions = {};
|
||||
|
||||
// const defaultsToMerge = { ... };
|
||||
if (sanitized.timestamps === false) {
|
||||
throw new TimestampsRequired(collection);
|
||||
}
|
||||
|
||||
// if (sanitized.versions.drafts === false) {
|
||||
// defaultsToMerge.drafts = false;
|
||||
// } else {
|
||||
// sanitized.fields = [
|
||||
// ...sanitized.fields,
|
||||
// ...baseVersionFields,
|
||||
// ];
|
||||
// }
|
||||
if (sanitized.versions.drafts) {
|
||||
const versionFields = mergeBaseFields(sanitized.fields, baseVersionFields);
|
||||
|
||||
sanitized.fields = [
|
||||
...versionFields,
|
||||
...sanitized.fields,
|
||||
];
|
||||
}
|
||||
|
||||
sanitized.versions = merge(versionCollectionDefaults, sanitized.versions);
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ function registerCollections(): void {
|
||||
type: collection.graphQL.type,
|
||||
args: {
|
||||
data: { type: collection.graphQL.mutationInputType },
|
||||
autosave: { type: GraphQLBoolean },
|
||||
},
|
||||
resolve: create(collection),
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function create(collection: Collection): Resolver {
|
||||
collection,
|
||||
data: args.data,
|
||||
req: context.req,
|
||||
autosave: args.autosave,
|
||||
};
|
||||
|
||||
const result = await this.operations.collections.create(options);
|
||||
|
||||
@@ -22,6 +22,7 @@ export type Arguments = {
|
||||
showHiddenFields?: boolean
|
||||
data: Record<string, unknown>
|
||||
overwriteExistingFiles?: boolean
|
||||
autosave?: boolean
|
||||
}
|
||||
|
||||
async function create(this: Payload, incomingArgs: Arguments): Promise<Document> {
|
||||
@@ -54,6 +55,7 @@ async function create(this: Payload, incomingArgs: Arguments): Promise<Document>
|
||||
overrideAccess,
|
||||
showHiddenFields,
|
||||
overwriteExistingFiles = false,
|
||||
autosave = false,
|
||||
} = args;
|
||||
|
||||
let { data } = args;
|
||||
@@ -142,6 +144,7 @@ async function create(this: Payload, incomingArgs: Arguments): Promise<Document>
|
||||
req,
|
||||
overrideAccess,
|
||||
unflattenLocales: true,
|
||||
skipValidation: autosave,
|
||||
});
|
||||
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -15,6 +15,7 @@ export type Options<T> = {
|
||||
filePath?: string
|
||||
overwriteExistingFiles?: boolean
|
||||
req: PayloadRequest
|
||||
autosave?: boolean
|
||||
}
|
||||
|
||||
export default async function create<T = any>(options: Options<T>): Promise<T> {
|
||||
@@ -31,6 +32,7 @@ export default async function create<T = any>(options: Options<T>): Promise<T> {
|
||||
filePath,
|
||||
overwriteExistingFiles = false,
|
||||
req,
|
||||
autosave,
|
||||
} = options;
|
||||
|
||||
const collection = this.collections[collectionSlug];
|
||||
@@ -43,6 +45,7 @@ export default async function create<T = any>(options: Options<T>): Promise<T> {
|
||||
disableVerificationEmail,
|
||||
showHiddenFields,
|
||||
overwriteExistingFiles,
|
||||
autosave,
|
||||
req: {
|
||||
...req,
|
||||
user,
|
||||
|
||||
@@ -188,6 +188,7 @@ async function update(this: Payload, incomingArgs: Arguments): Promise<Document>
|
||||
overrideAccess,
|
||||
unflattenLocales: true,
|
||||
docWithLocales,
|
||||
skipValidation: autosave,
|
||||
});
|
||||
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -16,6 +16,7 @@ export default async function create(req: PayloadRequest, res: Response, next: N
|
||||
collection: req.collection,
|
||||
data: req.body,
|
||||
depth: req.query.depth,
|
||||
autosave: req.query.autosave === 'true',
|
||||
});
|
||||
|
||||
return res.status(httpStatus.CREATED).json({
|
||||
|
||||
10
src/errors/TimestampsRequired.ts
Normal file
10
src/errors/TimestampsRequired.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { CollectionConfig } from '../collections/config/types';
|
||||
import APIError from './APIError';
|
||||
|
||||
class TimestampsRequired extends APIError {
|
||||
constructor(collection: CollectionConfig) {
|
||||
super(`Timestamps are required in the collection ${collection.slug} because you have opted in to Versions.`);
|
||||
}
|
||||
}
|
||||
|
||||
export default TimestampsRequired;
|
||||
Reference in New Issue
Block a user