Repository: decaporg/decap-cms (2025-06-06T12:04:20Z)

Issue:
List draft entry in list page (collection)
using editorial workflow, draft entries are not listed in page list. Would be nice to list them with some labels. So it'll be easier for simple users to see new pages

**Is your feature request related to a problem? Please describe.**
Add-hoc users are very confused, as this is not listed

**Describe the solution you'd like**
add draft label in collection list and details page

**Describe alternatives you've considered**
<!--
A clear and concise description of any alternative solutions or features you've considered.
-->
--

**Additional context**
<img width="995" alt="Знімок екрана 2020-12-04 о 17 30 29" src="https://user-images.githubusercontent.com/11796465/101182223-7ac8cf00-3656-11eb-91d1-99661da95ca9.png">




Discussion:
Hey  @PetroPavlenko how did you add this netlify success badge 👀 ? do you know if I can add github badge?
> Hey  @PetroPavlenko how did you add this netlify success badge 👀 ? do you know if I can add github badge?

Hi this was added as a build status banner with high z-index, that time
really nice one, do you have any example how did you do that?



Patch:
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 b898ff8bc35b..0ef11da57274 100644
--- a/packages/decap-cms-core/src/components/Collection/Entries/Entries.js
+++ b/packages/decap-cms-core/src/components/Collection/Entries/Entries.js
@@ -26,6 +26,9 @@ function Entries({
   handleCursorActions,
   t,
   page,
+  getWorkflowStatus,
+  getUnpublishedEntries,
+  filterTerm,
 }) {
   const loadingMessages = [
     t('collection.entries.loadingEntries'),
@@ -48,6 +51,9 @@ function Entries({
           cursor={cursor}
           handleCursorActions={handleCursorActions}
           page={page}
+          getWorkflowStatus={getWorkflowStatus}
+          getUnpublishedEntries={getUnpublishedEntries}
+          filterTerm={filterTerm}
         />
         {isFetching && page !== undefined && entries.size > 0 ? (
           <PaginationMessage>{t('collection.entries.loadingEntries')}</PaginationMessage>
@@ -68,6 +74,9 @@ Entries.propTypes = {
   cursor: PropTypes.any.isRequired,
   handleCursorActions: PropTypes.func.isRequired,
   t: PropTypes.func.isRequired,
+  getWorkflowStatus: PropTypes.func,
+  getUnpublishedEntries: PropTypes.func,
+  filterTerm: PropTypes.string,
 };
 
 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 cfe5e122d936..8847f3b7c242 100644
--- a/packages/decap-cms-core/src/components/Collection/Entries/EntriesCollection.js
+++ b/packages/decap-cms-core/src/components/Collection/Entries/EntriesCollection.js
@@ -12,12 +12,14 @@ import {
   loadEntries as actionLoadEntries,
   traverseCollectionCursor as actionTraverseCollectionCursor,
 } from '../../../actions/entries';
+import { loadUnpublishedEntries } from '../../../actions/editorialWorkflow';
 import {
   selectEntries,
   selectEntriesLoaded,
   selectIsFetching,
   selectGroups,
 } from '../../../reducers/entries';
+import { selectUnpublishedEntry, selectUnpublishedEntriesByStatus } from '../../../reducers';
 import { selectCollectionEntriesCursor } from '../../../reducers/cursors';
 import Entries from './Entries';
 
@@ -61,6 +63,7 @@ function withGroups(groups, entries, EntriesToRender, t) {
 export class EntriesCollection extends React.Component {
   static propTypes = {
     collection: ImmutablePropTypes.map.isRequired,
+    collections: ImmutablePropTypes.iterable,
     page: PropTypes.number,
     entries: ImmutablePropTypes.list,
     groups: PropTypes.array,
@@ -70,23 +73,57 @@ export class EntriesCollection extends React.Component {
     loadEntries: PropTypes.func.isRequired,
     traverseCollectionCursor: PropTypes.func.isRequired,
     entriesLoaded: PropTypes.bool,
+    loadUnpublishedEntries: PropTypes.func.isRequired,
+    unpublishedEntriesLoaded: PropTypes.bool,
+    isEditorialWorkflowEnabled: PropTypes.bool,
+    getWorkflowStatus: PropTypes.func.isRequired,
+    getUnpublishedEntries: PropTypes.func.isRequired,
   };
 
   componentDidMount() {
     // Manually validate PropTypes - React 19 breaking change
     PropTypes.checkPropTypes(EntriesCollection.propTypes, this.props, 'prop', 'EntriesCollection');
 
-    const { collection, entriesLoaded, loadEntries } = this.props;
+    const {
+      collection,
+      collections,
+      entriesLoaded,
+      loadEntries,
+      unpublishedEntriesLoaded,
+      loadUnpublishedEntries,
+      isEditorialWorkflowEnabled,
+    } = this.props;
+
     if (collection && !entriesLoaded) {
       loadEntries(collection);
     }
+
+    if (isEditorialWorkflowEnabled && !unpublishedEntriesLoaded) {
+      loadUnpublishedEntries(collections);
+    }
   }
 
   componentDidUpdate(prevProps) {
-    const { collection, entriesLoaded, loadEntries } = this.props;
+    const {
+      collection,
+      collections,
+      entriesLoaded,
+      loadEntries,
+      unpublishedEntriesLoaded,
+      loadUnpublishedEntries,
+      isEditorialWorkflowEnabled,
+    } = this.props;
+
     if (collection !== prevProps.collection && !entriesLoaded) {
       loadEntries(collection);
     }
+
+    if (
+      isEditorialWorkflowEnabled &&
+      (!unpublishedEntriesLoaded || collection !== prevProps.collection)
+    ) {
+      loadUnpublishedEntries(collections);
+    }
   }
 
   handleCursorActions = (cursor, action) => {
@@ -95,7 +132,19 @@ export class EntriesCollection extends React.Component {
   };
 
   render() {
-    const { collection, entries, groups, isFetching, viewStyle, cursor, page, t } = this.props;
+    const {
+      collection,
+      entries,
+      groups,
+      isFetching,
+      viewStyle,
+      cursor,
+      page,
+      t,
+      getWorkflowStatus,
+      getUnpublishedEntries,
+      filterTerm,
+    } = this.props;
 
     const EntriesToRender = ({ entries }) => {
       return (
@@ -108,6 +157,9 @@ export class EntriesCollection extends React.Component {
           cursor={cursor}
           handleCursorActions={partial(this.handleCursorActions, cursor)}
           page={page}
+          getWorkflowStatus={getWorkflowStatus}
+          getUnpublishedEntries={getUnpublishedEntries}
+          filterTerm={filterTerm}
         />
       );
     };
@@ -150,6 +202,8 @@ function mapStateToProps(state, ownProps) {
   const { collection, viewStyle, filterTerm } = ownProps;
   const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
 
+  const collections = state.collections;
+
   let entries = selectEntries(state.entries, collection);
   const groups = selectGroups(state.entries, collection);
 
@@ -168,12 +222,54 @@ function mapStateToProps(state, ownProps) {
   const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name'));
   const cursor = Cursor.create(rawCursor).clearData();
 
-  return { collection, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor };
+  const isEditorialWorkflowEnabled = state.config?.publish_mode === 'editorial_workflow';
+  const unpublishedEntriesLoaded = isEditorialWorkflowEnabled
+    ? !!state.editorialWorkflow?.getIn(['pages', 'ids'], false)
+    : true;
+
+  return {
+    collection,
+    collections,
+    page,
+    entries,
+    groups,
+    entriesLoaded,
+    isFetching,
+    viewStyle,
+    cursor,
+    unpublishedEntriesLoaded,
+    isEditorialWorkflowEnabled,
+    getWorkflowStatus: (collectionName, slug) => {
+      const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
+      return unpublishedEntry ? unpublishedEntry.get('status') : null;
+    },
+    getUnpublishedEntries: collectionName => {
+      if (!isEditorialWorkflowEnabled) return [];
+
+      const allStatuses = ['draft', 'pending_review', 'pending_publish'];
+      const unpublishedEntries = [];
+
+      allStatuses.forEach(statusKey => {
+        const entriesForStatus = selectUnpublishedEntriesByStatus(state, statusKey);
+        if (entriesForStatus) {
+          entriesForStatus.forEach(entry => {
+            if (entry.get('collection') === collectionName) {
+              const entryWithCollection = entry.set('collection', collectionName);
+              unpublishedEntries.push(entryWithCollection);
+            }
+          });
+        }
+      });
+
+      return unpublishedEntries;
+    },
+  };
 }
 
 const mapDispatchToProps = {
   loadEntries: actionLoadEntries,
   traverseCollectionCursor: actionTraverseCollectionCursor,
+  loadUnpublishedEntries: collections => loadUnpublishedEntries(collections),
 };
 
 const ConnectedEntriesCollection = connect(mapStateToProps, mapDispatchToProps)(EntriesCollection);
diff --git a/packages/decap-cms-core/src/components/Collection/Entries/EntryCard.js b/packages/decap-cms-core/src/components/Collection/Entries/EntryCard.js
index 01e0027d7069..99b4361201b1 100644
--- a/packages/decap-cms-core/src/components/Collection/Entries/EntryCard.js
+++ b/packages/decap-cms-core/src/components/Collection/Entries/EntryCard.js
@@ -3,6 +3,7 @@ import styled from '@emotion/styled';
 import { connect } from 'react-redux';
 import { Link } from 'react-router-dom';
 import { colors, colorsRaw, components, lengths, zIndex } from 'decap-cms-ui-default';
+import { translate } from 'react-polyglot';
 
 import { boundGetAsset } from '../../../actions/media';
 import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from '../../../constants/collectionViews';
@@ -56,10 +57,14 @@ const CollectionLabel = styled.h2`
 
 const ListCardTitle = styled.h2`
   margin-bottom: 0;
+  display: flex;
+  justify-content: space-between;
 `;
 
 const CardHeading = styled.h2`
   margin: 0 0 2px;
+  display: flex;
+  justify-content: space-between;
 `;
 
 const CardBody = styled.div`
@@ -89,6 +94,43 @@ const CardImage = styled.div`
   height: 150px;
 `;
 
+const TitleIcons = styled.div`
+  display: flex;
+  align-items: center;
+  gap: 8px;
+`;
+
+const WorkflowBadge = styled.span`
+  padding: 2px 6px;
+  border-radius: 3px;
+  font-size: 11px;
+  text-transform: uppercase;
+  background-color: ${props => {
+    switch (props.status) {
+      case 'draft':
+        return colors.statusDraftBackground;
+      case 'pending_review':
+        return colors.statusReviewBackground;
+      case 'pending_publish':
+        return colors.statusReadyBackground;
+      default:
+        return colors.background;
+    }
+  }};
+  color: ${props => {
+    switch (props.status) {
+      case 'draft':
+        return colors.statusDraftText;
+      case 'pending_review':
+        return colors.statusReviewText;
+      case 'pending_publish':
+        return colors.statusReadyText;
+      default:
+        return colors.text;
+    }
+  }};
+`;
+
 function EntryCard({
   path,
   summary,
@@ -96,14 +138,38 @@ function EntryCard({
   imageField,
   collectionLabel,
   viewStyle = VIEW_STYLE_LIST,
+  workflowStatus,
   getAsset,
+  t,
 }) {
+  function getStatusLabel(status) {
+    switch (status) {
+      case 'pending_review':
+        return t('editor.editorToolbar.inReview');
+      case 'pending_publish':
+        return t('editor.editorToolbar.ready');
+      case 'draft':
+        return t('editor.editorToolbar.draft');
+      default:
+        return status;
+    }
+  }
+
   if (viewStyle === VIEW_STYLE_LIST) {
     return (
       <ListCard>
         <ListCardLink to={path}>
           {collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
-          <ListCardTitle>{summary}</ListCardTitle>
+          <ListCardTitle>
+            {summary}
+            <TitleIcons>
+              {workflowStatus && (
+                <WorkflowBadge status={workflowStatus}>
+                  {getStatusLabel(workflowStatus)}
+                </WorkflowBadge>
+              )}
+            </TitleIcons>
+          </ListCardTitle>
         </ListCardLink>
       </ListCard>
     );
@@ -115,7 +181,16 @@ function EntryCard({
         <GridCardLink to={path}>
           <CardBody hasImage={image}>
             {collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
-            <CardHeading>{summary}</CardHeading>
+            <CardHeading>
+              {summary}
+              <TitleIcons>
+                {workflowStatus && (
+                  <WorkflowBadge status={workflowStatus}>
+                    {getStatusLabel(workflowStatus)}
+                  </WorkflowBadge>
+                )}
+              </TitleIcons>
+            </CardHeading>
           </CardBody>
           {image ? <CardImage src={getAsset(image, imageField).toString()} /> : null}
         </GridCardLink>
@@ -162,6 +237,10 @@ function mergeProps(stateProps, dispatchProps, ownProps) {
   };
 }
 
-const ConnectedEntryCard = connect(mapStateToProps, mapDispatchToProps, mergeProps)(EntryCard);
+const ConnectedEntryCard = connect(
+  mapStateToProps,
+  mapDispatchToProps,
+  mergeProps,
+)(translate()(EntryCard));
 
 export default ConnectedEntryCard;
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 cce388f5920e..c6d0e391457d 100644
--- a/packages/decap-cms-core/src/components/Collection/Entries/EntryListing.js
+++ b/packages/decap-cms-core/src/components/Collection/Entries/EntryListing.js
@@ -3,9 +3,10 @@ import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import styled from '@emotion/styled';
 import { Waypoint } from 'react-waypoint';
-import { Map } from 'immutable';
+import { Map, List } from 'immutable';
 
 import { selectFields, selectInferredField } from '../../../reducers/collections';
+import { filterNestedEntries } from './EntriesCollection';
 import EntryCard from './EntryCard';
 
 const CardsGrid = styled.ul`
@@ -17,7 +18,7 @@ const CardsGrid = styled.ul`
   margin-bottom: 16px;
 `;
 
-export default class EntryListing extends React.Component {
+class EntryListing extends React.Component {
   static propTypes = {
     collections: ImmutablePropTypes.iterable.isRequired,
     entries: ImmutablePropTypes.list,
@@ -25,6 +26,9 @@ export default class EntryListing extends React.Component {
     cursor: PropTypes.any.isRequired,
     handleCursorActions: PropTypes.func.isRequired,
     page: PropTypes.number,
+    getUnpublishedEntries: PropTypes.func.isRequired,
+    getWorkflowStatus: PropTypes.func.isRequired,
+    filterTerm: PropTypes.string,
   };
 
   componentDidMount() {
@@ -54,11 +58,58 @@ export default class EntryListing extends React.Component {
     return { titleField, descriptionField, imageField, remainingFields };
   };
 
+  getAllEntries = () => {
+    const { entries, collections, filterTerm } = this.props;
+    const collectionName = Map.isMap(collections) ? collections.get('name') : null;
+
+    if (!collectionName) {
+      return entries;
+    }
+
+    const unpublishedEntries = this.props.getUnpublishedEntries(collectionName);
+
+    if (!unpublishedEntries || unpublishedEntries.length === 0) {
+      return entries;
+    }
+
+    let unpublishedList = List(unpublishedEntries.map(entry => entry));
+
+    if (collections.has('nested') && filterTerm) {
+      const collectionFolder = collections.get('folder');
+      const subfolders = collections.get('nested').get('subfolders') !== false;
+
+      unpublishedList = filterNestedEntries(
+        filterTerm,
+        collectionFolder,
+        unpublishedList,
+        subfolders,
+      );
+    }
+
+    const publishedSlugs = entries.map(entry => entry.get('slug')).toSet();
+    const uniqueUnpublished = unpublishedList.filterNot(entry =>
+      publishedSlugs.has(entry.get('slug')),
+    );
+
+    return entries.concat(uniqueUnpublished);
+  };
+
   renderCardsForSingleCollection = () => {
-    const { collections, entries, viewStyle } = this.props;
+    const { collections, viewStyle } = this.props;
+    const allEntries = this.getAllEntries();
     const inferredFields = this.inferFields(collections);
     const entryCardProps = { collection: collections, inferredFields, viewStyle };
-    return entries.map((entry, idx) => <EntryCard {...entryCardProps} entry={entry} key={idx} />);
+
+    return allEntries.map((entry, idx) => {
+      const workflowStatus = this.props.getWorkflowStatus(
+        collections.get('name'),
+        entry.get('slug'),
+      );
+
+      return (
+        <EntryCard {...entryCardProps} entry={entry} workflowStatus={workflowStatus} key={idx} />
+      );
+    });
   };
 
   renderCardsForMultipleCollections = () => {
@@ -69,7 +120,14 @@ export default class EntryListing extends React.Component {
       const collection = collections.find(coll => coll.get('name') === collectionName);
       const collectionLabel = !isSingleCollectionInList && collection.get('label');
       const inferredFields = this.inferFields(collection);
-      const entryCardProps = { collection, entry, inferredFields, collectionLabel };
+      const workflowStatus = this.props.getWorkflowStatus(collectionName, entry.get('slug'));
+      const entryCardProps = {
+        collection,
+        entry,
+        inferredFields,
+        collectionLabel,
+        workflowStatus,
+      };
       return <EntryCard {...entryCardProps} key={idx} />;
     });
   };
@@ -89,3 +147,5 @@ export default class EntryListing extends React.Component {
     );
   }
 }
+
+export default EntryListing;


Test patch:
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 c1aedfa9c464..9d719a8248c0 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
@@ -14,6 +14,19 @@ jest.mock('../Entries', () => 'mock-entries');
 const middlewares = [];
 const mockStore = configureStore(middlewares);
 
+function createMockStore(collection, entriesArray, additionalState = {}) {
+  return mockStore({
+    entries: toEntriesState(collection, entriesArray),
+    cursors: fromJS({}),
+    config: fromJS({ publish_mode: 'simple' }),
+    collections: fromJS({ [collection.get('name')]: collection }),
+    editorialWorkflow: fromJS({
+      pages: { ids: [] },
+    }),
+    ...additionalState,
+  });
+}
+
 function renderWithRedux(component, { store } = {}) {
   function Wrapper({ children }) {
     return <Provider store={store}>{children}</Provider>;
@@ -70,10 +83,18 @@ describe('EntriesCollection', () => {
     t: jest.fn(),
     loadEntries: jest.fn(),
     traverseCollectionCursor: jest.fn(),
+    loadUnpublishedEntries: jest.fn(),
     isFetching: false,
     cursor: {},
     collection,
+    collections: fromJS({ pages: collection }),
+    entriesLoaded: true,
+    unpublishedEntriesLoaded: true,
+    isEditorialWorkflowEnabled: false,
+    getWorkflowStatus: jest.fn(),
+    getUnpublishedEntries: jest.fn(() => []),
   };
+
   it('should render with entries', () => {
     const entries = fromJS([{ slug: 'index' }]);
     const { asFragment } = render(<EntriesCollection {...props} entries={entries} />);
@@ -88,10 +109,7 @@ describe('EntriesCollection', () => {
       { slug: 'dir2/index', path: 'src/pages/dir2/index.md', data: { title: 'File 2' } },
     ];
 
-    const store = mockStore({
-      entries: toEntriesState(collection, entriesArray),
-      cursors: fromJS({}),
-    });
+    const store = createMockStore(collection, entriesArray);
 
     const { asFragment } = renderWithRedux(<ConnectedEntriesCollection collection={collection} />, {
       store,
@@ -109,18 +127,13 @@ describe('EntriesCollection', () => {
       { slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
     ];
 
-    const store = mockStore({
-      entries: toEntriesState(collection, entriesArray),
-      cursors: fromJS({}),
-    });
+    const store = createMockStore(collection, entriesArray);
 
     const { asFragment } = renderWithRedux(
       <ConnectedEntriesCollection
         collection={collection.set('nested', fromJS({ depth: 10, subfolders: false }))}
       />,
-      {
-        store,
-      },
+      { store },
     );
 
     expect(asFragment()).toMatchSnapshot();
@@ -135,19 +148,14 @@ describe('EntriesCollection', () => {
       { slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
     ];
 
-    const store = mockStore({
-      entries: toEntriesState(collection, entriesArray),
-      cursors: fromJS({}),
-    });
+    const store = createMockStore(collection, entriesArray);
 
     const { asFragment } = renderWithRedux(
       <ConnectedEntriesCollection
         collection={collection.set('nested', fromJS({ depth: 10, subfolders: false }))}
         filterTerm="dir3/dir4"
       />,
-      {
-        store,
-      },
+      { store },
     );
 
     expect(asFragment()).toMatchSnapshot();
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 3997449de5fe..71e9ec1b5572 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
@@ -29,6 +29,7 @@ exports[`EntriesCollection should render with applied filter term for nested col
     collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\", \\"nested\\": Map { \\"depth\\": 10, \\"subfolders\\": false } }"
     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"
   />
 </DocumentFragment>
 `;