diff --git a/dev-test/backends/test/config.yml b/dev-test/backends/test/config.yml
index f9fd7e303dd5..c4b0ee55a7e5 100644
--- a/dev-test/backends/test/config.yml
+++ b/dev-test/backends/test/config.yml
@@ -253,4 +253,4 @@ collections: # A list of collections the CMS should be able to edit
- label: Title
name: title
widget: string
- meta: { path: { widget: string, label: 'Path', index_file: 'index' } }
+ meta: { path: { widget: string, label: 'Path' } }
diff --git a/dev-test/config.yml b/dev-test/config.yml
index fbdf95f73a8e..3b8d15d82fbc 100644
--- a/dev-test/config.yml
+++ b/dev-test/config.yml
@@ -289,4 +289,4 @@ collections: # A list of collections the CMS should be able to edit
- label: Title
name: title
widget: string
- meta: { path: { widget: string, label: 'Path', index_file: 'index' } }
+ meta: { path: { widget: string, label: 'Path' } }
diff --git a/packages/decap-cms-backend-azure/src/API.ts b/packages/decap-cms-backend-azure/src/API.ts
index 8a83102f41a5..c439e6dfd173 100644
--- a/packages/decap-cms-backend-azure/src/API.ts
+++ b/packages/decap-cms-backend-azure/src/API.ts
@@ -22,7 +22,7 @@ import {
readFileMetadata,
branchFromContentKey,
} from 'decap-cms-lib-util';
-import { dirname, basename } from 'path';
+import { basename, dirname } from 'path';
import type { ApiRequest, AssetProxy, PersistOptions, DataFile } from 'decap-cms-lib-util';
import type { Map } from 'immutable';
@@ -503,7 +503,11 @@ export default class API {
}));
}
- async getCommitItems(files: { path: string; newPath?: string }[], branch: string) {
+ async getCommitItems(
+ files: { path: string; newPath?: string }[],
+ branch: string,
+ subfolders = true,
+ ) {
const items = await Promise.all(
files.map(async file => {
const [base64Content, fileExists] = await Promise.all([
@@ -526,20 +530,24 @@ export default class API {
}),
);
- // move children
- for (const item of items.filter(i => i.oldPath && i.action === AzureCommitChangeType.RENAME)) {
- const sourceDir = dirname(item.oldPath as string);
- const destDir = dirname(item.path);
- const children = await this.listFiles(sourceDir, true, branch);
- children
- .filter(file => file.path !== item.oldPath)
- .forEach(file => {
- items.push({
- action: AzureCommitChangeType.RENAME,
- path: file.path.replace(sourceDir, destDir),
- oldPath: file.path,
+ // move children when subfolders is true (legacy/default behavior)
+ if (subfolders) {
+ for (const item of items.filter(
+ i => i.oldPath && i.action === AzureCommitChangeType.RENAME,
+ )) {
+ const sourceDir = dirname(item.oldPath as string);
+ const destDir = dirname(item.path);
+ const children = await this.listFiles(sourceDir, true, branch);
+ children
+ .filter(file => file.path !== item.oldPath)
+ .forEach(file => {
+ items.push({
+ action: AzureCommitChangeType.RENAME,
+ path: file.path.replace(sourceDir, destDir),
+ oldPath: file.path,
+ });
});
- });
+ }
}
return items;
@@ -547,11 +555,12 @@ export default class API {
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
const files = [...dataFiles, ...mediaFiles];
+ const subfolders = options.hasSubfolders !== false; // default to true
if (options.useWorkflow) {
const slug = dataFiles[0].slug;
return this.editorialWorkflowGit(files, slug, options);
} else {
- const items = await this.getCommitItems(files, this.branch);
+ const items = await this.getCommitItems(files, this.branch, subfolders);
return this.uploadAndCommit(items, options.commitMessage, this.branch, true);
}
@@ -677,9 +686,10 @@ export default class API {
const contentKey = generateContentKey(options.collectionName as string, slug);
const branch = branchFromContentKey(contentKey);
const unpublished = options.unpublished || false;
+ const subfolders = options.hasSubfolders !== false; // default to true
if (!unpublished) {
- const items = await this.getCommitItems(files, this.branch);
+ const items = await this.getCommitItems(files, this.branch, subfolders);
await this.uploadAndCommit(items, options.commitMessage, branch, true);
await this.createPullRequest(
@@ -688,7 +698,7 @@ export default class API {
options.status || this.initialWorkflowStatus,
);
} else {
- const items = await this.getCommitItems(files, branch);
+ const items = await this.getCommitItems(files, branch, subfolders);
await this.uploadAndCommit(items, options.commitMessage, branch, false);
}
}
diff --git a/packages/decap-cms-backend-bitbucket/src/API.ts b/packages/decap-cms-backend-bitbucket/src/API.ts
index b2cab29f6c37..04b15e3ff39c 100644
--- a/packages/decap-cms-backend-bitbucket/src/API.ts
+++ b/packages/decap-cms-backend-bitbucket/src/API.ts
@@ -24,9 +24,9 @@ import {
readFileMetadata,
throwOnConflictingBranches,
} from 'decap-cms-lib-util';
-import { dirname } from 'path';
import { oneLine } from 'common-tags';
import { parse } from 'what-the-diff';
+import { dirname } from 'path';
import type {
ApiRequest,
@@ -432,17 +432,18 @@ export default class API {
commitMessage,
branch,
parentSha,
- }: { commitMessage: string; branch: string; parentSha?: string },
+ hasSubfolders = true,
+ }: { commitMessage: string; branch: string; parentSha?: string; hasSubfolders?: boolean },
) {
const formData = new FormData();
- const toMove: { from: string; to: string; contentBlob: Blob }[] = [];
+ const toMove: { from: string; to: string; contentBlob: Blob; hasSubfolders: boolean }[] = [];
files.forEach(file => {
if (file.delete) {
// delete the file
formData.append('files', file.path);
} else if (file.newPath) {
const contentBlob = get(file, 'fileObj', new Blob([(file as DataFile).raw]));
- toMove.push({ from: file.path, to: file.newPath, contentBlob });
+ toMove.push({ from: file.path, to: file.newPath, contentBlob, hasSubfolders });
} else {
// add/modify the file
const contentBlob = get(file, 'fileObj', new Blob([(file as DataFile).raw]));
@@ -450,27 +451,44 @@ export default class API {
formData.append(file.path, contentBlob, basename(file.path));
}
});
- for (const { from, to, contentBlob } of toMove) {
- const sourceDir = dirname(from);
- const destDir = dirname(to);
- const filesBranch = parentSha ? this.branch : branch;
- const files = await this.listAllFiles(sourceDir, 100, filesBranch);
- for (const file of files) {
+ for (const { from, to, contentBlob, hasSubfolders } of toMove) {
+ if (!hasSubfolders) {
+ // New behavior (subfolders: false): Only move the specific file
// to move a file in Bitbucket we need to delete the old path
// and upload the file content to the new path
// NOTE: this is very wasteful, and also the Bitbucket `diff` API
// reports these files as deleted+added instead of renamed
// delete current path
- formData.append('files', file.path);
+ formData.append('files', from);
// create in new path
- const content =
- file.path === from
- ? contentBlob
- : await this.readFile(file.path, null, {
- branch: filesBranch,
- parseText: false,
- });
- formData.append(file.path.replace(sourceDir, destDir), content, basename(file.path));
+ formData.append(to, contentBlob, basename(to));
+ } else {
+ // Legacy behavior (subfolders: true, default): Move all files in the directory
+ const sourceDir = dirname(from);
+ const destDir = dirname(to);
+ const filesBranch = parentSha ? this.branch : branch;
+ const files = await this.listAllFiles(sourceDir, 100, filesBranch);
+ for (const file of files) {
+ // to move a file in Bitbucket we need to delete the old path
+ // and upload the file content to the new path
+ // NOTE: this is very wasteful, and also the Bitbucket `diff` API
+ // reports these files as deleted+added instead of renamed
+ // delete current path
+ formData.append('files', file.path);
+ // create in new path
+ const content =
+ file.path === from
+ ? contentBlob
+ : await this.readFile(file.path, null, {
+ branch: filesBranch,
+ parseText: false,
+ });
+ formData.append(
+ file.path.replace(sourceDir, destDir),
+ content as Blob,
+ basename(file.path),
+ );
+ }
}
}
@@ -508,11 +526,16 @@ export default class API {
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
const files = [...dataFiles, ...mediaFiles];
+ const hasSubfolders = options.hasSubfolders !== false; // default to true
if (options.useWorkflow) {
const slug = dataFiles[0].slug;
return this.editorialWorkflowGit(files, slug, options);
} else {
- return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch });
+ return this.uploadFiles(files, {
+ commitMessage: options.commitMessage,
+ branch: this.branch,
+ hasSubfolders,
+ });
}
}
@@ -599,12 +622,14 @@ export default class API {
const contentKey = generateContentKey(options.collectionName as string, slug);
const branch = branchFromContentKey(contentKey);
const unpublished = options.unpublished || false;
+ const hasSubfolders = options.hasSubfolders !== false; // default to true
if (!unpublished) {
const defaultBranchSha = await this.branchCommitSha(this.branch);
await this.uploadFiles(files, {
commitMessage: options.commitMessage,
branch,
parentSha: defaultBranchSha,
+ hasSubfolders,
});
await this.createPullRequest(
branch,
@@ -624,6 +649,7 @@ export default class API {
await this.uploadFiles([...files, ...toDelete], {
commitMessage: options.commitMessage,
branch,
+ hasSubfolders,
});
}
}
diff --git a/packages/decap-cms-backend-github/src/API.ts b/packages/decap-cms-backend-github/src/API.ts
index 13fb7327b692..a37691221478 100644
--- a/packages/decap-cms-backend-github/src/API.ts
+++ b/packages/decap-cms-backend-github/src/API.ts
@@ -7,6 +7,7 @@ import result from 'lodash/result';
import trimStart from 'lodash/trimStart';
import trim from 'lodash/trim';
import { oneLine } from 'common-tags';
+import { dirname } from 'path';
import {
getAllResponses,
APIError,
@@ -29,7 +30,6 @@ import {
unsentRequest,
throwOnConflictingBranches,
} from 'decap-cms-lib-util';
-import { dirname } from 'path';
import type {
AssetProxy,
@@ -1011,9 +1011,15 @@ export default class API {
const contentKey = this.generateContentKey(options.collectionName as string, slug);
const branch = branchFromContentKey(contentKey);
const unpublished = options.unpublished || false;
+ const hasSubfolders = options.hasSubfolders !== false; // default to true
if (!unpublished) {
const branchData = await this.getDefaultBranch();
- const changeTree = await this.updateTree(branchData.commit.sha, files);
+ const changeTree = await this.updateTree(
+ branchData.commit.sha,
+ files,
+ this.branch,
+ hasSubfolders,
+ );
const commitResponse = await this.commit(options.commitMessage, changeTree);
if (this.useOpenAuthoring) {
@@ -1048,7 +1054,7 @@ export default class API {
// rebase the branch before applying new changes
const rebasedHead = await this.rebaseBranch(branch);
const treeFiles = mediaFilesToRemove.concat(files);
- const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch);
+ const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch, hasSubfolders);
const commit = await this.commit(options.commitMessage, changeTree);
return this.patchBranch(branch, commit.sha, { force: true });
@@ -1418,6 +1424,7 @@ export default class API {
baseSha: string,
files: { path: string; sha: string | null; newPath?: string }[],
branch = this.branch,
+ hasSubfolders = true,
) {
const toMove: { from: string; to: string; sha: string }[] = [];
const tree = files.reduce((acc, file) => {
@@ -1438,24 +1445,44 @@ export default class API {
}, [] as TreeEntry[]);
for (const { from, to, sha } of toMove) {
- const sourceDir = dirname(from);
- const destDir = dirname(to);
- const files = await this.listFiles(sourceDir, { branch, depth: 100 });
- for (const file of files) {
- // delete current path
+ if (!hasSubfolders) {
+ // New behavior (subfolders: false): Only move the specific file
+ // Delete the file at the old path
tree.push({
- path: file.path,
+ path: trimStart(from, '/'),
mode: '100644',
type: 'blob',
sha: null,
});
- // create in new path
+ // Create the file at the new path
tree.push({
- path: file.path.replace(sourceDir, destDir),
+ path: trimStart(to, '/'),
mode: '100644',
type: 'blob',
- sha: file.path === from ? sha : file.id,
+ sha,
});
+ } else {
+ // Legacy behavior (subfolders: true, default): Move all files in the directory
+ // This is for collections where all files in a folder represent a single entry
+ const sourceDir = dirname(from);
+ const destDir = dirname(to);
+ const files = await this.listFiles(sourceDir, { branch, depth: 100 });
+ for (const file of files) {
+ // delete current path
+ tree.push({
+ path: file.path,
+ mode: '100644',
+ type: 'blob',
+ sha: null,
+ });
+ // create in new path
+ tree.push({
+ path: file.path.replace(sourceDir, destDir),
+ mode: '100644',
+ type: 'blob',
+ sha: file.path === from ? sha : file.id,
+ });
+ }
}
}
diff --git a/packages/decap-cms-backend-gitlab/src/API.ts b/packages/decap-cms-backend-gitlab/src/API.ts
index c058be4bdf1c..e44d151ea986 100644
--- a/packages/decap-cms-backend-gitlab/src/API.ts
+++ b/packages/decap-cms-backend-gitlab/src/API.ts
@@ -26,13 +26,13 @@ import {
readFileMetadata,
throwOnConflictingBranches,
} from 'decap-cms-lib-util';
+import { dirname } from 'path';
import { Base64 } from 'js-base64';
import { Map } from 'immutable';
import flow from 'lodash/flow';
import partial from 'lodash/partial';
import result from 'lodash/result';
import trimStart from 'lodash/trimStart';
-import { dirname } from 'path';
const NO_CACHE = 'no-cache';
import * as queries from './queries';
@@ -619,7 +619,11 @@ export default class API {
}
}
- async getCommitItems(files: { path: string; newPath?: string }[], branch: string) {
+ async getCommitItems(
+ files: { path: string; newPath?: string }[],
+ branch: string,
+ hasSubfolders = true,
+ ) {
const items: CommitItem[] = await Promise.all(
files.map(async file => {
const [base64Content, fileExists] = await Promise.all([
@@ -646,20 +650,22 @@ export default class API {
}),
);
- // move children
- for (const item of items.filter(i => i.oldPath && i.action === CommitAction.MOVE)) {
- const sourceDir = dirname(item.oldPath as string);
- const destDir = dirname(item.path);
- const children = await this.listAllFiles(sourceDir, true, branch);
- children
- .filter(f => f.path !== item.oldPath)
- .forEach(file => {
- items.push({
- action: CommitAction.MOVE,
- path: file.path.replace(sourceDir, destDir),
- oldPath: file.path,
+ // Move children if subfolders is true (legacy/default behavior)
+ if (hasSubfolders) {
+ for (const item of items.filter(i => i.oldPath && i.action === CommitAction.MOVE)) {
+ const sourceDir = dirname(item.oldPath as string);
+ const destDir = dirname(item.path);
+ const children = await this.listAllFiles(sourceDir, true, branch);
+ children
+ .filter(f => f.path !== item.oldPath)
+ .forEach(file => {
+ items.push({
+ action: CommitAction.MOVE,
+ path: file.path.replace(sourceDir, destDir),
+ oldPath: file.path,
+ });
});
- });
+ }
}
return items;
@@ -667,11 +673,12 @@ export default class API {
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
const files = [...dataFiles, ...mediaFiles];
+ const hasSubfolders = options.hasSubfolders !== false; // default to true
if (options.useWorkflow) {
const slug = dataFiles[0].slug;
return this.editorialWorkflowGit(files, slug, options);
} else {
- const items = await this.getCommitItems(files, this.branch);
+ const items = await this.getCommitItems(files, this.branch, hasSubfolders);
return this.uploadAndCommit(items, {
commitMessage: options.commitMessage,
});
@@ -875,8 +882,9 @@ export default class API {
const contentKey = generateContentKey(options.collectionName as string, slug);
const branch = branchFromContentKey(contentKey);
const unpublished = options.unpublished || false;
+ const hasSubfolders = options.hasSubfolders !== false; // default to true
if (!unpublished) {
- const items = await this.getCommitItems(files, this.branch);
+ const items = await this.getCommitItems(files, this.branch, hasSubfolders);
await this.uploadAndCommit(items, {
commitMessage: options.commitMessage,
branch,
@@ -891,7 +899,7 @@ export default class API {
const mergeRequest = await this.getBranchMergeRequest(branch);
await this.rebaseMergeRequest(mergeRequest);
const [items, diffs] = await Promise.all([
- this.getCommitItems(files, branch),
+ this.getCommitItems(files, branch, hasSubfolders),
this.getDifferences(branch),
]);
// mark files for deletion
diff --git a/packages/decap-cms-core/index.d.ts b/packages/decap-cms-core/index.d.ts
index 03d23dcb4975..9d1d2297aa82 100644
--- a/packages/decap-cms-core/index.d.ts
+++ b/packages/decap-cms-core/index.d.ts
@@ -323,7 +323,7 @@ declare module 'decap-cms-core' {
depth: number;
subfolders?: boolean;
};
- meta?: { path?: { label: string; widget: string; index_file: string } };
+ meta?: { path?: { label: string; widget: string; index_file?: string } };
/**
* It accepts the following values: yml, yaml, toml, json, md, markdown, html
diff --git a/packages/decap-cms-core/src/__tests__/backend.spec.js b/packages/decap-cms-core/src/__tests__/backend.spec.js
index c6c74269dc1e..8b722941bd31 100644
--- a/packages/decap-cms-core/src/__tests__/backend.spec.js
+++ b/packages/decap-cms-core/src/__tests__/backend.spec.js
@@ -1068,4 +1068,156 @@ describe('Backend', () => {
]);
});
});
+
+ describe('persistEntry with nested collections', () => {
+ it('should pass hasSubfolders=true when subfolders is true (default)', async () => {
+ const implementation = {
+ init: jest.fn(() => implementation),
+ persistEntry: jest.fn(),
+ };
+
+ const config = {
+ backend: { commit_messages: {} },
+ };
+ const collection = Map({
+ name: 'pages',
+ type: FOLDER,
+ folder: '_pages',
+ create: true,
+ fields: List([Map({ name: 'title', widget: 'string' })]),
+ nested: Map({ depth: 10, subfolders: true }),
+ meta: Map({ path: Map({ label: 'Path', widget: 'string' }) }),
+ });
+ const entryDraft = Map({
+ entry: Map({
+ data: Map({ title: 'Test' }),
+ meta: Map({ path: 'blog' }),
+ newRecord: true,
+ }),
+ });
+ const user = { login: 'user', name: 'User' };
+ const backend = new Backend(implementation, { config, backendName: 'test' });
+
+ backend.currentUser = jest.fn().mockResolvedValue(user);
+ backend.entryToRaw = jest.fn().mockReturnValue('content');
+ backend.generateUniqueSlug = jest.fn().mockResolvedValue('test-slug');
+ backend.invokePreSaveEvent = jest.fn().mockResolvedValue(entryDraft.get('entry'));
+ backend.invokePostSaveEvent = jest.fn().mockResolvedValue();
+
+ await backend.persistEntry({
+ config,
+ collection,
+ entryDraft,
+ assetProxies: [],
+ usedSlugs: List(),
+ });
+
+ expect(implementation.persistEntry).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ hasSubfolders: true,
+ }),
+ );
+ });
+
+ it('should pass hasSubfolders=false when subfolders is false', async () => {
+ const implementation = {
+ init: jest.fn(() => implementation),
+ persistEntry: jest.fn(),
+ };
+
+ const config = {
+ backend: { commit_messages: {} },
+ };
+ const collection = Map({
+ name: 'pages',
+ type: FOLDER,
+ folder: '_pages',
+ create: true,
+ fields: List([Map({ name: 'title', widget: 'string' })]),
+ nested: Map({ depth: 10, subfolders: false }),
+ meta: Map({ path: Map({ label: 'Path', widget: 'string' }) }),
+ });
+ const entryDraft = Map({
+ entry: Map({
+ data: Map({ title: 'Test' }),
+ meta: Map({ path: 'blog' }),
+ newRecord: true,
+ }),
+ });
+ const user = { login: 'user', name: 'User' };
+ const backend = new Backend(implementation, { config, backendName: 'test' });
+
+ backend.currentUser = jest.fn().mockResolvedValue(user);
+ backend.entryToRaw = jest.fn().mockReturnValue('content');
+ backend.generateUniqueSlug = jest.fn().mockResolvedValue('test-slug');
+ backend.invokePreSaveEvent = jest.fn().mockResolvedValue(entryDraft.get('entry'));
+ backend.invokePostSaveEvent = jest.fn().mockResolvedValue();
+
+ await backend.persistEntry({
+ config,
+ collection,
+ entryDraft,
+ assetProxies: [],
+ usedSlugs: List(),
+ });
+
+ expect(implementation.persistEntry).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ hasSubfolders: false,
+ }),
+ );
+ });
+
+ it('should default to hasSubfolders=true when subfolders is not specified', async () => {
+ const implementation = {
+ init: jest.fn(() => implementation),
+ persistEntry: jest.fn(),
+ };
+
+ const config = {
+ backend: { commit_messages: {} },
+ };
+ const collection = Map({
+ name: 'pages',
+ type: FOLDER,
+ folder: '_pages',
+ create: true,
+ fields: List([Map({ name: 'title', widget: 'string' })]),
+ nested: Map({ depth: 10 }),
+ meta: Map({ path: Map({ label: 'Path', widget: 'string' }) }),
+ });
+ const entryDraft = Map({
+ entry: Map({
+ data: Map({ title: 'Test' }),
+ meta: Map({ path: 'blog' }),
+ newRecord: true,
+ }),
+ });
+ const user = { login: 'user', name: 'User' };
+ const backend = new Backend(implementation, { config, backendName: 'test' });
+
+ backend.currentUser = jest.fn().mockResolvedValue(user);
+ backend.entryToRaw = jest.fn().mockReturnValue('content');
+ backend.generateUniqueSlug = jest.fn().mockResolvedValue('test-slug');
+ backend.invokePreSaveEvent = jest.fn().mockResolvedValue(entryDraft.get('entry'));
+ backend.invokePostSaveEvent = jest.fn().mockResolvedValue();
+
+ await backend.persistEntry({
+ config,
+ collection,
+ entryDraft,
+ assetProxies: [],
+ usedSlugs: List(),
+ });
+
+ expect(implementation.persistEntry).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ hasSubfolders: true,
+ }),
+ );
+ });
+ });
});
diff --git a/packages/decap-cms-core/src/actions/config.ts b/packages/decap-cms-core/src/actions/config.ts
index fd7028760387..e887dc975e6b 100644
--- a/packages/decap-cms-core/src/actions/config.ts
+++ b/packages/decap-cms-core/src/actions/config.ts
@@ -308,11 +308,12 @@ export function applyDefaults(originalConfig: CmsConfig) {
collection.folder = trim(folder, '/');
if (meta && meta.path) {
+ const metaPath = meta.path;
const metaField = {
name: 'path',
meta: true,
required: true,
- ...meta.path,
+ ...metaPath,
};
collection.fields = [metaField, ...(collection.fields || [])];
}
diff --git a/packages/decap-cms-core/src/backend.ts b/packages/decap-cms-core/src/backend.ts
index 6e5f03d9e068..2216f86a29af 100644
--- a/packages/decap-cms-core/src/backend.ts
+++ b/packages/decap-cms-core/src/backend.ts
@@ -1184,6 +1184,7 @@ export class Backend {
);
const collectionName = collection.get('name');
+ const hasSubfolders = collection.get('nested')?.get('subfolders') !== false;
const updatedOptions = { unpublished, status };
const opts = {
@@ -1191,6 +1192,7 @@ export class Backend {
commitMessage,
collectionName,
useWorkflow,
+ hasSubfolders,
...updatedOptions,
};
diff --git a/packages/decap-cms-core/src/components/Collection/NestedCollection.js b/packages/decap-cms-core/src/components/Collection/NestedCollection.js
index bb38ebd5ed62..9c24141688d5 100644
--- a/packages/decap-cms-core/src/components/Collection/NestedCollection.js
+++ b/packages/decap-cms-core/src/components/Collection/NestedCollection.js
@@ -67,18 +67,25 @@ const TreeNavLink = styled(NavLink)`
`};
`;
-function getNodeTitle(node) {
- const title = node.isRoot
- ? node.title
- : node.children.find(c => !c.isDir && c.title)?.title || node.title;
- return title;
+function getNodeTitle(node, collection) {
+ // Backward compatibility: when `nested.subfolders` is true(default) or undefined,
+ // directory nodes should use the title of their index entry.
+ // Otherwise, use the folder name already stored in `node.title`.
+ const subfolders = collection.getIn(['nested', 'subfolders']) !== false;
+ if (!node.isRoot && node.isDir && subfolders) {
+ const indexChild = node.children.find(child => !child.isDir);
+ if (indexChild && indexChild.title) {
+ return indexChild.title;
+ }
+ }
+ return node.title;
}
function TreeNode(props) {
const { collection, treeData, depth = 0, onToggle } = props;
const collectionName = collection.get('name');
- const sortedData = sortBy(treeData, getNodeTitle);
+ const sortedData = sortBy(treeData, node => getNodeTitle(node, collection));
const subfolders = collection.get('nested')?.get('subfolders') !== false;
return sortedData.map(node => {
const leaf =
@@ -93,7 +100,7 @@ function TreeNode(props) {
if (depth > 0) {
to = `${to}/filter${node.path}`;
}
- const title = getNodeTitle(node);
+ const title = getNodeTitle(node, collection);
const hasChildren =
depth === 0 ||
diff --git a/packages/decap-cms-core/src/components/Collection/__tests__/NestedCollection.spec.js b/packages/decap-cms-core/src/components/Collection/__tests__/NestedCollection.spec.js
index bdeac4040567..7c17cd18c68e 100644
--- a/packages/decap-cms-core/src/components/Collection/__tests__/NestedCollection.spec.js
+++ b/packages/decap-cms-core/src/components/Collection/__tests__/NestedCollection.spec.js
@@ -38,7 +38,7 @@ describe('NestedCollection', () => {
folder: 'src/pages',
fields: [{ name: 'title', widget: 'string' }],
nested: {
- subfolders: false,
+ subfolders: true,
},
});
diff --git a/packages/decap-cms-core/src/components/Collection/__tests__/__snapshots__/NestedCollection.spec.js.snap b/packages/decap-cms-core/src/components/Collection/__tests__/__snapshots__/NestedCollection.spec.js.snap
index b643246f4411..dd3f5fcd55ac 100644
--- a/packages/decap-cms-core/src/components/Collection/__tests__/__snapshots__/NestedCollection.spec.js.snap
+++ b/packages/decap-cms-core/src/components/Collection/__tests__/__snapshots__/NestedCollection.spec.js.snap
@@ -138,20 +138,6 @@ exports[`NestedCollection should render connected component 1`] = `
margin-right: 4px;
}
-.emotion-6 {
- position: relative;
- top: 2px;
- color: #fff;
- width: 0;
- height: 0;
- border: 5px solid transparent;
- border-radius: 2px;
- border-left: 6px solid currentColor;
- border-right: 0;
- color: currentColor;
- left: 2px;
-}
-
File 1
-
.emotion-0 {
@@ -224,20 +207,6 @@ exports[`NestedCollection should render connected component 1`] = `
margin-right: 4px;
}
-.emotion-6 {
- position: relative;
- top: 2px;
- color: #fff;
- width: 0;
- height: 0;
- border: 5px solid transparent;
- border-radius: 2px;
- border-left: 6px solid currentColor;
- border-right: 0;
- color: currentColor;
- left: 2px;
-}
-
File 2
-
@@ -401,20 +367,6 @@ exports[`NestedCollection should render correctly with nested entries 1`] = `
margin-right: 4px;
}
-.emotion-6 {
- position: relative;
- top: 2px;
- color: #fff;
- width: 0;
- height: 0;
- border: 5px solid transparent;
- border-radius: 2px;
- border-left: 6px solid currentColor;
- border-right: 0;
- color: currentColor;
- left: 2px;
-}
-
File 1
-
.emotion-0 {
@@ -487,20 +436,6 @@ exports[`NestedCollection should render correctly with nested entries 1`] = `
margin-right: 4px;
}
-.emotion-6 {
- position: relative;
- top: 2px;
- color: #fff;
- width: 0;
- height: 0;
- border: 5px solid transparent;
- border-radius: 2px;
- border-left: 6px solid currentColor;
- border-right: 0;
- color: currentColor;
- left: 2px;
-}
-
File 2
-
diff --git a/packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js b/packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js
index bd72e790708e..7a377761f0fc 100644
--- a/packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js
+++ b/packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js
@@ -477,22 +477,25 @@ describe('config', () => {
merge({}, validConfig, { collections: [{ meta: { path: { label: 'Label' } } }] }),
);
}).toThrowError("'collections[0].meta.path' must have required property 'widget'");
+ });
+
+ it('should allow collection meta to have a path configuration with index_file', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
- collections: [{ meta: { path: { label: 'Label', widget: 'widget' } } }],
+ collections: [
+ { meta: { path: { label: 'Path', widget: 'string', index_file: 'index' } } },
+ ],
}),
);
- }).toThrowError("'collections[0].meta.path' must have required property 'index_file'");
+ }).not.toThrow();
});
- it('should allow collection meta to have a path configuration', () => {
+ it('should allow collection meta to have a path configuration without index_file', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
- collections: [
- { meta: { path: { label: 'Path', widget: 'string', index_file: 'index' } } },
- ],
+ collections: [{ meta: { path: { label: 'Path', widget: 'string' } } }],
}),
);
}).not.toThrow();
diff --git a/packages/decap-cms-core/src/constants/configSchema.js b/packages/decap-cms-core/src/constants/configSchema.js
index 1b3f28833b6b..6fbffee8fe7a 100644
--- a/packages/decap-cms-core/src/constants/configSchema.js
+++ b/packages/decap-cms-core/src/constants/configSchema.js
@@ -305,7 +305,7 @@ function getConfigSchema() {
widget: { type: 'string' },
index_file: { type: 'string' },
},
- required: ['label', 'widget', 'index_file'],
+ required: ['label', 'widget'],
},
},
additionalProperties: false,
diff --git a/packages/decap-cms-core/src/reducers/__tests__/entryDraft.spec.js b/packages/decap-cms-core/src/reducers/__tests__/entryDraft.spec.js
index 8899ed85d47c..7120f0d53d3f 100644
--- a/packages/decap-cms-core/src/reducers/__tests__/entryDraft.spec.js
+++ b/packages/decap-cms-core/src/reducers/__tests__/entryDraft.spec.js
@@ -195,4 +195,121 @@ describe('entryDraft reducer', () => {
});
});
});
+
+ describe('selectCustomPath', () => {
+ let selectCustomPath;
+ let selectHasMetaPath;
+ let selectFolderEntryExtension;
+
+ beforeEach(() => {
+ jest.resetModules();
+ selectHasMetaPath = jest.fn(
+ collection => collection.has('meta') && collection.get('meta').has('path'),
+ );
+ selectFolderEntryExtension = jest.fn(collection => collection.get('extension') || 'md');
+
+ jest.doMock('../collections', () => ({
+ selectHasMetaPath,
+ selectFolderEntryExtension,
+ }));
+
+ const entryDraftModule = require('../entryDraft');
+ selectCustomPath = entryDraftModule.selectCustomPath;
+ });
+
+ afterEach(() => {
+ jest.unmock('../collections');
+ });
+
+ it('should generate dynamic filename for new entries without index_file', () => {
+ const collection = fromJS({
+ folder: '_pages',
+ extension: 'md',
+ meta: { path: { label: 'Path', widget: 'string' } },
+ });
+ const entryDraft = fromJS({
+ entry: {
+ newRecord: true,
+ data: { title: 'My Great Article' },
+ meta: { path: 'blog' },
+ },
+ });
+
+ const result = selectCustomPath(collection, entryDraft);
+ expect(result).toBe('_pages/blog/my-great-article.md');
+ });
+
+ it('should preserve filename for existing entries without index_file', () => {
+ const collection = fromJS({
+ folder: '_pages',
+ extension: 'md',
+ meta: { path: { label: 'Path', widget: 'string' } },
+ });
+ const entryDraft = fromJS({
+ entry: {
+ newRecord: false,
+ path: '_pages/old-folder/existing-file.md',
+ data: { title: 'Updated Title' },
+ meta: { path: 'new-folder' },
+ },
+ });
+
+ const result = selectCustomPath(collection, entryDraft);
+ expect(result).toBe('_pages/new-folder/existing-file.md');
+ });
+
+ it('should use index_file when specified (backward compatibility)', () => {
+ const collection = fromJS({
+ folder: '_pages',
+ extension: 'md',
+ meta: { path: { label: 'Path', widget: 'string', index_file: 'index' } },
+ });
+ const entryDraft = fromJS({
+ entry: {
+ newRecord: true,
+ data: { title: 'My Article' },
+ meta: { path: 'blog' },
+ },
+ });
+
+ const result = selectCustomPath(collection, entryDraft);
+ expect(result).toBe('_pages/blog/index.md');
+ });
+
+ it('should return undefined when path is not set', () => {
+ const collection = fromJS({
+ folder: '_pages',
+ extension: 'md',
+ meta: { path: { label: 'Path', widget: 'string' } },
+ });
+ const entryDraft = fromJS({
+ entry: {
+ newRecord: true,
+ data: { title: 'My Article' },
+ meta: {},
+ },
+ });
+
+ const result = selectCustomPath(collection, entryDraft);
+ expect(result).toBeUndefined();
+ });
+
+ it('should preserve non-latin characters in generated filename', () => {
+ const collection = fromJS({
+ folder: '_pages',
+ extension: 'md',
+ meta: { path: { label: 'Path', widget: 'string' } },
+ });
+ const entryDraft = fromJS({
+ entry: {
+ newRecord: true,
+ data: { title: '日本語のタイトル' },
+ meta: { path: 'blog' },
+ },
+ });
+
+ const result = selectCustomPath(collection, entryDraft);
+ expect(result).toBe('_pages/blog/日本語のタイトル.md');
+ });
+ });
});
diff --git a/packages/decap-cms-core/src/reducers/entryDraft.js b/packages/decap-cms-core/src/reducers/entryDraft.js
index e3d2dec29f04..ca2a350c6268 100644
--- a/packages/decap-cms-core/src/reducers/entryDraft.js
+++ b/packages/decap-cms-core/src/reducers/entryDraft.js
@@ -1,8 +1,9 @@
import { Map, List, fromJS } from 'immutable';
import { v4 as uuid } from 'uuid';
import get from 'lodash/get';
-import { join } from 'path';
+import { join, basename } from 'path';
+import { sanitizeSlug } from '../lib/urlHelper';
import {
DRAFT_CREATE_FROM_ENTRY,
DRAFT_CREATE_EMPTY,
@@ -204,15 +205,54 @@ function entryDraftReducer(state = Map(), action) {
}
}
+function cleanTitleForFilename(title) {
+ if (!title) return 'untitled';
+
+ const cleanedTitle = sanitizeSlug(title.toString().toLowerCase().trim(), {
+ sanitize_replacement: '-',
+ encoding: 'unicode',
+ });
+
+ return cleanedTitle || 'untitled';
+}
+
export function selectCustomPath(collection, entryDraft) {
if (!selectHasMetaPath(collection)) {
return;
}
const meta = entryDraft.getIn(['entry', 'meta']);
const path = meta && meta.get('path');
- const indexFile = get(collection.toJS(), ['meta', 'path', 'index_file']);
+
+ if (!path) {
+ return;
+ }
+
const extension = selectFolderEntryExtension(collection);
- const customPath = path && join(collection.get('folder'), path, `${indexFile}.${extension}`);
+ const indexFile = get(collection.toJS(), ['meta', 'path', 'index_file']);
+
+ // If index_file is specified, use the old behavior for backward compatibility
+ if (indexFile) {
+ const customPath = join(collection.get('folder'), path, `${indexFile}.${extension}`);
+ return customPath;
+ }
+
+ // New behavior: generate filename from entry title
+ const isNewEntry = entryDraft.getIn(['entry', 'newRecord']);
+ const currentPath = entryDraft.getIn(['entry', 'path']);
+
+ let filename;
+ if (isNewEntry || !currentPath) {
+ // For new entries, generate filename from title
+ const entryData = entryDraft.getIn(['entry', 'data']);
+ const title = entryData && entryData.get('title');
+ filename = cleanTitleForFilename(title);
+ } else {
+ // For existing entries, preserve the current filename
+ const currentFilename = basename(currentPath, `.${extension}`);
+ filename = currentFilename;
+ }
+
+ const customPath = join(collection.get('folder'), path, `${filename}.${extension}`);
return customPath;
}
diff --git a/packages/decap-cms-core/src/types/redux.ts b/packages/decap-cms-core/src/types/redux.ts
index 6ae1522a201b..29cc9a2e9254 100644
--- a/packages/decap-cms-core/src/types/redux.ts
+++ b/packages/decap-cms-core/src/types/redux.ts
@@ -258,7 +258,7 @@ export interface CmsFieldMeta {
label: string;
widget: string;
required: boolean;
- index_file: string;
+ index_file?: string;
meta: boolean;
}
@@ -340,7 +340,7 @@ export interface CmsCollection {
depth: number;
};
type: typeof FOLDER | typeof FILES;
- meta?: { path?: { label: string; widget: string; index_file: string } };
+ meta?: { path?: { label: string; widget: string; index_file?: string } };
/**
* It accepts the following values: yml, yaml, toml, json, md, markdown, html
diff --git a/packages/decap-cms-lib-util/src/implementation.ts b/packages/decap-cms-lib-util/src/implementation.ts
index 3080bf99072f..1321c37c761c 100644
--- a/packages/decap-cms-lib-util/src/implementation.ts
+++ b/packages/decap-cms-lib-util/src/implementation.ts
@@ -81,6 +81,7 @@ export type PersistOptions = {
useWorkflow?: boolean;
unpublished?: boolean;
status?: string;
+ hasSubfolders?: boolean;
};
export type DeleteOptions = {};
diff --git a/packages/decap-server/src/middlewares/localFs/index.ts b/packages/decap-server/src/middlewares/localFs/index.ts
index e1c265763d10..103e99c7d008 100644
--- a/packages/decap-server/src/middlewares/localFs/index.ts
+++ b/packages/decap-server/src/middlewares/localFs/index.ts
@@ -68,7 +68,9 @@ export function localFsMiddleware({ repoPath, logger }: FsOptions) {
entry,
dataFiles = [entry as DataFile],
assets,
+ options,
} = body.params as PersistEntryParams;
+ const hasSubfolders = options?.hasSubfolders !== false;
await Promise.all(
dataFiles.map(dataFile => writeFile(path.join(repoPath, dataFile.path), dataFile.raw)),
);
@@ -83,6 +85,7 @@ export function localFsMiddleware({ repoPath, logger }: FsOptions) {
await move(
path.join(repoPath, dataFile.path),
path.join(repoPath, dataFile.newPath!),
+ hasSubfolders,
);
});
}
diff --git a/packages/decap-server/src/middlewares/localGit/index.ts b/packages/decap-server/src/middlewares/localGit/index.ts
index a8765223ef1a..c5d15f0e8019 100644
--- a/packages/decap-server/src/middlewares/localGit/index.ts
+++ b/packages/decap-server/src/middlewares/localGit/index.ts
@@ -86,6 +86,7 @@ async function commitEntry(
dataFiles: DataFile[],
assets: Asset[],
commitMessage: string,
+ hasSubfolders = true,
) {
// save entry content
await Promise.all(
@@ -97,7 +98,11 @@ async function commitEntry(
);
if (dataFiles.every(dataFile => dataFile.newPath)) {
dataFiles.forEach(async dataFile => {
- await move(path.join(repoPath, dataFile.path), path.join(repoPath, dataFile.newPath!));
+ await move(
+ path.join(repoPath, dataFile.path),
+ path.join(repoPath, dataFile.newPath!),
+ hasSubfolders,
+ );
});
}
@@ -310,10 +315,18 @@ export function localGitMiddleware({ repoPath, logger }: GitOptions) {
assets,
options,
} = body.params as PersistEntryParams;
+ const hasSubfolders = options?.hasSubfolders !== false;
if (!options.useWorkflow) {
await runOnBranch(git, branch, async () => {
- await commitEntry(git, repoPath, dataFiles, assets, options.commitMessage);
+ await commitEntry(
+ git,
+ repoPath,
+ dataFiles,
+ assets,
+ options.commitMessage,
+ hasSubfolders,
+ );
});
} else {
const slug = dataFiles[0].slug;
@@ -334,7 +347,14 @@ export function localGitMiddleware({ repoPath, logger }: GitOptions) {
d => d.binary && !assets.map(a => a.path).includes(d.path),
);
await Promise.all(toDelete.map(f => fs.unlink(path.join(repoPath, f.path))));
- await commitEntry(git, repoPath, dataFiles, assets, options.commitMessage);
+ await commitEntry(
+ git,
+ repoPath,
+ dataFiles,
+ assets,
+ options.commitMessage,
+ hasSubfolders,
+ );
// add status for new entries
if (!branchExists) {
diff --git a/packages/decap-server/src/middlewares/types.ts b/packages/decap-server/src/middlewares/types.ts
index be69316d9456..31b7b00b80f4 100644
--- a/packages/decap-server/src/middlewares/types.ts
+++ b/packages/decap-server/src/middlewares/types.ts
@@ -68,6 +68,7 @@ export type PersistEntryParams = {
commitMessage: string;
useWorkflow: boolean;
status: string;
+ hasSubfolders?: boolean;
};
};
diff --git a/packages/decap-server/src/middlewares/utils/fs.ts b/packages/decap-server/src/middlewares/utils/fs.ts
index a15c1e094261..35c077578214 100644
--- a/packages/decap-server/src/middlewares/utils/fs.ts
+++ b/packages/decap-server/src/middlewares/utils/fs.ts
@@ -46,15 +46,18 @@ async function moveFile(from: string, to: string) {
await fs.rename(from, to);
}
-export async function move(from: string, to: string) {
+export async function move(from: string, to: string, hasSubfolders = true) {
// move file
await moveFile(from, to);
- // move children
- const sourceDir = path.dirname(from);
- const destDir = path.dirname(to);
- const allFiles = await listFiles(sourceDir, '', 100);
- await Promise.all(allFiles.map(file => moveFile(file, file.replace(sourceDir, destDir))));
+ if (hasSubfolders) {
+ // Legacy behavior (subfolders: true, default): move all files in the directory
+ // This is for collections where all files in a folder represent a single entry
+ const sourceDir = path.dirname(from);
+ const destDir = path.dirname(to);
+ const allFiles = await listFiles(sourceDir, '', 100);
+ await Promise.all(allFiles.map(file => moveFile(file, file.replace(sourceDir, destDir))));
+ }
}
export async function getUpdateDate(repoPath: string, filePath: string) {