feat: builds group and iterable diffs

This commit is contained in:
James
2021-12-21 20:18:53 -05:00
parent 242584fd49
commit bddaefdae7
15 changed files with 430 additions and 221 deletions

View File

@@ -2,4 +2,5 @@
.field-diff-label {
margin-bottom: base(.25);
font-weight: 600;
}

View File

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

View File

@@ -1,21 +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';
type Props = {
revision: string
comparison: string
}
const Iterable: React.FC<Props & { field: ArrayField | BlockField }> = ({
revision,
comparison,
permissions,
field,
locale,
locales,
fieldComponents,
}) => {
const revisionRowCount = Array.isArray(revision) ? revision.length : 0;
const comparisonRowCount = Array.isArray(comparison) ? comparison.length : 0;
const maxRows = Math.max(revisionRowCount, comparisonRowCount);
const Iterable: React.FC<Props> = ({ revision, comparison }) => (
<div className={baseClass}>
<div className={`${baseClass}__revision`}>
{revision}
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 revisionRow = revision?.[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 (revisionRow?.blockType === comparisonRow?.blockType) {
const matchedBlock = field.blocks.find((block) => block.slug === revisionRow?.blockType) || { fields: [] };
subFields = [
...subFields,
...matchedBlock.fields,
];
} else {
const matchedRevisionBlock = field.blocks.find((block) => block.slug === revisionRow?.blockType) || { fields: [] };
const matchedComparisonBlock = field.blocks.find((block) => block.slug === comparisonRow?.blockType) || { fields: [] };
subFields = getUniqueListBy<Field>([
...subFields,
...matchedRevisionBlock.fields,
...matchedComparisonBlock.fields,
], 'name');
}
}
return (
<div
className={`${baseClass}__wrap`}
key={i}
>
<RenderFieldsToDiff
locales={locales}
revision={revisionRow}
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>
<div className={`${baseClass}__comparison`}>
{comparison}
</div>
</div>
);
);
};
export default Iterable;

View File

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

View File

@@ -1,19 +1,45 @@
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';
type Props = {
revision: string
comparison: string
}
const Nested: React.FC<Props> = ({ revision, comparison }) => (
const Nested: React.FC<Props & { field: FieldWithSubFields}> = ({
revision,
comparison,
permissions,
field,
locale,
locales,
fieldComponents,
disableGutter = false,
}) => (
<div className={baseClass}>
<div className={`${baseClass}__revision`}>
{revision}
</div>
<div className={`${baseClass}__comparison`}>
{comparison}
{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}
revision={revision}
comparison={comparison}
fieldPermissions={permissions}
fields={field.fields}
fieldComponents={fieldComponents}
/>
</div>
</div>
);

View File

@@ -1,13 +1,17 @@
import React from 'react';
import ReactDiffViewer from 'react-diff-viewer';
import { Props } from '../types';
import Label from '../../Label';
import { Props } from '../types';
import './index.scss';
const baseClass = 'text-diff';
const Text: React.FC<Props> = ({ field, locale, revision, comparison }) => {
let placeholder = '';
if (revision === comparison) placeholder = '[no value]';
return (
<div className={baseClass}>
<Label>
@@ -17,8 +21,8 @@ const Text: React.FC<Props> = ({ field, locale, revision, comparison }) => {
{field.label}
</Label>
<ReactDiffViewer
oldValue={String(revision)}
newValue={String(comparison)}
oldValue={typeof revision !== 'undefined' ? String(revision) : placeholder}
newValue={typeof comparison !== 'undefined' ? String(comparison) : placeholder}
splitView
hideLineNumbers
showDiffOnly={false}

View File

@@ -1,6 +1,9 @@
import Text from './Text';
import Iterable from './Iterable';
import Nested from './Nested';
import Iterable from './Iterable';
// import Point from './Point';
// import Relationship from './Relationship';
// import Date from './Date';
export default {
text: Text,
@@ -8,15 +11,15 @@ export default {
number: Text,
email: Text,
code: Text,
// group: Nested,
// row: Nested,
// array: Iterable,
blocks: Iterable,
checkbox: Text,
date: Text,
radio: Text,
select: Text,
relationship: Text,
upload: Text,
point: Text,
row: Nested,
group: Nested,
array: Iterable,
blocks: Iterable,
// date: Text,
// select: Text,
// relationship: Relationship,
// upload: Relationship,
// point: Point,
};

View File

@@ -1,8 +1,14 @@
import React from 'react';
import { Field } from '../../../../../../fields/config/types';
import { FieldPermissions } from '../../../../../../auth';
export type Props = {
revision: string
comparison: string
fieldComponents: Record<string, React.FC<Props>>
revision: any
comparison: any
field: Field
permissions?: Record<string, FieldPermissions>
locale?: string
locales?: string[]
disableGutter?: boolean
}

View File

@@ -1,24 +1,32 @@
import React from 'react';
import { Props } from './types';
import fieldComponents from './fields';
import { fieldAffectsData } from '../../../../../fields/config/types';
import Label from './Label';
import { fieldAffectsData, fieldHasSubFields } from '../../../../../fields/config/types';
import Nested from './fields/Nested';
import './index.scss';
const baseClass = 'render-field-diffs';
const RenderFieldsToDiff: React.FC<Props> = ({ fields, fieldPermissions, revision, comparison, locales }) => (
const RenderFieldsToDiff: React.FC<Props> = ({
fields,
fieldComponents,
fieldPermissions,
revision,
comparison,
locales,
}) => (
<div className={baseClass}>
{fields.map((field, i) => {
const Component = fieldComponents[field.type];
if (Component) {
if (fieldAffectsData(field)) {
const revisionValue = revision[field.name];
const comparisonValue = comparison?.[field.name];
const hasPermission = fieldPermissions?.[field.name]?.read?.permission;
const subFieldPermissions = fieldPermissions?.[field.name]?.fields;
if (!hasPermission) return null;
if (hasPermission === false) return null;
if (field.localized) {
return (
@@ -37,9 +45,12 @@ const RenderFieldsToDiff: React.FC<Props> = ({ fields, fieldPermissions, revisio
<div className={`${baseClass}__locale-value`}>
<Component
locale={locale}
locales={locales}
field={field}
fieldComponents={fieldComponents}
revision={revisionLocaleValue}
comparison={comparisonLocaleValue}
permissions={subFieldPermissions}
/>
</div>
</div>
@@ -55,13 +66,32 @@ const RenderFieldsToDiff: React.FC<Props> = ({ fields, fieldPermissions, revisio
key={i}
>
<Component
locales={locales}
field={field}
fieldComponents={fieldComponents}
revision={revisionValue}
comparison={comparisonValue}
permissions={subFieldPermissions}
/>
</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}
revision={revision}
comparison={comparison}
permissions={fieldPermissions}
/>
);
}
}
return null;

View File

@@ -1,8 +1,11 @@
import React from 'react';
import { FieldPermissions } from '../../../../../auth';
import { Field } from '../../../../../fields/config/types';
import { Props as FieldProps } from './fields/types';
export type Props = {
fields: Field[]
fieldComponents: Record<string, React.FC<FieldProps>>
fieldPermissions: Record<string, FieldPermissions>
revision: Record<string, any>
comparison: Record<string, any>

View File

@@ -0,0 +1,180 @@
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 CompareRevision 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 } from '../../../../fields/config/types';
import { FieldPermissions } from '../../../../auth';
import './index.scss';
const baseClass = 'view-revision';
const RevisionView: React.FC<Props> = ({ collection, global }) => {
const { serverURL, routes: { admin, api }, admin: { dateFormat }, localization } = useConfig();
const { setStepNav } = useStepNav();
const { params: { id, revisionID } } = useRouteMatch<{ id?: string, revisionID: 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();
let originalDocFetchURL: string;
let revisionFetchURL: 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}`;
revisionFetchURL = `${serverURL}${api}/${slug}/revisions/${revisionID}`;
compareBaseURL = `${serverURL}${api}/${slug}/revisions`;
entityLabel = collection.labels.singular;
parentID = id;
fields = collection.fields;
fieldPermissions = permissions.collections[collection.slug].fields;
}
if (global) {
({ slug } = global);
originalDocFetchURL = `${serverURL}${api}/globals/${slug}`;
revisionFetchURL = `${serverURL}${api}/globals/${slug}/revisions/${revisionID}`;
compareBaseURL = `${serverURL}${api}/globals/${slug}/revisions`;
entityLabel = global.label;
fields = global.fields;
fieldPermissions = permissions.globals[global.slug].fields;
}
const useAsTitle = collection?.admin?.useAsTitle || 'id';
const compareFetchURL = compareValue?.value === 'published' ? originalDocFetchURL : `${compareBaseURL}/${compareValue.value}`;
const [{ data: doc, isLoading }] = usePayloadAPI(revisionFetchURL, { initialParams: { locale: '*', depth: 1 } });
const [{ data: originalDoc }] = usePayloadAPI(originalDocFetchURL, { initialParams: { depth: 1 } });
const [{ data: compareDoc }] = usePayloadAPI(compareFetchURL, { initialParams: { locale: '*', depth: 1 } });
useEffect(() => {
let nav: StepNavItem[] = [];
if (collection) {
nav = [
{
url: `${admin}/collections/${collection.slug}`,
label: collection.labels.plural,
},
{
label: originalDoc ? originalDoc[useAsTitle] : '',
url: `${admin}/collections/${collection.slug}/${id}`,
},
{
label: 'Revisions',
url: `${admin}/collections/${collection.slug}/${id}/revisions`,
},
{
label: doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '',
},
];
}
if (global) {
nav = [
{
url: `${admin}/globals/${global.slug}`,
label: global.label,
},
{
label: 'Revisions',
url: `${admin}/globals/${global.slug}/revisions`,
},
{
label: doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '',
},
];
}
setStepNav(nav);
}, [setStepNav, collection, global, useAsTitle, dateFormat, doc, originalDoc, admin, id]);
let metaTitle: string;
let metaDesc: string;
const formattedCreatedAt = doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '';
if (collection) {
metaTitle = `Revision - ${formattedCreatedAt} - ${doc[useAsTitle]} - ${entityLabel}`;
metaDesc = `Viewing revision for the ${entityLabel} ${doc[useAsTitle]}`;
}
if (global) {
metaTitle = `Revision - ${formattedCreatedAt} - ${entityLabel}`;
metaDesc = `Viewing revision for the global ${entityLabel}`;
}
return (
<div className={baseClass}>
<Meta
title={metaTitle}
description={metaDesc}
/>
<Eyebrow />
<div className={`${baseClass}__wrap`}>
<div className={`${baseClass}__intro`}>Revision created on:</div>
<header className={`${baseClass}__header`}>
<h2>
{formattedCreatedAt}
</h2>
<Restore
className={`${baseClass}__restore`}
collection={collection}
global={global}
/>
</header>
<div className={`${baseClass}__controls`}>
{localization && (
<SelectLocales
onChange={setLocales}
options={localeOptions}
value={locales}
/>
)}
<CompareRevision
baseURL={compareBaseURL}
parentID={parentID}
value={compareValue}
onChange={setCompareValue}
/>
</div>
{isLoading && (
<Loading />
)}
{doc?.revision && (
<RenderFieldsToDiff
locales={locales.map((locale) => locale.value)}
fields={fields}
fieldComponents={fieldComponents}
fieldPermissions={fieldPermissions}
revision={doc?.revision}
comparison={compareValue?.value === 'published' ? compareDoc : compareDoc?.revision}
/>
)}
</div>
</div>
);
};
export default RevisionView;

View File

@@ -40,20 +40,18 @@
@include mid-break {
&__wrap {
padding: $baseline 0;
padding: $baseline;
margin-right: 0;
}
&__intro,
&__header {
padding-left: $baseline;
padding-right: $baseline;
display: block;
}
&__controls {
display: block;
margin: 0 base(.5);
margin: 0 base(-.5) base(2);
> * {
margin-bottom: base(.5);

View File

@@ -1,177 +1,13 @@
import { useAuth, useConfig } from '@payloadcms/config-provider';
import React, { useEffect, useState } from 'react';
import { useRouteMatch } from 'react-router-dom';
import format from 'date-fns/format';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import Eyebrow from '../../elements/Eyebrow';
import React, { Suspense, lazy } from 'react';
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 CompareRevision from './Compare';
import { publishedVersionOption } from './shared';
import Restore from './Restore';
import SelectLocales from './SelectLocales';
import RenderFieldsToDiff from './RenderFieldsToDiff';
import { Props } from './types';
import './index.scss';
import { Field } from '../../../../fields/config/types';
import { FieldPermissions } from '../../../../auth';
const RevisionView = lazy(() => import('./Revision'));
const baseClass = 'view-revision';
const Revision: React.FC<Props> = (props) => (
<Suspense fallback={<Loading />}>
<RevisionView {...props} />
</Suspense>
);
const ViewRevision: React.FC<Props> = ({ collection, global }) => {
const { serverURL, routes: { admin, api }, admin: { dateFormat }, localization } = useConfig();
const { setStepNav } = useStepNav();
const { params: { id, revisionID } } = useRouteMatch<{ id?: string, revisionID: 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();
let originalDocFetchURL: string;
let revisionFetchURL: 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}`;
revisionFetchURL = `${serverURL}${api}/${slug}/revisions/${revisionID}`;
compareBaseURL = `${serverURL}${api}/${slug}/revisions`;
entityLabel = collection.labels.singular;
parentID = id;
fields = collection.fields;
fieldPermissions = permissions.collections[collection.slug].fields;
}
if (global) {
({ slug } = global);
originalDocFetchURL = `${serverURL}${api}/globals/${slug}`;
revisionFetchURL = `${serverURL}${api}/globals/${slug}/revisions/${revisionID}`;
compareBaseURL = `${serverURL}${api}/globals/${slug}/revisions`;
entityLabel = global.label;
fields = global.fields;
fieldPermissions = permissions.globals[global.slug].fields;
}
const useAsTitle = collection?.admin?.useAsTitle || 'id';
const compareFetchURL = compareValue?.value === 'published' ? originalDocFetchURL : `${compareBaseURL}/${compareValue.value}`;
const [{ data: doc, isLoading }] = usePayloadAPI(revisionFetchURL, { initialParams: { locale: '*' } });
const [{ data: originalDoc }] = usePayloadAPI(originalDocFetchURL);
const [{ data: compareDoc }] = usePayloadAPI(compareFetchURL, { initialParams: { locale: '*' } });
useEffect(() => {
let nav: StepNavItem[] = [];
if (collection) {
nav = [
{
url: `${admin}/collections/${collection.slug}`,
label: collection.labels.plural,
},
{
label: originalDoc ? originalDoc[useAsTitle] : '',
url: `${admin}/collections/${collection.slug}/${id}`,
},
{
label: 'Revisions',
url: `${admin}/collections/${collection.slug}/${id}/revisions`,
},
{
label: doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '',
},
];
}
if (global) {
nav = [
{
url: `${admin}/globals/${global.slug}`,
label: global.label,
},
{
label: 'Revisions',
url: `${admin}/globals/${global.slug}/revisions`,
},
{
label: doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '',
},
];
}
setStepNav(nav);
}, [setStepNav, collection, global, useAsTitle, dateFormat, doc, originalDoc, admin, id]);
let metaTitle: string;
let metaDesc: string;
const formattedCreatedAt = doc?.createdAt ? format(new Date(doc.createdAt), dateFormat) : '';
if (collection) {
metaTitle = `Revision - ${formattedCreatedAt} - ${doc[useAsTitle]} - ${entityLabel}`;
metaDesc = `Viewing revision for the ${entityLabel} ${doc[useAsTitle]}`;
}
if (global) {
metaTitle = `Revision - ${formattedCreatedAt} - ${entityLabel}`;
metaDesc = `Viewing revision for the global ${entityLabel}`;
}
return (
<div className={baseClass}>
<Meta
title={metaTitle}
description={metaDesc}
/>
<Eyebrow />
<div className={`${baseClass}__wrap`}>
<div className={`${baseClass}__intro`}>Revision created on:</div>
<header className={`${baseClass}__header`}>
<h2>
{formattedCreatedAt}
</h2>
<Restore
className={`${baseClass}__restore`}
collection={collection}
global={global}
/>
</header>
<div className={`${baseClass}__controls`}>
{localization && (
<SelectLocales
onChange={setLocales}
options={localeOptions}
value={locales}
/>
)}
<CompareRevision
baseURL={compareBaseURL}
parentID={parentID}
value={compareValue}
onChange={setCompareValue}
/>
</div>
{isLoading && (
<Loading />
)}
{doc?.revision && (
<RenderFieldsToDiff
locales={locales.map((locale) => locale.value)}
fields={fields}
fieldPermissions={fieldPermissions}
revision={doc?.revision}
comparison={compareValue?.value === 'published' ? compareDoc : compareDoc?.revision}
/>
)}
</div>
</div>
);
};
export default ViewRevision;
export default Revision;

View File

@@ -0,0 +1,3 @@
export default function getUniqueListBy<T>(arr: T[], key: string): T[] {
return [...new Map(arr.map((item) => [item[key], item])).values()];
}