feat: initial drafts and versions merge
This commit is contained in:
@@ -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 />
|
||||
|
||||
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;
|
||||
}
|
||||
136
src/admin/components/elements/Autosave/index.tsx
Normal file
136
src/admin/components/elements/Autosave/index.tsx
Normal 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
|
||||
{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
|
||||
publishedDocUpdatedAt: string
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
11
src/admin/components/elements/IDLabel/index.scss
Normal file
11
src/admin/components/elements/IDLabel/index.scss
Normal 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;
|
||||
}
|
||||
15
src/admin/components/elements/IDLabel/index.tsx
Normal file
15
src/admin/components/elements/IDLabel/index.tsx
Normal 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}
|
||||
|
||||
{id}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default IDLabel;
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
1
src/admin/components/elements/PreviewButton/index.scss
Normal file
1
src/admin/components/elements/PreviewButton/index.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
@@ -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) {
|
||||
|
||||
34
src/admin/components/elements/Publish/index.tsx
Normal file
34
src/admin/components/elements/Publish/index.tsx
Normal 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;
|
||||
1
src/admin/components/elements/Publish/types.ts
Normal file
1
src/admin/components/elements/Publish/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Props = {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,4 +20,5 @@ export type Props = {
|
||||
onInputChange?: (val: string) => void
|
||||
onMenuScrollToBottom?: () => void
|
||||
placeholder?: string
|
||||
isSearchable?: boolean
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
</Fragment>
|
||||
)}
|
||||
<span className={baseClass}>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
|
||||
0
src/admin/components/elements/SaveDraft/index.scss
Normal file
0
src/admin/components/elements/SaveDraft/index.scss
Normal file
57
src/admin/components/elements/SaveDraft/index.tsx
Normal file
57
src/admin/components/elements/SaveDraft/index.tsx
Normal 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;
|
||||
11
src/admin/components/elements/Status/index.scss
Normal file
11
src/admin/components/elements/Status/index.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
.status {
|
||||
&__label {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
35
src/admin/components/elements/Status/index.tsx
Normal file
35
src/admin/components/elements/Status/index.tsx
Normal 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;
|
||||
3
src/admin/components/elements/Status/types.ts
Normal file
3
src/admin/components/elements/Status/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type Props = {
|
||||
|
||||
}
|
||||
9
src/admin/components/elements/VersionsCount/index.scss
Normal file
9
src/admin/components/elements/VersionsCount/index.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
.versions-count__button {
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
56
src/admin/components/elements/VersionsCount/index.tsx
Normal file
56
src/admin/components/elements/VersionsCount/index.tsx
Normal 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;
|
||||
8
src/admin/components/elements/VersionsCount/types.ts
Normal file
8
src/admin/components/elements/VersionsCount/types.ts
Normal 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
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -42,6 +42,7 @@ const initialContextState: Context = {
|
||||
initialState: {},
|
||||
fields: {},
|
||||
disabled: false,
|
||||
formRef: null,
|
||||
};
|
||||
|
||||
export default initialContextState;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'> & {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
24
src/admin/components/utilities/DocumentInfo/types.ts
Normal file
24
src/admin/components/utilities/DocumentInfo/types.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
10
src/admin/components/views/NotFound/index.scss
Normal file
10
src/admin/components/views/NotFound/index.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
@import '../../../scss/styles';
|
||||
|
||||
.not-found {
|
||||
&__wrap {
|
||||
@include mid-break {
|
||||
padding-left: $baseline;
|
||||
padding-right: $baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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—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—there is nothing to correspond with your request.</p>
|
||||
<Button
|
||||
el="link"
|
||||
to={`${admin}`}
|
||||
>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
15
src/admin/components/views/Version/Compare/index.scss
Normal file
15
src/admin/components/views/Version/Compare/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
108
src/admin/components/views/Version/Compare/index.tsx
Normal file
108
src/admin/components/views/Version/Compare/index.tsx
Normal 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;
|
||||
29
src/admin/components/views/Version/Compare/types.ts
Normal file
29
src/admin/components/views/Version/Compare/types.ts
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@import '../../../../../scss/styles.scss';
|
||||
|
||||
.field-diff-label {
|
||||
margin-bottom: base(.25);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 '';
|
||||
};
|
||||
@@ -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 '';
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export const diffMethods = {
|
||||
select: 'WORDS_WITH_SPACE',
|
||||
relationship: 'WORDS_WITH_SPACE',
|
||||
upload: 'WORDS_WITH_SPACE',
|
||||
radio: 'WORDS_WITH_SPACE',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
|
||||
.render-field-diffs {
|
||||
&__field {
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
|
||||
&__locale {
|
||||
margin-bottom: base(.5);
|
||||
}
|
||||
}
|
||||
111
src/admin/components/views/Version/RenderFieldsToDiff/index.tsx
Normal file
111
src/admin/components/views/Version/RenderFieldsToDiff/index.tsx
Normal 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;
|
||||
@@ -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[]
|
||||
}
|
||||
20
src/admin/components/views/Version/Restore/index.scss
Normal file
20
src/admin/components/views/Version/Restore/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
84
src/admin/components/views/Version/Restore/index.tsx
Normal file
84
src/admin/components/views/Version/Restore/index.tsx
Normal 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;
|
||||
11
src/admin/components/views/Version/Restore/types.ts
Normal file
11
src/admin/components/views/Version/Restore/types.ts
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
|
||||
.select-version-locales {
|
||||
flex-grow: 1;
|
||||
|
||||
&__label {
|
||||
margin-bottom: base(.25);
|
||||
}
|
||||
}
|
||||
24
src/admin/components/views/Version/SelectLocales/index.tsx
Normal file
24
src/admin/components/views/Version/SelectLocales/index.tsx
Normal 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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { LocaleOption } from '../types';
|
||||
|
||||
export type Props = {
|
||||
onChange: (options: LocaleOption[]) => void
|
||||
value: LocaleOption[]
|
||||
options: LocaleOption[]
|
||||
}
|
||||
222
src/admin/components/views/Version/Version.tsx
Normal file
222
src/admin/components/views/Version/Version.tsx
Normal 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;
|
||||
65
src/admin/components/views/Version/index.scss
Normal file
65
src/admin/components/views/Version/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/admin/components/views/Version/index.tsx
Normal file
13
src/admin/components/views/Version/index.tsx
Normal 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;
|
||||
9
src/admin/components/views/Version/shared.ts
Normal file
9
src/admin/components/views/Version/shared.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const mostRecentVersionOption = {
|
||||
label: 'Most recent draft',
|
||||
value: 'mostRecent',
|
||||
};
|
||||
|
||||
export const publishedVersionOption = {
|
||||
label: 'Most recently published',
|
||||
value: 'published',
|
||||
};
|
||||
19
src/admin/components/views/Version/types.ts
Normal file
19
src/admin/components/views/Version/types.ts
Normal 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
|
||||
}
|
||||
110
src/admin/components/views/Versions/columns.tsx
Normal file
110
src/admin/components/views/Versions/columns.tsx
Normal 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>
|
||||
|
||||
</React.Fragment>
|
||||
)}
|
||||
{row?.version._status === 'published' && (
|
||||
<React.Fragment>
|
||||
<Pill pillStyle="success">
|
||||
Published
|
||||
</Pill>
|
||||
|
||||
</React.Fragment>
|
||||
)}
|
||||
{row?.version._status === 'draft' && (
|
||||
<Pill>
|
||||
Draft
|
||||
</Pill>
|
||||
)}
|
||||
</TextCell>
|
||||
),
|
||||
},
|
||||
},
|
||||
];
|
||||
69
src/admin/components/views/Versions/index.scss
Normal file
69
src/admin/components/views/Versions/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
216
src/admin/components/views/Versions/index.tsx
Normal file
216
src/admin/components/views/Versions/index.tsx
Normal 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;
|
||||
7
src/admin/components/views/Versions/types.ts
Normal file
7
src/admin/components/views/Versions/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||
import { SanitizedGlobalConfig } from '../../../../globals/config/types';
|
||||
|
||||
export type Props = {
|
||||
collection?: SanitizedCollectionConfig
|
||||
global?: SanitizedGlobalConfig
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,4 +17,5 @@ export type Props = IndexProps & {
|
||||
apiURL: string
|
||||
action: string
|
||||
hasSavePermission: boolean
|
||||
autosaveEnabled: boolean
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ const DefaultList: React.FC<Props> = (props) => {
|
||||
{data.totalDocs}
|
||||
</div>
|
||||
<PerPage
|
||||
collection={collection}
|
||||
limits={collection?.admin?.pagination?.limits}
|
||||
limit={limit}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
@@ -77,6 +77,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
||||
useEffect(() => {
|
||||
const params = {
|
||||
depth: 1,
|
||||
draft: 'true',
|
||||
page: undefined,
|
||||
sort: undefined,
|
||||
where: undefined,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Field } from '../config/types';
|
||||
import { Field } from '../../fields/config/types';
|
||||
|
||||
export default [
|
||||
{
|
||||
@@ -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);
|
||||
@@ -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 [
|
||||
{
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user