fix: path generation and usages in saving new and existing files for nested collections

This commit is contained in:
Daniel Lautzenheiser 2023-04-17 12:11:06 -04:00
parent 2eaecc948e
commit 117320046f
13 changed files with 109 additions and 38 deletions

View File

@ -955,7 +955,11 @@ export function getSerializedEntry(collection: Collection, entry: Entry): Entry
return serializedEntry; return serializedEntry;
} }
export function persistEntry(collection: Collection, navigate: NavigateFunction) { export function persistEntry(
collection: Collection,
rootSlug: string | undefined,
navigate: NavigateFunction,
) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => { return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState(); const state = getState();
const entryDraft = state.entryDraft; const entryDraft = state.entryDraft;
@ -1019,6 +1023,7 @@ export function persistEntry(collection: Collection, navigate: NavigateFunction)
return backend return backend
.persistEntry({ .persistEntry({
config: configState.config, config: configState.config,
rootSlug,
collection, collection,
entryDraft: newEntryDraft, entryDraft: newEntryDraft,
assetProxies, assetProxies,

View File

@ -822,6 +822,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
async persistEntry({ async persistEntry({
config, config,
rootSlug,
collection, collection,
entryDraft: draft, entryDraft: draft,
assetProxies, assetProxies,
@ -841,7 +842,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
const newEntry = entryDraft.entry.newRecord ?? false; const newEntry = entryDraft.entry.newRecord ?? false;
const customPath = selectCustomPath(draft.entry, collection); const customPath = selectCustomPath(draft.entry, collection, rootSlug, config);
let dataFile: DataFile; let dataFile: DataFile;
if (newEntry) { if (newEntry) {

View File

@ -206,6 +206,10 @@ const App = ({
path="/collections/:name/new" path="/collections/:name/new"
element={<EditorRoute collections={collections} newRecord />} element={<EditorRoute collections={collections} newRecord />}
/> />
<Route
path="/collections/:name/new/*"
element={<EditorRoute collections={collections} newRecord />}
/>
<Route <Route
path="/collections/:name/entries/*" path="/collections/:name/entries/*"
element={<EditorRoute collections={collections} />} element={<EditorRoute collections={collections} />}

View File

@ -1,6 +1,6 @@
import React, { useCallback, useMemo } from 'react'; import React, { useMemo } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { useNavigate, useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import useEntries from '@staticcms/core/lib/hooks/useEntries'; import useEntries from '@staticcms/core/lib/hooks/useEntries';
import useIcon from '@staticcms/core/lib/hooks/useIcon'; import useIcon from '@staticcms/core/lib/hooks/useIcon';
@ -24,17 +24,9 @@ const CollectionHeader = ({
newEntryUrl, newEntryUrl,
t, t,
}: TranslatedProps<CollectionHeaderProps>) => { }: TranslatedProps<CollectionHeaderProps>) => {
const navigate = useNavigate();
const collectionLabel = collection.label; const collectionLabel = collection.label;
const collectionLabelSingular = collection.label_singular; const collectionLabelSingular = collection.label_singular;
const onNewClick = useCallback(() => {
if (newEntryUrl) {
navigate(newEntryUrl);
}
}, [navigate, newEntryUrl]);
const icon = useIcon(collection.icon); const icon = useIcon(collection.icon);
const params = useParams(); const params = useParams();
@ -89,7 +81,7 @@ const CollectionHeader = ({
{pluralLabel} {pluralLabel}
</h2> </h2>
{newEntryUrl ? ( {newEntryUrl ? (
<Button onClick={onNewClick}> <Button to={newEntryUrl}>
{t('collection.collectionTop.newButton', { {t('collection.collectionTop.newButton', {
collectionLabel: collectionLabelSingular || pluralLabel, collectionLabel: collectionLabelSingular || pluralLabel,
})} })}

View File

@ -15,6 +15,7 @@ import {
selectViewFilters, selectViewFilters,
selectViewGroups, selectViewGroups,
} from '@staticcms/core/lib/util/collection.util'; } from '@staticcms/core/lib/util/collection.util';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { import {
selectEntriesFilter, selectEntriesFilter,
selectEntriesGroup, selectEntriesGroup,
@ -71,8 +72,10 @@ const CollectionView = ({
return undefined; return undefined;
} }
return 'fields' in collection && collection.create ? getNewEntryUrl(collectionName) : ''; return 'fields' in collection && collection.create
}, [collection, collectionName]); ? `${getNewEntryUrl(collectionName)}${isNotEmpty(filterTerm) ? `/${filterTerm}` : ''}`
: '';
}, [collection, collectionName, filterTerm]);
const searchResultKey = useMemo( const searchResultKey = useMemo(
() => `collection.collectionTop.searchResults${isSingleSearchResult ? 'InCollection' : ''}`, () => `collection.collectionTop.searchResults${isSingleSearchResult ? 'InCollection' : ''}`,

View File

@ -50,6 +50,7 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
slug, slug,
localBackup, localBackup,
scrollSyncActive, scrollSyncActive,
newRecord,
t, t,
}) => { }) => {
const [version, setVersion] = useState(0); const [version, setVersion] = useState(0);
@ -90,7 +91,7 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
setTimeout(async () => { setTimeout(async () => {
try { try {
await dispatch(persistEntry(collection, navigate)); await dispatch(persistEntry(collection, slug, navigate));
setVersion(version + 1); setVersion(version + 1);
deleteBackup(); deleteBackup();
@ -112,7 +113,7 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
} catch (e) {} } catch (e) {}
}, 100); }, 100);
}, },
[collection, deleteBackup, dispatch, entryDraft.entry, navigate, version], [collection, deleteBackup, dispatch, entryDraft.entry, navigate, slug, version],
); );
const handleDuplicateEntry = useCallback(() => { const handleDuplicateEntry = useCallback(() => {
@ -145,7 +146,7 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
return; return;
} }
if (!slug) { if (!slug || newRecord) {
return navigate(`/collections/${collection.name}`); return navigate(`/collections/${collection.name}`);
} }
@ -154,7 +155,7 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
deleteBackup(); deleteBackup();
return navigate(`/collections/${collection.name}`); return navigate(`/collections/${collection.name}`);
}, 0); }, 0);
}, [collection, deleteBackup, dispatch, entryDraft.hasChanged, navigate, slug]); }, [collection, deleteBackup, dispatch, entryDraft.hasChanged, navigate, newRecord, slug]);
const [prevLocalBackup, setPrevLocalBackup] = useState< const [prevLocalBackup, setPrevLocalBackup] = useState<
| { | {
@ -198,11 +199,11 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
const [prevCollection, setPrevCollection] = useState<Collection | null>(null); const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
const [prevSlug, setPrevSlug] = useState<string | undefined | null>(null); const [prevSlug, setPrevSlug] = useState<string | undefined | null>(null);
useEffect(() => { useEffect(() => {
if (!slug && prevSlug !== slug) { if (newRecord && slug !== prevSlug) {
setTimeout(() => { setTimeout(() => {
dispatch(createEmptyDraft(collection, location.search)); dispatch(createEmptyDraft(collection, location.search));
}); });
} else if (slug && (prevCollection !== collection || prevSlug !== slug)) { } else if (!newRecord && slug && (prevCollection !== collection || prevSlug !== slug)) {
setTimeout(() => { setTimeout(() => {
dispatch(retrieveLocalBackup(collection, slug)); dispatch(retrieveLocalBackup(collection, slug));
dispatch(loadEntry(collection, slug)); dispatch(loadEntry(collection, slug));
@ -211,7 +212,7 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
setPrevCollection(collection); setPrevCollection(collection);
setPrevSlug(slug); setPrevSlug(slug);
}, [collection, entryDraft.entry, prevSlug, prevCollection, slug, dispatch]); }, [collection, entryDraft.entry, prevSlug, prevCollection, slug, dispatch, newRecord]);
const leaveMessage = useMemo(() => t('editor.editor.onLeavePage'), [t]); const leaveMessage = useMemo(() => t('editor.editor.onLeavePage'), [t]);
@ -236,7 +237,12 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
const isPersisting = entryDraft.entry?.isPersisting; const isPersisting = entryDraft.entry?.isPersisting;
const newRecord = entryDraft.entry?.newRecord; const newRecord = entryDraft.entry?.newRecord;
const newEntryPath = `/collections/${collection.name}/new`; const newEntryPath = `/collections/${collection.name}/new`;
if (isPersisting && newRecord && location.pathname === newEntryPath && action === 'PUSH') { if (
isPersisting &&
newRecord &&
location.pathname.startsWith(newEntryPath) &&
action === 'PUSH'
) {
return; return;
} }
@ -293,12 +299,13 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
onDuplicate={handleDuplicateEntry} onDuplicate={handleDuplicateEntry}
hasChanged={hasChanged} hasChanged={hasChanged}
displayUrl={displayUrl} displayUrl={displayUrl}
isNewEntry={!slug} isNewEntry={newRecord}
isModification={isModification} isModification={isModification}
toggleScroll={handleToggleScroll} toggleScroll={handleToggleScroll}
scrollSyncActive={scrollSyncActive} scrollSyncActive={scrollSyncActive}
loadScroll={handleLoadScroll} loadScroll={handleLoadScroll}
submitted={submitted} submitted={submitted}
slug={slug}
t={t} t={t}
/> />
<MediaLibraryModal /> <MediaLibraryModal />

View File

@ -70,6 +70,7 @@ interface EditorInterfaceProps {
scrollSyncActive: boolean; scrollSyncActive: boolean;
loadScroll: () => void; loadScroll: () => void;
submitted: boolean; submitted: boolean;
slug: string | undefined;
} }
const EditorInterface = ({ const EditorInterface = ({
@ -90,6 +91,7 @@ const EditorInterface = ({
loadScroll, loadScroll,
toggleScroll, toggleScroll,
submitted, submitted,
slug,
}: TranslatedProps<EditorInterfaceProps>) => { }: TranslatedProps<EditorInterfaceProps>) => {
const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {}; const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {};
const [selectedLocale, setSelectedLocale] = useState<string>(locales?.[1] ?? 'en'); const [selectedLocale, setSelectedLocale] = useState<string>(locales?.[1] ?? 'en');
@ -179,6 +181,7 @@ const EditorInterface = ({
locale={defaultLocale} locale={defaultLocale}
submitted={submitted} submitted={submitted}
hideBorder={!finalPreviewActive && !i18nActive} hideBorder={!finalPreviewActive && !i18nActive}
slug={slug}
t={t} t={t}
/> />
</div> </div>

View File

@ -64,6 +64,7 @@ const EditorControl = ({
i18n, i18n,
fieldName, fieldName,
isMeta = false, isMeta = false,
controlled = false,
}: TranslatedProps<EditorControlProps>) => { }: TranslatedProps<EditorControlProps>) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -185,6 +186,7 @@ const EditorControl = ({
hasErrors, hasErrors,
errors, errors,
theme, theme,
controlled,
})} })}
</div> </div>
); );
@ -232,6 +234,7 @@ interface EditorControlOwnProps {
forSingleList?: boolean; forSingleList?: boolean;
i18n: I18nSettings | undefined; i18n: I18nSettings | undefined;
fieldName?: string; fieldName?: string;
controlled?: boolean;
isMeta?: boolean; isMeta?: boolean;
} }

View File

@ -3,7 +3,9 @@ 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 { getNestedSlug } from '@staticcms/core/lib/util/nested.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { useAppSelector } from '@staticcms/core/store/hooks';
import EditorControl from './EditorControl'; import EditorControl from './EditorControl';
import LocaleDropdown from './LocaleDropdown'; import LocaleDropdown from './LocaleDropdown';
@ -26,6 +28,7 @@ export interface EditorControlPaneProps {
locale?: string; locale?: string;
canChangeLocale?: boolean; canChangeLocale?: boolean;
hideBorder: boolean; hideBorder: boolean;
slug?: string;
onLocaleChange?: (locale: string) => void; onLocaleChange?: (locale: string) => void;
} }
@ -38,14 +41,10 @@ const EditorControlPane = ({
locale, locale,
canChangeLocale = false, canChangeLocale = false,
hideBorder, hideBorder,
slug,
onLocaleChange, onLocaleChange,
t, t,
}: TranslatedProps<EditorControlPaneProps>) => { }: TranslatedProps<EditorControlPaneProps>) => {
const nestedFieldPath = useMemo(
() => customPathFromSlug(collection, entry.slug),
[collection, entry.slug],
);
const pathField = useMemo( const pathField = useMemo(
() => () =>
({ ({
@ -56,10 +55,18 @@ const EditorControlPane = ({
: 'Path', : 'Path',
widget: 'string', widget: 'string',
i18n: 'none', i18n: 'none',
hint: ``,
} as StringOrTextField), } as StringOrTextField),
[collection], [collection],
); );
const config = useAppSelector(selectConfig);
const defaultNestedPath = useMemo(
() => getNestedSlug(collection, entry, slug, config),
[collection, config, entry, slug],
);
const i18n = useMemo(() => { const i18n = useMemo(() => {
if (hasI18n(collection)) { if (hasI18n(collection)) {
const { locales, defaultLocale } = getI18nInfo(collection); const { locales, defaultLocale } = getI18nInfo(collection);
@ -117,12 +124,13 @@ const EditorControlPane = ({
<EditorControl <EditorControl
key="entry-path" key="entry-path"
field={pathField} field={pathField}
value={entry.meta?.path ?? nestedFieldPath} value={entry.meta?.path ?? defaultNestedPath}
fieldsErrors={fieldsErrors} fieldsErrors={fieldsErrors}
submitted={submitted} submitted={submitted}
locale={locale} locale={locale}
parentPath="" parentPath=""
i18n={i18n} i18n={i18n}
controlled
isMeta isMeta
/> />
) : null} ) : null}

View File

@ -282,6 +282,7 @@ export interface WidgetControlProps<T, F extends BaseField = UnknownField, EV =
t: t; t: t;
value: T | undefined | null; value: T | undefined | null;
theme: 'dark' | 'light'; theme: 'dark' | 'light';
controlled: boolean;
} }
export interface WidgetPreviewProps<T = unknown, F extends BaseField = UnknownField> { export interface WidgetPreviewProps<T = unknown, F extends BaseField = UnknownField> {
@ -385,6 +386,7 @@ export interface PersistOptions {
export interface PersistArgs { export interface PersistArgs {
config: Config; config: Config;
rootSlug: string | undefined;
collection: Collection; collection: Collection;
entryDraft: EntryDraft; entryDraft: EntryDraft;
assetProxies: AssetProxy[]; assetProxies: AssetProxy[];

View File

@ -78,6 +78,13 @@ export function selectFileEntryLabel<EF extends BaseField>(
export function selectEntryPath<EF extends BaseField>(collection: Collection<EF>, 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(/\/$/, '');
if (collection.nested) {
if (collection.nested.path?.index_file) {
return `${folder}/${slug}.${selectFolderEntryExtension(collection)}`;
}
}
return `${folder}/${slug}.${selectFolderEntryExtension(collection)}`; return `${folder}/${slug}.${selectFolderEntryExtension(collection)}`;
} }

View File

@ -1,18 +1,28 @@
import trim from 'lodash/trim'; import trim from 'lodash/trim';
import { basename, dirname, extname, join } from 'path'; import { basename, dirname, extname, join } from 'path';
import { selectFolderEntryExtension } from './collection.util'; import { sanitizeSlug } from '../urlHelper';
import { selectEntryCollectionTitle, selectFolderEntryExtension } from './collection.util';
import { isEmpty, isNotEmpty } from './string.util';
import type { Collection, Entry } from '@staticcms/core/interface'; import type { Collection, Config, Entry } from '@staticcms/core/interface';
export function selectCustomPath(entry: Entry, collection: Collection): string | undefined { export function selectCustomPath(
if (!('nested' in collection) || !collection.nested?.path || !entry.meta) { entry: Entry,
collection: Collection,
rootSlug: string | undefined,
config: Config | undefined,
): string | undefined {
if (!('nested' in collection) || !collection.nested?.path) {
return undefined; return undefined;
} }
const indexFile = collection.nested.path.index_file; const indexFile = collection.nested.path.index_file;
const extension = selectFolderEntryExtension(collection); const extension = selectFolderEntryExtension(collection);
const customPath = join(collection.folder, entry.meta.path, `${indexFile}.${extension}`);
const slug = entry.meta?.path ?? getNestedSlug(collection, entry, rootSlug, config);
const customPath = join(collection.folder, slug, `${indexFile}.${extension}`);
return customPath; return customPath;
} }
@ -40,3 +50,28 @@ export function slugFromCustomPath(collection: Collection, customPath: string):
const slug = join(dirname(trim(entryPath, '/')), basename(entryPath, extname(customPath))); const slug = join(dirname(trim(entryPath, '/')), basename(entryPath, extname(customPath)));
return slug; return slug;
} }
export function getNestedSlug(
collection: Collection,
entry: Entry,
slug: string | undefined,
config: Config | undefined,
) {
if ('nested' in collection && collection.nested?.path) {
if (isNotEmpty(entry.slug)) {
return entry.slug.replace(new RegExp(`/${collection.nested.path.index_file}$`, 'g'), '');
} else if (slug) {
let summarySlug = selectEntryCollectionTitle(collection, entry);
if (isEmpty(summarySlug)) {
summarySlug = `new-${collection.label_singular ?? collection.label}`;
}
return `${customPathFromSlug(collection, slug)}/${sanitizeSlug(
summarySlug.toLowerCase(),
config?.slug,
)}`;
}
}
return '';
}

View File

@ -14,12 +14,13 @@ const StringControl: FC<WidgetControlProps<string, StringOrTextField>> = ({
field, field,
forSingleList, forSingleList,
duplicate, duplicate,
controlled,
onChange, onChange,
}) => { }) => {
const [internalRawValue, setInternalValue] = useState(value ?? ''); const [internalRawValue, setInternalValue] = useState(value ?? '');
const internalValue = useMemo( const internalValue = useMemo(
() => (duplicate ? value ?? '' : internalRawValue), () => (controlled || duplicate ? value ?? '' : internalRawValue),
[internalRawValue, duplicate, value], [controlled, duplicate, value, internalRawValue],
); );
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);