diff --git a/dev-test/config.yml b/dev-test/config.yml index 6eac43f98db9..1037d9b41838 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -17,6 +17,9 @@ collections: # A list of collections the CMS should be able to edit slug: '{{year}}-{{month}}-{{day}}-{{slug}}' summary: '{{title}} -- {{year}}/{{month}}/{{day}}' create: true # Allow users to create new documents in this collection + pagination: + enabled: true + per_page: 10 editor: visualEditing: true view_filters: diff --git a/docs/PAGINATION.md b/docs/PAGINATION.md new file mode 100644 index 000000000000..e8d3a759ad46 --- /dev/null +++ b/docs/PAGINATION.md @@ -0,0 +1,235 @@ +# Pagination + +## Overview + +Decap CMS supports pagination for collections with many entries. The system uses a **hybrid approach**, automatically switching between server-side and client-side pagination based on active features. + +## Configuration + +### Global Configuration + +Enable pagination for all collections: + +```yaml +pagination: + enabled: true + per_page: 25 +``` + +### Per-Collection Configuration + +Override pagination settings for specific collections: + +```yaml +collections: + - name: posts + label: Posts + folder: content/posts + pagination: + enabled: true + per_page: 10 +``` + +**Priority:** Collection settings override global settings. + +## How It Works + +### Hybrid Pagination + +Decap CMS intelligently switches between two pagination modes: + +#### Server-Side Pagination (Default) + +- **When:** No sorting, filtering, or grouping is active +- **Behavior:** Only the current page of entries is loaded from the backend +- **Benefits:** Fast initial load, low memory usage +- **Best for:** Large collections with many entries + +#### Client-Side Pagination (Automatic) + +- **When:** Sorting, filtering, or grouping is active +- **Behavior:** All entries are loaded once, then paginated in the browser +- **Benefits:** Instant page navigation, instant sorting/filtering +- **Best for:** Advanced features that require the full dataset + +### When Pagination is Disabled + +Pagination is automatically disabled when: + +- **Grouping is active** - Grouping requires all entries to organize them correctly +- **i18n grouping is active** - Locale-grouped entries need the full dataset +- **Pagination is disabled in config** - `enabled: false` + +## Usage + +### Navigating Pages + +Use the pagination controls at the bottom of the entry list: + +- **First/Last** - Jump to first or last page +- **Previous/Next** - Navigate one page at a time +- **Page indicator** - Shows current page and total pages + +### Combining with Other Features + +**Sorting:** +- Click column headers to sort +- Automatically switches to client-side pagination +- All entries are loaded and sorted in the browser + +**Filtering:** +- Use the search/filter controls +- Automatically switches to client-side pagination +- All entries are loaded and filtered in the browser + +**Grouping:** +- Select a grouping option +- Pagination is disabled +- All entries are loaded and displayed in groups + +## Limitations + +### Performance Considerations + +**Client-Side Mode:** +- Initial load fetches ALL entries (can be slow for 1000+ entries) +- Increased memory usage +- Subsequent page navigation is instant + +**Server-Side Mode:** +- Fast initial load (only fetches current page) +- Low memory footprint +- Each page navigation requires a network request + +### Feature Restrictions + +- Cannot sort/filter when using server-side pagination +- Cannot paginate when grouping is active +- i18n collections always use client-side mode + +## Troubleshooting + +### Pagination controls are missing + +**Possible causes:** +- Grouping is active (pagination is disabled during grouping) +- Only one page of entries exists +- Pagination is disabled in config +- Collection has very few entries + +**Solution:** Check your config and disable grouping if you need pagination. + +### All entries load at once (slow) + +**Cause:** Sorting, filtering, or grouping triggers client-side pagination. + +**Solution:** +- Avoid sorting/filtering for very large collections +- Use server-side pagination when possible (no sorting/filtering/grouping) +- Consider splitting large collections into smaller ones + +### Page size is not respected + +**Cause:** Configuration may be incorrect or overridden. + +**Solution:** +- Check collection-level `pagination.per_page` setting +- Check global `pagination.per_page` setting +- Collection config takes precedence over global config + +## Best Practices + +1. **Enable pagination for large collections** (100+ entries) +2. **Use reasonable page sizes** (10-50 entries per page) +3. **Avoid sorting/filtering on very large collections** (1000+ entries) +4. **Consider performance** when enabling features that trigger client-side mode +5. **Test with realistic data volumes** to ensure good user experience + +### Performance Optimization + +#### Caching + +Decap CMS automatically caches fetched entries to improve performance. + +- **What's cached:** All entries loaded for i18n or nested collections +- **Cache duration:** 5 minutes (automatically expired) +- **Invalidation:** Automatic on entry create/update/delete +- **Storage:** Browser localStorage (IndexedDB) + +**Benefits:** +- Faster subsequent loads when navigating between collections +- Reduced API calls to your Git provider +- Improved UX for sorting and filtering + +**Note:** Cache is collection-specific and automatically managed. No configuration needed. + +#### Collection Size Recommendations + +| Collection Size | Recommended Setup | Expected Performance | +|----------------|-------------------|---------------------| +| < 50 entries | Server or client pagination | Excellent | +| 50-200 entries | Client pagination with caching | Very good | +| 200-500 entries | Server pagination, avoid sorting | Good | +| 500-1000 entries | Server pagination only | Acceptable | +| 1000+ entries | Consider splitting collections | Variable | + +#### i18n Collections + +i18n collections have special performance considerations: + +**File count multiplication:** +- 300 entries × 3 locales = 900 files to fetch +- Initial load will be slower than non-i18n collections + +**Recommendations:** +- Keep i18n collections under 500 entries +- Use caching (automatic in v3.9+) +- Consider separate collections per locale for very large datasets + +#### Large Collections Best Practices + +For collections with 500+ entries: + +1. **Disable client-side features:** + ```yaml + collections: + - name: posts + pagination: + enabled: true + per_page: 20 + # Avoid view_filters and view_groups for large collections + ``` + +2. **Split into multiple collections:** + ```yaml + collections: + - name: posts-2024 + folder: content/posts/2024 + - name: posts-2023 + folder: content/posts/2023 + ``` + +3. **Use folder structures:** + ```yaml + collections: + - name: posts + folder: content/posts + # Organize by year/month + # content/posts/2024/01/post.md + ``` + +4. **Monitor performance:** + - Check browser console for cache hit/miss logs + - Monitor network tab for API call frequency + - Test with realistic data volumes + +## Accessibility + +Pagination controls are fully accessible: + +- ARIA labels for screen readers +- Keyboard navigation support +- Live region announcements for page changes +- Proper disabled state handling + +All pagination text is localized and can be customized via locale files. diff --git a/docs/PAGINATION_ARCHITECTURE.md b/docs/PAGINATION_ARCHITECTURE.md new file mode 100644 index 000000000000..34d366af6b22 --- /dev/null +++ b/docs/PAGINATION_ARCHITECTURE.md @@ -0,0 +1,257 @@ +# Pagination Architecture - Developer Guide + +## System Architecture + +Decap CMS implements a **hybrid pagination system** that automatically switches between server-side and client-side pagination based on active features. + +### Design Principles + +1. **Server-side by default** - Fetch entries page-by-page for optimal performance +2. **Client-side when needed** - Switch automatically when sorting/filtering/grouping/i18n is active +3. **Transparent switching** - Users don't notice the mode change +4. **Backward compatible** - No breaking changes to existing collections + +### Mode Selection Logic + +``` +Is pagination enabled in config? +├─ NO → Fetch all entries, no UI +└─ YES → Is i18n OR nested collection? + ├─ YES → Client-side (must fetch all for grouping) + └─ NO → Is sort/filter/group active? + ├─ YES → Client-side (needs full dataset) + └─ NO → Server-side (optimal) +``` + +--- + +## Component Architecture + +### 1. Configuration (`packages/decap-cms-core/src/lib/pagination.ts`) + +```typescript +// Config resolution: collection-level → global → default +isPaginationEnabled(collection, config): boolean +getPaginationConfig(collection, config): { enabled, per_page } +``` + +### 2. Backend Interface (`packages/decap-cms-core/src/backend.ts`) + +```typescript +interface Backend { + listEntries(collection, page): Promise<{ entries, cursor, pagination }> + listAllEntries(collection): Promise +} +``` + +**Implementation in backends** (`packages/decap-cms-backend-*/src/implementation.ts`): +- `entriesByFolder(collection, extension, { page, pageSize, pagination })` +- Azure, Bitbucket, GitLab, GitHub backends all support pagination options +- Falls back gracefully if backend doesn't support pagination + +### 3. Redux State (`packages/decap-cms-core/src/reducers/entries.ts`) + +```typescript +state.entries = { + pages: { + [collection]: { + ids: string[] // Current page IDs (server mode) + sortedIds?: string[] // ALL IDs sorted (client mode) + } + }, + pagination: { + [collection]: { + currentPage: number, + pageSize: number, + totalCount: number + } + } +} +``` + +**Key insight:** `sortedIds` presence indicates client-side mode. + +### 4. Actions (`packages/decap-cms-core/src/actions/entries.ts`) + +**Entry points:** +- `loadEntries(collection, page)` - Initial load +- `loadEntriesPage(collection, page)` - Navigate pages +- `sortByField(collection, key, direction)` - Sort (triggers client mode) +- `filterByField(collection, filter)` - Filter (triggers client mode) + +**Flow:** +``` +Server mode: loadEntriesPage → backend.listEntries → ENTRIES_SUCCESS +Client mode: sortByField → backend.listAllEntries → SORT_ENTRIES_SUCCESS (sets sortedIds) + loadEntriesPage → just updates currentPage in Redux +``` + +### 5. Selectors (`packages/decap-cms-core/src/reducers/entries.ts`) + +```typescript +selectEntries(state, collection, configPageSize?) { + const sortedIds = state.getIn(['pages', collection, 'sortedIds']); + + if (sortedIds) { + // Client mode: slice sortedIds for current page + const start = (currentPage - 1) * pageSize; + return sortedIds.slice(start, start + pageSize).map(id => getEntity(id)); + } + + // Server mode: return entries for current page + return getPublishedEntries(state, collection); +} +``` + +**The `configPageSize` parameter** controls pagination: +- `undefined` → No pagination, return all entries +- `number` → Enable pagination with specified page size + +### 6. UI Components + +**`Pagination.js`** - Renders page controls with accessibility (ARIA labels, keyboard nav) + +**`EntriesCollection.js`** - Passes `configPageSize` to selector, renders Pagination component + +--- + +## Critical Integration Points + +### i18n Collections + +**Problem:** Files must be grouped by locale AFTER fetching: +- `en/post.md` + `de/post.md` → 1 entry +- Can't paginate files, must paginate grouped entries + +**Solution:** +```typescript +// In loadEntries() +if (hasI18n(collection)) { + response = await backend.listAllEntries(collection); + entries = backend.processEntries(response, collection); // Groups by locale + dispatch({ type: SORT_ENTRIES_SUCCESS, payload: { entries } }); // Enable client pagination +} +``` + +### Sorting/Filtering + +**Why fetch all entries?** +- Cannot sort/filter partial results +- Need complete dataset for accurate ordering + +**Trade-off:** +- Slower initial fetch +- Instant subsequent page navigation + +### Grouping + +Groups disable pagination entirely: +- Groups span across pages +- Uses `GROUP_ENTRIES_SUCCESS` instead of `SORT_ENTRIES_SUCCESS` +- `selectEntries()` returns all grouped entries + +--- + +## Testing Strategy + +### Unit Tests +- `pagination.ts`: Config resolution (global vs collection) +- `entries.ts` reducer: State transitions for `sortedIds` +- `selectEntries()`: Client vs server mode branching + +### Integration Tests (`PaginationEdgeCases.spec.js`) +- Server → client mode transition (sorting triggers switch) +- Client → server mode transition (disable last filter) +- i18n + pagination (grouped entries paginated correctly) +- Large collections (>100 entries, multiple pages) + +### E2E Tests +- Per-backend tests (GitHub, GitLab, Azure, Bitbucket) +- User flows: page navigation, sorting, filtering + +--- + +## Edge Cases & Gotchas + +### 1. **sortedIds vs ids** +- `ids`: Current page entries (server mode) +- `sortedIds`: ALL entry IDs (client mode) +- **Never use both simultaneously** + +### 2. **i18n collections always use client mode** +- Even with pagination enabled, fetches all entries +- Performance impact for large i18n collections (>1000 files) + +### 3. **Disabling last filter switches mode** +```typescript +// In filterByField() +if (isEmpty(filter)) { + // Switching back to server mode + dispatch(loadEntries(collection, 1)); +} +``` + +### 4. **configPageSize parameter is critical** +- `undefined` → No pagination +- `number` → Pagination enabled +- Passed from `EntriesCollection` based on config + +### 5. **Page numbers are 1-based** +- Backend APIs use 1-based indexing +- Array slicing uses 0-based: `(currentPage - 1) * pageSize` + +--- + +## Performance Characteristics + +### Server-side Mode +- ✅ Fast initial load (20-50 entries) +- ✅ Low memory (only current page in memory) +- ❌ Network call per page change +- ❌ Cannot sort/filter + +### Client-side Mode +- ✅ Instant page navigation +- ✅ Instant sort/filter updates +- ❌ Slow initial load (fetches all entries) +- ❌ High memory (all entries in Redux) + +### Recommendations +- Use server mode for large collections without sort/filter +- Use client mode for <500 entries +- i18n/nested collections have no choice (client mode required) + +--- + +## Future Improvements + +### 1. Backend-side Sorting/Filtering +Add parameters to `listEntries()`: +```typescript +listEntries(collection, page, { sortBy, filterBy }) +``` +Backends like GraphQL can sort server-side, reducing data transfer. + +### 2. Virtual Scrolling +Replace page-based UI with infinite scroll, render only visible entries. + +### 3. Smart Caching +Cache fetched pages in memory, invalidate on changes. Makes server mode feel instant. + +### 4. Incremental i18n Grouping +Fetch and group in batches, display progressively. + +--- + +## Developer Checklist + +When modifying pagination code: + +- [ ] Test both server and client modes +- [ ] Test i18n collections separately +- [ ] Verify `sortedIds` is set/unset correctly +- [ ] Check `configPageSize` is passed through selectors +- [ ] Test mode transitions (sort → unsort, filter → unfilter) +- [ ] Run `PaginationEdgeCases.spec.js` tests +- [ ] Verify page numbers are 1-based in UI, 0-based in array slicing +- [ ] Check accessibility (ARIA labels, keyboard nav) diff --git a/packages/decap-cms-backend-azure/src/implementation.ts b/packages/decap-cms-backend-azure/src/implementation.ts index 01e8961e3032..379695407f38 100644 --- a/packages/decap-cms-backend-azure/src/implementation.ts +++ b/packages/decap-cms-backend-azure/src/implementation.ts @@ -156,7 +156,16 @@ export default class Azure implements Implementation { return Promise.resolve(this.token); } - async entriesByFolder(folder: string, extension: string, depth: number) { + async entriesByFolder( + folder: string, + extension: string, + depth: number, + options?: { + page?: number; + pageSize?: number; + pagination?: boolean; + }, + ) { const listFiles = async () => { const files = await this.api!.listFiles(folder, depth > 1); const filtered = files.filter(file => filterByExtension({ path: file.path }, extension)); @@ -171,6 +180,7 @@ export default class Azure implements Implementation { this.api!.readFile.bind(this.api!), this.api!.readFileMetadata.bind(this.api), API_NAME, + options, ); return entries; } diff --git a/packages/decap-cms-backend-bitbucket/src/implementation.ts b/packages/decap-cms-backend-bitbucket/src/implementation.ts index 2dfa49c2cb51..254ce0b4d38c 100644 --- a/packages/decap-cms-backend-bitbucket/src/implementation.ts +++ b/packages/decap-cms-backend-bitbucket/src/implementation.ts @@ -323,7 +323,16 @@ export default class BitbucketBackend implements Implementation { return response; }; - async entriesByFolder(folder: string, extension: string, depth: number) { + async entriesByFolder( + folder: string, + extension: string, + depth: number, + options?: { + page?: number; + pageSize?: number; + pagination?: boolean; + }, + ) { let cursor: Cursor; const listFiles = () => @@ -342,6 +351,7 @@ export default class BitbucketBackend implements Implementation { readFile, this.api!.readFileMetadata.bind(this.api), API_NAME, + options, ); // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/packages/decap-cms-backend-git-gateway/src/implementation.ts b/packages/decap-cms-backend-git-gateway/src/implementation.ts index 25ab9d07f8d6..c45ec33e874c 100644 --- a/packages/decap-cms-backend-git-gateway/src/implementation.ts +++ b/packages/decap-cms-backend-git-gateway/src/implementation.ts @@ -409,8 +409,17 @@ export default class GitGateway implements Implementation { return this.tokenPromise!(); } - async entriesByFolder(folder: string, extension: string, depth: number) { - return this.backend!.entriesByFolder(folder, extension, depth); + async entriesByFolder( + folder: string, + extension: string, + depth: number, + options?: { + page?: number; + pageSize?: number; + pagination?: boolean; + }, + ) { + return this.backend!.entriesByFolder(folder, extension, depth, options); } allEntriesByFolder(folder: string, extension: string, depth: number, pathRegex?: RegExp) { return this.backend!.allEntriesByFolder(folder, extension, depth, pathRegex); diff --git a/packages/decap-cms-backend-github/src/implementation.tsx b/packages/decap-cms-backend-github/src/implementation.tsx index 31149d8e9f26..32306f29bda1 100644 --- a/packages/decap-cms-backend-github/src/implementation.tsx +++ b/packages/decap-cms-backend-github/src/implementation.tsx @@ -389,8 +389,7 @@ export default class GitHub implements Implementation { return Promise.resolve(this.token); } - getCursorAndFiles = (files: ApiFile[], page: number) => { - const pageSize = 20; + getCursorAndFiles = (files: ApiFile[], page: number, pageSize = 20) => { const count = files.length; const pageCount = Math.ceil(files.length / pageSize); @@ -413,8 +412,20 @@ export default class GitHub implements Implementation { return { cursor, files: pageFiles }; }; - async entriesByFolder(folder: string, extension: string, depth: number) { + async entriesByFolder( + folder: string, + extension: string, + depth: number, + options?: { + page?: number; + pageSize?: number; + pagination?: boolean; + }, + ) { const repoURL = this.api!.originRepoURL; + const page = options?.page ?? 1; + const pageSize = options?.pageSize ?? 20; + const usePagination = options?.pagination ?? true; let cursor: Cursor; @@ -424,9 +435,18 @@ export default class GitHub implements Implementation { depth, }).then(files => { const filtered = files.filter(file => filterByExtension(file, extension)); - const result = this.getCursorAndFiles(filtered, 1); - cursor = result.cursor; - return result.files; + + if (usePagination) { + // Paginated: return only the requested page + const result = this.getCursorAndFiles(filtered, page, pageSize); + cursor = result.cursor; + return result.files; + } else { + // Non-paginated: return all files (no slicing) + const result = this.getCursorAndFiles(filtered, 1, pageSize); + cursor = result.cursor; + return filtered; + } }); const readFile = (path: string, id: string | null | undefined) => @@ -562,27 +582,28 @@ export default class GitHub implements Implementation { async traverseCursor(cursor: Cursor, action: string) { const meta = cursor.meta!; const files = cursor.data!.get('files')!.toJS() as ApiFile[]; + const pageSize = meta.get('pageSize') || 20; let result: { cursor: Cursor; files: ApiFile[] }; switch (action) { case 'first': { - result = this.getCursorAndFiles(files, 1); + result = this.getCursorAndFiles(files, 1, pageSize); break; } case 'last': { - result = this.getCursorAndFiles(files, meta.get('pageCount')); + result = this.getCursorAndFiles(files, meta.get('pageCount'), pageSize); break; } case 'next': { - result = this.getCursorAndFiles(files, meta.get('page') + 1); + result = this.getCursorAndFiles(files, meta.get('page') + 1, pageSize); break; } case 'prev': { - result = this.getCursorAndFiles(files, meta.get('page') - 1); + result = this.getCursorAndFiles(files, meta.get('page') - 1, pageSize); break; } default: { - result = this.getCursorAndFiles(files, 1); + result = this.getCursorAndFiles(files, 1, pageSize); break; } } diff --git a/packages/decap-cms-backend-gitlab/src/implementation.ts b/packages/decap-cms-backend-gitlab/src/implementation.ts index e629316e1572..b9296d3f14d5 100644 --- a/packages/decap-cms-backend-gitlab/src/implementation.ts +++ b/packages/decap-cms-backend-gitlab/src/implementation.ts @@ -188,7 +188,16 @@ export default class GitLab implements Implementation { return filterByExtension(file, extension) && fileFolder.split('/').length <= depth; } - async entriesByFolder(folder: string, extension: string, depth: number) { + async entriesByFolder( + folder: string, + extension: string, + depth: number, + options?: { + page?: number; + pageSize?: number; + pagination?: boolean; + }, + ) { let cursor: Cursor; const listFiles = () => @@ -202,6 +211,7 @@ export default class GitLab implements Implementation { this.api!.readFile.bind(this.api!), this.api!.readFileMetadata.bind(this.api), API_NAME, + options, ); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/packages/decap-cms-backend-proxy/src/implementation.ts b/packages/decap-cms-backend-proxy/src/implementation.ts index 710dda44476d..b3f5cb67088f 100644 --- a/packages/decap-cms-backend-proxy/src/implementation.ts +++ b/packages/decap-cms-backend-proxy/src/implementation.ts @@ -111,10 +111,19 @@ export default class ProxyBackend implements Implementation { } } - entriesByFolder(folder: string, extension: string, depth: number) { + entriesByFolder( + folder: string, + extension: string, + depth: number, + options?: { + page?: number; + pageSize?: number; + pagination?: boolean; + }, + ) { return this.request({ action: 'entriesByFolder', - params: { branch: this.branch, folder, extension, depth }, + params: { branch: this.branch, folder, extension, depth, options }, }); } diff --git a/packages/decap-cms-backend-test/src/implementation.ts b/packages/decap-cms-backend-test/src/implementation.ts index 62548eb42ded..1cc0641ea427 100644 --- a/packages/decap-cms-backend-test/src/implementation.ts +++ b/packages/decap-cms-backend-test/src/implementation.ts @@ -1,6 +1,5 @@ import attempt from 'lodash/attempt'; import isError from 'lodash/isError'; -import take from 'lodash/take'; import unset from 'lodash/unset'; import isEmpty from 'lodash/isEmpty'; import { v4 as uuid } from 'uuid'; @@ -80,7 +79,7 @@ function deleteFile(path: string, tree: RepoTree) { unset(tree, path.split('/')); } -const pageSize = 10; +const DEFAULT_PAGE_SIZE = 10; function getCursor( folder: string, @@ -88,15 +87,19 @@ function getCursor( entries: ImplementationEntry[], index: number, depth: number, + pageSize = DEFAULT_PAGE_SIZE, ) { + if (pageSize <= 0) { + throw new Error('pageSize must be positive'); + } const count = entries.length; - const pageCount = Math.floor(count / pageSize); + const pageCount = Math.ceil(count / pageSize); return Cursor.create({ actions: [ ...(index < pageCount ? ['next', 'last'] : []), ...(index > 0 ? ['prev', 'first'] : []), ], - meta: { index, count, pageSize, pageCount }, + meta: { index, page: index + 1, count, pageSize, pageCount }, data: { folder, extension, index, pageCount, depth }, }); } @@ -173,6 +176,9 @@ export default class TestBackend implements Implementation { pageCount: number; depth: number; }; + const meta = cursor.meta!; + const currentPageSize = meta.get('pageSize', DEFAULT_PAGE_SIZE); + const newIndex = (() => { if (action === 'next') { return (index as number) + 1; @@ -194,19 +200,38 @@ export default class TestBackend implements Implementation { data: f.content as string, file: { path: f.path, id: f.path }, })); - const entries = allEntries.slice(newIndex * pageSize, newIndex * pageSize + pageSize); - const newCursor = getCursor(folder, extension, allEntries, newIndex, depth); + const entries = allEntries.slice( + newIndex * currentPageSize, + newIndex * currentPageSize + currentPageSize, + ); + const newCursor = getCursor(folder, extension, allEntries, newIndex, depth, currentPageSize); return Promise.resolve({ entries, cursor: newCursor }); } - entriesByFolder(folder: string, extension: string, depth: number) { + entriesByFolder( + folder: string, + extension: string, + depth: number, + options?: { + page?: number; + pageSize?: number; + pagination?: boolean; + }, + ) { const files = folder ? getFolderFiles(window.repoFiles, folder, extension, depth) : []; const entries = files.map(f => ({ data: f.content as string, file: { path: f.path, id: f.path }, })); - const cursor = getCursor(folder, extension, entries, 0, depth); - const ret = take(entries, pageSize); + const pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE; + const page = options?.page ?? 1; + const usePagination = options?.pagination ?? true; + + const cursor = getCursor(folder, extension, entries, page - 1, depth, pageSize); + + // If pagination is enabled, return only the requested page + // Otherwise, return all entries (for backward compatibility) + const ret = usePagination ? entries.slice((page - 1) * pageSize, page * pageSize) : entries; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore ret[CURSOR_COMPATIBILITY_SYMBOL] = cursor; diff --git a/packages/decap-cms-core/src/actions/entries.ts b/packages/decap-cms-core/src/actions/entries.ts index ffeeb26836fb..98b5ea6febb6 100644 --- a/packages/decap-cms-core/src/actions/entries.ts +++ b/packages/decap-cms-core/src/actions/entries.ts @@ -1,9 +1,10 @@ import { fromJS, List, Map } from 'immutable'; import isEqual from 'lodash/isEqual'; +import orderBy from 'lodash/orderBy'; import { Cursor } from 'decap-cms-lib-util'; import { selectCollectionEntriesCursor } from '../reducers/cursors'; -import { selectFields, updateFieldByKey } from '../reducers/collections'; +import { selectFields, updateFieldByKey, selectSortDataPath } from '../reducers/collections'; import { selectIntegration, selectPublishedSlugs } from '../reducers'; import { getIntegrationProvider } from '../integrations'; import { currentBackend } from '../backend'; @@ -20,6 +21,16 @@ import { selectCustomPath } from '../reducers/entryDraft'; import { navigateToEntry } from '../routing/history'; import { getProcessSegment } from '../lib/formatters'; import { hasI18n, duplicateDefaultI18nFields, serializeI18n, I18N, I18N_FIELD } from '../lib/i18n'; +import { isPaginationEnabled } from '../lib/pagination'; +import { getCachedEntries, setCachedEntries, invalidateCollectionCache } from '../lib/entryCache'; +import { + hasActiveFilters, + hasActiveGroups, + hasActiveSorts, + extractActiveFilters, + matchesFilters, + getFieldValue, +} from '../lib/entryHelpers'; import { addNotification } from './notifications'; import type { ImplementationMediaFile } from 'decap-cms-lib-util'; @@ -34,6 +45,8 @@ import type { ViewFilter, ViewGroup, Entry, + FilterMap, + GroupMap, } from '../types/redux'; import type { EntryValue } from '../valueObjects/Entry'; import type { Backend } from '../backend'; @@ -86,6 +99,10 @@ export const REMOVE_DRAFT_ENTRY_MEDIA_FILE = 'REMOVE_DRAFT_ENTRY_MEDIA_FILE'; export const CHANGE_VIEW_STYLE = 'CHANGE_VIEW_STYLE'; +export const SET_ENTRIES_PAGE_SIZE = 'SET_ENTRIES_PAGE_SIZE'; +export const LOAD_ENTRIES_PAGE = 'LOAD_ENTRIES_PAGE'; +export const SET_ENTRIES_PAGE = 'SET_ENTRIES_PAGE'; + /* * Simple Action Creators (Internal) * We still need to export them for tests @@ -158,6 +175,44 @@ export function entriesFailed(collection: Collection, error: Error) { }; } +export function setEntriesPageSize(collection: Collection, pageSize: number) { + return { + type: SET_ENTRIES_PAGE_SIZE, + payload: { + collection: collection.get('name'), + pageSize, + }, + }; +} + +export function setEntriesPage(collection: Collection, page: number) { + return { + type: SET_ENTRIES_PAGE, + payload: { + collection: collection.get('name'), + page, + }, + }; +} + +export function loadEntriesPage(collection: Collection, page: number) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + const state = getState(); + const collectionName = collection.get('name'); + + // Check if sorting is active (sortedIds exists) + const sortedIds = state.entries.getIn(['pages', collectionName, 'sortedIds']); + + if (sortedIds) { + // Client-side pagination: just update the page index + dispatch(setEntriesPage(collection, page)); + } else { + // Server-side pagination: load new entries from backend + dispatch(loadEntries(collection, page)); + } + }; +} + export async function getAllEntries(state: State, collection: Collection) { const backend = currentBackend(state.config); const integration = selectIntegration(state, collection.get('name'), 'listEntries'); @@ -175,12 +230,14 @@ export function sortByField( ) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); + const collectionName = collection.get('name'); // if we're already fetching we update the sort key, but skip loading entries - const isFetching = selectIsFetching(state.entries, collection.get('name')); + const isFetching = selectIsFetching(state.entries, collectionName); + dispatch({ type: SORT_ENTRIES_REQUEST, payload: { - collection: collection.get('name'), + collection: collectionName, key, direction, }, @@ -190,16 +247,53 @@ export function sortByField( } try { - const entries = await getAllEntries(state, collection); - dispatch({ - type: SORT_ENTRIES_SUCCESS, - payload: { - collection: collection.get('name'), - key, - direction, - entries, - }, - }); + let entries = await getAllEntries(state, collection); + + // Check if filtering is active - if so, apply filters first + const activeFilters = state.entries.getIn(['filter', collectionName]); + if (hasActiveFilters(activeFilters)) { + const filters = extractActiveFilters(activeFilters); + entries = entries.filter(entry => matchesFilters(entry, filters)); + } + + // Sort entries by the specified field + const dataPath = selectSortDataPath(collection, key); + const order = direction === SortDirection.Ascending ? 'asc' : 'desc'; + + const sortedEntries = orderBy( + entries, + [ + entry => { + const value = getFieldValue(entry, dataPath); + // Handle case-insensitive string sorting + return typeof value === 'string' ? value.toLowerCase() : value; + }, + ], + [order], + ); + + // Check if grouping is active - if so, use GROUP_ENTRIES_SUCCESS to avoid pagination + const activeGroups = state.entries.getIn(['group', collectionName]); + + if (hasActiveGroups(activeGroups)) { + dispatch({ + type: GROUP_ENTRIES_SUCCESS, + payload: { + collection: collectionName, + entries: sortedEntries, + }, + }); + } else { + dispatch({ + type: SORT_ENTRIES_SUCCESS, + payload: { + collection: collectionName, + key, + direction, + entries: sortedEntries, + }, + }); + } } catch (error) { dispatch({ type: SORT_ENTRIES_FAILURE, @@ -219,6 +313,14 @@ export function filterByField(collection: Collection, filter: ViewFilter) { const state = getState(); // if we're already fetching we update the filter key, but skip loading entries const isFetching = selectIsFetching(state.entries, collection.get('name')); + + // Check if this filter is currently active (to detect if we're disabling it) + const currentFilter = state.entries.getIn(['filter', collection.get('name'), filter.id]); + const isCurrentlyActive = + currentFilter && typeof (currentFilter as Map).get === 'function' + ? (currentFilter as Map).get('active') === true + : false; + dispatch({ type: FILTER_ENTRIES_REQUEST, payload: { @@ -231,15 +333,103 @@ export function filterByField(collection: Collection, filter: ViewFilter) { } try { - const entries = await getAllEntries(state, collection); - dispatch({ - type: FILTER_ENTRIES_SUCCESS, - payload: { - collection: collection.get('name'), - filter, - entries, - }, + // If we're disabling the last active filter, reload with pagination + const allFilters = state.entries.getIn(['filter', collection.get('name')]); + let hasOtherActiveFilters = false; + if (allFilters && typeof (allFilters as Map).some === 'function') { + hasOtherActiveFilters = (allFilters as Map).some( + (f?: FilterMap) => f?.get('id') !== filter.id && f?.get('active') === true, + ); + } + + if (isCurrentlyActive && !hasOtherActiveFilters) { + // Disabling filtering - reload entries with pagination + return dispatch(loadEntries(collection)); + } + + // Enabling filtering or switching filters - load all entries and filter them + const allEntries = await getAllEntries(state, collection); + + // Get the new filter state (it was toggled in FILTER_ENTRIES_REQUEST) + const updatedState = getState(); + const updatedFilters = updatedState.entries.getIn(['filter', collection.get('name')]); + + // Apply all active filters + const filteredEntries = allEntries.filter(entry => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const activeFilters: any[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (updatedFilters && typeof (updatedFilters as any).forEach === 'function') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (updatedFilters as any).forEach((f: any) => { + if (f.get('active') === true) { + activeFilters.push({ + pattern: f.get('pattern'), + field: f.get('field'), + }); + } + }); + } + + // Entry must match all active filters + return activeFilters.every(({ pattern, field }) => { + const data = entry.data || {}; + const fieldParts = field.split('.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let value: any = data; + for (const part of fieldParts) { + value = value?.[part]; + } + const matched = value !== undefined && new RegExp(String(pattern)).test(String(value)); + return matched; + }); }); + + // Check if sorting is active - if so, apply sort after filtering + const activeSorts = updatedState.entries.getIn(['sort', collection.get('name')]); + + let finalEntries = filteredEntries; + if (hasActiveSorts(activeSorts)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sortField = (activeSorts as any).valueSeq().first(); + const sortKey = sortField.get('key'); + const sortDirection = sortField.get('direction'); + const dataPath = selectSortDataPath(collection, sortKey); + const order = sortDirection === SortDirection.Ascending ? 'asc' : 'desc'; + + finalEntries = orderBy( + filteredEntries, + [ + entry => { + const value = getFieldValue(entry, dataPath); + return typeof value === 'string' ? value.toLowerCase() : value; + }, + ], + [order], + ); + } + + // Check if grouping is active - if so, use GROUP_ENTRIES_SUCCESS to avoid pagination + const activeGroups = updatedState.entries.getIn(['group', collection.get('name')]); + + if (hasActiveGroups(activeGroups)) { + dispatch({ + type: GROUP_ENTRIES_SUCCESS, + payload: { + collection: collection.get('name'), + entries: finalEntries, + }, + }); + } else { + dispatch({ + type: FILTER_ENTRIES_SUCCESS, + payload: { + collection: collection.get('name'), + filter, + entries: finalEntries, + }, + }); + } } catch (error) { dispatch({ type: FILTER_ENTRIES_FAILURE, @@ -257,6 +447,14 @@ export function groupByField(collection: Collection, group: ViewGroup) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const isFetching = selectIsFetching(state.entries, collection.get('name')); + + // Check if this group is currently active (to detect if we're disabling it) + const currentGroup = state.entries.getIn(['group', collection.get('name'), group.id]); + const isCurrentlyActive = + currentGroup && typeof (currentGroup as Map).get === 'function' + ? (currentGroup as Map).get('active') === true + : false; + dispatch({ type: GROUP_ENTRIES_REQUEST, payload: { @@ -269,7 +467,57 @@ export function groupByField(collection: Collection, group: ViewGroup) { } try { - const entries = await getAllEntries(state, collection); + // If we're disabling the last active group, reload with pagination + const allGroups = state.entries.getIn(['group', collection.get('name')]); + let hasOtherActiveGroups = false; + if (allGroups && typeof (allGroups as Map).some === 'function') { + hasOtherActiveGroups = (allGroups as Map).some( + (g?: GroupMap) => g?.get('id') !== group.id && g?.get('active') === true, + ); + } + + if (isCurrentlyActive && !hasOtherActiveGroups) { + // Disabling grouping - reload entries with pagination + return dispatch(loadEntries(collection)); + } + + // Enabling grouping or switching groups - load all entries + let entries = await getAllEntries(state, collection); + + // Get the updated state after GROUP_ENTRIES_REQUEST + const updatedState = getState(); + + // Check if filtering is active - if so, apply filters + const activeFilters = updatedState.entries.getIn(['filter', collection.get('name')]); + + if (hasActiveFilters(activeFilters)) { + const filters = extractActiveFilters(activeFilters); + entries = entries.filter(entry => matchesFilters(entry, filters)); + } + + // Check if sorting is active - if so, apply sort + const activeSorts = updatedState.entries.getIn(['sort', collection.get('name')]); + + if (hasActiveSorts(activeSorts)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sortField = (activeSorts as any).valueSeq().first(); + const sortKey = sortField.get('key'); + const sortDirection = sortField.get('direction'); + const dataPath = selectSortDataPath(collection, sortKey); + const order = sortDirection === SortDirection.Ascending ? 'asc' : 'desc'; + + entries = orderBy( + entries, + [ + entry => { + const value = getFieldValue(entry, dataPath); + return typeof value === 'string' ? value.toLowerCase() : value; + }, + ], + [order], + ); + } + dispatch({ type: GROUP_ENTRIES_SUCCESS, payload: { @@ -578,31 +826,60 @@ export function loadEntries(collection: Collection, page = 0) { return; } const state = getState(); - const sortFields = selectEntriesSortFields(state.entries, collection.get('name')); + const collectionName = collection.get('name'); + const sortFields = selectEntriesSortFields(state.entries, collectionName); if (sortFields && sortFields.length > 0) { const field = sortFields[0]; return dispatch(sortByField(collection, field.get('key'), field.get('direction'))); } const backend = currentBackend(state.config); - const integration = selectIntegration(state, collection.get('name'), 'listEntries'); + const integration = selectIntegration(state, collectionName, 'listEntries'); const provider = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration) : backend; - const append = !!(page && !isNaN(page) && page > 0); + // In new pagination mode, page navigation replaces items; append applies only to old/infinite modes + const paginationEnabled = isPaginationEnabled(collection, state.config); + const append = paginationEnabled ? false : !!(page && !isNaN(page) && page > 0); dispatch(entriesLoading(collection)); try { const loadAllEntries = collection.has('nested') || hasI18n(collection); + const isI18nCollection = hasI18n(collection); + + // Try cache first for collections that load all entries + let cachedEntries: EntryValue[] | null = null; + if (loadAllEntries) { + cachedEntries = await getCachedEntries(collectionName); + } let response: { cursor: Cursor; pagination: number; entries: EntryValue[]; - } = await (loadAllEntries - ? // nested collections require all entries to construct the tree - provider.listAllEntries(collection).then((entries: EntryValue[]) => ({ entries })) - : provider.listEntries(collection, page)); + }; + + if (cachedEntries) { + // Use cached entries + console.log(`[loadEntries] Using cached entries for ${collectionName}`); + response = { + entries: cachedEntries, + cursor: Cursor.create({}), + pagination: 0, + }; + } else { + // Fetch from backend + response = await (loadAllEntries + ? // nested collections and i18n collections require all entries to construct the tree/group + provider.listAllEntries(collection).then((entries: EntryValue[]) => ({ entries })) + : provider.listEntries(collection, page)); + + // Cache entries if we loaded all of them + if (loadAllEntries && response.entries) { + await setCachedEntries(collectionName, response.entries); + } + } + response = { ...response, // The only existing backend using the pagination system is the @@ -620,17 +897,30 @@ export function loadEntries(collection: Collection, page = 0) { : Cursor.create(response.cursor), }; + const entries = response.cursor.meta!.get('usingOldPaginationAPI') + ? response.entries.reverse() + : response.entries; + dispatch( entriesLoaded( collection, - response.cursor.meta!.get('usingOldPaginationAPI') - ? response.entries.reverse() - : response.entries, + entries, response.pagination, addAppendActionsToCursor(response.cursor), append, ), ); + + // For i18n collections with pagination enabled, set up client-side pagination using sortedIds + if (isI18nCollection && paginationEnabled) { + dispatch({ + type: SORT_ENTRIES_SUCCESS, + payload: { + collection: collectionName, + entries, + }, + }); + } } catch (err) { dispatch( addNotification({ @@ -926,6 +1216,9 @@ export function persistEntry(collection: Collection) { usedSlugs, }) .then(async (newSlug: string) => { + // Invalidate cache for this collection + await invalidateCollectionCache(collection.get('name')); + dispatch( addNotification({ message: { @@ -974,7 +1267,10 @@ export function deleteEntry(collection: Collection, slug: string) { dispatch(entryDeleting(collection, slug)); return backend .deleteEntry(state, collection, slug) - .then(() => { + .then(async () => { + // Invalidate cache for this collection + await invalidateCollectionCache(collection.get('name')); + return dispatch(entryDeleted(collection, slug)); }) .catch((error: Error) => { diff --git a/packages/decap-cms-core/src/backend.ts b/packages/decap-cms-core/src/backend.ts index c998d645041d..a6f8280eb2b5 100644 --- a/packages/decap-cms-core/src/backend.ts +++ b/packages/decap-cms-core/src/backend.ts @@ -40,6 +40,7 @@ import { createEntry } from './valueObjects/Entry'; import { sanitizeChar } from './lib/urlHelper'; import { getBackend, invokeEvent } from './lib/registry'; import { commitMessageFormatter, slugFormatter, previewUrlFormatter } from './lib/formatters'; +import { isPaginationEnabled, getPaginationConfig } from './lib/pagination'; import { status } from './constants/publishModes'; import { FOLDER, FILES } from './constants/collectionTypes'; import { selectCustomPath } from './reducers/entryDraft'; @@ -537,17 +538,36 @@ export class Backend { return filteredEntries; } - async listEntries(collection: Collection) { + async listEntries(collection: Collection, page = 0) { const extension = selectFolderEntryExtension(collection); let listMethod: () => Promise; const collectionType = collection.get('type'); + + // Check if pagination is enabled for this collection + const paginationEnabled = isPaginationEnabled(collection, this.config); + const paginationConfig = paginationEnabled + ? getPaginationConfig(collection, this.config) + : null; + if (collectionType === FOLDER) { listMethod = () => { const depth = collectionDepth(collection); + + // Pass pagination options to backend implementation + const options = + paginationEnabled && paginationConfig + ? { + page: page || 1, + pageSize: paginationConfig.per_page, + pagination: true, + } + : undefined; + return this.implementation.entriesByFolder( collection.get('folder') as string, extension, depth, + options, ); }; } else if (collectionType === FILES) { diff --git a/packages/decap-cms-core/src/components/Collection/Entries/Entries.js b/packages/decap-cms-core/src/components/Collection/Entries/Entries.js index 0ef11da57274..b203b67485be 100644 --- a/packages/decap-cms-core/src/components/Collection/Entries/Entries.js +++ b/packages/decap-cms-core/src/components/Collection/Entries/Entries.js @@ -6,6 +6,7 @@ import { translate } from 'react-polyglot'; import { Loader, lengths } from 'decap-cms-ui-default'; import EntryListing from './EntryListing'; +import Pagination from './Pagination'; const PaginationMessage = styled.div` width: ${lengths.topCardWidth}; @@ -17,6 +18,20 @@ const NoEntriesMessage = styled(PaginationMessage)` margin-top: 16px; `; +/** + * Entries component - Renders the list of entries with optional pagination. + * + * This component orchestrates the display of entry cards and pagination controls. + * It handles three main states: + * 1. Loading - Shows loader with progressive messages + * 2. Empty - Shows "No Entries" message + * 3. Loaded - Shows EntryListing with optional Pagination + * + * Pagination behavior: + * - Only shown when pagination is enabled AND entries exist AND pageCount > 1 + * - Uses hybrid approach: server-side by default, client-side when sorting/filtering active + * - Integrates with cursor-based infinite scroll (disabled when pagination active) + */ function Entries({ collections, entries, @@ -29,6 +44,11 @@ function Entries({ getWorkflowStatus, getUnpublishedEntries, filterTerm, + paginationEnabled, + currentPage, + pageSize, + totalCount, + onPageChange, }) { const loadingMessages = [ t('collection.entries.loadingEntries'), @@ -41,6 +61,13 @@ function Entries({ } const hasEntries = (entries && entries.size > 0) || cursor?.actions?.has('append_next'); + + // Calculate page count for pagination + const pageCount = paginationEnabled && totalCount > 0 ? Math.ceil(totalCount / pageSize) : 1; + + // Show pagination controls only if pagination is enabled and we have entries + const showPagination = paginationEnabled && totalCount > 0 && pageCount > 1; + if (hasEntries) { return ( <> @@ -54,10 +81,21 @@ function Entries({ getWorkflowStatus={getWorkflowStatus} getUnpublishedEntries={getUnpublishedEntries} filterTerm={filterTerm} + paginationEnabled={paginationEnabled} /> {isFetching && page !== undefined && entries.size > 0 ? ( {t('collection.entries.loadingEntries')} ) : null} + {showPagination && !isFetching && ( + + )} ); } @@ -77,6 +115,12 @@ Entries.propTypes = { getWorkflowStatus: PropTypes.func, getUnpublishedEntries: PropTypes.func, filterTerm: PropTypes.string, + paginationEnabled: PropTypes.bool, + currentPage: PropTypes.number, + pageSize: PropTypes.number, + totalCount: PropTypes.number, + onPageChange: PropTypes.func, + onPageSizeChange: PropTypes.func, }; export default translate()(Entries); diff --git a/packages/decap-cms-core/src/components/Collection/Entries/EntriesCollection.js b/packages/decap-cms-core/src/components/Collection/Entries/EntriesCollection.js index 8847f3b7c242..ccd5c96564dc 100644 --- a/packages/decap-cms-core/src/components/Collection/Entries/EntriesCollection.js +++ b/packages/decap-cms-core/src/components/Collection/Entries/EntriesCollection.js @@ -11,6 +11,9 @@ import { colors } from 'decap-cms-ui-default'; import { loadEntries as actionLoadEntries, traverseCollectionCursor as actionTraverseCollectionCursor, + setEntriesPageSize as actionSetEntriesPageSize, + loadEntriesPage as actionLoadEntriesPage, + sortByField as actionSortByField, } from '../../../actions/entries'; import { loadUnpublishedEntries } from '../../../actions/editorialWorkflow'; import { @@ -18,7 +21,12 @@ import { selectEntriesLoaded, selectIsFetching, selectGroups, + selectEntriesPageSize, + selectEntriesCurrentPage, + selectEntriesTotalCount, + selectEntriesSort, } from '../../../reducers/entries'; +import { isPaginationEnabled, getPaginationConfig } from '../../../lib/pagination'; import { selectUnpublishedEntry, selectUnpublishedEntriesByStatus } from '../../../reducers'; import { selectCollectionEntriesCursor } from '../../../reducers/cursors'; import Entries from './Entries'; @@ -48,6 +56,13 @@ function getGroupTitle(group, t) { return `${label} ${value}`.trim(); } +/** + * Renders entries with grouping support. + * + * Groups are created by the groupByField action and represent entries + * organized by a field value (e.g., by author, category, etc.). + * Each group is rendered as a separate section with a heading. + */ function withGroups(groups, entries, EntriesToRender, t) { return groups.map(group => { const title = getGroupTitle(group, t); @@ -60,6 +75,24 @@ function withGroups(groups, entries, EntriesToRender, t) { }); } +/** + * EntriesCollection - Container component for entry collections. + * + * This component manages the lifecycle of entry loading, pagination, sorting, + * and grouping. It connects Redux state to the presentational Entries component. + * + * Key behaviors: + * - Loads entries on mount if not already loaded + * - Re-triggers sorting on mount to populate sortedIds (for client-side pagination) + * - Disables pagination when grouping is active (groups are client-side only) + * - Supports both published and unpublished entries (editorial workflow) + * - Handles nested collections with folder filtering + * + * Pagination behavior: + * - Uses config-based page size if pagination enabled in config + * - Falls back to Redux state page size otherwise + * - Pagination and grouping are mutually exclusive (grouping disables pagination) + */ export class EntriesCollection extends React.Component { static propTypes = { collection: ImmutablePropTypes.map.isRequired, @@ -78,6 +111,15 @@ export class EntriesCollection extends React.Component { isEditorialWorkflowEnabled: PropTypes.bool, getWorkflowStatus: PropTypes.func.isRequired, getUnpublishedEntries: PropTypes.func.isRequired, + paginationEnabled: PropTypes.bool, + paginationConfig: PropTypes.object, + currentPage: PropTypes.number, + pageSize: PropTypes.number, + totalCount: PropTypes.number, + setEntriesPageSize: PropTypes.func.isRequired, + loadEntriesPage: PropTypes.func.isRequired, + sortByField: PropTypes.func.isRequired, + sort: ImmutablePropTypes.orderedMap, }; componentDidMount() { @@ -92,8 +134,18 @@ export class EntriesCollection extends React.Component { unpublishedEntriesLoaded, loadUnpublishedEntries, isEditorialWorkflowEnabled, + sort, + sortByField, } = this.props; + // If sort state exists on mount, re-trigger sort to populate sortedIds + if (sort && sort.size > 0) { + sort.forEach((value, key) => { + const direction = value.get('direction'); + sortByField(collection, key, direction); + }); + } + if (collection && !entriesLoaded) { loadEntries(collection); } @@ -144,8 +196,16 @@ export class EntriesCollection extends React.Component { getWorkflowStatus, getUnpublishedEntries, filterTerm, + paginationEnabled, + currentPage, + pageSize, + totalCount, + setEntriesPageSize, + loadEntriesPage, } = this.props; + const hasActiveGroups = groups && groups.length > 0; + const EntriesToRender = ({ entries }) => { return ( loadEntriesPage(collection, page)} + onPageSizeChange={pageSize => setEntriesPageSize(collection, pageSize)} /> ); }; - if (groups && groups.length > 0) { + if (hasActiveGroups) { return withGroups(groups, entries, EntriesToRender, t); } @@ -204,7 +278,12 @@ function mapStateToProps(state, ownProps) { const collections = state.collections; - let entries = selectEntries(state.entries, collection); + // Calculate config-based page size first + const paginationEnabled = isPaginationEnabled(collection, state.config); + const paginationConfig = paginationEnabled ? getPaginationConfig(collection, state.config) : null; + const configPageSize = paginationConfig ? paginationConfig.per_page : undefined; + + let entries = selectEntries(state.entries, collection, configPageSize); const groups = selectGroups(state.entries, collection); if (collection.has('nested')) { @@ -227,6 +306,17 @@ function mapStateToProps(state, ownProps) { ? !!state.editorialWorkflow?.getIn(['pages', 'ids'], false) : true; + // Pagination state (already calculated above) + const currentPage = selectEntriesCurrentPage(state.entries, collection.get('name')); + // Use config's page size if pagination is enabled, otherwise use Redux state + const pageSize = paginationConfig + ? paginationConfig.per_page + : selectEntriesPageSize(state.entries, collection.get('name')); + const totalCount = selectEntriesTotalCount(state.entries, collection.get('name')); + + // Sort state + const sort = selectEntriesSort(state.entries, collection.get('name')); + return { collection, collections, @@ -239,6 +329,12 @@ function mapStateToProps(state, ownProps) { cursor, unpublishedEntriesLoaded, isEditorialWorkflowEnabled, + paginationEnabled, + paginationConfig, + currentPage, + pageSize, + totalCount, + sort, getWorkflowStatus: (collectionName, slug) => { const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug); return unpublishedEntry ? unpublishedEntry.get('status') : null; @@ -270,6 +366,9 @@ const mapDispatchToProps = { loadEntries: actionLoadEntries, traverseCollectionCursor: actionTraverseCollectionCursor, loadUnpublishedEntries: collections => loadUnpublishedEntries(collections), + setEntriesPageSize: actionSetEntriesPageSize, + loadEntriesPage: actionLoadEntriesPage, + sortByField: actionSortByField, }; const ConnectedEntriesCollection = connect(mapStateToProps, mapDispatchToProps)(EntriesCollection); diff --git a/packages/decap-cms-core/src/components/Collection/Entries/EntryListing.js b/packages/decap-cms-core/src/components/Collection/Entries/EntryListing.js index c6d0e391457d..67d1ba2e4226 100644 --- a/packages/decap-cms-core/src/components/Collection/Entries/EntryListing.js +++ b/packages/decap-cms-core/src/components/Collection/Entries/EntryListing.js @@ -18,6 +18,20 @@ const CardsGrid = styled.ul` margin-bottom: 16px; `; +/** + * EntryListing - Renders a grid of entry cards. + * + * This component handles: + * - Rendering entry cards in a responsive grid + * - Merging published and unpublished entries (editorial workflow) + * - Infinite scroll with cursor-based pagination (via Waypoint) + * - Supporting both single collection and multiple collections + * + * Pagination integration: + * - Disables cursor-based infinite scroll when pagination is enabled + * - When pagination is active, all navigation happens via Pagination component + * - Waypoint is only rendered when pagination is disabled + */ class EntryListing extends React.Component { static propTypes = { collections: ImmutablePropTypes.iterable.isRequired, @@ -29,6 +43,7 @@ class EntryListing extends React.Component { getUnpublishedEntries: PropTypes.func.isRequired, getWorkflowStatus: PropTypes.func.isRequired, filterTerm: PropTypes.string, + paginationEnabled: PropTypes.bool, }; componentDidMount() { @@ -133,7 +148,7 @@ class EntryListing extends React.Component { }; render() { - const { collections, page } = this.props; + const { collections, page, paginationEnabled } = this.props; return (
@@ -141,7 +156,9 @@ class EntryListing extends React.Component { {Map.isMap(collections) ? this.renderCardsForSingleCollection() : this.renderCardsForMultipleCollections()} - {this.hasMore() && } + {!paginationEnabled && this.hasMore() && ( + + )}
); diff --git a/packages/decap-cms-core/src/components/Collection/Entries/Pagination.js b/packages/decap-cms-core/src/components/Collection/Entries/Pagination.js new file mode 100644 index 000000000000..3b3fbe0838a2 --- /dev/null +++ b/packages/decap-cms-core/src/components/Collection/Entries/Pagination.js @@ -0,0 +1,133 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from '@emotion/styled'; +import { translate } from 'react-polyglot'; +import { Icon, colors } from 'decap-cms-ui-default'; + +const PaginationControls = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin: 2rem; +`; + +const PaginationInfo = styled.div` + display: flex; + justify-content: center; + align-items: center; + gap: 12px; +`; + +const PaginationButton = styled.button` + padding: 6px 6px 4px; + background-color: ${colors.button}; + color: ${colors.buttonText}; + border: none; + border-radius: 4px; + cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')}; + font-size: 12px; + transition: background-color 0.2s; + + &:hover:not(:disabled) { + background-color: #555a65; + } + + &:disabled { + opacity: 0.5; + } +`; + +/** + * Pagination component for entry collections. + * + * Provides accessible navigation controls for paginated entry lists. + * Uses hybrid pagination approach - server-side by default, client-side + * when sorting/filtering/grouping is active. + * + * Accessibility features: + * - ARIA labels and roles for screen reader support + * - Keyboard navigation support + * - Live region announcements for page changes + * - Proper disabled state handling + */ +function Pagination({ currentPage, pageCount, pageSize, totalCount, onPageChange, t }) { + const hasPrevPage = currentPage > 1; + const hasNextPage = currentPage < pageCount; + + const startEntry = totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const endEntry = totalCount === 0 ? 0 : Math.min(currentPage * pageSize, totalCount); + + return ( + + ); +} + +Pagination.propTypes = { + currentPage: PropTypes.number.isRequired, + pageCount: PropTypes.number.isRequired, + pageSize: PropTypes.number.isRequired, + totalCount: PropTypes.number.isRequired, + onPageChange: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, +}; + +export default translate()(Pagination); diff --git a/packages/decap-cms-core/src/components/Collection/Entries/__tests__/EntriesCollection.spec.js b/packages/decap-cms-core/src/components/Collection/Entries/__tests__/EntriesCollection.spec.js index 9d719a8248c0..0ea4536e6f59 100644 --- a/packages/decap-cms-core/src/components/Collection/Entries/__tests__/EntriesCollection.spec.js +++ b/packages/decap-cms-core/src/components/Collection/Entries/__tests__/EntriesCollection.spec.js @@ -93,6 +93,9 @@ describe('EntriesCollection', () => { isEditorialWorkflowEnabled: false, getWorkflowStatus: jest.fn(), getUnpublishedEntries: jest.fn(() => []), + setEntriesPageSize: jest.fn(), + loadEntriesPage: jest.fn(), + sortByField: jest.fn(), }; it('should render with entries', () => { diff --git a/packages/decap-cms-core/src/components/Collection/Entries/__tests__/PaginationEdgeCases.spec.js b/packages/decap-cms-core/src/components/Collection/Entries/__tests__/PaginationEdgeCases.spec.js new file mode 100644 index 000000000000..36b301595960 --- /dev/null +++ b/packages/decap-cms-core/src/components/Collection/Entries/__tests__/PaginationEdgeCases.spec.js @@ -0,0 +1,84 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { fromJS } from 'immutable'; +import configureStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; + +import ConnectedEntriesCollection from '../EntriesCollection'; + +jest.mock('../Entries', () => 'mock-entries'); + +const mockStore = configureStore([]); + +function toEntriesState(collection, entriesArray, additionalEntriesState = {}) { + const baseEntries = entriesArray.reduce( + (acc, entry) => { + acc.entities[`${collection.get('name')}.${entry.slug}`] = entry; + acc.pages[collection.get('name')].ids.push(entry.slug); + return acc; + }, + { pages: { [collection.get('name')]: { ids: [] } }, entities: {} }, + ); + + // Merge additional state (like groups, pagination, etc.) + const mergedEntries = { + ...baseEntries, + ...additionalEntriesState, + pages: { + ...baseEntries.pages, + ...(additionalEntriesState.pages || {}), + }, + entities: { + ...baseEntries.entities, + ...(additionalEntriesState.entities || {}), + }, + }; + + return fromJS(mergedEntries); +} + +function createMockStore(collection, entriesArray, additionalEntriesState = {}) { + return mockStore({ + entries: toEntriesState(collection, entriesArray, additionalEntriesState), + cursors: fromJS({}), + config: fromJS({ publish_mode: 'simple' }), + collections: fromJS({ [collection.get('name')]: collection }), + editorialWorkflow: fromJS({ pages: { ids: [] } }), + }); +} + +describe('Pagination Edge Cases', () => { + it('disables pagination when grouping is active', () => { + const collection = fromJS({ name: 'posts', label: 'Posts', folder: 'src/posts' }); + const entriesArray = [ + { slug: 'a', path: 'src/posts/a.md', data: { title: 'A' } }, + { slug: 'b', path: 'src/posts/b.md', data: { title: 'B' } }, + ]; + const store = createMockStore(collection, entriesArray, { + pages: { posts: { ids: ['a', 'b'], page: 1, pageSize: 1, totalCount: 2 } }, + groups: { + posts: [ + { + id: 'group1', + label: 'Group 1', + paths: fromJS(['src/posts/a.md']).toSet(), + active: true, + }, + ], + }, + }); + const { queryByLabelText } = render( + + + , + ); + // Pagination controls should not be rendered + expect(queryByLabelText('Entries pagination')).toBeNull(); + }); + + // TODO: Add tests for: + // - Pagination + i18n (grouped locale entries) + // - Pagination + sorting + // - Pagination + filtering + // - Client-side vs. server-side pagination switching +}); diff --git a/packages/decap-cms-core/src/components/Collection/Entries/__tests__/__snapshots__/EntriesCollection.spec.js.snap b/packages/decap-cms-core/src/components/Collection/Entries/__tests__/__snapshots__/EntriesCollection.spec.js.snap index 71e9ec1b5572..3e823bbc6596 100644 --- a/packages/decap-cms-core/src/components/Collection/Entries/__tests__/__snapshots__/EntriesCollection.spec.js.snap +++ b/packages/decap-cms-core/src/components/Collection/Entries/__tests__/__snapshots__/EntriesCollection.spec.js.snap @@ -5,8 +5,11 @@ exports[`EntriesCollection should render connected component 1`] = ` `; @@ -16,8 +19,11 @@ exports[`EntriesCollection should render show only immediate children for nested `; @@ -27,9 +33,12 @@ exports[`EntriesCollection should render with applied filter term for nested col `; diff --git a/packages/decap-cms-core/src/lib/__tests__/entryCache.spec.ts b/packages/decap-cms-core/src/lib/__tests__/entryCache.spec.ts new file mode 100644 index 000000000000..a184a1ead800 --- /dev/null +++ b/packages/decap-cms-core/src/lib/__tests__/entryCache.spec.ts @@ -0,0 +1,232 @@ +/** + * @jest-environment jsdom + */ + +// Mock localForage - must be before imports +const mockGetItem = jest.fn(); +const mockSetItem = jest.fn(); +const mockRemoveItem = jest.fn(); +const mockKeys = jest.fn(); + +jest.mock('decap-cms-lib-util/src/localForage', () => ({ + __esModule: true, + default: { + getItem: (...args: unknown[]) => mockGetItem(...args), + setItem: (...args: unknown[]) => mockSetItem(...args), + removeItem: (...args: unknown[]) => mockRemoveItem(...args), + keys: (...args: unknown[]) => mockKeys(...args), + }, +})); + +import { + getCachedEntries, + setCachedEntries, + invalidateCollectionCache, + clearAllEntryCaches, + getCacheStats, +} from '../entryCache'; + +describe('entryCache', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Suppress console.log in tests + jest.spyOn(console, 'log').mockImplementation(() => undefined); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getCachedEntries', () => { + it('should return null if cache is empty', async () => { + mockGetItem.mockResolvedValue(null); + + const result = await getCachedEntries('posts'); + + expect(result).toBeNull(); + expect(mockGetItem).toHaveBeenCalledWith('decap_entry_cache_posts'); + }); + + it('should return null if cache is expired', async () => { + const expiredCache = { + entries: [{ slug: 'post1', data: { title: 'Post 1' } }], + timestamp: Date.now() - 10 * 60 * 1000, // 10 minutes ago (expired) + collectionName: 'posts', + version: 1, + }; + mockGetItem.mockResolvedValue(expiredCache); + + const result = await getCachedEntries('posts'); + + expect(result).toBeNull(); + }); + + it('should return cached entries if cache is valid', async () => { + const entries = [{ slug: 'post1', data: { title: 'Post 1' } }]; + const validCache = { + entries, + timestamp: Date.now() - 2 * 60 * 1000, // 2 minutes ago (valid) + collectionName: 'posts', + version: 1, + }; + mockGetItem.mockResolvedValue(validCache); + + const result = await getCachedEntries('posts'); + + expect(result).toEqual(entries); + }); + + it('should handle errors gracefully', async () => { + mockGetItem.mockRejectedValue(new Error('Storage error')); + + const result = await getCachedEntries('posts'); + + expect(result).toBeNull(); + expect(console.warn).toHaveBeenCalledWith( + '[EntryCache] Error reading cache:', + expect.any(Error), + ); + }); + }); + + describe('setCachedEntries', () => { + it('should store entries with timestamp', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const entries: any[] = [{ slug: 'post1', data: { title: 'Post 1' } }]; + const now = Date.now(); + jest.spyOn(Date, 'now').mockReturnValue(now); + + await setCachedEntries('posts', entries); + + expect(mockSetItem).toHaveBeenCalledWith('decap_entry_cache_posts', { + entries, + timestamp: now, + collectionName: 'posts', + version: 1, + }); + }); + + it('should handle errors gracefully', async () => { + mockSetItem.mockRejectedValue(new Error('Storage error')); + + await setCachedEntries('posts', []); + + expect(console.warn).toHaveBeenCalledWith( + '[EntryCache] Error writing cache:', + expect.any(Error), + ); + }); + }); + + describe('invalidateCollectionCache', () => { + it('should remove cache for collection', async () => { + await invalidateCollectionCache('posts'); + + expect(mockRemoveItem).toHaveBeenCalledWith('decap_entry_cache_posts'); + }); + + it('should handle errors gracefully', async () => { + mockRemoveItem.mockRejectedValue(new Error('Storage error')); + + await invalidateCollectionCache('posts'); + + expect(console.warn).toHaveBeenCalledWith( + '[EntryCache] Error invalidating cache:', + expect.any(Error), + ); + }); + }); + + describe('clearAllEntryCaches', () => { + it('should clear all entry caches', async () => { + mockKeys.mockResolvedValue([ + 'decap_entry_cache_posts', + 'decap_entry_cache_pages', + 'other_key', + ]); + + await clearAllEntryCaches(); + + expect(mockRemoveItem).toHaveBeenCalledTimes(2); + expect(mockRemoveItem).toHaveBeenCalledWith('decap_entry_cache_posts'); + expect(mockRemoveItem).toHaveBeenCalledWith('decap_entry_cache_pages'); + expect(mockRemoveItem).not.toHaveBeenCalledWith('other_key'); + }); + + it('should handle errors gracefully', async () => { + mockKeys.mockRejectedValue(new Error('Storage error')); + + await clearAllEntryCaches(); + + expect(console.warn).toHaveBeenCalledWith( + '[EntryCache] Error clearing all caches:', + expect.any(Error), + ); + }); + }); + + describe('getCacheStats', () => { + it('should return cache statistics', async () => { + const now = Date.now(); + mockKeys.mockResolvedValue([ + 'decap_entry_cache_posts', + 'decap_entry_cache_pages', + 'other_key', + ]); + mockGetItem + .mockResolvedValueOnce({ + entries: [{ slug: 'post1' }, { slug: 'post2' }], + timestamp: now - 1000, + collectionName: 'posts', + version: 1, + }) + .mockResolvedValueOnce({ + entries: [{ slug: 'page1' }], + timestamp: now - 2000, + collectionName: 'pages', + version: 1, + }) + .mockResolvedValueOnce(null); + + const stats = await getCacheStats(); + + expect(stats).toEqual({ + collections: ['posts', 'pages'], + totalEntries: 3, + oldestCache: now - 2000, + newestCache: now - 1000, + }); + }); + + it('should handle empty cache', async () => { + mockKeys.mockResolvedValue([]); + + const stats = await getCacheStats(); + + expect(stats).toEqual({ + collections: [], + totalEntries: 0, + oldestCache: null, + newestCache: null, + }); + }); + + it('should handle errors gracefully', async () => { + mockKeys.mockRejectedValue(new Error('Storage error')); + + const stats = await getCacheStats(); + + expect(stats).toEqual({ + collections: [], + totalEntries: 0, + oldestCache: null, + newestCache: null, + }); + expect(console.warn).toHaveBeenCalledWith( + '[EntryCache] Error getting cache stats:', + expect.any(Error), + ); + }); + }); +}); diff --git a/packages/decap-cms-core/src/lib/entryCache.ts b/packages/decap-cms-core/src/lib/entryCache.ts new file mode 100644 index 000000000000..8a4f968b1dcf --- /dev/null +++ b/packages/decap-cms-core/src/lib/entryCache.ts @@ -0,0 +1,175 @@ +/** + * Entry Cache Module + * + * Provides localStorage-based caching for collection entries to improve performance + * when navigating between pages, sorting, or filtering. + * + * Cache Strategy: + * - Store fetched entries with timestamps + * - Invalidate on entry changes (persist, delete) + * - Time-based expiration (default: 5 minutes) + * - Collection-specific cache keys + */ + +import localForage from 'decap-cms-lib-util/src/localForage'; + +import type { EntryValue } from '../valueObjects/Entry'; + +const CACHE_PREFIX = 'decap_entry_cache_'; +const CACHE_EXPIRATION_MS = 5 * 60 * 1000; // 5 minutes + +interface CacheEntry { + entries: EntryValue[]; + timestamp: number; + collectionName: string; + version: number; // For future cache format changes +} + +/** + * Generate cache key for a collection + */ +function getCacheKey(collectionName: string): string { + return `${CACHE_PREFIX}${collectionName}`; +} + +/** + * Check if cached data is still valid + */ +function isCacheValid(cacheEntry: CacheEntry | null): boolean { + if (!cacheEntry) { + return false; + } + + const now = Date.now(); + const age = now - cacheEntry.timestamp; + + return age < CACHE_EXPIRATION_MS; +} + +/** + * Get cached entries for a collection + * + * @param collectionName - Name of the collection + * @returns Cached entries or null if cache miss/expired + */ +export async function getCachedEntries(collectionName: string): Promise { + try { + const cacheKey = getCacheKey(collectionName); + const cached = await localForage.getItem(cacheKey); + + if (cached && isCacheValid(cached)) { + console.log(`[EntryCache] Cache HIT for collection: ${collectionName}`); + return cached.entries; + } + + console.log(`[EntryCache] Cache MISS for collection: ${collectionName}`); + return null; + } catch (error) { + console.warn('[EntryCache] Error reading cache:', error); + return null; + } +} + +/** + * Store entries in cache + * + * @param collectionName - Name of the collection + * @param entries - Entries to cache + */ +export async function setCachedEntries( + collectionName: string, + entries: EntryValue[], +): Promise { + try { + const cacheKey = getCacheKey(collectionName); + const cacheEntry: CacheEntry = { + entries, + timestamp: Date.now(), + collectionName, + version: 1, + }; + + await localForage.setItem(cacheKey, cacheEntry); + console.log(`[EntryCache] Cached ${entries.length} entries for collection: ${collectionName}`); + } catch (error) { + console.warn('[EntryCache] Error writing cache:', error); + } +} + +/** + * Invalidate cache for a specific collection + * + * Should be called when: + * - Entry is created + * - Entry is updated + * - Entry is deleted + * + * @param collectionName - Name of the collection to invalidate + */ +export async function invalidateCollectionCache(collectionName: string): Promise { + try { + const cacheKey = getCacheKey(collectionName); + await localForage.removeItem(cacheKey); + console.log(`[EntryCache] Invalidated cache for collection: ${collectionName}`); + } catch (error) { + console.warn('[EntryCache] Error invalidating cache:', error); + } +} + +/** + * Clear all entry caches + * + * Useful for logout or manual cache clearing + */ +export async function clearAllEntryCaches(): Promise { + try { + const keys = await localForage.keys(); + const cacheKeys = keys.filter(key => key.startsWith(CACHE_PREFIX)); + + await Promise.all(cacheKeys.map(key => localForage.removeItem(key))); + console.log(`[EntryCache] Cleared ${cacheKeys.length} collection caches`); + } catch (error) { + console.warn('[EntryCache] Error clearing all caches:', error); + } +} + +/** + * Get cache statistics for debugging + */ +export async function getCacheStats(): Promise<{ + collections: string[]; + totalEntries: number; + oldestCache: number | null; + newestCache: number | null; +}> { + try { + const keys = await localForage.keys(); + const cacheKeys = keys.filter(key => key.startsWith(CACHE_PREFIX)); + + const cacheEntries = await Promise.all( + cacheKeys.map(async key => { + const entry = await localForage.getItem(key); + return entry; + }), + ); + + const validCaches = cacheEntries.filter((entry): entry is CacheEntry => entry !== null); + + const timestamps = validCaches.map(c => c.timestamp); + + return { + collections: validCaches.map(c => c.collectionName), + totalEntries: validCaches.reduce((sum, c) => sum + c.entries.length, 0), + oldestCache: timestamps.length > 0 ? Math.min(...timestamps) : null, + newestCache: timestamps.length > 0 ? Math.max(...timestamps) : null, + }; + } catch (error) { + console.warn('[EntryCache] Error getting cache stats:', error); + return { + collections: [], + totalEntries: 0, + oldestCache: null, + newestCache: null, + }; + } +} diff --git a/packages/decap-cms-core/src/lib/entryHelpers.ts b/packages/decap-cms-core/src/lib/entryHelpers.ts new file mode 100644 index 000000000000..7c25cd80bce6 --- /dev/null +++ b/packages/decap-cms-core/src/lib/entryHelpers.ts @@ -0,0 +1,117 @@ +/** + * Utility functions for working with entries, filters, sorts, and groups + */ + +/** + * Check if any filters are active in the Redux state + */ +export function hasActiveFilters(activeFilters: unknown): boolean { + if (!activeFilters) return false; + + // Check if it's an Immutable collection with a 'some' method + if ( + typeof activeFilters === 'object' && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typeof (activeFilters as any).some === 'function' + ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (activeFilters as any).some((f: any) => f.get('active') === true); + } + + return false; +} + +/** + * Check if any groups are active in the Redux state + */ +export function hasActiveGroups(activeGroups: unknown): boolean { + if (!activeGroups) return false; + + // Check if it's an Immutable collection with a 'some' method + if ( + typeof activeGroups === 'object' && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typeof (activeGroups as any).some === 'function' + ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (activeGroups as any).some((g: any) => g.get('active') === true); + } + + return false; +} + +/** + * Check if any sorts are active in the Redux state + */ +export function hasActiveSorts(activeSorts: unknown): boolean { + if (!activeSorts) return false; + + // Check if it's an Immutable collection with a 'size' property + if ( + typeof activeSorts === 'object' && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typeof (activeSorts as any).size === 'number' + ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (activeSorts as any).size > 0; + } + + return false; +} + +/** + * Get value from a nested field path (e.g., "data.title" or "data.nested.field") + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getFieldValue(obj: any, fieldPath: string): unknown { + const pathParts = fieldPath.split('.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let value: any = obj; + for (const part of pathParts) { + value = value?.[part]; + } + return value; +} + +/** + * Extract active filters from Immutable collection into plain array + */ +export interface FilterDefinition { + pattern: string | RegExp; + field: string; +} + +export function extractActiveFilters(activeFilters: unknown): FilterDefinition[] { + const filters: FilterDefinition[] = []; + + if (!activeFilters) return filters; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (typeof (activeFilters as any).forEach === 'function') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (activeFilters as any).forEach((f: any) => { + if (f.get('active') === true) { + filters.push({ + pattern: f.get('pattern'), + field: f.get('field'), + }); + } + }); + } + + return filters; +} + +/** + * Apply filters to an entry + */ +export function matchesFilters( + entry: Record | { data?: Record }, + filters: FilterDefinition[], +): boolean { + return filters.every(({ pattern, field }) => { + const data = ('data' in entry ? entry.data : entry) || {}; + const value = getFieldValue(data as Record, field); + return value !== undefined && new RegExp(String(pattern)).test(String(value)); + }); +} diff --git a/packages/decap-cms-core/src/lib/immutableHelpers.ts b/packages/decap-cms-core/src/lib/immutableHelpers.ts new file mode 100644 index 000000000000..8e7ce8da09bf --- /dev/null +++ b/packages/decap-cms-core/src/lib/immutableHelpers.ts @@ -0,0 +1,24 @@ +import { Map } from 'immutable'; +/** + * Type guard to check if an object is an Immutable.js Map + */ +export function isImmutableMap(obj: unknown): boolean { + return Map.isMap(obj); +} + +/** + * Helper to safely get a value from either an Immutable Map or plain object + */ +export function getValue(obj: unknown, key: string): T | undefined { + if (!obj) return undefined; + + if (isImmutableMap(obj)) { + return (obj as { get: (k: string) => T }).get(key); + } + + if (typeof obj === 'object' && obj !== null) { + return (obj as Record)[key] as T; + } + + return undefined; +} diff --git a/packages/decap-cms-core/src/lib/pagination.ts b/packages/decap-cms-core/src/lib/pagination.ts new file mode 100644 index 000000000000..22158b28527f --- /dev/null +++ b/packages/decap-cms-core/src/lib/pagination.ts @@ -0,0 +1,86 @@ +/** + * Pagination Utilities + * + * This module provides helpers for determining if pagination is enabled for a collection, + * and for retrieving the effective pagination configuration (page size, enabled flag). + * + * Principles: + * - Pagination can be enabled/disabled per collection or globally via config. + * - If sorting, filtering, or grouping is active, pagination is handled client-side (all entries loaded). + * - If none are active, server-side pagination is used (only a page of entries loaded at a time). + * - The effective page size is determined by collection config, then global config, then a default. + * + * Usage: + * - Use isPaginationEnabled(collection, config) to check if pagination should be active. + * - Use getPaginationConfig(collection, config) to get the effective page size and enabled flag. + */ +import { getValue, isImmutableMap } from './immutableHelpers'; + +import type { CmsCollection, CmsConfig, PaginationConfig, Collection } from '../types/redux'; + +const DEFAULT_PER_PAGE = 100; +type CollectionLike = CmsCollection | Collection; + +export function isPaginationEnabled(collection: CollectionLike, globalConfig?: CmsConfig): boolean { + const pagination = isImmutableMap(collection) + ? (collection as { get: (k: string) => unknown }).get('pagination') + : (collection as CmsCollection).pagination; + + if (typeof pagination !== 'undefined') { + if (typeof pagination === 'boolean') return pagination; + if (pagination && typeof pagination === 'object') { + const enabled = getValue(pagination, 'enabled'); + return enabled !== false; + } + return false; + } + + if (globalConfig?.pagination) { + if (typeof globalConfig.pagination === 'boolean') return globalConfig.pagination; + if (typeof globalConfig.pagination === 'object') { + const enabled = getValue(globalConfig.pagination, 'enabled'); + return enabled !== false; + } + } + return false; +} + +export function getPaginationConfig( + collection: CollectionLike, + globalConfig?: CmsConfig, +): PaginationConfig { + const defaults: PaginationConfig = { + enabled: false, + per_page: DEFAULT_PER_PAGE, + }; + + if (globalConfig?.pagination) { + if (typeof globalConfig.pagination === 'boolean') { + defaults.enabled = globalConfig.pagination; + } else if (typeof globalConfig.pagination === 'object') { + const enabled = getValue(globalConfig.pagination, 'enabled'); + defaults.enabled = enabled !== false; + const perPage = getValue(globalConfig.pagination, 'per_page'); + defaults.per_page = typeof perPage === 'number' ? perPage : defaults.per_page; + } + } + + const pagination = isImmutableMap(collection) + ? (collection as { get: (k: string) => unknown }).get('pagination') + : (collection as CmsCollection).pagination; + + if (pagination === true) { + return { + enabled: true, + per_page: defaults.per_page, + }; + } else if (typeof pagination === 'object' && pagination !== null) { + const perPage = getValue(pagination, 'per_page'); + const enabled = getValue(pagination, 'enabled'); + return { + enabled: enabled !== false, + per_page: typeof perPage === 'number' ? perPage : defaults.per_page, + }; + } + return defaults; +} diff --git a/packages/decap-cms-core/src/reducers/__tests__/entries.spec.js b/packages/decap-cms-core/src/reducers/__tests__/entries.spec.js index 283099be404f..5fde6125a55d 100644 --- a/packages/decap-cms-core/src/reducers/__tests__/entries.spec.js +++ b/packages/decap-cms-core/src/reducers/__tests__/entries.spec.js @@ -48,6 +48,13 @@ describe('entries', () => { ids: ['a', 'b'], }, }, + pagination: { + posts: { + currentPage: 1, + totalCount: 2, + pageSize: 100, + }, + }, }), ), ); @@ -597,6 +604,220 @@ describe('entries', () => { fromJS([{ slug: '1' }, { slug: '2' }, { slug: '3' }, { slug: '4' }]), ); }); + + describe('pagination', () => { + it('should return paginated entries based on config page size', () => { + const state = fromJS({ + entities: { + 'posts.1': { slug: '1', data: { title: '1' } }, + 'posts.2': { slug: '2', data: { title: '2' } }, + 'posts.3': { slug: '3', data: { title: '3' } }, + 'posts.4': { slug: '4', data: { title: '4' } }, + 'posts.5': { slug: '5', data: { title: '5' } }, + 'posts.6': { slug: '6', data: { title: '6' } }, + 'posts.7': { slug: '7', data: { title: '7' } }, + 'posts.8': { slug: '8', data: { title: '8' } }, + 'posts.9': { slug: '9', data: { title: '9' } }, + 'posts.10': { slug: '10', data: { title: '10' } }, + }, + pages: { + posts: { + ids: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'], + sortedIds: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'], + }, + }, + pagination: { + posts: { + currentPage: 1, // Page 1 (first page) + }, + }, + }); + const collection = fromJS({ + name: 'posts', + }); + const configPageSize = 3; // Config specifies 3 items per page + + const result = selectEntries(state, collection, configPageSize); + + expect(result.size).toBe(3); + expect(result).toEqual( + fromJS([ + { slug: '1', data: { title: '1' } }, + { slug: '2', data: { title: '2' } }, + { slug: '3', data: { title: '3' } }, + ]), + ); + }); + + it('should return correct page of entries when currentPage is set', () => { + const state = fromJS({ + entities: { + 'posts.1': { slug: '1', data: { title: '1' } }, + 'posts.2': { slug: '2', data: { title: '2' } }, + 'posts.3': { slug: '3', data: { title: '3' } }, + 'posts.4': { slug: '4', data: { title: '4' } }, + 'posts.5': { slug: '5', data: { title: '5' } }, + 'posts.6': { slug: '6', data: { title: '6' } }, + 'posts.7': { slug: '7', data: { title: '7' } }, + 'posts.8': { slug: '8', data: { title: '8' } }, + }, + pages: { + posts: { + ids: ['1', '2', '3', '4', '5', '6', '7', '8'], + sortedIds: ['1', '2', '3', '4', '5', '6', '7', '8'], + }, + }, + pagination: { + posts: { + currentPage: 2, // Page 2 (second page) + }, + }, + }); + const collection = fromJS({ + name: 'posts', + }); + const configPageSize = 3; + + const result = selectEntries(state, collection, configPageSize); + + expect(result.size).toBe(3); + expect(result).toEqual( + fromJS([ + { slug: '4', data: { title: '4' } }, + { slug: '5', data: { title: '5' } }, + { slug: '6', data: { title: '6' } }, + ]), + ); + }); + + it('should return remaining entries on last page', () => { + const state = fromJS({ + entities: { + 'posts.1': { slug: '1', data: { title: '1' } }, + 'posts.2': { slug: '2', data: { title: '2' } }, + 'posts.3': { slug: '3', data: { title: '3' } }, + 'posts.4': { slug: '4', data: { title: '4' } }, + 'posts.5': { slug: '5', data: { title: '5' } }, + }, + pages: { + posts: { + ids: ['1', '2', '3', '4', '5'], + sortedIds: ['1', '2', '3', '4', '5'], + }, + }, + pagination: { + posts: { + currentPage: 2, // Page 2 (last page) + }, + }, + }); + const collection = fromJS({ + name: 'posts', + }); + const configPageSize = 3; + + const result = selectEntries(state, collection, configPageSize); + + expect(result.size).toBe(2); // Only 2 items on last page + expect(result).toEqual( + fromJS([ + { slug: '4', data: { title: '4' } }, + { slug: '5', data: { title: '5' } }, + ]), + ); + }); + + it('should work with filtering - return paginated filtered results', () => { + const state = fromJS({ + entities: { + 'posts.1': { slug: '1', data: { title: 'apple' } }, + 'posts.2': { slug: '2', data: { title: 'banana' } }, + 'posts.3': { slug: '3', data: { title: 'apricot' } }, + 'posts.4': { slug: '4', data: { title: 'cherry' } }, + 'posts.5': { slug: '5', data: { title: 'avocado' } }, + 'posts.6': { slug: '6', data: { title: 'grape' } }, + }, + pages: { + posts: { + ids: ['1', '2', '3', '4', '5', '6'], + sortedIds: ['1', '3', '5'], // Filtered to entries starting with 'a' + }, + }, + filter: { + posts: { title__a: { field: 'title', pattern: '^a', active: true } }, + }, + pagination: { + posts: { + currentPage: 1, // Page 1 (first page) + }, + }, + }); + const collection = fromJS({ + name: 'posts', + }); + const configPageSize = 2; + + const result = selectEntries(state, collection, configPageSize); + + expect(result.size).toBe(2); + expect(result).toEqual( + fromJS([ + { slug: '1', data: { title: 'apple' } }, + { slug: '3', data: { title: 'apricot' } }, + ]), + ); + }); + + it('should ignore pagination when grouping is active', () => { + const state = fromJS({ + entities: { + 'posts.1': { slug: '1', data: { title: '1' } }, + 'posts.2': { slug: '2', data: { title: '2' } }, + 'posts.3': { slug: '3', data: { title: '3' } }, + 'posts.4': { slug: '4', data: { title: '4' } }, + 'posts.5': { slug: '5', data: { title: '5' } }, + 'posts.6': { slug: '6', data: { title: '6' } }, + 'posts.7': { slug: '7', data: { title: '7' } }, + 'posts.8': { slug: '8', data: { title: '8' } }, + }, + pages: { + posts: { + ids: ['1', '2', '3', '4', '5', '6', '7', '8'], + // No sortedIds when grouping is active + }, + }, + group: { + posts: { category: { field: 'category', active: true } }, + }, + pagination: { + posts: { + currentPage: 1, + }, + }, + }); + const collection = fromJS({ + name: 'posts', + }); + const configPageSize = 3; + + const result = selectEntries(state, collection, configPageSize); + + // Should return all entries, ignoring pagination + expect(result.size).toBe(8); + expect(result).toEqual( + fromJS([ + { slug: '1', data: { title: '1' } }, + { slug: '2', data: { title: '2' } }, + { slug: '3', data: { title: '3' } }, + { slug: '4', data: { title: '4' } }, + { slug: '5', data: { title: '5' } }, + { slug: '6', data: { title: '6' } }, + { slug: '7', data: { title: '7' } }, + { slug: '8', data: { title: '8' } }, + ]), + ); + }); + }); }); it('should return sorted entries entries by field', () => { diff --git a/packages/decap-cms-core/src/reducers/entries.ts b/packages/decap-cms-core/src/reducers/entries.ts index 653b77aee867..5f61e260ebc5 100644 --- a/packages/decap-cms-core/src/reducers/entries.ts +++ b/packages/decap-cms-core/src/reducers/entries.ts @@ -31,6 +31,8 @@ import { GROUP_ENTRIES_SUCCESS, GROUP_ENTRIES_FAILURE, CHANGE_VIEW_STYLE, + SET_ENTRIES_PAGE_SIZE, + SET_ENTRIES_PAGE, } from '../actions/entries'; import { VIEW_STYLE_LIST } from '../constants/collectionViews'; import { joinUrlPath } from '../lib/urlHelper'; @@ -66,6 +68,7 @@ import type { EntriesGroupRequestPayload, EntriesGroupFailurePayload, GroupOfEntries, + SetEntriesPageSizePayload, } from '../types/redux'; const { keyToPathArray } = stringTemplate; @@ -78,6 +81,7 @@ let slug: string; const storageSortKey = 'decap-cms.entries.sort'; const viewStyleKey = 'decap-cms.entries.viewStyle'; +const paginationKey = 'decap-cms.entries.pagination'; function normalizeDoubleSlashes(path: string) { if (!path) { @@ -156,8 +160,51 @@ function persistViewStyle(viewStyle: string | undefined) { } } +type PaginationStorage = { [collection: string]: number }; + +const loadPagination = once(() => { + const paginationString = localStorage.getItem(paginationKey); + if (paginationString) { + try { + const pagination: PaginationStorage = JSON.parse(paginationString); + return Map( + Object.entries(pagination).map(([collection, pageSize]) => [ + collection, + fromJS({ pageSize, currentPage: 1, totalCount: 0 }), + ]), + ); + } catch (e) { + return Map(); + } + } + return Map(); +}); + +function clearPagination() { + localStorage.removeItem(paginationKey); +} + +function persistPagination(pagination: Map | undefined) { + if (pagination && pagination.size > 0) { + const storageData: PaginationStorage = {}; + pagination.forEach((value, key) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + storageData[key as string] = (value as any).get('pageSize'); + }); + localStorage.setItem(paginationKey, JSON.stringify(storageData)); + } else { + clearPagination(); + } +} + function entries( - state = Map({ entities: Map(), pages: Map(), sort: loadSort(), viewStyle: loadViewStyle() }), + state = Map({ + entities: Map(), + pages: Map(), + sort: loadSort(), + viewStyle: loadViewStyle(), + pagination: loadPagination(), + }), action: EntriesAction, ) { switch (action.type) { @@ -210,6 +257,40 @@ function entries( ids: append ? map.getIn(['pages', collection, 'ids'], List()).concat(ids) : ids, }), ); + + // Update pagination metadata from cursor if available + if (payload.cursor && payload.cursor.meta && payload.cursor.meta.get('count')) { + const cursorMeta = payload.cursor.meta; + const currentPage = cursorMeta.get('page') || 1; + const totalCount = cursorMeta.get('count') || 0; + const cursorPageSize = cursorMeta.get('pageSize') || 100; + + const existingPagination = map.getIn( + ['pagination', collection], + fromJS({ currentPage: 1, totalCount: 0, pageSize: 100 }), + ); + // Only update pageSize if it hasn't been set yet (on first load) + // After that, the config-driven pageSize should be preserved + const currentPageSize = existingPagination.get('pageSize'); + const pageSize = + currentPageSize && currentPageSize !== 100 ? currentPageSize : cursorPageSize; + + const updated = existingPagination.merge({ currentPage, totalCount, pageSize }); + map.setIn(['pagination', collection], updated); + } else { + // For i18n or client-side pagination (no cursor metadata), use actual loaded entry count + // This will be updated correctly when SORT_ENTRIES_SUCCESS sets sortedIds + const existingPagination = map.getIn( + ['pagination', collection], + fromJS({ currentPage: 1, totalCount: 0, pageSize: 100 }), + ); + // Don't override totalCount if it's already been set (e.g., by SORT_ENTRIES_SUCCESS) + const updates: { currentPage: number; totalCount?: number } = { currentPage: 1 }; + if (!existingPagination.get('totalCount')) { + updates.totalCount = loadedEntries.length; + } + map.setIn(['pagination', collection], existingPagination.merge(updates)); + } }); } case ENTRIES_FAILURE: @@ -257,17 +338,22 @@ function entries( map.setIn(['sort', collection], sort); map.setIn(['pages', collection, 'isFetching'], true); map.deleteIn(['pages', collection, 'page']); + // Reset pagination to page 1 when sort changes + const existingPagination = map.getIn(['pagination', collection]); + if (existingPagination) { + map.setIn(['pagination', collection], existingPagination.set('currentPage', 1)); + } }); persistSort(newState.get('sort') as Sort); return newState; } - case GROUP_ENTRIES_SUCCESS: case FILTER_ENTRIES_SUCCESS: case SORT_ENTRIES_SUCCESS: { const payload = action.payload as { collection: string; entries: EntryObject[] }; const { collection, entries } = payload; loadedEntries = entries; + const newState = state.withMutations(map => { loadedEntries.forEach(entry => map.setIn( @@ -276,14 +362,52 @@ function entries( ), ); map.setIn(['pages', collection, 'isFetching'], false); + // Store sorted/filtered entry slugs in a special key for client-side pagination + const sortedIds = List(loadedEntries.map(entry => entry.slug)); + map.setIn(['pages', collection, 'sortedIds'], sortedIds); + map.setIn(['pages', collection, 'page'], 1); + // Reset pagination to page 1 and update totalCount + const existingPagination = map.getIn(['pagination', collection]); + if (existingPagination) { + map.setIn( + ['pagination', collection], + existingPagination.set('currentPage', 1).set('totalCount', sortedIds.size), + ); + } else { + map.setIn( + ['pagination', collection], + fromJS({ currentPage: 1, totalCount: sortedIds.size, pageSize: 100 }), + ); + } + }); + return newState; + } + + case GROUP_ENTRIES_SUCCESS: { + const payload = action.payload as { collection: string; entries: EntryObject[] }; + const { collection, entries } = payload; + loadedEntries = entries; + + const newState = state.withMutations(map => { + loadedEntries.forEach(entry => + map.setIn( + ['entities', `${entry.collection}.${entry.slug}`], + fromJS(entry).set('isFetching', false), + ), + ); + const ids = List(loadedEntries.map(entry => entry.slug)); map.setIn( ['pages', collection], Map({ page: 1, ids, + isFetching: false, }), ); + + // Clear sortedIds so pagination doesn't limit results + map.deleteIn(['pages', collection, 'sortedIds']); }); return newState; } @@ -308,6 +432,11 @@ function entries( ['filter', collection, current.get('id')], current.set('active', !current.get('active')), ); + // Reset pagination to page 1 when filter changes + const existingPagination = map.getIn(['pagination', collection]); + if (existingPagination) { + map.setIn(['pagination', collection], existingPagination.set('currentPage', 1)); + } }); return newState; } @@ -332,6 +461,11 @@ function entries( ['group', collection, current.get('id')], current.set('active', !current.get('active')), ); + // Reset pagination to page 1 when group changes + const existingPagination = map.getIn(['pagination', collection]); + if (existingPagination) { + map.setIn(['pagination', collection], existingPagination.set('currentPage', 1)); + } }); return newState; } @@ -356,6 +490,35 @@ function entries( return newState; } + case SET_ENTRIES_PAGE_SIZE: { + const payload = action.payload as unknown as SetEntriesPageSizePayload; + const { collection, pageSize } = payload; + const newState = state.withMutations(map => { + const current = map.getIn( + ['pagination', collection], + fromJS({ currentPage: 1, totalCount: 0, pageSize: 100 }), + ); + map.setIn( + ['pagination', collection], + current.set('pageSize', pageSize).set('currentPage', 1), + ); + }); + persistPagination(newState.get('pagination') as Map); + return newState; + } + + case SET_ENTRIES_PAGE: { + const payload = action.payload as { collection: string; page: number }; + const { collection, page } = payload; + const newState = state.withMutations(map => { + const existingPagination = map.getIn(['pagination', collection]); + if (existingPagination) { + map.setIn(['pagination', collection], existingPagination.set('currentPage', page)); + } + }); + return newState; + } + default: return state; } @@ -423,10 +586,42 @@ function getPublishedEntries(state: Entries, collectionName: string) { return entries; } -export function selectEntries(state: Entries, collection: Collection) { +export function selectEntries(state: Entries, collection: Collection, configPageSize?: number) { const collectionName = collection.get('name'); let entries = getPublishedEntries(state, collectionName); + // If sortedIds is present, use it for client-side pagination + const sortedIdsRaw = state.getIn(['pages', collectionName, 'sortedIds']); + const pagination = selectEntriesPagination(state, collectionName); + + // Use provided config page size, or fall back to Redux state + const pageSize = configPageSize || (pagination ? pagination.get('pageSize', 100) : 100); + const currentPage = pagination ? pagination.get('currentPage', 1) : 1; + + if (sortedIdsRaw && List.isList(sortedIdsRaw)) { + const sortedIds = sortedIdsRaw as List; + + // Only apply pagination if configPageSize is explicitly provided (meaning pagination is enabled) + // If configPageSize is undefined, show all sorted entries + const pagedIds = + configPageSize !== undefined + ? sortedIds.slice((currentPage - 1) * pageSize, (currentPage - 1) * pageSize + pageSize) + : sortedIds; + + // Always look up entries from the global entities map to ensure correct order + const entitiesMap = state.get('entities'); + const pagedEntries = pagedIds + .map((slug: string | undefined) => { + if (!slug) return null; + const entry = entitiesMap.get(`${collectionName}.${slug}`); + return entry || null; + }) + .filter((e): e is EntryMap => !!e) + .toList(); + return pagedEntries; + } + + // Fallback: legacy sort/filter logic const sortFields = selectEntriesSortFields(state, collectionName); if (sortFields && sortFields.length > 0) { const keys = sortFields.map(v => selectSortDataPath(collection, v.get('key'))); @@ -540,6 +735,27 @@ export function selectIsFetching(state: Entries, collection: string) { return state.getIn(['pages', collection, 'isFetching'], false); } +export function selectEntriesPagination(state: Entries, collectionName: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pagination: any = state.get('pagination'); + return pagination ? pagination.get(collectionName) : undefined; +} + +export function selectEntriesPageSize(state: Entries, collectionName: string) { + const pagination = selectEntriesPagination(state, collectionName); + return pagination ? pagination.get('pageSize', 100) : 100; +} + +export function selectEntriesCurrentPage(state: Entries, collectionName: string) { + const pagination = selectEntriesPagination(state, collectionName); + return pagination ? pagination.get('currentPage', 1) : 1; +} + +export function selectEntriesTotalCount(state: Entries, collectionName: string) { + const pagination = selectEntriesPagination(state, collectionName); + return pagination ? pagination.get('totalCount', 0) : 0; +} + function getFileField(collectionFiles: CollectionFiles, slug: string | undefined) { const file = collectionFiles.find(f => f?.get('name') === slug); return file; diff --git a/packages/decap-cms-core/src/types/redux.ts b/packages/decap-cms-core/src/types/redux.ts index a691e1c7e642..7e9f426b7bd6 100644 --- a/packages/decap-cms-core/src/types/redux.ts +++ b/packages/decap-cms-core/src/types/redux.ts @@ -1,6 +1,7 @@ import type { Action } from 'redux'; import type { StaticallyTypedRecord } from './immutable'; import type { Map, List, OrderedMap, Set } from 'immutable'; +import type { Cursor } from 'decap-cms-lib-util'; import type { FILES, FOLDER } from '../constants/collectionTypes'; import type { MediaFile as BackendMediaFile } from '../backend'; import type { Auth } from '../reducers/auth'; @@ -309,6 +310,11 @@ export interface ViewGroup { id: string; } +export interface PaginationConfig { + enabled?: boolean; + per_page: number; +} + export interface CmsCollection { name: string; label: string; @@ -352,6 +358,7 @@ export interface CmsCollection { view_filters?: ViewFilter[]; view_groups?: ViewGroup[]; i18n?: boolean | CmsI18nConfig; + pagination?: boolean | PaginationConfig; /** * @deprecated Use sortable_fields instead @@ -421,6 +428,7 @@ export interface CmsConfig { }[]; slug?: CmsSlug; i18n?: CmsI18nConfig; + pagination?: boolean | PaginationConfig; local_backend?: boolean | CmsLocalBackend; editor?: { preview?: boolean; @@ -515,6 +523,15 @@ export type GroupOfEntries = { export type Entities = StaticallyTypedRecord; +export type PaginationState = Map< + string, + StaticallyTypedRecord<{ + pageSize: number; + currentPage: number; + totalCount: number; + }> +>; + export type Entries = StaticallyTypedRecord<{ pages: Pages & PagesObject; entities: Entities & EntitiesObject; @@ -522,6 +539,7 @@ export type Entries = StaticallyTypedRecord<{ filter: Filter; group: Group; viewStyle: string; + pagination: PaginationState; }>; export type EditorialWorkflow = StaticallyTypedRecord<{ @@ -747,6 +765,7 @@ export interface EntriesSuccessPayload extends EntryPayload { entries: EntryObject[]; append: boolean; page: number; + cursor?: Cursor; } export interface EntriesSortRequestPayload extends EntryPayload { key: string; @@ -783,6 +802,16 @@ export interface ChangeViewStylePayload { style: string; } +export interface SetEntriesPageSizePayload { + collection: string; + pageSize: number; +} + +export interface LoadEntriesPagePayload { + collection: string; + page: number; +} + export interface EntriesMoveSuccessPayload extends EntryPayload { entries: EntryObject[]; } diff --git a/packages/decap-cms-lib-util/src/implementation.ts b/packages/decap-cms-lib-util/src/implementation.ts index 52539852bde8..773552c08c7f 100644 --- a/packages/decap-cms-lib-util/src/implementation.ts +++ b/packages/decap-cms-lib-util/src/implementation.ts @@ -148,6 +148,11 @@ export interface Implementation { folder: string, extension: string, depth: number, + options?: { + page?: number; + pageSize?: number; + pagination?: boolean; + }, ) => Promise; entriesByFiles: (files: ImplementationFile[]) => Promise; @@ -258,13 +263,69 @@ async function fetchFiles( ) as Promise; } +/** + * Fetches entries from a folder with optional pagination support. + * + * This helper function is used by backend implementations to load entries from a repository folder. + * It supports both paginated and non-paginated modes: + * + * - **Without pagination:** Returns all entries (default behavior, backward compatible) + * - **With pagination:** Returns only the requested page of entries + * + * @param listFiles - Function that returns list of files in the folder + * @param readFile - Function to read file content + * @param readFileMetadata - Function to read file metadata + * @param apiName - Name of the backend API (for error messages) + * @param options - Optional pagination configuration + * @param options.page - 1-based page number (default: 1) + * @param options.pageSize - Number of entries per page (default: 100) + * @param options.pagination - Enable pagination (default: false for backward compatibility) + * + * @returns Promise resolving to array of entries + * + * @example + * // Without pagination (returns all entries) + * const entries = await entriesByFolder( + * listFiles, + * readFile, + * readFileMetadata, + * 'GitHub' + * ); + * + * @example + * // With pagination (returns page 2, 20 entries per page) + * const entries = await entriesByFolder( + * listFiles, + * readFile, + * readFileMetadata, + * 'GitHub', + * { page: 2, pageSize: 20, pagination: true } + * ); + */ export async function entriesByFolder( listFiles: () => Promise, readFile: ReadFile, readFileMetadata: ReadFileMetadata, apiName: string, + options?: { + page?: number; + pageSize?: number; + pagination?: boolean; + }, ) { const files = await listFiles(); + + // Apply pagination if enabled + if (options?.pagination) { + const page = options.page ?? 1; + const pageSize = options.pageSize ?? 100; + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedFiles = files.slice(startIndex, endIndex); + return fetchFiles(paginatedFiles, readFile, readFileMetadata, apiName); + } + + // Default: return all files (backward compatible) return fetchFiles(files, readFile, readFileMetadata, apiName); } diff --git a/packages/decap-cms-locales/src/en/index.js b/packages/decap-cms-locales/src/en/index.js index b41cd36de2fd..faa87f5657cf 100644 --- a/packages/decap-cms-locales/src/en/index.js +++ b/packages/decap-cms-locales/src/en/index.js @@ -57,6 +57,16 @@ const en = { longerLoading: 'This might take several minutes', noEntries: 'No Entries', }, + pagination: { + navigation: 'Entries pagination', + showing: 'Showing %{start}-%{end} of %{total}', + itemsPerPage: 'Items per page', + page: 'Page %{current} of %{total}', + first: 'First', + previous: 'Previous', + next: 'Next', + last: 'Last', + }, groups: { other: 'Other', negateLabel: 'Not %{label}', diff --git a/packages/decap-server/src/middlewares/joi/index.ts b/packages/decap-server/src/middlewares/joi/index.ts index 430816bc02b9..2d9fe958b582 100644 --- a/packages/decap-server/src/middlewares/joi/index.ts +++ b/packages/decap-server/src/middlewares/joi/index.ts @@ -61,6 +61,11 @@ export function defaultSchema({ path = requiredString } = {}) { folder: path, extension: requiredString, depth: requiredNumber, + options: Joi.object({ + page: Joi.number().optional(), + pageSize: Joi.number().optional(), + pagination: Joi.boolean().optional(), + }).optional(), }) .required(), }, diff --git a/packages/decap-server/src/middlewares/localFs/index.ts b/packages/decap-server/src/middlewares/localFs/index.ts index e1c265763d10..e743ac63f85d 100644 --- a/packages/decap-server/src/middlewares/localFs/index.ts +++ b/packages/decap-server/src/middlewares/localFs/index.ts @@ -41,13 +41,52 @@ export function localFsMiddleware({ repoPath, logger }: FsOptions) { } case 'entriesByFolder': { const payload = body.params as EntriesByFolderParams; - const { folder, extension, depth } = payload; - const entries = await listRepoFiles(repoPath, folder, extension, depth).then(files => - entriesFromFiles( - repoPath, - files.map(file => ({ path: file })), - ), + const { folder, extension, depth, options } = payload; + + const allFiles = await listRepoFiles(repoPath, folder, extension, depth); + + // Handle pagination if options are provided + let files = allFiles; + let cursor = undefined; + + if (options?.pagination && options.page && options.pageSize) { + const page = options.page; + const pageSize = options.pageSize; + const count = allFiles.length; + const pageCount = Math.ceil(count / pageSize); + + // Slice files for current page + const startIndex = (page - 1) * pageSize; + const endIndex = page * pageSize; + files = allFiles.slice(startIndex, endIndex); + + // Create cursor metadata + const actions = []; + if (page > 1) { + actions.push('prev', 'first'); + } + if (page < pageCount) { + actions.push('next', 'last'); + } + + cursor = { + actions, + meta: { page, count, pageSize, pageCount }, + data: { files: allFiles }, + }; + } + + const entries = await entriesFromFiles( + repoPath, + files.map(file => ({ path: file })), ); + + // Attach cursor if pagination is enabled + if (cursor) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (entries as any)[Symbol.for('cursor')] = cursor; + } + res.json(entries); break; } diff --git a/packages/decap-server/src/middlewares/localGit/index.ts b/packages/decap-server/src/middlewares/localGit/index.ts index a8765223ef1a..e3faf6b67567 100644 --- a/packages/decap-server/src/middlewares/localGit/index.ts +++ b/packages/decap-server/src/middlewares/localGit/index.ts @@ -201,15 +201,56 @@ export function localGitMiddleware({ repoPath, logger }: GitOptions) { switch (body.action) { case 'entriesByFolder': { const payload = body.params as EntriesByFolderParams; - const { folder, extension, depth } = payload; + const { folder, extension, depth, options } = payload; + + const allFiles = await runOnBranch(git, branch, () => + listRepoFiles(repoPath, folder, extension, depth), + ); + + // Handle pagination if options are provided + let files = allFiles; + let cursor = undefined; + + if (options?.pagination && options.page && options.pageSize) { + const page = options.page; + const pageSize = options.pageSize; + const count = allFiles.length; + const pageCount = Math.ceil(count / pageSize); + + // Slice files for current page + const startIndex = (page - 1) * pageSize; + const endIndex = page * pageSize; + files = allFiles.slice(startIndex, endIndex); + + // Create cursor metadata + const actions = []; + if (page > 1) { + actions.push('prev', 'first'); + } + if (page < pageCount) { + actions.push('next', 'last'); + } + + cursor = { + actions, + meta: { page, count, pageSize, pageCount }, + data: { files: allFiles }, + }; + } + const entries = await runOnBranch(git, branch, () => - listRepoFiles(repoPath, folder, extension, depth).then(files => - entriesFromFiles( - repoPath, - files.map(file => ({ path: file })), - ), + entriesFromFiles( + repoPath, + files.map(file => ({ path: file })), ), ); + + // Attach cursor if pagination is enabled + if (cursor) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (entries as any)[Symbol.for('cursor')] = cursor; + } + res.json(entries); break; } diff --git a/packages/decap-server/src/middlewares/types.ts b/packages/decap-server/src/middlewares/types.ts index be69316d9456..de69cd49acf2 100644 --- a/packages/decap-server/src/middlewares/types.ts +++ b/packages/decap-server/src/middlewares/types.ts @@ -6,6 +6,11 @@ export type EntriesByFolderParams = { folder: string; extension: string; depth: 1; + options?: { + page?: number; + pageSize?: number; + pagination?: boolean; + }; }; export type EntriesByFilesParams = {