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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 2671 additions and 295 deletions

View File

@ -7,6 +7,7 @@ BREAKING_CHANGES
- widget prop `isHidden` renamed to `hidden`
- useMediaInsert now requires collection to be passed
- media path changed from `string | string[]` to `{ path: string | string[], alt?: string }`
- Nested collections, meta config moved into nested config.
ADDED
- `forSingleList` - Allows for changing styles for single list items

View File

@ -0,0 +1,5 @@
---
title: An Author
---
Author details go here!.

View File

@ -0,0 +1,3 @@
---
title: Authors
---

View File

@ -0,0 +1,3 @@
---
title: Pages
---

View File

@ -0,0 +1,5 @@
---
title: Hello World
---
Coffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species.

View File

@ -0,0 +1,3 @@
---
title: Posts
---

View File

@ -504,3 +504,22 @@ collections:
label: Date
widget: datetime
i18n: duplicate
- name: pages
label: Nested Pages
label_singular: 'Page'
folder: packages/core/dev-test/backends/proxy/_nested_pages
create: true
# adding a nested object will show the collection folder structure
nested:
depth: 100 # max depth to show in the collection tree
summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field
# adding a path object allows editing the path of entries
# moving an existing entry will move the entire sub tree of the entry to the new location
path: { label: 'Path', index_file: 'index' }
fields:
- label: Title
name: title
widget: string
- label: Body
name: body
widget: markdown

View File

@ -1248,3 +1248,22 @@ collections:
label: Date
widget: datetime
i18n: duplicate
- name: pages
label: Nested Pages
label_singular: 'Page'
folder: _nested_pages
create: true
# adding a nested object will show the collection folder structure
nested:
depth: 100 # max depth to show in the collection tree
summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field
# adding a path object allows editing the path of entries
# moving an existing entry will move the entire sub tree of the entry to the new location
path: { label: 'Path', index_file: 'index' }
fields:
- label: Title
name: title
widget: string
- label: Body
name: body
widget: markdown

View File

@ -131,6 +131,32 @@
"---\ndescription: Le café est un petit arbre ou un arbuste qui pousse dans le sous-étage de la forêt sous sa forme sauvage et qui était traditionnellement cultivé commercialement sous d'autres arbres qui fournissaient de l'ombre. La structure forestière des plantations de café d'ombre fournit un habitat à un grand nombre d'espèces migratrices et résidentes.\ndate: 2015-02-14T00:00:00.000Z\n---\n",
},
},
_nested_pages: {
authors: {
'author-1': {
'index.md': {
content: '---\ntitle: An Author\n---\nAuthor details go here!.\n',
},
},
'index.md': {
content: '---\ntitle: Authors\n---\n',
},
},
posts: {
'hello-world': {
'index.md': {
content:
'---\ntitle: Hello World\n---\nCoffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species.\n',
},
},
'index.md': {
content: '---\ntitle: Posts\n---\n',
},
},
'index.md': {
content: '---\ntitle: Pages\n---\n',
},
},
};
var ONE_DAY = 60 * 60 * 24 * 1000;

File diff suppressed because it is too large Load Diff

View File

@ -39,9 +39,9 @@ function isFieldList<F extends BaseField = UnknownField>(field: Field<F>): field
return 'types' in (field as ListField) || 'field' in (field as ListField);
}
function traverseFieldsJS<F extends Field>(
function traverseFieldsJS<F extends BaseField = UnknownField>(
fields: F[],
updater: <T extends Field>(field: T) => T,
updater: <T extends BaseField = UnknownField>(field: T) => T,
): F[] {
return fields.map(field => {
const newField = updater(field);
@ -68,14 +68,14 @@ function getConfigUrl() {
return 'config.yml';
}
function setDefaultPublicFolderForField<T extends Field>(field: T) {
function setDefaultPublicFolderForField<T extends BaseField = UnknownField>(field: T) {
if ('media_folder' in field && !('public_folder' in field)) {
return { ...field, public_folder: field.media_folder };
}
return field;
}
function setI18nField<T extends Field>(field: T) {
function setI18nField<T extends BaseField = UnknownField>(field: T) {
if (field[I18N] === true) {
return { ...field, [I18N]: I18N_FIELD_TRANSLATE };
} else if (field[I18N] === false || !field[I18N]) {

View File

@ -39,10 +39,10 @@ import {
} from '../constants';
import ValidationErrorTypes from '../constants/validationErrorTypes';
import {
duplicateDefaultI18nFields,
hasI18n,
I18N_FIELD_DUPLICATE,
I18N_FIELD_TRANSLATE,
duplicateDefaultI18nFields,
hasI18n,
serializeI18n,
} from '../lib/i18n';
import { serializeValues } from '../lib/serializeEntryValues';
@ -463,15 +463,17 @@ export function changeDraftField({
field,
value,
i18n,
isMeta,
}: {
path: string;
field: Field;
value: ValueOrNestedValue;
i18n?: I18nSettings;
isMeta: boolean;
}) {
return {
type: DRAFT_CHANGE_FIELD,
payload: { path, field, value, i18n },
payload: { path, field, value, i18n, isMeta },
} as const;
}

View File

@ -12,7 +12,7 @@ import { getMediaFile } from './mediaLibrary';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { BaseField, Collection, Entry, Field, UnknownField } from '../interface';
import type { BaseField, Collection, Entry, Field, MediaField, UnknownField } from '../interface';
import type { RootState } from '../store';
import type AssetProxy from '../valueObjects/AssetProxy';
@ -72,11 +72,11 @@ async function loadAsset(
const promiseCache: Record<string, Promise<AssetProxy>> = {};
export function getAsset<F extends BaseField = UnknownField>(
collection: Collection<F> | null | undefined,
export function getAsset<T extends MediaField, EF extends BaseField = UnknownField>(
collection: Collection<EF> | null | undefined,
entry: Entry | null | undefined,
path: string,
field?: F,
field?: T,
) {
return (
dispatch: ThunkDispatch<RootState, {}, AnyAction>,

View File

@ -77,7 +77,7 @@ export function removeMediaControl(id: string) {
};
}
export function openMediaLibrary<F extends BaseField = UnknownField>(
export function openMediaLibrary<EF extends BaseField = UnknownField>(
payload: {
controlID?: string;
forImage?: boolean;
@ -86,8 +86,8 @@ export function openMediaLibrary<F extends BaseField = UnknownField>(
allowMultiple?: boolean;
replaceIndex?: number;
config?: Record<string, unknown>;
collection?: Collection<F>;
field?: F;
collection?: Collection<EF>;
field?: EF;
insertOptions?: MediaLibrarInsertOptions;
} = {},
) {

View File

@ -20,10 +20,10 @@ import {
import { getBackend, invokeEvent } from './lib/registry';
import { sanitizeChar } from './lib/urlHelper';
import {
CURSOR_COMPATIBILITY_SYMBOL,
Cursor,
asyncLock,
blobToFileObj,
Cursor,
CURSOR_COMPATIBILITY_SYMBOL,
getPathDepth,
localForage,
} from './lib/util';
@ -39,6 +39,7 @@ import {
selectMediaFolders,
} from './lib/util/collection.util';
import { selectMediaFilePath, selectMediaFilePublicPath } from './lib/util/media.util';
import { selectCustomPath, slugFromCustomPath } from './lib/util/nested.util';
import { set } from './lib/util/object.util';
import { dateParsers, expandPath, extractTemplateVars } from './lib/widgets/stringTemplate';
import createEntry from './valueObjects/createEntry';
@ -59,6 +60,7 @@ import type {
Field,
FilterRule,
ImplementationEntry,
PersistArgs,
SearchQueryResponse,
SearchResponse,
UnknownField,
@ -230,9 +232,9 @@ interface AuthStore {
logout: () => void;
}
interface BackendOptions {
interface BackendOptions<EF extends BaseField> {
backendName: string;
config: Config;
config: Config<EF>;
authStore?: AuthStore;
}
@ -258,16 +260,7 @@ interface BackupEntry {
i18n?: Record<string, { raw: string }>;
}
interface PersistArgs {
config: Config;
collection: Collection;
entryDraft: EntryDraft;
assetProxies: AssetProxy[];
usedSlugs: string[];
status?: string;
}
function collectionDepth(collection: Collection) {
function collectionDepth<EF extends BaseField>(collection: Collection<EF>) {
let depth;
depth =
('nested' in collection && collection.nested?.depth) || getPathDepth(collection.path ?? '');
@ -279,17 +272,17 @@ function collectionDepth(collection: Collection) {
return depth;
}
export class Backend<BC extends BackendClass = BackendClass> {
export class Backend<EF extends BaseField = UnknownField, BC extends BackendClass = BackendClass> {
implementation: BC;
backendName: string;
config: Config;
config: Config<EF>;
authStore?: AuthStore;
user?: User | null;
backupSync: AsyncLock;
constructor(
implementation: BackendInitializer,
{ backendName, authStore, config }: BackendOptions,
implementation: BackendInitializer<EF>,
{ backendName, authStore, config }: BackendOptions<EF>,
) {
// We can't reliably run this on exit, so we do cleanup on load.
this.deleteAnonymousBackup();
@ -401,9 +394,15 @@ export class Backend<BC extends BackendClass = BackendClass> {
entryData: EntryData,
config: Config,
usedSlugs: string[],
customPath: string | undefined,
) {
const slugConfig = config.slug;
const slug = slugFormatter(collection, entryData, slugConfig);
let slug: string;
if (customPath) {
slug = slugFromCustomPath(collection, customPath);
} else {
slug = slugFormatter(collection, entryData, slugConfig);
}
let i = 1;
let uniqueSlug = slug;
@ -417,7 +416,10 @@ export class Backend<BC extends BackendClass = BackendClass> {
return uniqueSlug;
}
processEntries(loadedEntries: ImplementationEntry[], collection: Collection): Entry[] {
processEntries<EF extends BaseField>(
loadedEntries: ImplementationEntry[],
collection: Collection<EF>,
): Entry[] {
const entries = loadedEntries.map(loadedEntry =>
createEntry(
collection.name,
@ -486,13 +488,13 @@ export class Backend<BC extends BackendClass = BackendClass> {
// repeats the process. Once there is no available "next" action, it
// returns all the collected entries. Used to retrieve all entries
// for local searches and queries.
async listAllEntries<T extends BaseField = UnknownField>(collection: Collection<T>) {
async listAllEntries<EF extends BaseField>(collection: Collection<EF>) {
if ('folder' in collection && collection.folder && this.implementation.allEntriesByFolder) {
const depth = collectionDepth(collection as Collection);
const extension = selectFolderEntryExtension(collection as Collection);
const depth = collectionDepth(collection);
const extension = selectFolderEntryExtension(collection);
return this.implementation
.allEntriesByFolder(collection.folder as string, extension, depth)
.then(entries => this.processEntries(entries, collection as Collection));
.then(entries => this.processEntries(entries, collection));
}
const response = await this.listEntries(collection as Collection);
@ -565,8 +567,8 @@ export class Backend<BC extends BackendClass = BackendClass> {
return { entries: hits, pagination: 1 };
}
async query<T extends BaseField = UnknownField>(
collection: Collection<T>,
async query<EF extends BaseField>(
collection: Collection<EF>,
searchFields: string[],
searchTerm: string,
file?: string,
@ -714,7 +716,11 @@ export class Backend<BC extends BackendClass = BackendClass> {
return localForage.removeItem(getEntryBackupKey());
}
async getEntry(state: RootState, collection: Collection, slug: string) {
async getEntry<EF extends BaseField>(
state: RootState<EF>,
collection: Collection<EF>,
slug: string,
) {
const path = selectEntryPath(collection, slug) as string;
const label = selectFileEntryLabel(collection, slug);
const extension = selectFolderEntryExtension(collection);
@ -762,7 +768,7 @@ export class Backend<BC extends BackendClass = BackendClass> {
return Promise.reject(err);
}
entryWithFormat(collection: Collection) {
entryWithFormat<EF extends BaseField>(collection: Collection<EF>) {
return (entry: Entry): Entry => {
const format = resolveFormat(collection, entry);
if (entry && entry.raw !== undefined) {
@ -777,7 +783,11 @@ export class Backend<BC extends BackendClass = BackendClass> {
};
}
async processEntry(state: RootState, collection: Collection, entry: Entry) {
async processEntry<EF extends BaseField>(
state: RootState<EF>,
collection: Collection<EF>,
entry: Entry,
) {
const configState = state.config;
if (!configState.config) {
throw new Error('Config not loaded');
@ -826,6 +836,8 @@ export class Backend<BC extends BackendClass = BackendClass> {
const newEntry = entryDraft.entry.newRecord ?? false;
const customPath = selectCustomPath(draft.entry, collection);
let dataFile: DataFile;
if (newEntry) {
if (!selectAllowNewEntries(collection)) {
@ -836,8 +848,9 @@ export class Backend<BC extends BackendClass = BackendClass> {
entryDraft.entry.data,
config,
usedSlugs,
customPath,
);
const path = selectEntryPath(collection, slug) ?? '';
const path = customPath || (selectEntryPath(collection, slug) ?? '');
dataFile = {
path,
slug,
@ -849,8 +862,9 @@ export class Backend<BC extends BackendClass = BackendClass> {
const slug = entryDraft.entry.slug;
dataFile = {
path: entryDraft.entry.path,
slug,
slug: customPath ? slugFromCustomPath(collection, customPath) : slug,
raw: this.entryToRaw(collection, entryDraft.entry),
newPath: customPath,
};
}
@ -938,7 +952,11 @@ export class Backend<BC extends BackendClass = BackendClass> {
return this.implementation.persistMedia(file, options);
}
async deleteEntry(state: RootState, collection: Collection, slug: string) {
async deleteEntry<EF extends BaseField>(
state: RootState<EF>,
collection: Collection<EF>,
slug: string,
) {
const configState = state.config;
if (!configState.config) {
throw new Error('Config not loaded');
@ -1012,7 +1030,7 @@ export class Backend<BC extends BackendClass = BackendClass> {
}
}
export function resolveBackend(config?: Config) {
export function resolveBackend<EF extends BaseField>(config?: Config<EF>) {
if (!config?.backend.name) {
throw new Error('No backend defined in configuration');
}
@ -1020,22 +1038,22 @@ export function resolveBackend(config?: Config) {
const { name } = config.backend;
const authStore = new LocalStorageAuthStore();
const backend = getBackend(name);
const backend = getBackend<EF>(name);
if (!backend) {
throw new Error(`Backend not found: ${name}`);
} else {
return new Backend(backend, { backendName: name, authStore, config });
return new Backend<EF, BackendClass>(backend, { backendName: name, authStore, config });
}
}
export const currentBackend = (function () {
let backend: Backend;
return <T extends BaseField = UnknownField>(config: Config<T>) => {
return <EF extends BaseField = UnknownField>(config: Config<EF>) => {
if (backend) {
return backend;
}
return (backend = resolveBackend(config as Config));
return (backend = resolveBackend(config) as unknown as Backend);
};
})();

View File

@ -207,8 +207,14 @@ export default class TestBackend implements BackendClass {
async persistEntry(entry: BackendEntry) {
entry.dataFiles.forEach(dataFile => {
const { path, raw } = dataFile;
writeFile(path, raw, window.repoFiles);
const { path, newPath, raw } = dataFile;
if (newPath) {
deleteFile(path, window.repoFiles);
writeFile(newPath, raw, window.repoFiles);
} else {
writeFile(path, raw, window.repoFiles);
}
});
entry.assets.forEach(a => {
writeFile(a.path, a, window.repoFiles);

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>

View File

@ -292,6 +292,14 @@ function getConfigSchema() {
properties: {
depth: { type: 'number', minimum: 1, maximum: 1000 },
summary: { type: 'string' },
path: {
type: 'object',
properties: {
label: { type: 'string' },
index_file: { type: 'string' },
},
required: ['index_file'],
},
},
required: ['depth'],
},

View File

@ -1,11 +1,11 @@
import YamlFormatter from './YamlFormatter';
import TomlFormatter from './TomlFormatter';
import JsonFormatter from './JsonFormatter';
import TomlFormatter from './TomlFormatter';
import YamlFormatter from './YamlFormatter';
import { FrontmatterInfer, frontmatterJSON, frontmatterTOML, frontmatterYAML } from './frontmatter';
import type { Delimiter } from './frontmatter';
import type { Collection, Entry, Format } from '../interface';
import type { BaseField, Collection, Entry, Format } from '../interface';
import type FileFormatter from './FileFormatter';
import type { Delimiter } from './frontmatter';
export const frontmatterFormats = ['yaml-frontmatter', 'toml-frontmatter', 'json-frontmatter'];
@ -45,7 +45,10 @@ function formatByName(name: Format, customDelimiter?: Delimiter): FileFormatter
return fileFormatter[name];
}
export function resolveFormat(collection: Collection, entry: Entry): FileFormatter | undefined {
export function resolveFormat<EF extends BaseField>(
collection: Collection<EF>,
entry: Entry,
): FileFormatter | undefined {
// Check for custom delimiter
const frontmatter_delimiter = collection.frontmatter_delimiter;

View File

@ -105,6 +105,9 @@ export interface Entry<T = ObjectValue> {
data: EntryData;
};
};
meta?: {
path: string;
};
}
export type Entities = Record<string, Entry>;
@ -168,6 +171,10 @@ export interface CollectionFile<EF extends BaseField = UnknownField> {
interface Nested {
summary?: string;
depth: number;
path?: {
label?: string;
index_file: string;
};
}
export interface I18nSettings {
@ -385,6 +392,15 @@ export interface PersistOptions {
status?: string;
}
export interface PersistArgs {
config: Config;
collection: Collection;
entryDraft: EntryDraft;
assetProxies: AssetProxy[];
usedSlugs: string[];
status?: string;
}
export interface ImplementationEntry {
data: string;
file: { path: string; label?: string; id?: string | null; author?: string; updatedOn?: string };
@ -621,7 +637,7 @@ export interface ListField<EF extends BaseField = UnknownField> extends BaseFiel
max?: number;
min?: number;
add_to_top?: boolean;
types?: ObjectField[];
types?: ObjectField<EF>[];
type_key?: string;
}
@ -802,8 +818,8 @@ export interface BackendInitializerOptions {
updateUserCredentials: (credentials: Credentials) => void;
}
export interface BackendInitializer {
init: (config: Config, options: BackendInitializerOptions) => BackendClass;
export interface BackendInitializer<EF extends BaseField = UnknownField> {
init: (config: Config<EF>, options: BackendInitializerOptions) => BackendClass;
}
export interface EventData {

View File

@ -14,7 +14,7 @@ import {
parseDateFromEntry,
} from './widgets/stringTemplate';
import type { Collection, Config, Entry, EntryData, Slug } from '../interface';
import type { BaseField, Collection, Config, Entry, EntryData, Slug } from '../interface';
const commitMessageTemplates = {
create: 'Create {{collection}} “{{slug}}”',
@ -26,18 +26,18 @@ const commitMessageTemplates = {
const variableRegex = /\{\{([^}]+)\}\}/g;
type Options = {
type Options<EF extends BaseField> = {
slug?: string;
path?: string;
collection?: Collection;
collection?: Collection<EF>;
authorLogin?: string;
authorName?: string;
};
export function commitMessageFormatter(
export function commitMessageFormatter<EF extends BaseField>(
type: keyof typeof commitMessageTemplates,
config: Config,
{ slug, path, collection, authorLogin, authorName }: Options,
config: Config<EF>,
{ slug, path, collection, authorLogin, authorName }: Options<EF>,
) {
const templates = { ...commitMessageTemplates, ...(config.backend.commit_messages || {}) };
@ -106,7 +106,11 @@ export function slugFormatter(collection: Collection, entryData: EntryData, slug
}
}
export function summaryFormatter(summaryTemplate: string, entry: Entry, collection: Collection) {
export function summaryFormatter<EF extends BaseField>(
summaryTemplate: string,
entry: Entry,
collection: Collection<EF>,
) {
let entryData = entry.data;
const date = parseDateFromEntry(entry, selectInferredField(collection, 'date')) || null;
const identifier = get(entryData, keyToPathArray(selectIdentifier(collection)));
@ -125,10 +129,10 @@ export function summaryFormatter(summaryTemplate: string, entry: Entry, collecti
return summary;
}
export function folderFormatter(
export function folderFormatter<EF extends BaseField>(
folderTemplate: string,
entry: Entry | null | undefined,
collection: Collection,
collection: Collection<EF>,
defaultFolder: string,
folderKey: string,
slugConfig?: Slug,

View File

@ -0,0 +1,91 @@
import { useEffect, useMemo } from 'react';
import { loadEntries } from '@staticcms/core/actions/entries';
import { useAppDispatch } from '@staticcms/core/store/hooks';
import { selectEntryCollectionTitle, selectFolderEntryExtension } from '../util/collection.util';
import { addFileTemplateFields } from '../widgets/stringTemplate';
import useEntries from './useEntries';
import type { Breadcrumb, Collection, Entry } from '@staticcms/core/interface';
import type { t } from 'react-polyglot';
interface EntryDetails {
isNewEntry: boolean;
summary: string;
t: t;
}
export default function useBreadcrumbs(
collection: Collection,
filterTerm: string | undefined | null,
entryDetails?: EntryDetails,
) {
const entries = useEntries(collection);
const dispatch = useAppDispatch();
useEffect(() => {
if (!entries || entries.length === 0) {
dispatch(loadEntries(collection));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return useMemo(() => {
const crumbs: Breadcrumb[] = [
{
name: collection.label,
to: `/collections/${collection.name}`,
},
];
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);
for (let i = 0; i < path.length; i++) {
const pathSoFar = path.slice(0, i + 1).join('/');
let entry =
entriesByPath[
`${collection.folder}/${pathSoFar}/${collection.nested.path.index_file}.${extension}`
];
let title = path[i];
if (entry) {
entry = {
...entry,
data: addFileTemplateFields(entry.path, entry.data as Record<string, string>),
};
title = selectEntryCollectionTitle(collection, entry);
}
crumbs.push({
name: title,
to: `/collections/${collection.name}/filter/${pathSoFar}`,
});
}
return crumbs;
}
}
if (entryDetails) {
const { isNewEntry, summary, t } = entryDetails;
crumbs.push({
name: isNewEntry
? t('collection.collectionTop.newButton', {
collectionLabel: collection.label_singular || collection.label,
})
: summary,
});
}
return crumbs;
}, [collection, entries, entryDetails, filterTerm]);
}

View File

@ -5,11 +5,17 @@ import { useAppDispatch } from '@staticcms/core/store/hooks';
import { isEmpty, isNotEmpty } from '../util/string.util';
import useDebounce from './useDebounce';
import type { Collection, Entry, MediaField } from '@staticcms/core/interface';
import type {
BaseField,
Collection,
Entry,
MediaField,
UnknownField,
} from '@staticcms/core/interface';
export default function useIsMediaAsset<T extends MediaField>(
export default function useIsMediaAsset<T extends MediaField, EF extends BaseField = UnknownField>(
url: string,
collection: Collection<T>,
collection: Collection<EF>,
field: T,
entry: Entry,
): boolean {
@ -23,7 +29,7 @@ export default function useIsMediaAsset<T extends MediaField>(
}
const checkMediaExistence = async () => {
const asset = await dispatch(getAsset<T>(collection, entry, debouncedUrl, field));
const asset = await dispatch(getAsset<T, EF>(collection, entry, debouncedUrl, field));
setExists(
Boolean(asset && asset !== emptyAsset && isNotEmpty(asset.toString()) && asset.fileObj),
);

View File

@ -5,11 +5,17 @@ import { useAppDispatch } from '@staticcms/core/store/hooks';
import { isNotEmpty } from '../util/string.util';
import useDebounce from './useDebounce';
import type { Collection, Entry, MediaField } from '@staticcms/core/interface';
import type {
BaseField,
Collection,
Entry,
MediaField,
UnknownField,
} from '@staticcms/core/interface';
export default function useMediaAsset<T extends MediaField>(
export default function useMediaAsset<T extends MediaField, EF extends BaseField = UnknownField>(
url: string | undefined | null,
collection?: Collection<T>,
collection?: Collection<EF>,
field?: T,
entry?: Entry,
): string {
@ -28,7 +34,7 @@ export default function useMediaAsset<T extends MediaField>(
}
const fetchMedia = async () => {
const asset = await dispatch(getAsset<T>(collection, entry, debouncedUrl, field));
const asset = await dispatch(getAsset<T, EF>(collection, entry, debouncedUrl, field));
if (asset !== emptyAsset) {
setAssetSource(asset?.toString() ?? '');
}

View File

@ -6,13 +6,14 @@ import { selectEntrySlug } from './util/collection.util';
import { set } from './util/object.util';
import type {
Field,
BaseField,
Collection,
Entry,
EntryData,
i18nCollection,
Field,
I18nInfo,
I18nStructure,
i18nCollection,
} from '../interface';
import type { EntryDraftState } from '../reducers/entryDraft';
@ -26,20 +27,24 @@ export const I18N_FIELD_TRANSLATE = 'translate';
export const I18N_FIELD_DUPLICATE = 'duplicate';
export const I18N_FIELD_NONE = 'none';
export function hasI18n(collection: Collection | i18nCollection): collection is i18nCollection {
export function hasI18n<EF extends BaseField>(
collection: Collection<EF> | i18nCollection<EF>,
): collection is i18nCollection<EF> {
return I18N in collection;
}
export function getI18nInfo(collection: i18nCollection): I18nInfo;
export function getI18nInfo(collection: Collection): I18nInfo | null;
export function getI18nInfo(collection: Collection | i18nCollection): I18nInfo | null {
export function getI18nInfo<EF extends BaseField>(collection: i18nCollection<EF>): I18nInfo;
export function getI18nInfo<EF extends BaseField>(collection: Collection<EF>): I18nInfo | null;
export function getI18nInfo<EF extends BaseField>(
collection: Collection<EF> | i18nCollection<EF>,
): I18nInfo | null {
if (!hasI18n(collection) || typeof collection[I18N] !== 'object') {
return null;
}
return collection.i18n;
}
export function getI18nFilesDepth(collection: Collection, depth: number) {
export function getI18nFilesDepth<EF extends BaseField>(collection: Collection<EF>, depth: number) {
const { structure } = getI18nInfo(collection) as I18nInfo;
if (structure === I18N_STRUCTURE_MULTIPLE_FOLDERS) {
return depth + 1;
@ -105,8 +110,8 @@ export function getLocaleFromPath(structure: I18nStructure, extension: string, p
}
}
export function getFilePaths(
collection: Collection,
export function getFilePaths<EF extends BaseField>(
collection: Collection<EF>,
extension: string,
path: string,
slug: string,
@ -136,8 +141,8 @@ export function normalizeFilePath(structure: I18nStructure, path: string, locale
}
}
export function getI18nFiles(
collection: Collection,
export function getI18nFiles<EF extends BaseField>(
collection: Collection<EF>,
extension: string,
entryDraft: Entry,
entryToRaw: (entryDraft: Entry) => string,
@ -232,8 +237,8 @@ export function formatI18nBackup(
return i18n;
}
function mergeValues(
collection: Collection,
function mergeValues<EF extends BaseField>(
collection: Collection<EF>,
structure: I18nStructure,
defaultLocale: string,
values: { locale: string; value: Entry }[],
@ -281,8 +286,8 @@ function mergeSingleFileValue(entryValue: Entry, defaultLocale: string, locales:
};
}
export async function getI18nEntry(
collection: Collection,
export async function getI18nEntry<EF extends BaseField>(
collection: Collection<EF>,
extension: string,
path: string,
slug: string,
@ -317,7 +322,11 @@ export async function getI18nEntry(
return entryValue;
}
export function groupEntries(collection: Collection, extension: string, entries: Entry[]): Entry[] {
export function groupEntries<EF extends BaseField>(
collection: Collection<EF>,
extension: string,
entries: Entry[],
): Entry[] {
const {
structure = I18N_STRUCTURE_SINGLE_FILE,
defaultLocale,

View File

@ -11,7 +11,6 @@ import type {
Entry,
EventData,
EventListener,
Field,
FieldPreviewComponent,
LocalePhrasesRoot,
MediaLibraryExternalLibrary,
@ -246,8 +245,10 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
}
}
export function getWidget<T = unknown, F extends Field = Field>(name: string): Widget<T, F> {
return registry.widgets[name] as unknown as Widget<T, F>;
export function getWidget<T = unknown, EF extends BaseField = UnknownField>(
name: string,
): Widget<T, EF> {
return registry.widgets[name] as unknown as Widget<T, EF>;
}
export function getWidgets(): ({
@ -259,7 +260,9 @@ export function getWidgets(): ({
}));
}
export function resolveWidget<T = unknown, F extends Field = Field>(name?: string): Widget<T, F> {
export function resolveWidget<T = unknown, EF extends BaseField = UnknownField>(
name?: string,
): Widget<T, EF> {
return getWidget(name || 'string') || getWidget('unknown');
}
@ -297,8 +300,10 @@ export function registerBackend<
}
}
export function getBackend(name: string): BackendInitializer {
return registry.backends[name];
export function getBackend<EF extends BaseField = UnknownField>(
name: string,
): BackendInitializer<EF> {
return registry.backends[name] as unknown as BackendInitializer<EF>;
}
/**

View File

@ -16,6 +16,7 @@ import { selectMediaFolder } from './media.util';
import type { Backend } from '@staticcms/core/backend';
import type {
BaseField,
Collection,
Collections,
Config,
@ -27,7 +28,7 @@ import type {
SortableField,
} from '@staticcms/core/interface';
function fileForEntry(collection: FilesCollection, slug?: string) {
function fileForEntry<EF extends BaseField>(collection: FilesCollection<EF>, slug?: string) {
const files = collection.files;
if (!slug) {
return files?.[0];
@ -35,7 +36,7 @@ function fileForEntry(collection: FilesCollection, slug?: string) {
return files && files.filter(f => f?.name === slug)?.[0];
}
export function selectFields(collection: Collection, slug?: string) {
export function selectFields<EF extends BaseField>(collection: Collection<EF>, slug?: string) {
if ('fields' in collection) {
return collection.fields;
}
@ -44,14 +45,17 @@ export function selectFields(collection: Collection, slug?: string) {
return file && file.fields;
}
export function selectFolderEntryExtension(collection: Collection) {
export function selectFolderEntryExtension<EF extends BaseField>(collection: Collection<EF>) {
return (collection.extension || formatExtensions[collection.format ?? 'frontmatter']).replace(
/^\./,
'',
);
}
export function selectFileEntryLabel(collection: Collection, slug: string) {
export function selectFileEntryLabel<EF extends BaseField>(
collection: Collection<EF>,
slug: string,
) {
if ('fields' in collection) {
return undefined;
}
@ -60,7 +64,7 @@ export function selectFileEntryLabel(collection: Collection, slug: string) {
return file && file.label;
}
export function selectEntryPath(collection: Collection, slug: string) {
export function selectEntryPath<EF extends BaseField>(collection: Collection<EF>, slug: string) {
if ('fields' in collection) {
const folder = collection.folder.replace(/\/$/, '');
return `${folder}/${slug}.${selectFolderEntryExtension(collection)}`;
@ -70,7 +74,7 @@ export function selectEntryPath(collection: Collection, slug: string) {
return file && file.file;
}
export function selectEntrySlug(collection: Collection, path: string) {
export function selectEntrySlug<EF extends BaseField>(collection: Collection<EF>, path: string) {
if ('fields' in collection) {
const folder = (collection.folder as string).replace(/\/$/, '');
const slug = path
@ -85,7 +89,7 @@ export function selectEntrySlug(collection: Collection, path: string) {
return file && file.name;
}
export function selectAllowNewEntries(collection: Collection) {
export function selectAllowNewEntries<EF extends BaseField>(collection: Collection<EF>) {
if ('fields' in collection) {
return collection.create ?? true;
}
@ -93,7 +97,7 @@ export function selectAllowNewEntries(collection: Collection) {
return false;
}
export function selectAllowDeletion(collection: Collection) {
export function selectAllowDeletion<EF extends BaseField>(collection: Collection<EF>) {
if ('fields' in collection) {
return collection.delete ?? true;
}
@ -101,7 +105,7 @@ export function selectAllowDeletion(collection: Collection) {
return false;
}
export function selectTemplateName(collection: Collection, slug: string) {
export function selectTemplateName<EF extends BaseField>(collection: Collection<EF>, slug: string) {
if ('fields' in collection) {
return collection.name;
}
@ -109,7 +113,10 @@ export function selectTemplateName(collection: Collection, slug: string) {
return slug;
}
export function selectEntryCollectionTitle(collection: Collection, entry: Entry): string {
export function selectEntryCollectionTitle<EF extends BaseField>(
collection: Collection<EF>,
entry: Entry,
): string {
// prefer formatted summary over everything else
const summaryTemplate = collection.summary;
if (summaryTemplate) {
@ -137,7 +144,10 @@ export function selectEntryCollectionTitle(collection: Collection, entry: Entry)
return result;
}
export function selectDefaultSortableFields(collection: Collection, backend: Backend) {
export function selectDefaultSortableFields<EF extends BaseField>(
collection: Collection<EF>,
backend: Backend<EF>,
) {
let defaultSortable = SORTABLE_FIELDS.map((type: string) => {
const field = selectInferredField(collection, type);
if (backend.isGitBackend() && type === 'author' && !field) {
@ -181,16 +191,19 @@ export function selectSortableFields(
return fields;
}
export function selectViewFilters(collection?: Collection) {
export function selectViewFilters<EF extends BaseField>(collection?: Collection<EF>) {
return collection?.view_filters;
}
export function selectViewGroups(collection?: Collection) {
export function selectViewGroups<EF extends BaseField>(collection?: Collection<EF>) {
return collection?.view_groups;
}
export function selectFieldsComments(collection: Collection, entryMap: Entry) {
let fields: Field[] = [];
export function selectFieldsComments<EF extends BaseField>(
collection: Collection<EF>,
entryMap: Entry,
) {
let fields: Field<EF>[] = [];
if ('folder' in collection) {
fields = collection.fields;
} else if ('files' in collection) {
@ -210,7 +223,7 @@ export function selectFieldsComments(collection: Collection, entryMap: Entry) {
return comments;
}
function getFieldsWithMediaFolders(fields: Field[]) {
function getFieldsWithMediaFolders<EF extends BaseField>(fields: Field<EF>[]) {
const fieldsWithMediaFolders = fields.reduce((acc, f) => {
if ('media_folder' in f) {
acc = [...acc, f];
@ -230,11 +243,17 @@ function getFieldsWithMediaFolders(fields: Field[]) {
return fieldsWithMediaFolders;
}
export function getFileFromSlug(collection: FilesCollection, slug: string) {
export function getFileFromSlug<EF extends BaseField>(
collection: FilesCollection<EF>,
slug: string,
) {
return collection.files?.find(f => f.name === slug);
}
export function selectFieldsWithMediaFolders(collection: Collection, slug: string) {
export function selectFieldsWithMediaFolders<EF extends BaseField>(
collection: Collection<EF>,
slug: string,
) {
if ('folder' in collection) {
const fields = collection.fields;
return getFieldsWithMediaFolders(fields);
@ -246,7 +265,11 @@ export function selectFieldsWithMediaFolders(collection: Collection, slug: strin
return [];
}
export function selectMediaFolders(config: Config, collection: Collection, entry: Entry) {
export function selectMediaFolders<EF extends BaseField>(
config: Config<EF>,
collection: Collection<EF>,
entry: Entry,
) {
const fields = selectFieldsWithMediaFolders(collection, entry.slug);
const folders = fields.map(f => selectMediaFolder(config, collection, entry, f));
if ('files' in collection) {
@ -263,7 +286,7 @@ export function selectMediaFolders(config: Config, collection: Collection, entry
return [...new Set(folders)];
}
export function getFieldsNames(fields: Field[] | undefined, prefix = '') {
export function getFieldsNames<EF extends BaseField>(fields: Field<EF>[] | undefined, prefix = '') {
let names = fields?.map(f => `${prefix}${f.name}`) ?? [];
fields?.forEach((f, index) => {
@ -333,7 +356,7 @@ export function updateFieldByKey(
return collection;
}
export function selectIdentifier(collection: Collection) {
export function selectIdentifier<EF extends BaseField>(collection: Collection<EF>) {
const identifier = collection.identifier_field;
const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : [...IDENTIFIER_FIELDS];
const fieldNames = getFieldsNames('fields' in collection ? collection.fields ?? [] : []);
@ -342,7 +365,10 @@ export function selectIdentifier(collection: Collection) {
);
}
export function selectInferredField(collection: Collection, fieldName: string) {
export function selectInferredField<EF extends BaseField>(
collection: Collection<EF>,
fieldName: string,
) {
if (fieldName === 'title' && collection.identifier_field) {
return selectIdentifier(collection);
}
@ -367,7 +393,7 @@ export function selectInferredField(collection: Collection, fieldName: string) {
}
// Try to return a field of the specified type with one of the synonyms
const mainTypeFields = fields
.filter((f: Field | Field) => (f.widget ?? 'string') === inferableField.type)
.filter((f: Field<EF>) => (f.widget ?? 'string') === inferableField.type)
.map(f => f?.name);
field = mainTypeFields.filter(f => inferableField.synonyms.indexOf(f as string) !== -1);
if (field && field.length > 0) {

View File

@ -3,13 +3,19 @@ import get from 'lodash/get';
import { getLocaleDataPath } from '../i18n';
import { keyToPathArray } from '../widgets/stringTemplate';
import type { Collection, Entry, Field, ValueOrNestedValue } from '@staticcms/core/interface';
import type {
BaseField,
Collection,
Entry,
Field,
ValueOrNestedValue,
} from '@staticcms/core/interface';
import type { t } from 'react-polyglot';
export function selectField(collection: Collection, key: string) {
export function selectField<EF extends BaseField>(collection: Collection<EF>, key: string) {
const array = keyToPathArray(key);
let name: string | undefined;
let field: Field | undefined;
let field: Field<EF> | undefined;
if ('fields' in collection) {
let fields = collection.fields ?? [];

View File

@ -1,26 +1,30 @@
import { dirname, join } from 'path';
import trim from 'lodash/trim';
import { dirname, join } from 'path';
import { basename, isAbsolutePath } from '.';
import { folderFormatter } from '../formatters';
import { joinUrlPath } from '../urlHelper';
import { basename, isAbsolutePath } from '.';
import type {
Config,
Field,
BaseField,
Collection,
CollectionFile,
Config,
Entry,
Field,
FileOrImageField,
MarkdownField,
ListField,
ObjectField,
MarkdownField,
MediaField,
ObjectField,
} from '@staticcms/core/interface';
export const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES';
function getFileField(collectionFiles: CollectionFile[], slug: string | undefined) {
function getFileField<EF extends BaseField>(
collectionFiles: CollectionFile<EF>[],
slug: string | undefined,
) {
const file = collectionFiles.find(f => f?.name === slug);
return file;
}
@ -32,9 +36,9 @@ function isMediaField(
return Boolean(field && folderKey in field);
}
function hasCustomFolder(
function hasCustomFolder<EF extends BaseField>(
folderKey: 'media_folder' | 'public_folder',
collection: Collection | undefined | null,
collection: Collection<EF> | undefined | null,
slug: string | undefined,
field: MediaField | undefined,
): field is FileOrImageField | MarkdownField {
@ -62,10 +66,10 @@ function hasCustomFolder(
return false;
}
function evaluateFolder(
function evaluateFolder<EF extends BaseField>(
folderKey: 'media_folder' | 'public_folder',
config: Config,
c: Collection,
config: Config<EF>,
c: Collection<EF>,
entryMap: Entry | null | undefined,
field: FileOrImageField | MarkdownField,
) {
@ -114,7 +118,7 @@ function evaluateFolder(
collection,
entryMap,
field,
file.fields! as Field[],
file.fields,
currentFolder,
);
@ -142,7 +146,7 @@ function evaluateFolder(
collection,
entryMap,
field,
collection.fields! as Field[],
collection.fields,
currentFolder,
);
@ -155,13 +159,13 @@ function evaluateFolder(
return currentFolder;
}
function traverseFields(
function traverseFields<EF extends BaseField>(
folderKey: 'media_folder' | 'public_folder',
config: Config,
collection: Collection,
config: Config<EF>,
collection: Collection<EF>,
entryMap: Entry | null | undefined,
field: FileOrImageField | MarkdownField | ListField | ObjectField,
fields: Field[],
field: FileOrImageField | MarkdownField | ListField<EF> | ObjectField<EF>,
fields: Field<EF>[],
currentFolder: string,
): string | null {
const matchedField = fields.filter(f => f === field)[0] as
@ -182,7 +186,7 @@ function traverseFields(
}
for (const f of fields) {
const childField: Field = { ...f };
const childField: Field<EF> = { ...f };
if (isMediaField(folderKey, childField) && !childField[folderKey]) {
// add identity template if doesn't exist
childField[folderKey] = `{{${folderKey}}}`;
@ -225,9 +229,9 @@ function traverseFields(
return null;
}
export function selectMediaFolder(
config: Config,
collection: Collection | undefined | null,
export function selectMediaFolder<EF extends BaseField>(
config: Config<EF>,
collection: Collection<EF> | undefined | null,
entryMap: Entry | null | undefined,
field: MediaField | undefined,
) {
@ -250,12 +254,12 @@ export function selectMediaFolder(
return trim(mediaFolder, '/');
}
export function selectMediaFilePublicPath(
config: Config,
collection: Collection | null,
export function selectMediaFilePublicPath<EF extends BaseField>(
config: Config<EF>,
collection: Collection<EF> | null,
mediaPath: string,
entryMap: Entry | undefined,
field: Field | undefined,
field: Field<EF> | undefined,
) {
if (isAbsolutePath(mediaPath)) {
return mediaPath;

View File

@ -0,0 +1,42 @@
import trim from 'lodash/trim';
import { basename, dirname, extname, join } from 'path';
import { selectFolderEntryExtension } from './collection.util';
import type { Collection, Entry } from '@staticcms/core/interface';
export function selectCustomPath(entry: Entry, collection: Collection): string | undefined {
if (!('nested' in collection) || !collection.nested?.path || !entry.meta) {
return undefined;
}
const indexFile = collection.nested.path.index_file;
const extension = selectFolderEntryExtension(collection);
const customPath = join(collection.folder, entry.meta.path, `${indexFile}.${extension}`);
return customPath;
}
export function customPathFromSlug(collection: Collection, slug: string): string {
if (!('nested' in collection) || !collection.nested) {
return '';
}
if (collection.nested.path) {
if ('nested' in collection && collection.nested?.path) {
return slug.replace(new RegExp(`/${collection.nested.path.index_file}$`, 'g'), '');
}
}
return slug;
}
export function slugFromCustomPath(collection: Collection, customPath: string): string {
if (!('folder' in collection)) {
return '';
}
const folderPath = collection.folder;
const entryPath = customPath.toLowerCase().replace(folderPath.toLowerCase(), '');
const slug = join(dirname(trim(entryPath, '/')), basename(entryPath, extname(customPath)));
return slug;
}

View File

@ -28,6 +28,7 @@ describe('entryDraft', () => {
},
value: 'newValue',
i18n: undefined,
isMeta: false,
},
});
@ -38,6 +39,28 @@ describe('entryDraft', () => {
});
});
it('should update meta path with value', () => {
const state = entryDraftReducer(startState, {
type: DRAFT_CHANGE_FIELD,
payload: {
path: 'path1.path2',
field: {
widget: 'string',
name: 'stringInput',
},
value: 'newValue',
i18n: undefined,
isMeta: true,
},
});
expect(state.entry?.meta).toEqual({
path1: {
path2: 'newValue',
},
});
});
it('should update path with value for singleton list', () => {
let state = entryDraftReducer(startState, {
type: DRAFT_CHANGE_FIELD,
@ -49,6 +72,7 @@ describe('entryDraft', () => {
},
value: ['newValue1', 'newValue2', 'newValue3'],
i18n: undefined,
isMeta: false,
},
});
@ -66,6 +90,7 @@ describe('entryDraft', () => {
},
value: 'newValue2Updated',
i18n: undefined,
isMeta: false,
},
});
@ -91,6 +116,7 @@ describe('entryDraft', () => {
defaultLocale: 'en',
currentLocale: 'en',
},
isMeta: false,
},
});
@ -138,6 +164,7 @@ describe('entryDraft', () => {
field,
value: ['newValue1', 'newValue2', 'newValue3'],
i18n,
isMeta: false,
},
});
@ -165,6 +192,7 @@ describe('entryDraft', () => {
field,
value: 'newValue2Updated',
i18n,
isMeta: false,
},
});

View File

@ -1,9 +1,9 @@
import { CONFIG_SUCCESS } from '../constants';
import type { ConfigAction } from '../actions/config';
import type { Collection, Collections } from '../interface';
import type { BaseField, Collection, Collections, UnknownField } from '../interface';
export type CollectionsState = Collections;
export type CollectionsState<EF extends BaseField = UnknownField> = Collections<EF>;
const defaultState: CollectionsState = {};

View File

@ -1,10 +1,10 @@
import { CONFIG_FAILURE, CONFIG_REQUEST, CONFIG_SUCCESS } from '../constants';
import type { ConfigAction } from '../actions/config';
import type { Config } from '../interface';
import type { BaseField, Config, UnknownField } from '../interface';
export interface ConfigState {
config?: Config;
export interface ConfigState<EF extends BaseField = UnknownField> {
config?: Config<EF>;
isFetching: boolean;
error?: string;
}

View File

@ -1,4 +1,3 @@
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import { v4 as uuid } from 'uuid';
@ -154,8 +153,10 @@ function entryDraftReducer(
return state;
}
const { path, field, value, i18n } = action.payload;
const dataPath = (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data'];
const { path, field, value, i18n, isMeta } = action.payload;
const dataPath = isMeta
? ['meta']
: (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data'];
newState = {
...newState,
@ -166,11 +167,20 @@ function entryDraftReducer(
newState = duplicateI18nFields(newState, field, i18n.locales, i18n.defaultLocale, path);
}
const newData = get(newState.entry, dataPath) ?? {};
let hasChanged =
!isEqual(newState.entry?.meta, newState.original?.meta) ||
!isEqual(newState.entry?.data, newState.original?.data);
const i18nData = newState.entry?.i18n ?? {};
for (const locale in i18nData) {
hasChanged =
hasChanged ||
!isEqual(newState.entry?.i18n?.[locale]?.data, newState.original?.i18n?.[locale]?.data);
}
return {
...newState,
hasChanged: !newState.original || !isEqual(newData, get(newState.original, dataPath)),
hasChanged: !newState.original || hasChanged,
};
}

View File

@ -1,10 +1,13 @@
/* eslint-disable import/prefer-default-export */
import type { BaseField, UnknownField } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
export const selectCollections = (state: RootState) => {
export const selectCollections = <EF extends BaseField = UnknownField>(state: RootState<EF>) => {
return state.collections;
};
export const selectCollection = (collectionName: string | undefined) => (state: RootState) => {
return Object.values(state.collections).find(collection => collection.name === collectionName);
};
export const selectCollection =
<EF extends BaseField = UnknownField>(collectionName: string | undefined) =>
(state: RootState<EF>) => {
return Object.values(state.collections).find(collection => collection.name === collectionName);
};

View File

@ -3,6 +3,10 @@ import { configureStore } from '@reduxjs/toolkit';
import createRootReducer from '../reducers/combinedReducer';
import { waitUntilAction } from './middleware/waitUntilAction';
import type { BaseField, UnknownField } from '../interface';
import type { CollectionsState } from '../reducers/collections';
import type { ConfigState } from '../reducers/config';
const store = configureStore({
reducer: createRootReducer(),
middleware: getDefaultMiddleware =>
@ -13,6 +17,12 @@ const store = configureStore({
});
export { store };
export type RootState = ReturnType<typeof store.getState>;
export type RootState<EF extends BaseField = UnknownField> = Omit<
ReturnType<typeof store.getState>,
'collection' | 'config'
> & {
collection: CollectionsState<EF>;
config: ConfigState<EF>;
};
export type AppStore = typeof store;
export type AppDispatch = typeof store.dispatch;

View File

@ -10,7 +10,13 @@ import { basename } from '@staticcms/core/lib/util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import SortableImage from './components/SortableImage';
import type { FileOrImageField, MediaPath, WidgetControlProps } from '@staticcms/core/interface';
import type {
BaseField,
Collection,
FileOrImageField,
MediaPath,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
const MAX_DISPLAY_LENGTH = 50;
@ -143,7 +149,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
replaceIndex: index,
allowMultiple: false,
config,
collection,
collection: collection as Collection<BaseField>,
field,
});
},

View File

@ -54,7 +54,10 @@ module.exports = {
},
{
test: /\.css$/,
include: [...['ol', 'codemirror', '@toast-ui'].map(moduleNameToPath), path.resolve(__dirname, 'src')],
include: [
...['ol', 'codemirror', '@toast-ui'].map(moduleNameToPath),
path.resolve(__dirname, 'src'),
],
use: [
!isProduction ? 'style-loader' : MiniCssExtractPlugin.loader,
'css-loader',
@ -112,6 +115,7 @@ module.exports = {
devServer: {
static: {
directory: './dev-test',
watch: false,
},
host: '0.0.0.0',
port: devServerPort,

View File

@ -1249,3 +1249,22 @@ collections:
label: Date
widget: datetime
i18n: duplicate
- name: pages
label: Nested Pages
label_singular: 'Page'
folder: _nested_pages
create: true
# adding a nested object will show the collection folder structure
nested:
depth: 100 # max depth to show in the collection tree
summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field
# adding a path object allows editing the path of entries
# moving an existing entry will move the entire sub tree of the entry to the new location
path: { label: 'Path', index_file: 'index' }
fields:
- label: Title
name: title
widget: string
- label: Body
name: body
widget: markdown

View File

@ -134,6 +134,32 @@
"---\ndescription: Le café est un petit arbre ou un arbuste qui pousse dans le sous-étage de la forêt sous sa forme sauvage et qui était traditionnellement cultivé commercialement sous d'autres arbres qui fournissaient de l'ombre. La structure forestière des plantations de café d'ombre fournit un habitat à un grand nombre d'espèces migratrices et résidentes.\ndate: 2015-02-14T00:00:00.000Z\n---\n",
},
},
_nested_pages: {
authors: {
'author-1': {
'index.md': {
content: '---\ntitle: An Author\n---\nAuthor details go here!.\n',
},
},
'index.md': {
content: '---\ntitle: Authors\n---\n',
},
},
posts: {
'hello-world': {
'index.md': {
content:
'---\ntitle: Hello World\n---\nCoffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species.\n',
},
},
'index.md': {
content: '---\ntitle: Posts\n---\n',
},
},
'index.md': {
content: '---\ntitle: Pages\n---\n',
},
},
};
var ONE_DAY = 60 * 60 * 24 * 1000;

View File

@ -311,6 +311,9 @@ collections:
nested:
depth: 100 # max depth to show in the collection tree
summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field
# adding a path object allows editing the path of entries
# moving an existing entry will move the entire sub tree of the entry to the new location
path: { widget: string, index_file: 'index' }
fields:
- label: Title
name: title
@ -318,9 +321,6 @@ collections:
- label: Body
name: body
widget: markdown
# adding a meta object with a path property allows editing the path of entries
# moving an existing entry will move the entire sub tree of the entry to the new location
meta: { path: { widget: string, label: 'Path', index_file: 'index' } }
```
```js
@ -334,7 +334,11 @@ collections:
"create": true,
"nested": {
"depth": 100,
"summary": "{{title}}"
"summary": "{{title}}",
"path": {
"label": "Path",
"index_file": "index"
}
},
"fields": [
{
@ -347,14 +351,7 @@ collections:
"name": "body",
"widget": "markdown"
}
],
"meta": {
"path": {
"widget": "string",
"label": "Path",
"index_file": "index"
}
}
]
}
]
}