diff --git a/.gitignore b/.gitignore index f1f4c9cfd15f..44f04aa17e62 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ coverage/ .temp/ storybook-static/ .nx +.claude/settings.local.json 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 50e8d15fd5f1..97d463a68252 100644 --- a/packages/decap-cms-core/src/components/Collection/Entries/Entries.js +++ b/packages/decap-cms-core/src/components/Collection/Entries/Entries.js @@ -30,6 +30,9 @@ function Entries({ getWorkflowStatus, getUnpublishedEntries, filterTerm, + sortFields, + showPublishedEntries = true, + showUnpublishedEntries = true, }) { const loadingMessages = [ t('collection.entries.loadingEntries'), @@ -37,12 +40,13 @@ function Entries({ t('collection.entries.longerLoading'), ]; - if (isFetching && page === undefined) { + if (showPublishedEntries && isFetching && page === undefined) { return {loadingMessages}; } - const hasEntries = (entries && entries.size > 0) || cursor?.actions?.has('append_next'); - if (hasEntries) { + const hasEntries = + showPublishedEntries && ((entries && entries.size > 0) || cursor?.actions?.has('append_next')); + if (hasEntries || !showPublishedEntries) { return ( <> - {isFetching && page !== undefined && entries.size > 0 ? ( + {showPublishedEntries && isFetching && page !== undefined && entries.size > 0 ? ( {t('collection.entries.loadingEntries')} ) : null} @@ -78,6 +85,9 @@ Entries.propTypes = { getWorkflowStatus: PropTypes.func, getUnpublishedEntries: PropTypes.func, filterTerm: PropTypes.string, + sortFields: PropTypes.array, + showPublishedEntries: PropTypes.bool, + showUnpublishedEntries: PropTypes.bool, }; 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..db8c9bfbc8fc 100644 --- a/packages/decap-cms-core/src/components/Collection/Entries/EntriesCollection.js +++ b/packages/decap-cms-core/src/components/Collection/Entries/EntriesCollection.js @@ -18,6 +18,7 @@ import { selectEntriesLoaded, selectIsFetching, selectGroups, + selectEntriesSortFields, } from '../../../reducers/entries'; import { selectUnpublishedEntry, selectUnpublishedEntriesByStatus } from '../../../reducers'; import { selectCollectionEntriesCursor } from '../../../reducers/cursors'; @@ -54,7 +55,10 @@ function withGroups(groups, entries, EntriesToRender, t) { return ( {title} - + ); }); @@ -144,9 +148,18 @@ export class EntriesCollection extends React.Component { getWorkflowStatus, getUnpublishedEntries, filterTerm, + sortFields, } = this.props; - const EntriesToRender = ({ entries }) => { + const EntriesToRender = ({ entries, showPublishedEntries, showUnpublishedEntries }) => { + const visibilityProps = {}; + if (showPublishedEntries !== undefined) { + visibilityProps.showPublishedEntries = showPublishedEntries; + } + if (showUnpublishedEntries !== undefined) { + visibilityProps.showUnpublishedEntries = showUnpublishedEntries; + } + return ( ); }; if (groups && groups.length > 0) { - return withGroups(groups, entries, EntriesToRender, t); + return ( + + {withGroups(groups, entries, EntriesToRender, t)} + + + ); } return ; @@ -206,6 +226,7 @@ function mapStateToProps(state, ownProps) { let entries = selectEntries(state.entries, collection); const groups = selectGroups(state.entries, collection); + const sortFields = selectEntriesSortFields(state.entries, collection.get('name')); if (collection.has('nested')) { const collectionFolder = collection.get('folder'); @@ -233,6 +254,7 @@ function mapStateToProps(state, ownProps) { page, entries, groups, + sortFields, entriesLoaded, isFetching, viewStyle, 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..b28b2102d19c 100644 --- a/packages/decap-cms-core/src/components/Collection/Entries/EntryListing.js +++ b/packages/decap-cms-core/src/components/Collection/Entries/EntryListing.js @@ -3,10 +3,18 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import styled from '@emotion/styled'; import { Waypoint } from 'react-waypoint'; -import { Map, List } from 'immutable'; +import { Map, List, fromJS } from 'immutable'; +import { translate } from 'react-polyglot'; +import orderBy from 'lodash/orderBy'; +import { colors, lengths } from 'decap-cms-ui-default'; -import { selectFields, selectInferredField } from '../../../reducers/collections'; +import { + selectFields, + selectInferredField, + selectSortDataPath, +} from '../../../reducers/collections'; import { filterNestedEntries } from './EntriesCollection'; +import { SortDirection } from '../../../types/redux'; import EntryCard from './EntryCard'; const CardsGrid = styled.ul` @@ -18,6 +26,20 @@ const CardsGrid = styled.ul` margin-bottom: 16px; `; +const SectionSeparator = styled.div` + width: ${lengths.topCardWidth}; + margin: 24px 0 16px 12px; + padding-top: 16px; + border-top: 2px solid ${colors.textFieldBorder}; +`; + +const SectionHeading = styled.p` + font-size: 16px; + font-weight: 600; + color: ${colors.textLead}; + margin: 0 0 8px; +`; + class EntryListing extends React.Component { static propTypes = { collections: ImmutablePropTypes.iterable.isRequired, @@ -29,6 +51,15 @@ class EntryListing extends React.Component { getUnpublishedEntries: PropTypes.func.isRequired, getWorkflowStatus: PropTypes.func.isRequired, filterTerm: PropTypes.string, + sortFields: PropTypes.array, + showPublishedEntries: PropTypes.bool, + showUnpublishedEntries: PropTypes.bool, + t: PropTypes.func.isRequired, + }; + + static defaultProps = { + showPublishedEntries: true, + showUnpublishedEntries: true, }; componentDidMount() { @@ -58,18 +89,30 @@ class EntryListing extends React.Component { return { titleField, descriptionField, imageField, remainingFields }; }; - getAllEntries = () => { - const { entries, collections, filterTerm } = this.props; + sortEntries = (entries, sortFields, collections) => { + if (!sortFields || sortFields.length === 0) { + return entries; + } + + const keys = sortFields.map(v => selectSortDataPath(collections, v.get('key'))); + const orders = sortFields.map(v => + v.get('direction') === SortDirection.Ascending ? 'asc' : 'desc', + ); + return fromJS(orderBy(entries.toJS(), keys, orders)); + }; + + getUnpublishedEntriesList = () => { + const { entries, collections, filterTerm, sortFields } = this.props; const collectionName = Map.isMap(collections) ? collections.get('name') : null; if (!collectionName) { - return entries; + return List(); } const unpublishedEntries = this.props.getUnpublishedEntries(collectionName); if (!unpublishedEntries || unpublishedEntries.length === 0) { - return entries; + return List(); } let unpublishedList = List(unpublishedEntries.map(entry => entry)); @@ -91,31 +134,91 @@ class EntryListing extends React.Component { publishedSlugs.has(entry.get('slug')), ); - return entries.concat(uniqueUnpublished); + return this.sortEntries(uniqueUnpublished, sortFields, collections); }; renderCardsForSingleCollection = () => { - const { collections, viewStyle } = this.props; - const allEntries = this.getAllEntries(); + const { + collections, + viewStyle, + entries, + page, + t, + showPublishedEntries, + showUnpublishedEntries, + } = this.props; const inferredFields = this.inferFields(collections); const entryCardProps = { collection: collections, inferredFields, viewStyle }; - return allEntries.map((entry, idx) => { + const publishedCards = showPublishedEntries + ? entries.map((entry, idx) => { + const workflowStatus = this.props.getWorkflowStatus( + collections.get('name'), + entry.get('slug'), + ); + + return ( + + ); + }) + : List(); + + const unpublishedEntries = showUnpublishedEntries ? this.getUnpublishedEntriesList() : List(); + + if (unpublishedEntries.size === 0) { + if (!showPublishedEntries) { + return null; + } + + return ( + + {publishedCards} + {this.hasMore() && } + + ); + } + + const unpublishedCards = unpublishedEntries.map((entry, idx) => { const workflowStatus = this.props.getWorkflowStatus( collections.get('name'), entry.get('slug'), ); return ( - + ); }); + + return ( + + {showPublishedEntries && ( + + {publishedCards} + {this.hasMore() && } + + )} + + {t('collection.entries.unpublishedHeader')} + + {unpublishedCards} + + ); }; renderCardsForMultipleCollections = () => { - const { collections, entries } = this.props; + const { collections, entries, page } = this.props; const isSingleCollectionInList = collections.size === 1; - return entries.map((entry, idx) => { + const entryCards = entries.map((entry, idx) => { const collectionName = entry.get('collection'); const collection = collections.find(coll => coll.get('name') === collectionName); const collectionLabel = !isSingleCollectionInList && collection.get('label'); @@ -130,22 +233,26 @@ class EntryListing extends React.Component { }; return ; }); + + return ( + + {entryCards} + {this.hasMore() && } + + ); }; render() { - const { collections, page } = this.props; + const { collections } = this.props; return (
- - {Map.isMap(collections) - ? this.renderCardsForSingleCollection() - : this.renderCardsForMultipleCollections()} - {this.hasMore() && } - + {Map.isMap(collections) + ? this.renderCardsForSingleCollection() + : this.renderCardsForMultipleCollections()}
); } } -export default EntryListing; +export default translate()(EntryListing); 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..8ef282154b15 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 @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { fromJS } from 'immutable'; +import { Set, fromJS } from 'immutable'; import configureStore from 'redux-mock-store'; import { Provider } from 'react-redux'; @@ -8,8 +8,12 @@ import ConnectedEntriesCollection, { EntriesCollection, filterNestedEntries, } from '../EntriesCollection'; +import Entries from '../Entries'; -jest.mock('../Entries', () => 'mock-entries'); +jest.mock('../Entries', () => { + const React = require('react'); + return jest.fn(props => ); +}); const middlewares = []; const mockStore = configureStore(middlewares); @@ -102,6 +106,31 @@ describe('EntriesCollection', () => { expect(asFragment()).toMatchSnapshot(); }); + it('should render unpublished entries once when entries are grouped', () => { + Entries.mockClear(); + + const entries = fromJS([ + { slug: 'one', path: 'src/pages/one.md' }, + { slug: 'two', path: 'src/pages/two.md' }, + ]); + const groups = [ + { id: 'Groupone', label: 'Group', value: 'one', paths: Set(['src/pages/one.md']) }, + { id: 'Grouptwo', label: 'Group', value: 'two', paths: Set(['src/pages/two.md']) }, + ]; + + const { container } = render( + , + ); + const renderedEntries = container.querySelectorAll('mock-entries'); + + expect(renderedEntries).toHaveLength(3); + expect(Entries).toHaveBeenCalledTimes(3); + expect(Entries.mock.calls[0][0].showUnpublishedEntries).toBe(false); + expect(Entries.mock.calls[1][0].showUnpublishedEntries).toBe(false); + expect(Entries.mock.calls[2][0].showPublishedEntries).toBe(false); + expect(Entries.mock.calls[2][0].entries).toBe(entries); + }); + it('should render connected component', () => { const entriesArray = [ { slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } }, 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..00b9bdb26969 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 @@ -7,6 +7,7 @@ exports[`EntriesCollection should render connected component 1`] = ` collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\" }" cursor="[object Object]" entries="List [ Map { \\"slug\\": \\"index\\", \\"path\\": \\"src/pages/index.md\\", \\"data\\": Map { \\"title\\": \\"Root\\" } }, Map { \\"slug\\": \\"dir1/index\\", \\"path\\": \\"src/pages/dir1/index.md\\", \\"data\\": Map { \\"title\\": \\"File 1\\" } }, Map { \\"slug\\": \\"dir2/index\\", \\"path\\": \\"src/pages/dir2/index.md\\", \\"data\\": Map { \\"title\\": \\"File 2\\" } } ]" + sortfields="" /> `; @@ -18,6 +19,7 @@ exports[`EntriesCollection should render show only immediate children for nested collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\", \\"nested\\": Map { \\"depth\\": 10, \\"subfolders\\": false } }" cursor="[object Object]" entries="List [ Map { \\"slug\\": \\"index\\", \\"path\\": \\"src/pages/index.md\\", \\"data\\": Map { \\"title\\": \\"Root\\" } } ]" + sortfields="" /> `; @@ -30,6 +32,7 @@ exports[`EntriesCollection should render with applied filter term for nested col cursor="[object Object]" entries="List [ Map { \\"slug\\": \\"dir3/dir4/index\\", \\"path\\": \\"src/pages/dir3/dir4/index.md\\", \\"data\\": Map { \\"title\\": \\"File 4\\" } } ]" filterterm="dir3/dir4" + sortfields="" /> `; diff --git a/packages/decap-cms-locales/src/en/index.js b/packages/decap-cms-locales/src/en/index.js index eea5209a5878..ebb49934b363 100644 --- a/packages/decap-cms-locales/src/en/index.js +++ b/packages/decap-cms-locales/src/en/index.js @@ -59,6 +59,7 @@ const en = { cachingEntries: 'Caching Entries...', longerLoading: 'This might take several minutes', noEntries: 'No Entries', + unpublishedHeader: 'Unpublished Entries', }, groups: { other: 'Other',