feat: nested collections (#680)

This commit is contained in:
Daniel Lautzenheiser
2023-04-04 15:12:32 -04:00
committed by GitHub
parent 22a1b8d9c0
commit d0ecae310c
54 changed files with 2671 additions and 295 deletions

View File

@ -1,11 +1,17 @@
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import useEntries from '@staticcms/core/lib/hooks/useEntries';
import useIcon from '@staticcms/core/lib/hooks/useIcon';
import {
selectEntryCollectionTitle,
selectFolderEntryExtension,
} from '@staticcms/core/lib/util/collection.util';
import { addFileTemplateFields } from '@staticcms/core/lib/widgets/stringTemplate';
import Button from '../common/button/Button';
import type { Collection, TranslatedProps } from '@staticcms/core/interface';
import type { Collection, Entry, TranslatedProps } from '@staticcms/core/interface';
interface CollectionHeaderProps {
collection: Collection;
@ -30,17 +36,54 @@ const CollectionHeader = ({
const icon = useIcon(collection.icon);
const params = useParams();
const filterTerm = useMemo(() => params['*'], [params]);
const entries = useEntries(collection);
const pluralLabel = useMemo(() => {
if ('nested' in collection && collection.nested?.path && filterTerm) {
const entriesByPath = entries.reduce((acc, entry) => {
acc[entry.path] = entry;
return acc;
}, {} as Record<string, Entry>);
const path = filterTerm.split('/');
if (path.length > 0) {
const extension = selectFolderEntryExtension(collection);
const finalPathPart = path[path.length - 1];
let entry =
entriesByPath[
`${collection.folder}/${finalPathPart}/${collection.nested.path.index_file}.${extension}`
];
if (entry) {
entry = {
...entry,
data: addFileTemplateFields(entry.path, entry.data as Record<string, string>),
};
const title = selectEntryCollectionTitle(collection, entry);
return title;
}
}
}
return collectionLabel;
}, [collection, collectionLabel, entries, filterTerm]);
return (
<>
<div className="flex flex-grow gap-4">
<h2 className="text-xl font-semibold flex items-center text-gray-800 dark:text-gray-300">
<div className="mr-2 flex">{icon}</div>
{collectionLabel}
{pluralLabel}
</h2>
{newEntryUrl ? (
<Button onClick={onNewClick}>
{t('collection.collectionTop.newButton', {
collectionLabel: collectionLabelSingular || collectionLabel,
collectionLabel: collectionLabelSingular || pluralLabel,
})}
</Button>
) : null}

View File

@ -0,0 +1,40 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import useBreadcrumbs from '@staticcms/core/lib/hooks/useBreadcrumbs';
import MainView from '../MainView';
import CollectionView from './CollectionView';
import type { Collection } from '@staticcms/core/interface';
import type { FC } from 'react';
interface CollectionViewProps {
collection: Collection;
isSearchResults?: boolean;
isSingleSearchResult?: boolean;
}
const CollectionPage: FC<CollectionViewProps> = ({
collection,
isSearchResults,
isSingleSearchResult,
}) => {
const { name, searchTerm, ...params } = useParams();
const filterTerm = params['*'];
const breadcrumbs = useBreadcrumbs(collection, filterTerm);
return (
<MainView breadcrumbs={breadcrumbs} showQuickCreate showLeftNav>
<CollectionView
name={name}
searchTerm={searchTerm}
filterTerm={filterTerm}
isSearchResults={isSearchResults}
isSingleSearchResult={isSingleSearchResult}
/>
</MainView>
);
};
export default CollectionPage;

View File

@ -7,8 +7,9 @@ import {
} from '@staticcms/core/reducers/selectors/collections';
import { useAppSelector } from '@staticcms/core/store/hooks';
import { getDefaultPath } from '../../lib/util/collection.util';
import MainView from '../MainView';
import Collection from './Collection';
import CollectionPage from './CollectionPage';
import type { Collection } from '@staticcms/core/interface';
interface CollectionRouteProps {
isSearchResults?: boolean;
@ -16,8 +17,7 @@ interface CollectionRouteProps {
}
const CollectionRoute = ({ isSearchResults, isSingleSearchResult }: CollectionRouteProps) => {
const { name, searchTerm, ...params } = useParams();
const filterTerm = params['*'];
const { name, searchTerm } = useParams();
const collectionSelector = useMemo(() => selectCollection(name), [name]);
const collection = useAppSelector(collectionSelector);
@ -34,15 +34,11 @@ const CollectionRoute = ({ isSearchResults, isSingleSearchResult }: CollectionRo
}
return (
<MainView breadcrumbs={[{ name: collection?.label }]} showQuickCreate showLeftNav>
<Collection
name={name}
searchTerm={searchTerm}
filterTerm={filterTerm}
isSearchResults={isSearchResults}
isSingleSearchResult={isSingleSearchResult}
/>
</MainView>
<CollectionPage
collection={collection as unknown as Collection}
isSearchResults={isSearchResults}
isSingleSearchResult={isSingleSearchResult}
/>
);
};

View File

@ -42,7 +42,6 @@ const CollectionView = ({
collection,
collections,
collectionName,
// TODO isSearchEnabled,
isSearchResults,
isSingleSearchResult,
searchTerm,
@ -72,13 +71,8 @@ const CollectionView = ({
return undefined;
}
let url = 'fields' in collection && collection.create ? getNewEntryUrl(collectionName) : '';
if (url && filterTerm) {
url = `${url}?path=${filterTerm}`;
}
return url;
}, [collection, collectionName, filterTerm]);
return 'fields' in collection && collection.create ? getNewEntryUrl(collectionName) : '';
}, [collection, collectionName]);
const searchResultKey = useMemo(
() => `collection.collectionTop.searchResults${isSingleSearchResult ? 'InCollection' : ''}`,
@ -248,7 +242,6 @@ interface CollectionViewOwnProps {
function mapStateToProps(state: RootState, ownProps: TranslatedProps<CollectionViewOwnProps>) {
const { collections } = state;
const isSearchEnabled = state.config.config && state.config.config.search != false;
const {
isSearchResults,
isSingleSearchResult,
@ -275,7 +268,6 @@ function mapStateToProps(state: RootState, ownProps: TranslatedProps<CollectionV
collection,
collections,
collectionName: name,
isSearchEnabled,
sort,
sortableFields,
viewFilters,

View File

@ -1,9 +1,12 @@
import { Article as ArticleIcon } from '@styled-icons/material/Article';
import { ChevronRight as ChevronRightIcon } from '@styled-icons/material/ChevronRight';
import sortBy from 'lodash/sortBy';
import { dirname, sep } from 'path';
import React, { Fragment, useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import useEntries from '@staticcms/core/lib/hooks/useEntries';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util';
import { stringTemplate } from '@staticcms/core/lib/widgets';
import NavLink from '../navbar/NavLink';
@ -61,27 +64,42 @@ const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps)
return (
<Fragment key={node.path}>
<NavLink
to={to}
onClick={() => onToggle({ node, expanded: !node.expanded })}
data-testid={node.path}
>
{/* TODO $activeClassName="sidebar-active" */}
{/* TODO $depth={depth} */}
<ArticleIcon className="h-5 w-5" />
<div>
<div>{title}</div>
{hasChildren && (node.expanded ? <div /> : <div />)}
<div className={classNames(depth !== 0 && 'ml-8')}>
<NavLink
to={to}
onClick={() => onToggle({ node, expanded: !node.expanded })}
data-testid={node.path}
icon={<ArticleIcon className={classNames(depth === 0 ? 'h-6 w-6' : 'h-5 w-5')} />}
>
<div className="flex w-full gap-2 items-center justify-between">
<div>{title}</div>
{hasChildren && (
<ChevronRightIcon
className={classNames(
node.expanded && 'rotate-90 transform',
`
transition-transform
h-5
w-5
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
/>
)}
</div>
</NavLink>
<div className="mt-2 space-y-1.5">
{node.expanded && (
<TreeNode
collection={collection}
depth={depth + 1}
treeData={node.children}
onToggle={onToggle}
/>
)}
</div>
</NavLink>
{node.expanded && (
<TreeNode
collection={collection}
depth={depth + 1}
treeData={node.children}
onToggle={onToggle}
/>
)}
</div>
</Fragment>
);
})}
@ -141,12 +159,12 @@ export function getTreeData(collection: Collection, entries: Entry[]): TreeNodeD
isRoot: false,
})),
...entriesObj.map((e, index) => {
let entryMap = entries[index];
entryMap = {
...entryMap,
data: addFileTemplateFields(entryMap.path, entryMap.data as Record<string, string>),
let entry = entries[index];
entry = {
...entry,
data: addFileTemplateFields(entry.path, entry.data as Record<string, string>),
};
const title = selectEntryCollectionTitle(collection, entryMap);
const title = selectEntryCollectionTitle(collection, entry);
return {
...e,
title,
@ -219,9 +237,11 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
const [selected, setSelected] = useState<TreeNodeData | null>(null);
const [useFilter, setUseFilter] = useState(true);
const [prevCollection, setPrevCollection] = useState(collection);
const [prevEntries, setPrevEntries] = useState(entries);
const [prevFilterTerm, setPrevFilterTerm] = useState(filterTerm);
const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
const [prevEntries, setPrevEntries] = useState<Entry[] | null>(null);
const [prevFilterTerm, setPrevFilterTerm] = useState<string | null>(null);
const { pathname } = useLocation();
useEffect(() => {
if (collection !== prevCollection || entries !== prevEntries || filterTerm !== prevFilterTerm) {
@ -235,7 +255,12 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
const path = `/${filterTerm}`;
walk(newTreeData, node => {
if (expanded[node.path] || (useFilter && path.startsWith(node.path))) {
if (
expanded[node.path] ||
(useFilter &&
path.startsWith(node.path) &&
pathname.startsWith(`/collections/${collection.name}`))
) {
node.expanded = true;
}
});
@ -250,6 +275,7 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
collection,
entries,
filterTerm,
pathname,
prevCollection,
prevEntries,
prevFilterTerm,

View File

@ -5,25 +5,25 @@ import classNames from '@staticcms/core/lib/util/classNames.util';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { useAppSelector } from '@staticcms/core/store/hooks';
import type { Collection, MediaField } from '@staticcms/core/interface';
import type { BaseField, Collection, MediaField, UnknownField } from '@staticcms/core/interface';
export interface ImageProps<F extends MediaField> {
export interface ImageProps<EF extends BaseField> {
src?: string;
alt?: string;
className?: string;
collection?: Collection<F>;
field?: F;
collection?: Collection<EF>;
field?: MediaField;
'data-testid'?: string;
}
const Image = <F extends MediaField>({
const Image = <EF extends BaseField = UnknownField>({
src,
alt,
className,
collection,
field,
'data-testid': dataTestId,
}: ImageProps<F>) => {
}: ImageProps<EF>) => {
const entry = useAppSelector(selectEditingDraft);
const assetSource = useMediaAsset(src, collection, field, entry);
@ -40,11 +40,11 @@ const Image = <F extends MediaField>({
);
};
export const withMdxImage = <F extends MediaField>({
export const withMdxImage = <EF extends BaseField = UnknownField>({
collection,
field,
}: Pick<ImageProps<F>, 'collection' | 'field'>) => {
const MdxImage = (props: Omit<ImageProps<F>, 'collection' | 'field'>) => (
}: Pick<ImageProps<EF>, 'collection' | 'field'>) => {
const MdxImage = (props: Omit<ImageProps<EF>, 'collection' | 'field'>) => (
<Image {...props} collection={collection} field={field} />
);

View File

@ -2,6 +2,7 @@ import React, { Fragment, isValidElement } from 'react';
import { resolveWidget } from '@staticcms/core/lib/registry';
import { selectField } from '@staticcms/core/lib/util/field.util';
import { isNullish } from '@staticcms/core/lib/util/null.util';
import { getTypedFieldForValue } from '@staticcms/list/typedListHelpers';
import PreviewHOC from './PreviewHOC';
@ -86,7 +87,7 @@ export default function getWidgetFor(
let renderedValue: ValueOrNestedValue | ReactNode = value;
if (inferredField) {
renderedValue = inferredField.defaultPreview(String(value));
renderedValue = inferredField.defaultPreview(isNullish(value) ? '' : String(value));
} else if (
value &&
fieldWithWidgets.widget &&
@ -103,7 +104,7 @@ export default function getWidgetFor(
"
>
{field.label ?? field.name}:
</strong>{' '}
</strong>
{value}
</>
</div>

View File

@ -1,15 +1,17 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ScrollSyncPane } from 'react-scroll-sync';
import useBreadcrumbs from '@staticcms/core/lib/hooks/useBreadcrumbs';
import { getI18nInfo, getPreviewEntry, hasI18n } from '@staticcms/core/lib/i18n';
import {
getFileFromSlug,
selectEntryCollectionTitle,
} from '@staticcms/core/lib/util/collection.util';
import { customPathFromSlug } from '@staticcms/core/lib/util/nested.util';
import MainView from '../MainView';
import EditorToolbar from './EditorToolbar';
import EditorControlPane from './editor-control-pane/EditorControlPane';
import EditorPreviewPane from './editor-preview-pane/EditorPreviewPane';
import EditorToolbar from './EditorToolbar';
import type {
Collection,
@ -84,7 +86,7 @@ const EditorInterface = ({
displayUrl,
isNewEntry,
isModification,
draftKey, // TODO Review usage
draftKey,
scrollSyncActive,
t,
loadScroll,
@ -232,22 +234,15 @@ const EditorInterface = ({
);
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
const nestedFieldPath = useMemo(
() => customPathFromSlug(collection, entry.slug),
[collection, entry.slug],
);
const breadcrumbs = useBreadcrumbs(collection, nestedFieldPath, { isNewEntry, summary, t });
return (
<MainView
breadcrumbs={[
{
name: collection.label,
to: `/collections/${collection.name}`,
},
{
name: isNewEntry
? t('collection.collectionTop.newButton', {
collectionLabel: collection.label_singular || collection.label,
})
: summary,
},
]}
breadcrumbs={breadcrumbs}
noMargin
noScroll
navbarActions={

View File

@ -65,6 +65,7 @@ const EditorControl = ({
changeDraftField,
i18n,
fieldName,
isMeta = false,
}: TranslatedProps<EditorControlProps>) => {
const dispatch = useAppDispatch();
@ -103,7 +104,6 @@ const EditorControl = ({
}
const validateValue = async () => {
console.log('VALIDATING', field.name);
const errors = await validate(field, value, widget, t);
dispatch(changeDraftFieldValidation(path, errors, i18n));
};
@ -114,9 +114,9 @@ const EditorControl = ({
const handleChangeDraftField = useCallback(
(value: ValueOrNestedValue) => {
setDirty(true);
changeDraftField({ path, field, value, i18n });
changeDraftField({ path, field, value, i18n, isMeta });
},
[changeDraftField, field, i18n, path],
[changeDraftField, field, i18n, isMeta, path],
);
const config = useMemo(() => configState.config, [configState.config]);
@ -232,6 +232,7 @@ interface EditorControlOwnProps {
forSingleList?: boolean;
i18n: I18nSettings | undefined;
fieldName?: string;
isMeta?: boolean;
}
function mapStateToProps(state: RootState, ownProps: EditorControlOwnProps) {

View File

@ -3,6 +3,7 @@ import React, { useMemo } from 'react';
import { getI18nInfo, hasI18n, isFieldTranslatable } from '@staticcms/core/lib/i18n';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { getFieldValue } from '@staticcms/core/lib/util/field.util';
import { customPathFromSlug } from '@staticcms/core/lib/util/nested.util';
import EditorControl from './EditorControl';
import LocaleDropdown from './LocaleDropdown';
@ -12,6 +13,7 @@ import type {
Field,
FieldsErrors,
I18nSettings,
StringOrTextField,
TranslatedProps,
} from '@staticcms/core/interface';
@ -39,6 +41,25 @@ const EditorControlPane = ({
onLocaleChange,
t,
}: TranslatedProps<EditorControlPaneProps>) => {
const nestedFieldPath = useMemo(
() => customPathFromSlug(collection, entry.slug),
[collection, entry.slug],
);
const pathField = useMemo(
() =>
({
name: 'path',
label:
'nested' in collection && collection.nested?.path?.label
? collection.nested.path.label
: 'Path',
widget: 'string',
i18n: 'none',
} as StringOrTextField),
[collection],
);
const i18n = useMemo(() => {
if (hasI18n(collection)) {
const { locales, defaultLocale } = getI18nInfo(collection);
@ -66,6 +87,7 @@ const EditorControlPane = ({
`
flex
flex-col
min-h-full
`,
!hideBorder &&
`
@ -91,6 +113,19 @@ const EditorControlPane = ({
/>
</div>
) : null}
{'nested' in collection && collection.nested?.path ? (
<EditorControl
key="entry-path"
field={pathField}
value={nestedFieldPath}
fieldsErrors={fieldsErrors}
submitted={submitted}
locale={locale}
parentPath=""
i18n={i18n}
isMeta
/>
) : null}
{fields.map(field => {
const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale);
const key = i18n ? `field-${locale}_${field.name}` : `field-${field.name}`;

View File

@ -8,7 +8,7 @@ import type { Collection, MediaField, MediaLibrarInsertOptions } from '@staticcm
import type { FC } from 'react';
interface CurrentMediaDetailsProps {
collection?: Collection<MediaField>;
collection?: Collection;
field?: MediaField;
canInsert: boolean;
url?: string | string[];

View File

@ -13,14 +13,16 @@ import Pill from '../../common/pill/Pill';
import CopyToClipBoardButton from './CopyToClipBoardButton';
import type {
BaseField,
Collection,
Field,
MediaField,
MediaLibraryDisplayURL,
TranslatedProps,
UnknownField,
} from '@staticcms/core/interface';
import type { FC, KeyboardEvent } from 'react';
interface MediaLibraryCardProps {
interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = UnknownField> {
isSelected?: boolean;
displayURL: MediaLibraryDisplayURL;
text: string;
@ -28,14 +30,14 @@ interface MediaLibraryCardProps {
type?: string;
isViewableImage: boolean;
isDraft?: boolean;
collection?: Collection;
field?: Field;
collection?: Collection<EF>;
field?: T;
onSelect: () => void;
loadDisplayURL: () => void;
onDelete: () => void;
}
const MediaLibraryCard: FC<TranslatedProps<MediaLibraryCardProps>> = ({
const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownField>({
isSelected = false,
displayURL,
text,
@ -49,7 +51,7 @@ const MediaLibraryCard: FC<TranslatedProps<MediaLibraryCardProps>> = ({
loadDisplayURL,
onDelete,
t,
}) => {
}: TranslatedProps<MediaLibraryCardProps<T, EF>>) => {
const entry = useAppSelector(selectEditingDraft);
const url = useMediaAsset(displayURL.url, collection, field, entry);
@ -258,4 +260,4 @@ const MediaLibraryCard: FC<TranslatedProps<MediaLibraryCardProps>> = ({
);
};
export default translate()(MediaLibraryCard) as FC<MediaLibraryCardProps>;
export default translate()(MediaLibraryCard) as FC<MediaLibraryCardProps<MediaField, UnknownField>>;

View File

@ -27,10 +27,10 @@ const linkClassNames = 'btn btn-text-primary w-full justify-start';
const NavLink = ({ icon, children, onClick, ...otherProps }: NavLinkProps) => {
const content = useMemo(
() => (
<>
<div className="flex w-full gap-3 items-center">
<span className="w-6 h-6">{icon}</span>
<span className="ml-3">{children}</span>
</>
<span className="flex-grow">{children}</span>
</div>
),
[children, icon],
);

View File

@ -10,6 +10,7 @@ import { selectCollections } from '@staticcms/core/reducers/selectors/collection
import { selectIsSearchEnabled } from '@staticcms/core/reducers/selectors/config';
import { useAppSelector } from '@staticcms/core/store/hooks';
import CollectionSearch from '../collections/CollectionSearch';
import NestedCollection from '../collections/NestedCollection';
import NavLink from './NavLink';
import type { Collection } from '@staticcms/core/interface';
@ -17,7 +18,9 @@ import type { FC } from 'react';
import type { TranslateProps } from 'react-polyglot';
const Sidebar: FC<TranslateProps> = ({ t }) => {
const { name, searchTerm } = useParams();
const { name, searchTerm, ...params } = useParams();
const filterTerm = useMemo(() => params['*'] ?? '', [params]);
const navigate = useNavigate();
const isSearchEnabled = useAppSelector(selectIsSearchEnabled);
const collections = useAppSelector(selectCollections);
@ -35,18 +38,17 @@ const Sidebar: FC<TranslateProps> = ({ t }) => {
const collectionName = collection.name;
const icon = getIcon(collection.icon);
// TODO
// if ('nested' in collection) {
// return (
// <li key={`nested-${collectionName}`}>
// <NestedCollection
// collection={collection}
// filterTerm={filterTerm}
// data-testid={collectionName}
// />
// </li>
// );
// }
if ('nested' in collection) {
return (
<li key={`nested-${collectionName}`}>
<NestedCollection
collection={collection}
filterTerm={filterTerm}
data-testid={collectionName}
/>
</li>
);
}
return (
<NavLink key={collectionName} to={`/collections/${collectionName}`} icon={icon}>
@ -54,7 +56,7 @@ const Sidebar: FC<TranslateProps> = ({ t }) => {
</NavLink>
);
}),
[collections],
[collections, filterTerm],
);
const additionalLinks = useMemo(() => getAdditionalLinks(), []);
@ -126,7 +128,7 @@ const Sidebar: FC<TranslateProps> = ({ t }) => {
)}
{collectionLinks}
{links}
<NavLink key="Media" to="/media" icon={<PhotoIcon className="h-5 w-5" />}>
<NavLink key="Media" to="/media" icon={<PhotoIcon className="h-6 w-6" />}>
{t('app.header.media')}
</NavLink>
</ul>