feat: custom collection card template (#433)

This commit is contained in:
Daniel Lautzenheiser
2023-01-25 15:11:59 -05:00
committed by GitHub
parent c6994ea45b
commit 1641630cfd
22 changed files with 1440 additions and 496 deletions

View File

@ -10,8 +10,16 @@ import { Link } from 'react-router-dom';
import { getAsset as getAssetAction } from '@staticcms/core/actions/media';
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '@staticcms/core/constants/collectionViews';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util';
import { getPreviewCard } from '@staticcms/core/lib/registry';
import {
selectEntryCollectionTitle,
selectFields,
selectTemplateName,
} from '@staticcms/core/lib/util/collection.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectIsLoadingAsset } from '@staticcms/core/reducers/selectors/medias';
import { useAppSelector } from '@staticcms/core/store/hooks';
import useWidgetsFor from '../../common/widget/useWidgetsFor';
import type { CollectionViewStyle } from '@staticcms/core/constants/collectionViews';
import type { Collection, Entry, Field } from '@staticcms/core/interface';
@ -29,8 +37,45 @@ const EntryCard = ({
}: NestedCollectionProps) => {
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
const fields = selectFields(collection, entry.slug);
const imageUrl = useMediaAsset(image, collection, imageField, entry);
const config = useAppSelector(selectConfig);
const { widgetFor, widgetsFor } = useWidgetsFor(config, collection, fields, entry);
const PreviewCardComponent = useMemo(
() => getPreviewCard(selectTemplateName(collection, entry.slug)) ?? null,
[collection, entry.slug],
);
if (PreviewCardComponent) {
return (
<Card>
<CardActionArea
component={Link}
to={path}
sx={{
height: '100%',
position: 'relative',
display: 'flex',
width: '100%',
justifyContent: 'start',
}}
>
<PreviewCardComponent
collection={collection}
fields={fields}
entry={entry}
viewStyle={viewStyle === VIEW_STYLE_LIST ? 'list' : 'grid'}
widgetFor={widgetFor}
widgetsFor={widgetsFor}
/>
</CardActionArea>
</Card>
);
}
return (
<Card>
<CardActionArea component={Link} to={path}>

View File

@ -1,7 +1,7 @@
import { styled } from '@mui/material/styles';
import React from 'react';
import type { TemplatePreviewProps } from '@staticcms/core/interface';
import type { ObjectValue, TemplatePreviewProps } from '@staticcms/core/interface';
const PreviewContainer = styled('div')`
overflow-y: auto;
@ -10,7 +10,7 @@ const PreviewContainer = styled('div')`
font-family: Roboto, 'Helvetica Neue', HelveticaNeue, Helvetica, Arial, sans-serif;
`;
const Preview = ({ collection, fields, widgetFor }: TemplatePreviewProps) => {
const Preview = ({ collection, fields, widgetFor }: TemplatePreviewProps<ObjectValue>) => {
if (!collection || !fields) {
return null;
}

View File

@ -1,5 +1,5 @@
import { styled } from '@mui/material/styles';
import React, { Fragment, isValidElement, useCallback, useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import ReactDOM from 'react-dom';
import Frame from 'react-frame-component';
import { translate } from 'react-polyglot';
@ -9,34 +9,23 @@ import { ScrollSyncPane } from 'react-scroll-sync';
import { getAsset as getAssetAction } from '@staticcms/core/actions/media';
import { ErrorBoundary } from '@staticcms/core/components/UI';
import { lengths } from '@staticcms/core/components/UI/styles';
import { getPreviewStyles, getPreviewTemplate, resolveWidget } from '@staticcms/core/lib/registry';
import { selectTemplateName, useInferredFields } from '@staticcms/core/lib/util/collection.util';
import { selectField } from '@staticcms/core/lib/util/field.util';
import { getPreviewStyles, getPreviewTemplate } from '@staticcms/core/lib/registry';
import { selectTemplateName } from '@staticcms/core/lib/util/collection.util';
import { selectIsLoadingAsset } from '@staticcms/core/reducers/selectors/medias';
import { getTypedFieldForValue } from '@staticcms/list/typedListHelpers';
import useWidgetsFor from '../../common/widget/useWidgetsFor';
import EditorPreview from './EditorPreview';
import EditorPreviewContent from './EditorPreviewContent';
import PreviewFrameContent from './PreviewFrameContent';
import PreviewHOC from './PreviewHOC';
import type { InferredField } from '@staticcms/core/constants/fieldInference';
import type {
Collection,
Config,
Entry,
EntryData,
Field,
GetAssetFunction,
ListField,
ObjectValue,
RenderedField,
TemplatePreviewProps,
TranslatedProps,
ValueOrNestedValue,
WidgetPreviewComponent,
} from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ComponentType, ReactFragment, ReactNode } from 'react';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
const PreviewPaneFrame = styled(Frame)`
@ -81,265 +70,10 @@ const StyledPreviewContent = styled('div')`
overflow: hidden;
`;
/**
* Returns the widget component for a named field, and makes recursive calls
* to retrieve components for nested and deeply nested fields, which occur in
* object and list type fields. Used internally to retrieve widgets, and also
* exposed for use in custom preview templates.
*/
function getWidgetFor(
config: Config,
collection: Collection,
name: string,
fields: Field[],
entry: Entry,
inferredFields: Record<string, InferredField>,
getAsset: GetAssetFunction,
widgetFields: Field[] = fields,
values: EntryData = entry.data,
idx: number | null = null,
): ReactNode {
// We retrieve the field by name so that this function can also be used in
// custom preview templates, where the field object can't be passed in.
const field = widgetFields && widgetFields.find(f => f.name === name);
if (!field) {
return null;
}
const value = values?.[field.name];
let fieldWithWidgets = field as RenderedField;
if ('fields' in field && field.fields) {
fieldWithWidgets = {
...fieldWithWidgets,
renderedFields: getNestedWidgets(
config,
collection,
fields,
entry,
inferredFields,
getAsset,
field.fields,
value as EntryData | EntryData[],
),
};
} else if ('types' in field && field.types) {
fieldWithWidgets = {
...fieldWithWidgets,
renderedFields: getTypedNestedWidgets(
config,
collection,
field,
entry,
inferredFields,
getAsset,
value as EntryData[],
),
};
}
const labelledWidgets = ['string', 'text', 'number'];
const inferredField = Object.entries(inferredFields)
.filter(([key]) => {
const fieldToMatch = selectField(collection, key);
return fieldToMatch === fieldWithWidgets;
})
.map(([, value]) => value)[0];
let renderedValue: ValueOrNestedValue | ReactNode = value;
if (inferredField) {
renderedValue = inferredField.defaultPreview(String(value));
} else if (
value &&
fieldWithWidgets.widget &&
labelledWidgets.indexOf(fieldWithWidgets.widget) !== -1 &&
value.toString().length < 50
) {
renderedValue = (
<div key={field.name}>
<>
<strong>{field.label ?? field.name}:</strong> {value}
</>
</div>
);
}
return renderedValue
? getWidget(config, fieldWithWidgets, collection, renderedValue, entry, getAsset, idx)
: null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isJsxElement(value: any): value is JSX.Element {
return isValidElement(value);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isReactFragment(value: any): value is ReactFragment {
if (value.type) {
return value.type === Fragment;
}
return value === Fragment;
}
function getWidget(
config: Config,
field: RenderedField<Field>,
collection: Collection,
value: ValueOrNestedValue | ReactNode,
entry: Entry,
getAsset: GetAssetFunction,
idx: number | null = null,
) {
if (!field.widget) {
return null;
}
const widget = resolveWidget(field.widget);
const key = idx ? field.name + '_' + idx : field.name;
if (field.widget === 'hidden' || !widget.preview) {
return null;
}
/**
* Use an HOC to provide conditional updates for all previews.
*/
return !widget.preview ? null : (
<PreviewHOC
previewComponent={widget.preview as WidgetPreviewComponent}
key={key}
field={field as RenderedField}
getAsset={getAsset}
config={config}
collection={collection}
value={
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
field.name in value &&
!isJsxElement(value) &&
!isReactFragment(value)
? (value as Record<string, unknown>)[field.name]
: value
}
entry={entry}
/>
);
}
/**
* Use getWidgetFor as a mapping function for recursive widget retrieval
*/
function widgetsForNestedFields(
config: Config,
collection: Collection,
fields: Field[],
entry: Entry,
inferredFields: Record<string, InferredField>,
getAsset: GetAssetFunction,
widgetFields: Field[],
values: EntryData,
idx: number | null = null,
) {
return widgetFields
.map(field =>
getWidgetFor(
config,
collection,
field.name,
fields,
entry,
inferredFields,
getAsset,
widgetFields,
values,
idx,
),
)
.filter(widget => Boolean(widget)) as JSX.Element[];
}
/**
* Retrieves widgets for nested fields (children of object/list fields)
*/
function getTypedNestedWidgets(
config: Config,
collection: Collection,
field: ListField,
entry: Entry,
inferredFields: Record<string, InferredField>,
getAsset: GetAssetFunction,
values: EntryData[],
) {
return values
?.flatMap((value, index) => {
const itemType = getTypedFieldForValue(field, value ?? {}, index);
if (!itemType) {
return null;
}
return widgetsForNestedFields(
config,
collection,
itemType.fields,
entry,
inferredFields,
getAsset,
itemType.fields,
value,
index,
);
})
.filter(Boolean);
}
/**
* Retrieves widgets for nested fields (children of object/list fields)
*/
function getNestedWidgets(
config: Config,
collection: Collection,
fields: Field[],
entry: Entry,
inferredFields: Record<string, InferredField>,
getAsset: GetAssetFunction,
widgetFields: Field[],
values: EntryData | EntryData[],
) {
// Fields nested within a list field will be paired with a List of value Maps.
if (Array.isArray(values)) {
return values.flatMap(value =>
widgetsForNestedFields(
config,
collection,
fields,
entry,
inferredFields,
getAsset,
widgetFields,
value,
),
);
}
// Fields nested within an object field will be paired with a single Record of values.
return widgetsForNestedFields(
config,
collection,
fields,
entry,
inferredFields,
getAsset,
widgetFields,
values,
);
}
const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
const { entry, collection, config, fields, previewInFrame, getAsset, t } = props;
const inferredFields = useInferredFields(collection);
const { widgetFor, widgetsFor } = useWidgetsFor(config.config, collection, fields, entry);
const handleGetAsset = useCallback(
(path: string, field?: Field) => {
@ -349,118 +83,6 @@ const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
[collection],
);
const widgetFor = useCallback(
(name: string) => {
if (!config.config) {
return null;
}
return getWidgetFor(
config.config,
collection,
name,
fields,
entry,
inferredFields,
handleGetAsset,
);
},
[collection, config, entry, fields, handleGetAsset, inferredFields],
);
/**
* This function exists entirely to expose nested widgets for object and list
* fields to custom preview templates.
*/
const widgetsFor = useCallback(
(name: string) => {
const cmsConfig = config.config;
if (!cmsConfig) {
return {
data: null,
widgets: {},
};
}
const field = fields.find(f => f.name === name);
if (!field || !('fields' in field)) {
return {
data: null,
widgets: {},
};
}
const value = entry.data?.[field.name];
const nestedFields = field && 'fields' in field ? field.fields ?? [] : [];
if (field.widget === 'list' || Array.isArray(value)) {
let finalValue: ObjectValue[];
if (!value || typeof value !== 'object') {
finalValue = [];
} else if (!Array.isArray(value)) {
finalValue = [value];
} else {
finalValue = value as ObjectValue[];
}
return finalValue
.filter((val: unknown) => typeof val === 'object')
.map((val: ObjectValue) => {
const widgets = nestedFields.reduce((acc, field, index) => {
acc[field.name] = (
<div key={index}>
{getWidgetFor(
cmsConfig,
collection,
field.name,
fields,
entry,
inferredFields,
handleGetAsset,
nestedFields,
val,
index,
)}
</div>
);
return acc;
}, {} as Record<string, ReactNode>);
return { data: val, widgets };
});
}
if (typeof value !== 'object') {
return {
data: {},
widgets: {},
};
}
return {
data: value,
widgets: nestedFields.reduce((acc, field, index) => {
acc[field.name] = (
<div key={index}>
{getWidgetFor(
cmsConfig,
collection,
field.name,
fields,
entry,
inferredFields,
handleGetAsset,
nestedFields,
value,
index,
)}
</div>
);
return acc;
}, {} as Record<string, ReactNode>),
};
},
[collection, config.config, entry, fields, handleGetAsset, inferredFields],
);
const previewStyles = useMemo(
() => [
...getPreviewStyles().map((style, i) => {

View File

@ -1,18 +1,13 @@
import React from 'react';
import { styled } from '@mui/material/styles';
import type { ReactNode } from 'react';
const StyledWidgetPreviewContainer = styled('div')`
margin: 15px 2px;
`;
interface WidgetPreviewContainerProps {
children?: ReactNode;
}
const WidgetPreviewContainer = ({ children }: WidgetPreviewContainerProps) => {
return <StyledWidgetPreviewContainer>{children}</StyledWidgetPreviewContainer>;
return <div>{children}</div>;
};
export default WidgetPreviewContainer;

View File

@ -0,0 +1,147 @@
import React, { useCallback } from 'react';
import { getAsset } from '@staticcms/core/actions/media';
import { useInferredFields } from '@staticcms/core/lib/util/collection.util';
import { useAppDispatch } from '@staticcms/core/store/hooks';
import getWidgetFor from './widgetFor';
import type {
Collection,
Config,
Entry,
EntryData,
Field,
ObjectValue,
WidgetFor,
WidgetsFor,
} from '@staticcms/core/interface';
import type { ReactNode } from 'react';
export default function useWidgetsFor(
config: Config | undefined,
collection: Collection,
fields: Field[],
entry: Entry,
): {
widgetFor: WidgetFor<EntryData>;
widgetsFor: WidgetsFor<EntryData>;
} {
const inferredFields = useInferredFields(collection);
const dispatch = useAppDispatch();
const handleGetAsset = useCallback(
(path: string, field?: Field) => {
return dispatch(getAsset(collection, entry, path, field));
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[collection],
);
const widgetFor = useCallback(
(name: string): ReturnType<WidgetFor<EntryData>> => {
if (!config) {
return null;
}
return getWidgetFor(config, collection, name, fields, entry, inferredFields, handleGetAsset);
},
[collection, config, entry, fields, handleGetAsset, inferredFields],
);
/**
* This function exists entirely to expose nested widgets for object and list
* fields to custom preview templates.
*/
const widgetsFor = useCallback(
(name: string): ReturnType<WidgetsFor<EntryData>> => {
if (!config) {
return {
data: null,
widgets: {},
};
}
const field = fields.find(f => f.name === name);
if (!field || !('fields' in field)) {
return {
data: null,
widgets: {},
};
}
const value = entry.data?.[field.name];
const nestedFields = field && 'fields' in field ? field.fields ?? [] : [];
if (field.widget === 'list' || Array.isArray(value)) {
let finalValue: ObjectValue[];
if (!value || typeof value !== 'object') {
finalValue = [];
} else if (!Array.isArray(value)) {
finalValue = [value];
} else {
finalValue = value as ObjectValue[];
}
return finalValue
.filter((val: unknown) => typeof val === 'object')
.map((val: ObjectValue) => {
const widgets = nestedFields.reduce((acc, field, index) => {
acc[field.name] = (
<div key={index}>
{getWidgetFor(
config,
collection,
field.name,
fields,
entry,
inferredFields,
handleGetAsset,
nestedFields,
val,
index,
)}
</div>
);
return acc;
}, {} as Record<string, ReactNode>);
return { data: val, widgets };
});
}
if (typeof value !== 'object') {
return {
data: {},
widgets: {},
};
}
return {
data: value,
widgets: nestedFields.reduce((acc, field, index) => {
acc[field.name] = (
<div key={index}>
{getWidgetFor(
config,
collection,
field.name,
fields,
entry,
inferredFields,
handleGetAsset,
nestedFields,
value,
index,
)}
</div>
);
return acc;
}, {} as Record<string, ReactNode>),
};
},
[collection, config, entry, fields, handleGetAsset, inferredFields],
);
return {
widgetFor,
widgetsFor,
};
}

View File

@ -0,0 +1,277 @@
import React, { Fragment, isValidElement } from 'react';
import { resolveWidget } from '@staticcms/core/lib/registry';
import { selectField } from '@staticcms/core/lib/util/field.util';
import { getTypedFieldForValue } from '@staticcms/list/typedListHelpers';
import PreviewHOC from './PreviewHOC';
import type {
Collection,
Config,
Entry,
EntryData,
Field,
GetAssetFunction,
InferredField,
ListField,
RenderedField,
ValueOrNestedValue,
WidgetPreviewComponent,
} from '@staticcms/core/interface';
import type { ReactFragment, ReactNode } from 'react';
/**
* Returns the widget component for a named field, and makes recursive calls
* to retrieve components for nested and deeply nested fields, which occur in
* object and list type fields. Used internally to retrieve widgets, and also
* exposed for use in custom preview templates.
*/
export default function getWidgetFor(
config: Config,
collection: Collection,
name: string,
fields: Field[],
entry: Entry,
inferredFields: Record<string, InferredField>,
getAsset: GetAssetFunction,
widgetFields: Field[] = fields,
values: EntryData = entry.data,
idx: number | null = null,
): ReactNode {
// We retrieve the field by name so that this function can also be used in
// custom preview templates, where the field object can't be passed in.
const field = widgetFields && widgetFields.find(f => f.name === name);
if (!field) {
return null;
}
const value = values?.[field.name];
let fieldWithWidgets = field as RenderedField;
if ('fields' in field && field.fields) {
fieldWithWidgets = {
...fieldWithWidgets,
renderedFields: getNestedWidgets(
config,
collection,
fields,
entry,
inferredFields,
getAsset,
field.fields,
value as EntryData | EntryData[],
),
};
} else if ('types' in field && field.types) {
fieldWithWidgets = {
...fieldWithWidgets,
renderedFields: getTypedNestedWidgets(
config,
collection,
field,
entry,
inferredFields,
getAsset,
value as EntryData[],
),
};
}
const labelledWidgets = ['string', 'text', 'number'];
const inferredField = Object.entries(inferredFields)
.filter(([key]) => {
const fieldToMatch = selectField(collection, key);
return fieldToMatch === fieldWithWidgets;
})
.map(([, value]) => value)[0];
let renderedValue: ValueOrNestedValue | ReactNode = value;
if (inferredField) {
renderedValue = inferredField.defaultPreview(String(value));
} else if (
value &&
fieldWithWidgets.widget &&
labelledWidgets.indexOf(fieldWithWidgets.widget) !== -1 &&
value.toString().length < 50
) {
renderedValue = (
<div key={field.name}>
<>
<strong>{field.label ?? field.name}:</strong> {value}
</>
</div>
);
}
return renderedValue
? getWidget(config, fieldWithWidgets, collection, renderedValue, entry, getAsset, idx)
: null;
}
/**
* Retrieves widgets for nested fields (children of object/list fields)
*/
function getNestedWidgets(
config: Config,
collection: Collection,
fields: Field[],
entry: Entry,
inferredFields: Record<string, InferredField>,
getAsset: GetAssetFunction,
widgetFields: Field[],
values: EntryData | EntryData[],
) {
// Fields nested within a list field will be paired with a List of value Maps.
if (Array.isArray(values)) {
return values.flatMap(value =>
widgetsForNestedFields(
config,
collection,
fields,
entry,
inferredFields,
getAsset,
widgetFields,
value,
),
);
}
// Fields nested within an object field will be paired with a single Record of values.
return widgetsForNestedFields(
config,
collection,
fields,
entry,
inferredFields,
getAsset,
widgetFields,
values,
);
}
/**
* Retrieves widgets for nested fields (children of object/list fields)
*/
function getTypedNestedWidgets(
config: Config,
collection: Collection,
field: ListField,
entry: Entry,
inferredFields: Record<string, InferredField>,
getAsset: GetAssetFunction,
values: EntryData[],
) {
return values
?.flatMap((value, index) => {
const itemType = getTypedFieldForValue(field, value ?? {}, index);
if (!itemType) {
return null;
}
return widgetsForNestedFields(
config,
collection,
itemType.fields,
entry,
inferredFields,
getAsset,
itemType.fields,
value,
index,
);
})
.filter(Boolean);
}
/**
* Use getWidgetFor as a mapping function for recursive widget retrieval
*/
function widgetsForNestedFields(
config: Config,
collection: Collection,
fields: Field[],
entry: Entry,
inferredFields: Record<string, InferredField>,
getAsset: GetAssetFunction,
widgetFields: Field[],
values: EntryData,
idx: number | null = null,
) {
return widgetFields
.map(field =>
getWidgetFor(
config,
collection,
field.name,
fields,
entry,
inferredFields,
getAsset,
widgetFields,
values,
idx,
),
)
.filter(widget => Boolean(widget)) as JSX.Element[];
}
function getWidget(
config: Config,
field: RenderedField<Field>,
collection: Collection,
value: ValueOrNestedValue | ReactNode,
entry: Entry,
getAsset: GetAssetFunction,
idx: number | null = null,
) {
if (!field.widget) {
return null;
}
const widget = resolveWidget(field.widget);
const key = idx ? field.name + '_' + idx : field.name;
if (field.widget === 'hidden' || !widget.preview) {
return null;
}
/**
* Use an HOC to provide conditional updates for all previews.
*/
return !widget.preview ? null : (
<PreviewHOC
previewComponent={widget.preview as WidgetPreviewComponent}
key={key}
field={field as RenderedField}
getAsset={getAsset}
config={config}
collection={collection}
value={
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
field.name in value &&
!isJsxElement(value) &&
!isReactFragment(value)
? (value as Record<string, unknown>)[field.name]
: value
}
entry={entry}
/>
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isJsxElement(value: any): value is JSX.Element {
return isValidElement(value);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isReactFragment(value: any): value is ReactFragment {
if (value.type) {
return value.type === Fragment;
}
return value === Fragment;
}