feat: Index fields - #7382
Conversation
cb61157 to
db6b1b0
Compare
… instead of entire folders
…or single/index files
There was a problem hiding this comment.
Pull request overview
This pull request adds support for index files with separate field configurations in folder collections, addressing issue #7381. The feature allows users to differentiate between index files (like _index.md) and regular content files within the same collection, each with their own field definitions.
Changes:
- Adds
index_fileconfiguration option to collection schemas with pattern matching, custom fields, and editor settings - Implements path type selection dropdown for nested collections to choose between creating index pages or content pages
- Updates backend implementations (GitHub, local Git, local FS) to handle index file identification and folder-aware file operations
Reviewed changes
Copilot reviewed 35 out of 36 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/decap-cms-core/src/types/redux.ts | Adds TypeScript types for index_file config, path_type meta field, readonly field property, and CmsCollectionMeta interface |
| packages/decap-cms-core/index.d.ts | Updates public TypeScript definitions with index_file configuration support |
| packages/decap-cms-core/src/constants/configSchema.js | Adds schema validation for index_file configuration including pattern validation and file collection restriction |
| packages/decap-cms-core/src/constants/tests/configSchema.spec.js | Adds comprehensive tests for index_file validation rules |
| packages/decap-cms-core/src/lib/indexFileHelper.ts | Creates new utility functions for identifying index files and entries with nested collection support |
| packages/decap-cms-core/src/reducers/collections.ts | Updates field selection logic to return index_file fields when appropriate and adds nested collection helpers |
| packages/decap-cms-core/src/reducers/entryDraft.js | Modifies custom path calculation to handle both index and slug path types |
| packages/decap-cms-core/src/reducers/entries.ts | Adds logic to ensure index file entries appear first in the entry list |
| packages/decap-cms-core/src/reducers/tests/entries.spec.js | Adds tests for index_file entry loading and path_type meta field handling |
| packages/decap-cms-core/src/backend.ts | Updates entry loading to set path_type meta field and implements folder-aware file operations |
| packages/decap-cms-core/src/actions/entries.ts | Updates path validation to check both index and slug path types, fixes duplicate entry action |
| packages/decap-cms-core/src/actions/config.ts | Adds meta field injection for path and path_type fields in collections with index_file |
| packages/decap-cms-core/src/actions/tests/entries.spec.js | Updates test expectations for path_type in meta field validation |
| packages/decap-cms-core/src/lib/i18n.ts | Adds meta field removal from i18n data, updates file path comparison for nested collections |
| packages/decap-cms-core/src/lib/tests/i18n.spec.js | Adds mock for isNestedSubfolders selector |
| packages/decap-cms-core/src/components/Collection/CollectionTop.js | Adds dropdown for path type selection in nested collections with index_file |
| packages/decap-cms-core/src/components/Collection/NestedCollection.js | Uses centralized isNestedSubfolders helper |
| packages/decap-cms-core/src/components/Collection/Entries/EntryCard.js | Adds home icon indicator for index file entries |
| packages/decap-cms-core/src/components/Editor/EditorInterface.js | Updates preview detection to respect index_file editor settings |
| packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js | Adds readonly field support to disable editing |
| packages/decap-cms-core/src/components/Editor/Editor.js | Passes path_type query param to field selection and fixes nested collection back link calculation |
| packages/decap-server/src/middlewares/utils/fs.ts | Adds isFolder parameter to move function to conditionally move child files |
| packages/decap-server/src/middlewares/types.ts | Adds isFolder property to DataFile type |
| packages/decap-server/src/middlewares/localGit/index.ts | Passes isFolder parameter to move function |
| packages/decap-server/src/middlewares/localFs/index.ts | Passes isFolder parameter to move function |
| packages/decap-cms-lib-util/src/implementation.ts | Adds isFolder property to DataFile type |
| packages/decap-cms-backend-github/src/API.ts | Updates tree operations to handle folder-aware file moves |
| packages/decap-cms-locales/src/en/index.js | Adds English translations for path type labels |
| packages/decap-cms-locales/src/sl/index.js | Adds Slovenian translations for path type labels |
| packages/decap-cms-core/src/tests/backend.spec.js | Updates test expectation to include path_type in meta |
| packages/decap-cms-core/src/valueObjects/Entry.ts | Adds path_type to meta object type and srcSlug to EntryValue |
| dev-test/config.yml | Adds index_file configuration example |
| dev-test/index.html | Adds _index.md test file data |
| dev-test/backends/test/config.yml | Adds index_file configuration example |
| dev-test/backends/test/index.html | Adds _index.md test file data |
| cypress/e2e/index_fields_spec.js | Adds E2E tests for index file feature |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| usedSlugs, | ||
| customPath, | ||
| ); | ||
| isFolder = prepareMetaPathType(slug, collection) === 'index'; |
There was a problem hiding this comment.
The condition pathType === 'index' means the code determines isFolder = true for index file entries and isFolder = false for slug-type entries. However, this naming is confusing because an "index file" is still a file, not a folder. The variable name isFolder suggests it represents a directory, but it actually represents whether the entry should be treated as having subfolders that need to be moved along with it. Consider renaming this to something more descriptive like moveSubfolders or hasSubfolders to clarify its purpose.
| data: entry.get('data'), | ||
| i18n: entry.get('i18n'), | ||
| meta: entry.get('meta').toJS(), | ||
| i18n: entry.get('i18n').toJS(), |
There was a problem hiding this comment.
The draftDuplicateEntry function is calling .toJS() on entry.get('i18n') which may be undefined. If the entry doesn't have i18n data, this will throw a TypeError. You should add a check to ensure i18n exists before calling .toJS(), or provide a fallback value like an empty object.
| payload: createEntry(entry.get('collection'), '', '', { | ||
| data: entry.get('data'), | ||
| i18n: entry.get('i18n'), | ||
| meta: entry.get('meta').toJS(), |
There was a problem hiding this comment.
Similarly, calling .toJS() on entry.get('meta') may fail if meta is undefined. While meta is more commonly present, it's safer to add a check or provide a fallback value to avoid potential runtime errors.
| meta: entry.get('meta').toJS(), | |
| meta: (entry.get('meta') || Map()).toJS(), |
| .get('fields') | ||
| ?.find(f => f.get('name') === inferredFields.imageField && f.get('widget') === 'image'), | ||
| isLoadingAsset, | ||
| showIndexFileIcon: indexFileConfig && new RegExp(indexFileConfig.get('pattern')).test(fileSlug), |
There was a problem hiding this comment.
The pattern matching logic doesn't consider the nested flag from the collection. The isIndexFile helper function accounts for nested collections by extracting just the filename from the slug when nested is true. However, here you're testing the entire fileSlug directly against the pattern without considering whether the collection is nested. This could lead to inconsistent behavior where a file is identified as an index file in some parts of the code but not others.
Consider using the isIndexFile helper function here instead of duplicating the pattern matching logic, or ensure the slug extraction logic is consistent with what's done in the helper.
| } | ||
| const meta = entryDraft.getIn(['entry', 'meta']); | ||
| const path = meta && meta.get('path'); | ||
| const pathType = meta && meta.get('path_type', 'index'); |
There was a problem hiding this comment.
The check condition seems backwards. When pathType === 'index' the default should be 'index', not 'slug'. Similarly, when checking for nested subfolders or index path type, both should use the index file name. The current logic may be correct but would benefit from clarification. Consider restructuring to make the intent clearer:
const pathType = meta && meta.get('path_type', 'slug'); // default to 'slug' for regular files
This makes it clearer that 'slug' is the default for content files, while 'index' is explicitly set for index files.
| const pathType = meta && meta.get('path_type', 'index'); | |
| const pathType = meta && meta.get('path_type', 'slug'); // default to 'slug' for regular files |
| path_type: string, | ||
| ) { | ||
| const customPath = selectCustomPath(collection, fromJS({ entry: { meta: { path, path_type } } })); |
There was a problem hiding this comment.
The function signature shows path_type using snake_case naming, which is inconsistent with JavaScript/TypeScript conventions where camelCase is typically used for variable names. Consider renaming this parameter to pathType for consistency with the codebase's naming conventions.
| path_type: string, | |
| ) { | |
| const customPath = selectCustomPath(collection, fromJS({ entry: { meta: { path, path_type } } })); | |
| pathType: string, | |
| ) { | |
| const customPath = selectCustomPath( | |
| collection, | |
| fromJS({ entry: { meta: { path, path_type: pathType } } }), | |
| ); |
| path_type: string, | ||
| ) { | ||
| const customPath = selectCustomPath(collection, fromJS({ entry: { meta: { path, path_type } } })); |
There was a problem hiding this comment.
The variable name uses snake_case (path_type) which is inconsistent with JavaScript/TypeScript naming conventions. While this matches the data property name, for local variables camelCase (pathType) is more conventional in JavaScript/TypeScript code.
| path_type: string, | |
| ) { | |
| const customPath = selectCustomPath(collection, fromJS({ entry: { meta: { path, path_type } } })); | |
| pathType: string, | |
| ) { | |
| const customPath = selectCustomPath( | |
| collection, | |
| fromJS({ entry: { meta: { path, path_type: pathType } } }), | |
| ); |
| @@ -443,7 +447,7 @@ function mapStateToProps(state, ownProps) { | |||
| if (collection.has('nested') && slug) { | |||
There was a problem hiding this comment.
The path calculation logic has changed from slice(0, -2) to slice(0, -1), but there's no accompanying test or comment explaining why this change is necessary. This appears to be fixing an off-by-one error in the back link calculation for nested collections, but without context it's unclear if this is correct for all nested collection scenarios. Consider adding a comment explaining the logic or ensuring test coverage validates this change works correctly for different path depths.
| if (collection.has('nested') && slug) { | |
| if (collection.has('nested') && slug) { | |
| // For nested collections, the slug is a path-like string: | |
| // <folder>/<subfolder>/.../<entrySlug> | |
| // We want the back link to point to the parent folder view, not the entry itself. | |
| // Therefore, we drop only the final entry segment with slice(0, -1) and keep | |
| // all leading folders, which are then used as the filter path. |
| if (!data || typeof data !== 'object' || !('delete' in data)) { | ||
| return data; | ||
| } | ||
| return (data as Map<string, unknown>).delete('path').delete('path_type'); |
There was a problem hiding this comment.
The removeMetaFields function has a fragile type check. The condition !('delete' in data) will return true for any object that doesn't have a 'delete' property, not just for Immutable.js Map objects. This could lead to unexpected behavior if non-Map objects are passed to this function. Consider using a more specific check like Map.isMap(data) to verify that the data is actually an Immutable.js Map before attempting to call the delete method.
| if (!data || typeof data !== 'object' || !('delete' in data)) { | |
| return data; | |
| } | |
| return (data as Map<string, unknown>).delete('path').delete('path_type'); | |
| if (!Map.isMap(data)) { | |
| return data; | |
| } | |
| return data.delete('path').delete('path_type'); |
| const [p1, p2] = [path1, path2].map(p => p.split('/')); | ||
| return hasSubfolders | ||
| ? p1.slice(-2).join('/') === p2.slice(-2).join('/') | ||
| : p1.at(-1) === p2.at(-1); |
There was a problem hiding this comment.
The array at() method is used here, but it's a relatively recent addition to JavaScript (ES2022). While the code may work in modern environments, this could cause issues in older browsers or Node.js versions that don't support this method. Consider using traditional array indexing instead: p1[p1.length - 1] === p2[p2.length - 1] for better compatibility, or ensure your build configuration properly transpiles this feature.
| : p1.at(-1) === p2.at(-1); | |
| : p1[p1.length - 1] === p2[p2.length - 1]; |
| const pathParts = slug.split('/'); | ||
| if (pathParts.length > 2) { | ||
| editorBackLink = `${editorBackLink}/filter/${pathParts.slice(0, -2).join('/')}`; | ||
| editorBackLink = `${editorBackLink}/filter/${pathParts.slice(0, -1).join('/')}`; |
There was a problem hiding this comment.
Should this change be applied to all nested collections or only ones using index fields? Just flagging in case that's a possible regression.
| .get('fields') | ||
| ?.find(f => f.get('name') === inferredFields.imageField && f.get('widget') === 'image'), | ||
| isLoadingAsset, | ||
| showIndexFileIcon: indexFileConfig && new RegExp(indexFileConfig.get('pattern')).test(fileSlug), |
There was a problem hiding this comment.
Maybe this should use the isIndexFile helper instead?
| @@ -83,6 +83,7 @@ export function localFsMiddleware({ repoPath, logger }: FsOptions) { | |||
| await move( | |||
| path.join(repoPath, dataFile.path), | |||
| path.join(repoPath, dataFile.newPath!), | |||
| dataFile.isFolder, | |||
There was a problem hiding this comment.
GitHub is weird with suggestions outside the lines changed, but AFAIK, forEach doesn't await properly, so this should be an for loop instead.
dataFiles.forEach(async dataFile => {
await move(
path.join(repoPath, dataFile.path),
path.join(repoPath, dataFile.newPath!),
dataFile.isFolder,
);
});
| 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!), | ||
| dataFile.isFolder, | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Same here, it should be a for loop
|
Added docs in decaporg/decap-website#157 |
Resolved conflicts from the pnpm migration and the entry-move refactor: - dev-test config.yml (x2): kept main's removal of the `draft` view group and its Relation Test group, kept our `index_file` block. - packages/decap-cms-backend-gitea/package.json: adopted main's pnpm catalog/workspace protocol, keeping `path-browserify` (now `catalog:`) and the `browser` path alias. - package-lock.json: deleted, superseded by pnpm-lock.yaml. - decap-cms-core index.d.ts / types/redux.ts / constants/configSchema.js: additive — kept both our `index_file` and main's `limit`. - backend-test implementation.ts: kept main's `moveFile` helper and passed the combined flag (`hasSubfolders && isFolder !== false`), so only folder-type index entries drag their subfolder contents along. Folded in our guard that skips rewriting the moved entry itself, since the caller writes it fresh at its new path. - decap-server utils/fs.ts `move()`: kept main's repo-boundary path resolution and added our optional `isFolder` param; children are moved using the resolved paths. - decap-server localFs/localGit: call `move()` with main's `repoPath` signature plus `dataFile.isFolder`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Deploy Preview for decap-cms ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
- await entry moves sequentially in the local fs/git middlewares; the `forEach(async ...)` did not await, so the response was sent and the commit was created before the moves had finished - use the app's shared history in CollectionTop instead of a second, unsynchronised createHashHistory() instance, and drop the leftover `t` argument at the getCollectionProps call site - guard the meta/i18n `.toJS()` calls in draftDuplicateEntry - check `Map.isMap` in removeMetaFields rather than sniffing for a `delete` property - rename the `path_type` parameter of getExistingEntry to `pathType` - explain what `srcSlug` is for at both sites that touch it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two behaviour changes leaked into collections that do not use the feature, both caught by the existing editorial workflow e2e spec: - prepareMetaPath returned the raw slug for any nested collection with `subfolders: false` whose entry was not index-type. With no `index_file` configured every entry is slug-type, so the meta path widget showed `directory/index` instead of `directory`. Gate the new branch on `index_file`. - the editor back link dropped one path segment instead of two for every nested collection. An index-file entry is addressed by the folder it lives in, but a legacy nested entry is addressed by the folder it represents, so it has to drop that folder too. Pick the number of segments from whether the collection uses index files. Also fix `editor.preview` not applying to a newly created index page: isPreviewEnabled matched on the slug, which a new entry does not have yet. Use the isIndexFileEntry helper, which consults `meta.path_type` first, as the review suggested for the duplicated pattern matching. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
index_fields_spec asserted on UI text that does not exist ('New Post',
and an editor-only 'Writing in' link clicked from the collection page),
so 3 of its 6 tests failed; the 3 that passed did not exercise index
files at all. Rewrite it against the real UI: sort order, the index
icon, index vs collection field sets, preview on/off, an edit round
trip, and the nested path type flow.
The `_posts/_index.md` fixture this feature added gives the posts
collection a 24th entry with no date, which the view filter and view
group specs still counted as 23 and grouped into 2 buckets. Update
those expectations, including the new `missing_value` group.
Add a `sections` collection to the dev-test and test-backend configs so
the nested path type dropdown can be exercised at all. It needs both
`index_file.pattern` and `meta.path.index_file`: the first picks the
fields, the second picks the filename an index entry is written to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting a partially translated entry still failed with
`API_ERROR: GitRPC::BadObjectState`, reproduced against a real
git-gateway/GitHub repo on the branch deploy: the request asked GitHub to
remove all three locale paths, two of which are not in the tree, and the
whole commit is rejected.
The existing guard probed `implementation.getEntry` for each path and
dropped the ones that threw, but the GitHub, Gitea and Forgejo backends
end `getEntry` with `.catch(() => ({ ..., data: '' }))`, so it resolves
for a missing file and nothing was ever filtered out. It also cost one
request per locale.
Derive the paths from the entry instead: a merged i18n entry's `i18n` map
holds every locale it was loaded with apart from the one its own path
points at, so no extra requests are needed. Unknown entries keep the old
behaviour of targeting every configured locale.
Known gap: an entry whose default locale file is missing still reports
the default locale path, because the merged entry normalises its path to
the default locale. That is the same case `srcSlug` works around.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dex file The collection view loads 20 entries at a time and hoists the index file to the top of the entries already loaded. dev-test's posts collection has 24 entries and the index file sorts last, so it is not on the first page at all and none of the posts assertions could see it. Scroll the whole collection in first. Also fix the boolean group count in view_groups_spec: the index file has no relation_test field either, so it joins that grouping's missing_value bucket too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion
The previous commit derived the delete paths from the entry's loaded
locales, but the entry itself was wrong: `getI18nEntry` keeps every
fulfilled read, and the GitHub, Gitea and Forgejo backends end `getEntry`
with `.catch(() => ({ ..., data: '' }))`, so a locale with no file
resolves and looks like an empty translation. gitlab, bitbucket and azure
reject instead, which is why this only bites on GitHub and git-gateway.
Drop reads that came back with empty raw content, so a partially
translated entry reports only the locales it actually has. Verified
against a real git-gateway repo on the branch deploy.
Also fix the paths helper: `mergeValues` omits the `i18n` key entirely
when an entry has no locale other than its own, so an absent key means
"default locale only", not "unknown".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #7381.
Folder collections can define a separate field set and editor configuration for their index file, so a collection can hold both regular entries and the page that describes the collection itself — a blog's posts plus its list page, a section's children plus its landing page.
A new
index_fileoption on folder collections:patternfieldsfieldseditor.previewEntries matching the pattern get the index field set, are marked with a home icon in the entry list, and are sorted to the top of the collection.
Nested collections
In a nested collection the feature also lets one collection mix index pages and content pages. The "New" button becomes a dropdown offering Index Page or Content Page, and the choice is carried through as a read-only
path_typemeta field that decides where the entry is written:An index page at path
guides/advancedis written toguides/advanced/_index.md; a content page atguides/first-stepsis written toguides/first-steps.md.i18n
Deleting an entry that is missing some of its translations used to fail with
API_ERROR: GitRPC::BadObjectState: the CMS asked the backend to remove a file for every configured locale, and GitHub rejects the whole commit when a path is not in the tree.Entries now report only the locales they were actually loaded with, and only those files are deleted. Two things had to change for that:
getI18nEntrykept every fulfilled read, and thegithub,giteaandforgejobackends endgetEntrywith.catch(() => ({ …, data: '' }))— it resolves for a missing file, so a locale with no file arrived looking like an empty translation.gitlab,bitbucketandazurereject, which is why this only reproduced on GitHub and git-gateway.Backends
Path handling for index vs. content entries is implemented for
github,gitlab,bitbucket,azure,gitea,proxy/decap-serverandtest.forgejolanded after this branch started and has not been updated.Docs: decaporg/decap-website#157
Test plan
Unit and e2e coverage:
cypress/e2e/index_fields_spec.jscovers sort order, the index icon, index vs. collection field sets, preview on/off, an edit round trip, and the nested path-type flow against thepostsandsectionscollections indev-test. New unit tests covergetExistingFilePaths,getI18nEntryand the config schema.Manually verified on a Netlify deploy preview of this branch, against a real repository over git-gateway, with
i18n: { structure: multiple_files, locales: [en, de, si] }:<path>/_index.md, Content Page writes<path>.mdStatus
index_filewithpattern/fields/editor)Known limitations
index_file.patternandmeta.path.index_file, and nothing validates that the two agree.