Skip to content

Commit 78fb8af

Browse files
pranaysahithyanthomasdevclaudemartinjagodic
authored
Fix bugs in nested collection (#7681)
* Fix bugs in nested collection * fix: remove unused config * revert package-lock.json changes * Update packages/decap-cms-core/src/reducers/entryDraft.js Co-authored-by: Yan <61414485+yanthomasdev@users.noreply.github.com> * fix: address review comments * fix: remove trailing whitespace in cleanTitleForFilename Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add hasSubfolders to localFs/Git * fix: preserve non-latin characters for better i18n * chore: formatting --------- Co-authored-by: Yan <61414485+yanthomasdev@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Martin Jagodic <jagodicmartin1@gmail.com>
1 parent 5be1d7a commit 78fb8af

23 files changed

Lines changed: 522 additions & 169 deletions

File tree

dev-test/backends/test/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,4 +253,4 @@ collections: # A list of collections the CMS should be able to edit
253253
- label: Title
254254
name: title
255255
widget: string
256-
meta: { path: { widget: string, label: 'Path', index_file: 'index' } }
256+
meta: { path: { widget: string, label: 'Path' } }

dev-test/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,4 +289,4 @@ collections: # A list of collections the CMS should be able to edit
289289
- label: Title
290290
name: title
291291
widget: string
292-
meta: { path: { widget: string, label: 'Path', index_file: 'index' } }
292+
meta: { path: { widget: string, label: 'Path' } }

packages/decap-cms-backend-azure/src/API.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
readFileMetadata,
2323
branchFromContentKey,
2424
} from 'decap-cms-lib-util';
25-
import { dirname, basename } from 'path';
25+
import { basename, dirname } from 'path';
2626

2727
import type { ApiRequest, AssetProxy, PersistOptions, DataFile } from 'decap-cms-lib-util';
2828
import type { Map } from 'immutable';
@@ -503,7 +503,11 @@ export default class API {
503503
}));
504504
}
505505

506-
async getCommitItems(files: { path: string; newPath?: string }[], branch: string) {
506+
async getCommitItems(
507+
files: { path: string; newPath?: string }[],
508+
branch: string,
509+
subfolders = true,
510+
) {
507511
const items = await Promise.all(
508512
files.map(async file => {
509513
const [base64Content, fileExists] = await Promise.all([
@@ -526,32 +530,37 @@ export default class API {
526530
}),
527531
);
528532

529-
// move children
530-
for (const item of items.filter(i => i.oldPath && i.action === AzureCommitChangeType.RENAME)) {
531-
const sourceDir = dirname(item.oldPath as string);
532-
const destDir = dirname(item.path);
533-
const children = await this.listFiles(sourceDir, true, branch);
534-
children
535-
.filter(file => file.path !== item.oldPath)
536-
.forEach(file => {
537-
items.push({
538-
action: AzureCommitChangeType.RENAME,
539-
path: file.path.replace(sourceDir, destDir),
540-
oldPath: file.path,
533+
// move children when subfolders is true (legacy/default behavior)
534+
if (subfolders) {
535+
for (const item of items.filter(
536+
i => i.oldPath && i.action === AzureCommitChangeType.RENAME,
537+
)) {
538+
const sourceDir = dirname(item.oldPath as string);
539+
const destDir = dirname(item.path);
540+
const children = await this.listFiles(sourceDir, true, branch);
541+
children
542+
.filter(file => file.path !== item.oldPath)
543+
.forEach(file => {
544+
items.push({
545+
action: AzureCommitChangeType.RENAME,
546+
path: file.path.replace(sourceDir, destDir),
547+
oldPath: file.path,
548+
});
541549
});
542-
});
550+
}
543551
}
544552

545553
return items;
546554
}
547555

548556
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
549557
const files = [...dataFiles, ...mediaFiles];
558+
const subfolders = options.hasSubfolders !== false; // default to true
550559
if (options.useWorkflow) {
551560
const slug = dataFiles[0].slug;
552561
return this.editorialWorkflowGit(files, slug, options);
553562
} else {
554-
const items = await this.getCommitItems(files, this.branch);
563+
const items = await this.getCommitItems(files, this.branch, subfolders);
555564

556565
return this.uploadAndCommit(items, options.commitMessage, this.branch, true);
557566
}
@@ -677,9 +686,10 @@ export default class API {
677686
const contentKey = generateContentKey(options.collectionName as string, slug);
678687
const branch = branchFromContentKey(contentKey);
679688
const unpublished = options.unpublished || false;
689+
const subfolders = options.hasSubfolders !== false; // default to true
680690

681691
if (!unpublished) {
682-
const items = await this.getCommitItems(files, this.branch);
692+
const items = await this.getCommitItems(files, this.branch, subfolders);
683693

684694
await this.uploadAndCommit(items, options.commitMessage, branch, true);
685695
await this.createPullRequest(
@@ -688,7 +698,7 @@ export default class API {
688698
options.status || this.initialWorkflowStatus,
689699
);
690700
} else {
691-
const items = await this.getCommitItems(files, branch);
701+
const items = await this.getCommitItems(files, branch, subfolders);
692702
await this.uploadAndCommit(items, options.commitMessage, branch, false);
693703
}
694704
}

packages/decap-cms-backend-bitbucket/src/API.ts

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ import {
2424
readFileMetadata,
2525
throwOnConflictingBranches,
2626
} from 'decap-cms-lib-util';
27-
import { dirname } from 'path';
2827
import { oneLine } from 'common-tags';
2928
import { parse } from 'what-the-diff';
29+
import { dirname } from 'path';
3030

3131
import type {
3232
ApiRequest,
@@ -432,45 +432,63 @@ export default class API {
432432
commitMessage,
433433
branch,
434434
parentSha,
435-
}: { commitMessage: string; branch: string; parentSha?: string },
435+
hasSubfolders = true,
436+
}: { commitMessage: string; branch: string; parentSha?: string; hasSubfolders?: boolean },
436437
) {
437438
const formData = new FormData();
438-
const toMove: { from: string; to: string; contentBlob: Blob }[] = [];
439+
const toMove: { from: string; to: string; contentBlob: Blob; hasSubfolders: boolean }[] = [];
439440
files.forEach(file => {
440441
if (file.delete) {
441442
// delete the file
442443
formData.append('files', file.path);
443444
} else if (file.newPath) {
444445
const contentBlob = get(file, 'fileObj', new Blob([(file as DataFile).raw]));
445-
toMove.push({ from: file.path, to: file.newPath, contentBlob });
446+
toMove.push({ from: file.path, to: file.newPath, contentBlob, hasSubfolders });
446447
} else {
447448
// add/modify the file
448449
const contentBlob = get(file, 'fileObj', new Blob([(file as DataFile).raw]));
449450
// Third param is filename header, in case path is `message`, `branch`, etc.
450451
formData.append(file.path, contentBlob, basename(file.path));
451452
}
452453
});
453-
for (const { from, to, contentBlob } of toMove) {
454-
const sourceDir = dirname(from);
455-
const destDir = dirname(to);
456-
const filesBranch = parentSha ? this.branch : branch;
457-
const files = await this.listAllFiles(sourceDir, 100, filesBranch);
458-
for (const file of files) {
454+
for (const { from, to, contentBlob, hasSubfolders } of toMove) {
455+
if (!hasSubfolders) {
456+
// New behavior (subfolders: false): Only move the specific file
459457
// to move a file in Bitbucket we need to delete the old path
460458
// and upload the file content to the new path
461459
// NOTE: this is very wasteful, and also the Bitbucket `diff` API
462460
// reports these files as deleted+added instead of renamed
463461
// delete current path
464-
formData.append('files', file.path);
462+
formData.append('files', from);
465463
// create in new path
466-
const content =
467-
file.path === from
468-
? contentBlob
469-
: await this.readFile(file.path, null, {
470-
branch: filesBranch,
471-
parseText: false,
472-
});
473-
formData.append(file.path.replace(sourceDir, destDir), content, basename(file.path));
464+
formData.append(to, contentBlob, basename(to));
465+
} else {
466+
// Legacy behavior (subfolders: true, default): Move all files in the directory
467+
const sourceDir = dirname(from);
468+
const destDir = dirname(to);
469+
const filesBranch = parentSha ? this.branch : branch;
470+
const files = await this.listAllFiles(sourceDir, 100, filesBranch);
471+
for (const file of files) {
472+
// to move a file in Bitbucket we need to delete the old path
473+
// and upload the file content to the new path
474+
// NOTE: this is very wasteful, and also the Bitbucket `diff` API
475+
// reports these files as deleted+added instead of renamed
476+
// delete current path
477+
formData.append('files', file.path);
478+
// create in new path
479+
const content =
480+
file.path === from
481+
? contentBlob
482+
: await this.readFile(file.path, null, {
483+
branch: filesBranch,
484+
parseText: false,
485+
});
486+
formData.append(
487+
file.path.replace(sourceDir, destDir),
488+
content as Blob,
489+
basename(file.path),
490+
);
491+
}
474492
}
475493
}
476494

@@ -508,11 +526,16 @@ export default class API {
508526

509527
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
510528
const files = [...dataFiles, ...mediaFiles];
529+
const hasSubfolders = options.hasSubfolders !== false; // default to true
511530
if (options.useWorkflow) {
512531
const slug = dataFiles[0].slug;
513532
return this.editorialWorkflowGit(files, slug, options);
514533
} else {
515-
return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch });
534+
return this.uploadFiles(files, {
535+
commitMessage: options.commitMessage,
536+
branch: this.branch,
537+
hasSubfolders,
538+
});
516539
}
517540
}
518541

@@ -599,12 +622,14 @@ export default class API {
599622
const contentKey = generateContentKey(options.collectionName as string, slug);
600623
const branch = branchFromContentKey(contentKey);
601624
const unpublished = options.unpublished || false;
625+
const hasSubfolders = options.hasSubfolders !== false; // default to true
602626
if (!unpublished) {
603627
const defaultBranchSha = await this.branchCommitSha(this.branch);
604628
await this.uploadFiles(files, {
605629
commitMessage: options.commitMessage,
606630
branch,
607631
parentSha: defaultBranchSha,
632+
hasSubfolders,
608633
});
609634
await this.createPullRequest(
610635
branch,
@@ -624,6 +649,7 @@ export default class API {
624649
await this.uploadFiles([...files, ...toDelete], {
625650
commitMessage: options.commitMessage,
626651
branch,
652+
hasSubfolders,
627653
});
628654
}
629655
}

packages/decap-cms-backend-github/src/API.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import result from 'lodash/result';
77
import trimStart from 'lodash/trimStart';
88
import trim from 'lodash/trim';
99
import { oneLine } from 'common-tags';
10+
import { dirname } from 'path';
1011
import {
1112
getAllResponses,
1213
APIError,
@@ -29,7 +30,6 @@ import {
2930
unsentRequest,
3031
throwOnConflictingBranches,
3132
} from 'decap-cms-lib-util';
32-
import { dirname } from 'path';
3333

3434
import type {
3535
AssetProxy,
@@ -1011,9 +1011,15 @@ export default class API {
10111011
const contentKey = this.generateContentKey(options.collectionName as string, slug);
10121012
const branch = branchFromContentKey(contentKey);
10131013
const unpublished = options.unpublished || false;
1014+
const hasSubfolders = options.hasSubfolders !== false; // default to true
10141015
if (!unpublished) {
10151016
const branchData = await this.getDefaultBranch();
1016-
const changeTree = await this.updateTree(branchData.commit.sha, files);
1017+
const changeTree = await this.updateTree(
1018+
branchData.commit.sha,
1019+
files,
1020+
this.branch,
1021+
hasSubfolders,
1022+
);
10171023
const commitResponse = await this.commit(options.commitMessage, changeTree);
10181024

10191025
if (this.useOpenAuthoring) {
@@ -1048,7 +1054,7 @@ export default class API {
10481054
// rebase the branch before applying new changes
10491055
const rebasedHead = await this.rebaseBranch(branch);
10501056
const treeFiles = mediaFilesToRemove.concat(files);
1051-
const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch);
1057+
const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch, hasSubfolders);
10521058
const commit = await this.commit(options.commitMessage, changeTree);
10531059

10541060
return this.patchBranch(branch, commit.sha, { force: true });
@@ -1418,6 +1424,7 @@ export default class API {
14181424
baseSha: string,
14191425
files: { path: string; sha: string | null; newPath?: string }[],
14201426
branch = this.branch,
1427+
hasSubfolders = true,
14211428
) {
14221429
const toMove: { from: string; to: string; sha: string }[] = [];
14231430
const tree = files.reduce((acc, file) => {
@@ -1438,24 +1445,44 @@ export default class API {
14381445
}, [] as TreeEntry[]);
14391446

14401447
for (const { from, to, sha } of toMove) {
1441-
const sourceDir = dirname(from);
1442-
const destDir = dirname(to);
1443-
const files = await this.listFiles(sourceDir, { branch, depth: 100 });
1444-
for (const file of files) {
1445-
// delete current path
1448+
if (!hasSubfolders) {
1449+
// New behavior (subfolders: false): Only move the specific file
1450+
// Delete the file at the old path
14461451
tree.push({
1447-
path: file.path,
1452+
path: trimStart(from, '/'),
14481453
mode: '100644',
14491454
type: 'blob',
14501455
sha: null,
14511456
});
1452-
// create in new path
1457+
// Create the file at the new path
14531458
tree.push({
1454-
path: file.path.replace(sourceDir, destDir),
1459+
path: trimStart(to, '/'),
14551460
mode: '100644',
14561461
type: 'blob',
1457-
sha: file.path === from ? sha : file.id,
1462+
sha,
14581463
});
1464+
} else {
1465+
// Legacy behavior (subfolders: true, default): Move all files in the directory
1466+
// This is for collections where all files in a folder represent a single entry
1467+
const sourceDir = dirname(from);
1468+
const destDir = dirname(to);
1469+
const files = await this.listFiles(sourceDir, { branch, depth: 100 });
1470+
for (const file of files) {
1471+
// delete current path
1472+
tree.push({
1473+
path: file.path,
1474+
mode: '100644',
1475+
type: 'blob',
1476+
sha: null,
1477+
});
1478+
// create in new path
1479+
tree.push({
1480+
path: file.path.replace(sourceDir, destDir),
1481+
mode: '100644',
1482+
type: 'blob',
1483+
sha: file.path === from ? sha : file.id,
1484+
});
1485+
}
14591486
}
14601487
}
14611488

0 commit comments

Comments
 (0)