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` - widget prop `isHidden` renamed to `hidden`
- useMediaInsert now requires collection to be passed - useMediaInsert now requires collection to be passed
- media path changed from `string | string[]` to `{ path: string | string[], alt?: string }` - media path changed from `string | string[]` to `{ path: string | string[], alt?: string }`
- Nested collections, meta config moved into nested config.
ADDED ADDED
- `forSingleList` - Allows for changing styles for single list items - `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 label: Date
widget: datetime widget: datetime
i18n: duplicate 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 label: Date
widget: datetime widget: datetime
i18n: duplicate 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", "---\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; 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); return 'types' in (field as ListField) || 'field' in (field as ListField);
} }
function traverseFieldsJS<F extends Field>( function traverseFieldsJS<F extends BaseField = UnknownField>(
fields: F[], fields: F[],
updater: <T extends Field>(field: T) => T, updater: <T extends BaseField = UnknownField>(field: T) => T,
): F[] { ): F[] {
return fields.map(field => { return fields.map(field => {
const newField = updater(field); const newField = updater(field);
@ -68,14 +68,14 @@ function getConfigUrl() {
return 'config.yml'; 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)) { if ('media_folder' in field && !('public_folder' in field)) {
return { ...field, public_folder: field.media_folder }; return { ...field, public_folder: field.media_folder };
} }
return field; return field;
} }
function setI18nField<T extends Field>(field: T) { function setI18nField<T extends BaseField = UnknownField>(field: T) {
if (field[I18N] === true) { if (field[I18N] === true) {
return { ...field, [I18N]: I18N_FIELD_TRANSLATE }; return { ...field, [I18N]: I18N_FIELD_TRANSLATE };
} else if (field[I18N] === false || !field[I18N]) { } else if (field[I18N] === false || !field[I18N]) {

View File

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

View File

@ -12,7 +12,7 @@ import { getMediaFile } from './mediaLibrary';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk'; 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 { RootState } from '../store';
import type AssetProxy from '../valueObjects/AssetProxy'; import type AssetProxy from '../valueObjects/AssetProxy';
@ -72,11 +72,11 @@ async function loadAsset(
const promiseCache: Record<string, Promise<AssetProxy>> = {}; const promiseCache: Record<string, Promise<AssetProxy>> = {};
export function getAsset<F extends BaseField = UnknownField>( export function getAsset<T extends MediaField, EF extends BaseField = UnknownField>(
collection: Collection<F> | null | undefined, collection: Collection<EF> | null | undefined,
entry: Entry | null | undefined, entry: Entry | null | undefined,
path: string, path: string,
field?: F, field?: T,
) { ) {
return ( return (
dispatch: ThunkDispatch<RootState, {}, AnyAction>, 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: { payload: {
controlID?: string; controlID?: string;
forImage?: boolean; forImage?: boolean;
@ -86,8 +86,8 @@ export function openMediaLibrary<F extends BaseField = UnknownField>(
allowMultiple?: boolean; allowMultiple?: boolean;
replaceIndex?: number; replaceIndex?: number;
config?: Record<string, unknown>; config?: Record<string, unknown>;
collection?: Collection<F>; collection?: Collection<EF>;
field?: F; field?: EF;
insertOptions?: MediaLibrarInsertOptions; insertOptions?: MediaLibrarInsertOptions;
} = {}, } = {},
) { ) {

View File

@ -20,10 +20,10 @@ import {
import { getBackend, invokeEvent } from './lib/registry'; import { getBackend, invokeEvent } from './lib/registry';
import { sanitizeChar } from './lib/urlHelper'; import { sanitizeChar } from './lib/urlHelper';
import { import {
CURSOR_COMPATIBILITY_SYMBOL,
Cursor,
asyncLock, asyncLock,
blobToFileObj, blobToFileObj,
Cursor,
CURSOR_COMPATIBILITY_SYMBOL,
getPathDepth, getPathDepth,
localForage, localForage,
} from './lib/util'; } from './lib/util';
@ -39,6 +39,7 @@ import {
selectMediaFolders, selectMediaFolders,
} from './lib/util/collection.util'; } from './lib/util/collection.util';
import { selectMediaFilePath, selectMediaFilePublicPath } from './lib/util/media.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 { set } from './lib/util/object.util';
import { dateParsers, expandPath, extractTemplateVars } from './lib/widgets/stringTemplate'; import { dateParsers, expandPath, extractTemplateVars } from './lib/widgets/stringTemplate';
import createEntry from './valueObjects/createEntry'; import createEntry from './valueObjects/createEntry';
@ -59,6 +60,7 @@ import type {
Field, Field,
FilterRule, FilterRule,
ImplementationEntry, ImplementationEntry,
PersistArgs,
SearchQueryResponse, SearchQueryResponse,
SearchResponse, SearchResponse,
UnknownField, UnknownField,
@ -230,9 +232,9 @@ interface AuthStore {
logout: () => void; logout: () => void;
} }
interface BackendOptions { interface BackendOptions<EF extends BaseField> {
backendName: string; backendName: string;
config: Config; config: Config<EF>;
authStore?: AuthStore; authStore?: AuthStore;
} }
@ -258,16 +260,7 @@ interface BackupEntry {
i18n?: Record<string, { raw: string }>; i18n?: Record<string, { raw: string }>;
} }
interface PersistArgs { function collectionDepth<EF extends BaseField>(collection: Collection<EF>) {
config: Config;
collection: Collection;
entryDraft: EntryDraft;
assetProxies: AssetProxy[];
usedSlugs: string[];
status?: string;
}
function collectionDepth(collection: Collection) {
let depth; let depth;
depth = depth =
('nested' in collection && collection.nested?.depth) || getPathDepth(collection.path ?? ''); ('nested' in collection && collection.nested?.depth) || getPathDepth(collection.path ?? '');
@ -279,17 +272,17 @@ function collectionDepth(collection: Collection) {
return depth; return depth;
} }
export class Backend<BC extends BackendClass = BackendClass> { export class Backend<EF extends BaseField = UnknownField, BC extends BackendClass = BackendClass> {
implementation: BC; implementation: BC;
backendName: string; backendName: string;
config: Config; config: Config<EF>;
authStore?: AuthStore; authStore?: AuthStore;
user?: User | null; user?: User | null;
backupSync: AsyncLock; backupSync: AsyncLock;
constructor( constructor(
implementation: BackendInitializer, implementation: BackendInitializer<EF>,
{ backendName, authStore, config }: BackendOptions, { backendName, authStore, config }: BackendOptions<EF>,
) { ) {
// We can't reliably run this on exit, so we do cleanup on load. // We can't reliably run this on exit, so we do cleanup on load.
this.deleteAnonymousBackup(); this.deleteAnonymousBackup();
@ -401,9 +394,15 @@ export class Backend<BC extends BackendClass = BackendClass> {
entryData: EntryData, entryData: EntryData,
config: Config, config: Config,
usedSlugs: string[], usedSlugs: string[],
customPath: string | undefined,
) { ) {
const slugConfig = config.slug; 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 i = 1;
let uniqueSlug = slug; let uniqueSlug = slug;
@ -417,7 +416,10 @@ export class Backend<BC extends BackendClass = BackendClass> {
return uniqueSlug; return uniqueSlug;
} }
processEntries(loadedEntries: ImplementationEntry[], collection: Collection): Entry[] { processEntries<EF extends BaseField>(
loadedEntries: ImplementationEntry[],
collection: Collection<EF>,
): Entry[] {
const entries = loadedEntries.map(loadedEntry => const entries = loadedEntries.map(loadedEntry =>
createEntry( createEntry(
collection.name, collection.name,
@ -486,13 +488,13 @@ export class Backend<BC extends BackendClass = BackendClass> {
// repeats the process. Once there is no available "next" action, it // repeats the process. Once there is no available "next" action, it
// returns all the collected entries. Used to retrieve all entries // returns all the collected entries. Used to retrieve all entries
// for local searches and queries. // 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) { if ('folder' in collection && collection.folder && this.implementation.allEntriesByFolder) {
const depth = collectionDepth(collection as Collection); const depth = collectionDepth(collection);
const extension = selectFolderEntryExtension(collection as Collection); const extension = selectFolderEntryExtension(collection);
return this.implementation return this.implementation
.allEntriesByFolder(collection.folder as string, extension, depth) .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); const response = await this.listEntries(collection as Collection);
@ -565,8 +567,8 @@ export class Backend<BC extends BackendClass = BackendClass> {
return { entries: hits, pagination: 1 }; return { entries: hits, pagination: 1 };
} }
async query<T extends BaseField = UnknownField>( async query<EF extends BaseField>(
collection: Collection<T>, collection: Collection<EF>,
searchFields: string[], searchFields: string[],
searchTerm: string, searchTerm: string,
file?: string, file?: string,
@ -714,7 +716,11 @@ export class Backend<BC extends BackendClass = BackendClass> {
return localForage.removeItem(getEntryBackupKey()); 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 path = selectEntryPath(collection, slug) as string;
const label = selectFileEntryLabel(collection, slug); const label = selectFileEntryLabel(collection, slug);
const extension = selectFolderEntryExtension(collection); const extension = selectFolderEntryExtension(collection);
@ -762,7 +768,7 @@ export class Backend<BC extends BackendClass = BackendClass> {
return Promise.reject(err); return Promise.reject(err);
} }
entryWithFormat(collection: Collection) { entryWithFormat<EF extends BaseField>(collection: Collection<EF>) {
return (entry: Entry): Entry => { return (entry: Entry): Entry => {
const format = resolveFormat(collection, entry); const format = resolveFormat(collection, entry);
if (entry && entry.raw !== undefined) { 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; const configState = state.config;
if (!configState.config) { if (!configState.config) {
throw new Error('Config not loaded'); throw new Error('Config not loaded');
@ -826,6 +836,8 @@ export class Backend<BC extends BackendClass = BackendClass> {
const newEntry = entryDraft.entry.newRecord ?? false; const newEntry = entryDraft.entry.newRecord ?? false;
const customPath = selectCustomPath(draft.entry, collection);
let dataFile: DataFile; let dataFile: DataFile;
if (newEntry) { if (newEntry) {
if (!selectAllowNewEntries(collection)) { if (!selectAllowNewEntries(collection)) {
@ -836,8 +848,9 @@ export class Backend<BC extends BackendClass = BackendClass> {
entryDraft.entry.data, entryDraft.entry.data,
config, config,
usedSlugs, usedSlugs,
customPath,
); );
const path = selectEntryPath(collection, slug) ?? ''; const path = customPath || (selectEntryPath(collection, slug) ?? '');
dataFile = { dataFile = {
path, path,
slug, slug,
@ -849,8 +862,9 @@ export class Backend<BC extends BackendClass = BackendClass> {
const slug = entryDraft.entry.slug; const slug = entryDraft.entry.slug;
dataFile = { dataFile = {
path: entryDraft.entry.path, path: entryDraft.entry.path,
slug, slug: customPath ? slugFromCustomPath(collection, customPath) : slug,
raw: this.entryToRaw(collection, entryDraft.entry), 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); 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; const configState = state.config;
if (!configState.config) { if (!configState.config) {
throw new Error('Config not loaded'); 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) { if (!config?.backend.name) {
throw new Error('No backend defined in configuration'); throw new Error('No backend defined in configuration');
} }
@ -1020,22 +1038,22 @@ export function resolveBackend(config?: Config) {
const { name } = config.backend; const { name } = config.backend;
const authStore = new LocalStorageAuthStore(); const authStore = new LocalStorageAuthStore();
const backend = getBackend(name); const backend = getBackend<EF>(name);
if (!backend) { if (!backend) {
throw new Error(`Backend not found: ${name}`); throw new Error(`Backend not found: ${name}`);
} else { } else {
return new Backend(backend, { backendName: name, authStore, config }); return new Backend<EF, BackendClass>(backend, { backendName: name, authStore, config });
} }
} }
export const currentBackend = (function () { export const currentBackend = (function () {
let backend: Backend; let backend: Backend;
return <T extends BaseField = UnknownField>(config: Config<T>) => { return <EF extends BaseField = UnknownField>(config: Config<EF>) => {
if (backend) { if (backend) {
return 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) { async persistEntry(entry: BackendEntry) {
entry.dataFiles.forEach(dataFile => { entry.dataFiles.forEach(dataFile => {
const { path, raw } = dataFile; const { path, newPath, raw } = dataFile;
if (newPath) {
deleteFile(path, window.repoFiles);
writeFile(newPath, raw, window.repoFiles);
} else {
writeFile(path, raw, window.repoFiles); writeFile(path, raw, window.repoFiles);
}
}); });
entry.assets.forEach(a => { entry.assets.forEach(a => {
writeFile(a.path, a, window.repoFiles); 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 { 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 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 Button from '../common/button/Button';
import type { Collection, TranslatedProps } from '@staticcms/core/interface'; import type { Collection, Entry, TranslatedProps } from '@staticcms/core/interface';
interface CollectionHeaderProps { interface CollectionHeaderProps {
collection: Collection; collection: Collection;
@ -30,17 +36,54 @@ const CollectionHeader = ({
const icon = useIcon(collection.icon); 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 ( return (
<> <>
<div className="flex flex-grow gap-4"> <div className="flex flex-grow gap-4">
<h2 className="text-xl font-semibold flex items-center text-gray-800 dark:text-gray-300"> <h2 className="text-xl font-semibold flex items-center text-gray-800 dark:text-gray-300">
<div className="mr-2 flex">{icon}</div> <div className="mr-2 flex">{icon}</div>
{collectionLabel} {pluralLabel}
</h2> </h2>
{newEntryUrl ? ( {newEntryUrl ? (
<Button onClick={onNewClick}> <Button onClick={onNewClick}>
{t('collection.collectionTop.newButton', { {t('collection.collectionTop.newButton', {
collectionLabel: collectionLabelSingular || collectionLabel, collectionLabel: collectionLabelSingular || pluralLabel,
})} })}
</Button> </Button>
) : null} ) : 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'; } from '@staticcms/core/reducers/selectors/collections';
import { useAppSelector } from '@staticcms/core/store/hooks'; import { useAppSelector } from '@staticcms/core/store/hooks';
import { getDefaultPath } from '../../lib/util/collection.util'; import { getDefaultPath } from '../../lib/util/collection.util';
import MainView from '../MainView'; import CollectionPage from './CollectionPage';
import Collection from './Collection';
import type { Collection } from '@staticcms/core/interface';
interface CollectionRouteProps { interface CollectionRouteProps {
isSearchResults?: boolean; isSearchResults?: boolean;
@ -16,8 +17,7 @@ interface CollectionRouteProps {
} }
const CollectionRoute = ({ isSearchResults, isSingleSearchResult }: CollectionRouteProps) => { const CollectionRoute = ({ isSearchResults, isSingleSearchResult }: CollectionRouteProps) => {
const { name, searchTerm, ...params } = useParams(); const { name, searchTerm } = useParams();
const filterTerm = params['*'];
const collectionSelector = useMemo(() => selectCollection(name), [name]); const collectionSelector = useMemo(() => selectCollection(name), [name]);
const collection = useAppSelector(collectionSelector); const collection = useAppSelector(collectionSelector);
@ -34,15 +34,11 @@ const CollectionRoute = ({ isSearchResults, isSingleSearchResult }: CollectionRo
} }
return ( return (
<MainView breadcrumbs={[{ name: collection?.label }]} showQuickCreate showLeftNav> <CollectionPage
<Collection collection={collection as unknown as Collection}
name={name}
searchTerm={searchTerm}
filterTerm={filterTerm}
isSearchResults={isSearchResults} isSearchResults={isSearchResults}
isSingleSearchResult={isSingleSearchResult} isSingleSearchResult={isSingleSearchResult}
/> />
</MainView>
); );
}; };

View File

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

View File

@ -1,9 +1,12 @@
import { Article as ArticleIcon } from '@styled-icons/material/Article'; import { Article as ArticleIcon } from '@styled-icons/material/Article';
import { ChevronRight as ChevronRightIcon } from '@styled-icons/material/ChevronRight';
import sortBy from 'lodash/sortBy'; import sortBy from 'lodash/sortBy';
import { dirname, sep } from 'path'; import { dirname, sep } from 'path';
import React, { Fragment, useCallback, useEffect, useState } from 'react'; import React, { Fragment, useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import useEntries from '@staticcms/core/lib/hooks/useEntries'; 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 { selectEntryCollectionTitle } from '@staticcms/core/lib/util/collection.util';
import { stringTemplate } from '@staticcms/core/lib/widgets'; import { stringTemplate } from '@staticcms/core/lib/widgets';
import NavLink from '../navbar/NavLink'; import NavLink from '../navbar/NavLink';
@ -61,19 +64,32 @@ const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps)
return ( return (
<Fragment key={node.path}> <Fragment key={node.path}>
<div className={classNames(depth !== 0 && 'ml-8')}>
<NavLink <NavLink
to={to} to={to}
onClick={() => onToggle({ node, expanded: !node.expanded })} onClick={() => onToggle({ node, expanded: !node.expanded })}
data-testid={node.path} data-testid={node.path}
icon={<ArticleIcon className={classNames(depth === 0 ? 'h-6 w-6' : 'h-5 w-5')} />}
> >
{/* TODO $activeClassName="sidebar-active" */} <div className="flex w-full gap-2 items-center justify-between">
{/* TODO $depth={depth} */}
<ArticleIcon className="h-5 w-5" />
<div>
<div>{title}</div> <div>{title}</div>
{hasChildren && (node.expanded ? <div /> : <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> </div>
</NavLink> </NavLink>
<div className="mt-2 space-y-1.5">
{node.expanded && ( {node.expanded && (
<TreeNode <TreeNode
collection={collection} collection={collection}
@ -82,6 +98,8 @@ const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps)
onToggle={onToggle} onToggle={onToggle}
/> />
)} )}
</div>
</div>
</Fragment> </Fragment>
); );
})} })}
@ -141,12 +159,12 @@ export function getTreeData(collection: Collection, entries: Entry[]): TreeNodeD
isRoot: false, isRoot: false,
})), })),
...entriesObj.map((e, index) => { ...entriesObj.map((e, index) => {
let entryMap = entries[index]; let entry = entries[index];
entryMap = { entry = {
...entryMap, ...entry,
data: addFileTemplateFields(entryMap.path, entryMap.data as Record<string, string>), data: addFileTemplateFields(entry.path, entry.data as Record<string, string>),
}; };
const title = selectEntryCollectionTitle(collection, entryMap); const title = selectEntryCollectionTitle(collection, entry);
return { return {
...e, ...e,
title, title,
@ -219,9 +237,11 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
const [selected, setSelected] = useState<TreeNodeData | null>(null); const [selected, setSelected] = useState<TreeNodeData | null>(null);
const [useFilter, setUseFilter] = useState(true); const [useFilter, setUseFilter] = useState(true);
const [prevCollection, setPrevCollection] = useState(collection); const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
const [prevEntries, setPrevEntries] = useState(entries); const [prevEntries, setPrevEntries] = useState<Entry[] | null>(null);
const [prevFilterTerm, setPrevFilterTerm] = useState(filterTerm); const [prevFilterTerm, setPrevFilterTerm] = useState<string | null>(null);
const { pathname } = useLocation();
useEffect(() => { useEffect(() => {
if (collection !== prevCollection || entries !== prevEntries || filterTerm !== prevFilterTerm) { if (collection !== prevCollection || entries !== prevEntries || filterTerm !== prevFilterTerm) {
@ -235,7 +255,12 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
const path = `/${filterTerm}`; const path = `/${filterTerm}`;
walk(newTreeData, node => { 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; node.expanded = true;
} }
}); });
@ -250,6 +275,7 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
collection, collection,
entries, entries,
filterTerm, filterTerm,
pathname,
prevCollection, prevCollection,
prevEntries, prevEntries,
prevFilterTerm, 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 { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { useAppSelector } from '@staticcms/core/store/hooks'; 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; src?: string;
alt?: string; alt?: string;
className?: string; className?: string;
collection?: Collection<F>; collection?: Collection<EF>;
field?: F; field?: MediaField;
'data-testid'?: string; 'data-testid'?: string;
} }
const Image = <F extends MediaField>({ const Image = <EF extends BaseField = UnknownField>({
src, src,
alt, alt,
className, className,
collection, collection,
field, field,
'data-testid': dataTestId, 'data-testid': dataTestId,
}: ImageProps<F>) => { }: ImageProps<EF>) => {
const entry = useAppSelector(selectEditingDraft); const entry = useAppSelector(selectEditingDraft);
const assetSource = useMediaAsset(src, collection, field, entry); 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, collection,
field, field,
}: Pick<ImageProps<F>, 'collection' | 'field'>) => { }: Pick<ImageProps<EF>, 'collection' | 'field'>) => {
const MdxImage = (props: Omit<ImageProps<F>, 'collection' | 'field'>) => ( const MdxImage = (props: Omit<ImageProps<EF>, 'collection' | 'field'>) => (
<Image {...props} collection={collection} field={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 { resolveWidget } from '@staticcms/core/lib/registry';
import { selectField } from '@staticcms/core/lib/util/field.util'; 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 { getTypedFieldForValue } from '@staticcms/list/typedListHelpers';
import PreviewHOC from './PreviewHOC'; import PreviewHOC from './PreviewHOC';
@ -86,7 +87,7 @@ export default function getWidgetFor(
let renderedValue: ValueOrNestedValue | ReactNode = value; let renderedValue: ValueOrNestedValue | ReactNode = value;
if (inferredField) { if (inferredField) {
renderedValue = inferredField.defaultPreview(String(value)); renderedValue = inferredField.defaultPreview(isNullish(value) ? '' : String(value));
} else if ( } else if (
value && value &&
fieldWithWidgets.widget && fieldWithWidgets.widget &&
@ -103,7 +104,7 @@ export default function getWidgetFor(
" "
> >
{field.label ?? field.name}: {field.label ?? field.name}:
</strong>{' '} </strong>
{value} {value}
</> </>
</div> </div>

View File

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

View File

@ -65,6 +65,7 @@ const EditorControl = ({
changeDraftField, changeDraftField,
i18n, i18n,
fieldName, fieldName,
isMeta = false,
}: TranslatedProps<EditorControlProps>) => { }: TranslatedProps<EditorControlProps>) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -103,7 +104,6 @@ const EditorControl = ({
} }
const validateValue = async () => { const validateValue = async () => {
console.log('VALIDATING', field.name);
const errors = await validate(field, value, widget, t); const errors = await validate(field, value, widget, t);
dispatch(changeDraftFieldValidation(path, errors, i18n)); dispatch(changeDraftFieldValidation(path, errors, i18n));
}; };
@ -114,9 +114,9 @@ const EditorControl = ({
const handleChangeDraftField = useCallback( const handleChangeDraftField = useCallback(
(value: ValueOrNestedValue) => { (value: ValueOrNestedValue) => {
setDirty(true); 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]); const config = useMemo(() => configState.config, [configState.config]);
@ -232,6 +232,7 @@ interface EditorControlOwnProps {
forSingleList?: boolean; forSingleList?: boolean;
i18n: I18nSettings | undefined; i18n: I18nSettings | undefined;
fieldName?: string; fieldName?: string;
isMeta?: boolean;
} }
function mapStateToProps(state: RootState, ownProps: EditorControlOwnProps) { 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 { getI18nInfo, hasI18n, isFieldTranslatable } from '@staticcms/core/lib/i18n';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { getFieldValue } from '@staticcms/core/lib/util/field.util'; import { getFieldValue } from '@staticcms/core/lib/util/field.util';
import { customPathFromSlug } from '@staticcms/core/lib/util/nested.util';
import EditorControl from './EditorControl'; import EditorControl from './EditorControl';
import LocaleDropdown from './LocaleDropdown'; import LocaleDropdown from './LocaleDropdown';
@ -12,6 +13,7 @@ import type {
Field, Field,
FieldsErrors, FieldsErrors,
I18nSettings, I18nSettings,
StringOrTextField,
TranslatedProps, TranslatedProps,
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
@ -39,6 +41,25 @@ const EditorControlPane = ({
onLocaleChange, onLocaleChange,
t, t,
}: TranslatedProps<EditorControlPaneProps>) => { }: 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(() => { const i18n = useMemo(() => {
if (hasI18n(collection)) { if (hasI18n(collection)) {
const { locales, defaultLocale } = getI18nInfo(collection); const { locales, defaultLocale } = getI18nInfo(collection);
@ -66,6 +87,7 @@ const EditorControlPane = ({
` `
flex flex
flex-col flex-col
min-h-full
`, `,
!hideBorder && !hideBorder &&
` `
@ -91,6 +113,19 @@ const EditorControlPane = ({
/> />
</div> </div>
) : null} ) : 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 => { {fields.map(field => {
const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale); const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale);
const key = i18n ? `field-${locale}_${field.name}` : `field-${field.name}`; 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'; import type { FC } from 'react';
interface CurrentMediaDetailsProps { interface CurrentMediaDetailsProps {
collection?: Collection<MediaField>; collection?: Collection;
field?: MediaField; field?: MediaField;
canInsert: boolean; canInsert: boolean;
url?: string | string[]; url?: string | string[];

View File

@ -13,14 +13,16 @@ import Pill from '../../common/pill/Pill';
import CopyToClipBoardButton from './CopyToClipBoardButton'; import CopyToClipBoardButton from './CopyToClipBoardButton';
import type { import type {
BaseField,
Collection, Collection,
Field, MediaField,
MediaLibraryDisplayURL, MediaLibraryDisplayURL,
TranslatedProps, TranslatedProps,
UnknownField,
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
import type { FC, KeyboardEvent } from 'react'; import type { FC, KeyboardEvent } from 'react';
interface MediaLibraryCardProps { interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = UnknownField> {
isSelected?: boolean; isSelected?: boolean;
displayURL: MediaLibraryDisplayURL; displayURL: MediaLibraryDisplayURL;
text: string; text: string;
@ -28,14 +30,14 @@ interface MediaLibraryCardProps {
type?: string; type?: string;
isViewableImage: boolean; isViewableImage: boolean;
isDraft?: boolean; isDraft?: boolean;
collection?: Collection; collection?: Collection<EF>;
field?: Field; field?: T;
onSelect: () => void; onSelect: () => void;
loadDisplayURL: () => void; loadDisplayURL: () => void;
onDelete: () => void; onDelete: () => void;
} }
const MediaLibraryCard: FC<TranslatedProps<MediaLibraryCardProps>> = ({ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownField>({
isSelected = false, isSelected = false,
displayURL, displayURL,
text, text,
@ -49,7 +51,7 @@ const MediaLibraryCard: FC<TranslatedProps<MediaLibraryCardProps>> = ({
loadDisplayURL, loadDisplayURL,
onDelete, onDelete,
t, t,
}) => { }: TranslatedProps<MediaLibraryCardProps<T, EF>>) => {
const entry = useAppSelector(selectEditingDraft); const entry = useAppSelector(selectEditingDraft);
const url = useMediaAsset(displayURL.url, collection, field, entry); 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 NavLink = ({ icon, children, onClick, ...otherProps }: NavLinkProps) => {
const content = useMemo( const content = useMemo(
() => ( () => (
<> <div className="flex w-full gap-3 items-center">
<span className="w-6 h-6">{icon}</span> <span className="w-6 h-6">{icon}</span>
<span className="ml-3">{children}</span> <span className="flex-grow">{children}</span>
</> </div>
), ),
[children, icon], [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 { selectIsSearchEnabled } from '@staticcms/core/reducers/selectors/config';
import { useAppSelector } from '@staticcms/core/store/hooks'; import { useAppSelector } from '@staticcms/core/store/hooks';
import CollectionSearch from '../collections/CollectionSearch'; import CollectionSearch from '../collections/CollectionSearch';
import NestedCollection from '../collections/NestedCollection';
import NavLink from './NavLink'; import NavLink from './NavLink';
import type { Collection } from '@staticcms/core/interface'; import type { Collection } from '@staticcms/core/interface';
@ -17,7 +18,9 @@ import type { FC } from 'react';
import type { TranslateProps } from 'react-polyglot'; import type { TranslateProps } from 'react-polyglot';
const Sidebar: FC<TranslateProps> = ({ t }) => { const Sidebar: FC<TranslateProps> = ({ t }) => {
const { name, searchTerm } = useParams(); const { name, searchTerm, ...params } = useParams();
const filterTerm = useMemo(() => params['*'] ?? '', [params]);
const navigate = useNavigate(); const navigate = useNavigate();
const isSearchEnabled = useAppSelector(selectIsSearchEnabled); const isSearchEnabled = useAppSelector(selectIsSearchEnabled);
const collections = useAppSelector(selectCollections); const collections = useAppSelector(selectCollections);
@ -35,18 +38,17 @@ const Sidebar: FC<TranslateProps> = ({ t }) => {
const collectionName = collection.name; const collectionName = collection.name;
const icon = getIcon(collection.icon); const icon = getIcon(collection.icon);
// TODO if ('nested' in collection) {
// if ('nested' in collection) { return (
// return ( <li key={`nested-${collectionName}`}>
// <li key={`nested-${collectionName}`}> <NestedCollection
// <NestedCollection collection={collection}
// collection={collection} filterTerm={filterTerm}
// filterTerm={filterTerm} data-testid={collectionName}
// data-testid={collectionName} />
// /> </li>
// </li> );
// ); }
// }
return ( return (
<NavLink key={collectionName} to={`/collections/${collectionName}`} icon={icon}> <NavLink key={collectionName} to={`/collections/${collectionName}`} icon={icon}>
@ -54,7 +56,7 @@ const Sidebar: FC<TranslateProps> = ({ t }) => {
</NavLink> </NavLink>
); );
}), }),
[collections], [collections, filterTerm],
); );
const additionalLinks = useMemo(() => getAdditionalLinks(), []); const additionalLinks = useMemo(() => getAdditionalLinks(), []);
@ -126,7 +128,7 @@ const Sidebar: FC<TranslateProps> = ({ t }) => {
)} )}
{collectionLinks} {collectionLinks}
{links} {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')} {t('app.header.media')}
</NavLink> </NavLink>
</ul> </ul>

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ import {
parseDateFromEntry, parseDateFromEntry,
} from './widgets/stringTemplate'; } from './widgets/stringTemplate';
import type { Collection, Config, Entry, EntryData, Slug } from '../interface'; import type { BaseField, Collection, Config, Entry, EntryData, Slug } from '../interface';
const commitMessageTemplates = { const commitMessageTemplates = {
create: 'Create {{collection}} “{{slug}}”', create: 'Create {{collection}} “{{slug}}”',
@ -26,18 +26,18 @@ const commitMessageTemplates = {
const variableRegex = /\{\{([^}]+)\}\}/g; const variableRegex = /\{\{([^}]+)\}\}/g;
type Options = { type Options<EF extends BaseField> = {
slug?: string; slug?: string;
path?: string; path?: string;
collection?: Collection; collection?: Collection<EF>;
authorLogin?: string; authorLogin?: string;
authorName?: string; authorName?: string;
}; };
export function commitMessageFormatter( export function commitMessageFormatter<EF extends BaseField>(
type: keyof typeof commitMessageTemplates, type: keyof typeof commitMessageTemplates,
config: Config, config: Config<EF>,
{ slug, path, collection, authorLogin, authorName }: Options, { slug, path, collection, authorLogin, authorName }: Options<EF>,
) { ) {
const templates = { ...commitMessageTemplates, ...(config.backend.commit_messages || {}) }; 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; let entryData = entry.data;
const date = parseDateFromEntry(entry, selectInferredField(collection, 'date')) || null; const date = parseDateFromEntry(entry, selectInferredField(collection, 'date')) || null;
const identifier = get(entryData, keyToPathArray(selectIdentifier(collection))); const identifier = get(entryData, keyToPathArray(selectIdentifier(collection)));
@ -125,10 +129,10 @@ export function summaryFormatter(summaryTemplate: string, entry: Entry, collecti
return summary; return summary;
} }
export function folderFormatter( export function folderFormatter<EF extends BaseField>(
folderTemplate: string, folderTemplate: string,
entry: Entry | null | undefined, entry: Entry | null | undefined,
collection: Collection, collection: Collection<EF>,
defaultFolder: string, defaultFolder: string,
folderKey: string, folderKey: string,
slugConfig?: Slug, 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 { isEmpty, isNotEmpty } from '../util/string.util';
import useDebounce from './useDebounce'; 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, url: string,
collection: Collection<T>, collection: Collection<EF>,
field: T, field: T,
entry: Entry, entry: Entry,
): boolean { ): boolean {
@ -23,7 +29,7 @@ export default function useIsMediaAsset<T extends MediaField>(
} }
const checkMediaExistence = async () => { 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( setExists(
Boolean(asset && asset !== emptyAsset && isNotEmpty(asset.toString()) && asset.fileObj), 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 { isNotEmpty } from '../util/string.util';
import useDebounce from './useDebounce'; 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, url: string | undefined | null,
collection?: Collection<T>, collection?: Collection<EF>,
field?: T, field?: T,
entry?: Entry, entry?: Entry,
): string { ): string {
@ -28,7 +34,7 @@ export default function useMediaAsset<T extends MediaField>(
} }
const fetchMedia = async () => { 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) { if (asset !== emptyAsset) {
setAssetSource(asset?.toString() ?? ''); setAssetSource(asset?.toString() ?? '');
} }

View File

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

View File

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

View File

@ -3,13 +3,19 @@ import get from 'lodash/get';
import { getLocaleDataPath } from '../i18n'; import { getLocaleDataPath } from '../i18n';
import { keyToPathArray } from '../widgets/stringTemplate'; 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'; 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); const array = keyToPathArray(key);
let name: string | undefined; let name: string | undefined;
let field: Field | undefined; let field: Field<EF> | undefined;
if ('fields' in collection) { if ('fields' in collection) {
let fields = collection.fields ?? []; let fields = collection.fields ?? [];

View File

@ -1,26 +1,30 @@
import { dirname, join } from 'path';
import trim from 'lodash/trim'; import trim from 'lodash/trim';
import { dirname, join } from 'path';
import { basename, isAbsolutePath } from '.';
import { folderFormatter } from '../formatters'; import { folderFormatter } from '../formatters';
import { joinUrlPath } from '../urlHelper'; import { joinUrlPath } from '../urlHelper';
import { basename, isAbsolutePath } from '.';
import type { import type {
Config, BaseField,
Field,
Collection, Collection,
CollectionFile, CollectionFile,
Config,
Entry, Entry,
Field,
FileOrImageField, FileOrImageField,
MarkdownField,
ListField, ListField,
ObjectField, MarkdownField,
MediaField, MediaField,
ObjectField,
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
export const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES'; 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); const file = collectionFiles.find(f => f?.name === slug);
return file; return file;
} }
@ -32,9 +36,9 @@ function isMediaField(
return Boolean(field && folderKey in field); return Boolean(field && folderKey in field);
} }
function hasCustomFolder( function hasCustomFolder<EF extends BaseField>(
folderKey: 'media_folder' | 'public_folder', folderKey: 'media_folder' | 'public_folder',
collection: Collection | undefined | null, collection: Collection<EF> | undefined | null,
slug: string | undefined, slug: string | undefined,
field: MediaField | undefined, field: MediaField | undefined,
): field is FileOrImageField | MarkdownField { ): field is FileOrImageField | MarkdownField {
@ -62,10 +66,10 @@ function hasCustomFolder(
return false; return false;
} }
function evaluateFolder( function evaluateFolder<EF extends BaseField>(
folderKey: 'media_folder' | 'public_folder', folderKey: 'media_folder' | 'public_folder',
config: Config, config: Config<EF>,
c: Collection, c: Collection<EF>,
entryMap: Entry | null | undefined, entryMap: Entry | null | undefined,
field: FileOrImageField | MarkdownField, field: FileOrImageField | MarkdownField,
) { ) {
@ -114,7 +118,7 @@ function evaluateFolder(
collection, collection,
entryMap, entryMap,
field, field,
file.fields! as Field[], file.fields,
currentFolder, currentFolder,
); );
@ -142,7 +146,7 @@ function evaluateFolder(
collection, collection,
entryMap, entryMap,
field, field,
collection.fields! as Field[], collection.fields,
currentFolder, currentFolder,
); );
@ -155,13 +159,13 @@ function evaluateFolder(
return currentFolder; return currentFolder;
} }
function traverseFields( function traverseFields<EF extends BaseField>(
folderKey: 'media_folder' | 'public_folder', folderKey: 'media_folder' | 'public_folder',
config: Config, config: Config<EF>,
collection: Collection, collection: Collection<EF>,
entryMap: Entry | null | undefined, entryMap: Entry | null | undefined,
field: FileOrImageField | MarkdownField | ListField | ObjectField, field: FileOrImageField | MarkdownField | ListField<EF> | ObjectField<EF>,
fields: Field[], fields: Field<EF>[],
currentFolder: string, currentFolder: string,
): string | null { ): string | null {
const matchedField = fields.filter(f => f === field)[0] as const matchedField = fields.filter(f => f === field)[0] as
@ -182,7 +186,7 @@ function traverseFields(
} }
for (const f of fields) { for (const f of fields) {
const childField: Field = { ...f }; const childField: Field<EF> = { ...f };
if (isMediaField(folderKey, childField) && !childField[folderKey]) { if (isMediaField(folderKey, childField) && !childField[folderKey]) {
// add identity template if doesn't exist // add identity template if doesn't exist
childField[folderKey] = `{{${folderKey}}}`; childField[folderKey] = `{{${folderKey}}}`;
@ -225,9 +229,9 @@ function traverseFields(
return null; return null;
} }
export function selectMediaFolder( export function selectMediaFolder<EF extends BaseField>(
config: Config, config: Config<EF>,
collection: Collection | undefined | null, collection: Collection<EF> | undefined | null,
entryMap: Entry | null | undefined, entryMap: Entry | null | undefined,
field: MediaField | undefined, field: MediaField | undefined,
) { ) {
@ -250,12 +254,12 @@ export function selectMediaFolder(
return trim(mediaFolder, '/'); return trim(mediaFolder, '/');
} }
export function selectMediaFilePublicPath( export function selectMediaFilePublicPath<EF extends BaseField>(
config: Config, config: Config<EF>,
collection: Collection | null, collection: Collection<EF> | null,
mediaPath: string, mediaPath: string,
entryMap: Entry | undefined, entryMap: Entry | undefined,
field: Field | undefined, field: Field<EF> | undefined,
) { ) {
if (isAbsolutePath(mediaPath)) { if (isAbsolutePath(mediaPath)) {
return 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', value: 'newValue',
i18n: undefined, 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', () => { it('should update path with value for singleton list', () => {
let state = entryDraftReducer(startState, { let state = entryDraftReducer(startState, {
type: DRAFT_CHANGE_FIELD, type: DRAFT_CHANGE_FIELD,
@ -49,6 +72,7 @@ describe('entryDraft', () => {
}, },
value: ['newValue1', 'newValue2', 'newValue3'], value: ['newValue1', 'newValue2', 'newValue3'],
i18n: undefined, i18n: undefined,
isMeta: false,
}, },
}); });
@ -66,6 +90,7 @@ describe('entryDraft', () => {
}, },
value: 'newValue2Updated', value: 'newValue2Updated',
i18n: undefined, i18n: undefined,
isMeta: false,
}, },
}); });
@ -91,6 +116,7 @@ describe('entryDraft', () => {
defaultLocale: 'en', defaultLocale: 'en',
currentLocale: 'en', currentLocale: 'en',
}, },
isMeta: false,
}, },
}); });
@ -138,6 +164,7 @@ describe('entryDraft', () => {
field, field,
value: ['newValue1', 'newValue2', 'newValue3'], value: ['newValue1', 'newValue2', 'newValue3'],
i18n, i18n,
isMeta: false,
}, },
}); });
@ -165,6 +192,7 @@ describe('entryDraft', () => {
field, field,
value: 'newValue2Updated', value: 'newValue2Updated',
i18n, i18n,
isMeta: false,
}, },
}); });

View File

@ -1,9 +1,9 @@
import { CONFIG_SUCCESS } from '../constants'; import { CONFIG_SUCCESS } from '../constants';
import type { ConfigAction } from '../actions/config'; 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 = {}; const defaultState: CollectionsState = {};

View File

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

View File

@ -1,4 +1,3 @@
import get from 'lodash/get';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
@ -154,8 +153,10 @@ function entryDraftReducer(
return state; return state;
} }
const { path, field, value, i18n } = action.payload; const { path, field, value, i18n, isMeta } = action.payload;
const dataPath = (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data']; const dataPath = isMeta
? ['meta']
: (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data'];
newState = { newState = {
...newState, ...newState,
@ -166,11 +167,20 @@ function entryDraftReducer(
newState = duplicateI18nFields(newState, field, i18n.locales, i18n.defaultLocale, path); 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 { return {
...newState, ...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 */ /* eslint-disable import/prefer-default-export */
import type { BaseField, UnknownField } from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store'; 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; return state.collections;
}; };
export const selectCollection = (collectionName: string | undefined) => (state: RootState) => { export const selectCollection =
<EF extends BaseField = UnknownField>(collectionName: string | undefined) =>
(state: RootState<EF>) => {
return Object.values(state.collections).find(collection => collection.name === collectionName); 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 createRootReducer from '../reducers/combinedReducer';
import { waitUntilAction } from './middleware/waitUntilAction'; 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({ const store = configureStore({
reducer: createRootReducer(), reducer: createRootReducer(),
middleware: getDefaultMiddleware => middleware: getDefaultMiddleware =>
@ -13,6 +17,12 @@ const store = configureStore({
}); });
export { store }; 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 AppStore = typeof store;
export type AppDispatch = typeof store.dispatch; 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 { isEmpty } from '@staticcms/core/lib/util/string.util';
import SortableImage from './components/SortableImage'; 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'; import type { FC, MouseEvent } from 'react';
const MAX_DISPLAY_LENGTH = 50; const MAX_DISPLAY_LENGTH = 50;
@ -143,7 +149,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
replaceIndex: index, replaceIndex: index,
allowMultiple: false, allowMultiple: false,
config, config,
collection, collection: collection as Collection<BaseField>,
field, field,
}); });
}, },

View File

@ -54,7 +54,10 @@ module.exports = {
}, },
{ {
test: /\.css$/, 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: [ use: [
!isProduction ? 'style-loader' : MiniCssExtractPlugin.loader, !isProduction ? 'style-loader' : MiniCssExtractPlugin.loader,
'css-loader', 'css-loader',
@ -112,6 +115,7 @@ module.exports = {
devServer: { devServer: {
static: { static: {
directory: './dev-test', directory: './dev-test',
watch: false,
}, },
host: '0.0.0.0', host: '0.0.0.0',
port: devServerPort, port: devServerPort,

View File

@ -1249,3 +1249,22 @@ collections:
label: Date label: Date
widget: datetime widget: datetime
i18n: duplicate 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", "---\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; var ONE_DAY = 60 * 60 * 24 * 1000;

View File

@ -311,6 +311,9 @@ collections:
nested: nested:
depth: 100 # max depth to show in the collection tree depth: 100 # max depth to show in the collection tree
summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field 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: fields:
- label: Title - label: Title
name: title name: title
@ -318,9 +321,6 @@ collections:
- label: Body - label: Body
name: body name: body
widget: markdown 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 ```js
@ -334,7 +334,11 @@ collections:
"create": true, "create": true,
"nested": { "nested": {
"depth": 100, "depth": 100,
"summary": "{{title}}" "summary": "{{title}}",
"path": {
"label": "Path",
"index_file": "index"
}
}, },
"fields": [ "fields": [
{ {
@ -347,14 +351,7 @@ collections:
"name": "body", "name": "body",
"widget": "markdown" "widget": "markdown"
} }
], ]
"meta": {
"path": {
"widget": "string",
"label": "Path",
"index_file": "index"
}
}
} }
] ]
} }