From c28cc0c9e7c7bc4bed07c02dfb869b2dedab9aab Mon Sep 17 00:00:00 2001 From: Shashank Bairy R Date: Sun, 24 May 2020 17:37:08 +0000 Subject: [PATCH] feat: add filter to collection view (#3741) --- cypress/integration/view_filters_spec.js | 105 ++++++++++++++++ cypress/support/commands.js | 8 +- dev-test/backends/test/config.yml | 12 ++ dev-test/backends/test/index.html | 2 +- dev-test/config.yml | 11 ++ dev-test/index.html | 2 +- .../netlify-cms-core/src/actions/config.js | 11 ++ .../netlify-cms-core/src/actions/entries.ts | 77 +++++++++--- .../src/components/Collection/Collection.js | 22 +++- .../Collection/CollectionControls.js | 37 +++--- .../Collection/Entries/EntriesCollection.js | 2 +- .../components/Collection/FilterControl.js | 62 ++++++++++ .../src/components/Collection/SortControl.js | 24 +++- .../src/constants/configSchema.js | 23 ++++ .../src/reducers/__tests__/entries.spec.js | 115 ++++++++++++++++++ .../src/reducers/collections.ts | 6 + .../netlify-cms-core/src/reducers/entries.ts | 93 ++++++++++++-- .../netlify-cms-core/src/reducers/index.ts | 4 +- packages/netlify-cms-core/src/types/redux.ts | 26 +++- packages/netlify-cms-locales/src/en/index.js | 1 + .../netlify-cms-ui-default/src/Dropdown.js | 39 +++++- packages/netlify-cms-ui-default/src/index.js | 9 +- website/content/docs/configuration-options.md | 24 +++- 23 files changed, 652 insertions(+), 63 deletions(-) create mode 100644 cypress/integration/view_filters_spec.js create mode 100644 packages/netlify-cms-core/src/components/Collection/FilterControl.js diff --git a/cypress/integration/view_filters_spec.js b/cypress/integration/view_filters_spec.js new file mode 100644 index 00000000..cdcf1f46 --- /dev/null +++ b/cypress/integration/view_filters_spec.js @@ -0,0 +1,105 @@ +import { login } from '../utils/steps'; + +const filter = term => { + cy.get('[class*=FilterButton]').click(); + cy.contains(term).click(); + cy.contains('Contents').click(); +}; +const assertEntriesCount = count => { + cy.get('[class*=ListCardLink]').should('have.length', count); +}; + +const assertInEntries = text => { + cy.get('[class*=ListCardLink]').within(() => { + cy.contains('h2', text); + }); +}; + +const assertNotInEntries = text => { + cy.get('[class*=ListCardLink]').within(() => { + cy.contains('h2', text).should('not.exist'); + }); +}; + +describe('View Filter', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + beforeEach(() => { + login(); + }); + + it('can apply string filter', () => { + // enable filter + filter('Posts With Index'); + + assertEntriesCount(20); + for (let i = 1; i <= 20; i++) { + assertInEntries(`This is post # ${i} --`); + } + assertNotInEntries('This is a YAML front matter post'); + assertNotInEntries('This is a JSON front matter post'); + assertNotInEntries('This is a TOML front matter post'); + + // disable filter + filter('Posts With Index'); + assertEntriesCount(23); + for (let i = 1; i <= 20; i++) { + assertInEntries(`This is post # ${i} --`); + } + assertInEntries('This is a YAML front matter post'); + assertInEntries('This is a JSON front matter post'); + assertInEntries('This is a TOML front matter post'); + }); + + it('can apply boolean filter', () => { + // enable filter + filter('Drafts'); + + assertEntriesCount(10); + for (let i = 1; i <= 20; i++) { + const draft = i % 2 === 0; + if (draft) { + assertInEntries(`This is post # ${i} --`); + } else { + assertNotInEntries(`This is post # ${i} --`); + } + } + assertNotInEntries('This is a YAML front matter post'); + assertNotInEntries('This is a JSON front matter post'); + assertNotInEntries('This is a TOML front matter post'); + + // disable filter + filter('Drafts'); + assertEntriesCount(23); + for (let i = 1; i <= 20; i++) { + assertInEntries(`This is post # ${i} --`); + } + assertInEntries('This is a YAML front matter post'); + assertInEntries('This is a JSON front matter post'); + assertInEntries('This is a TOML front matter post'); + }); + + it('can apply multiple filters', () => { + // enable filter + filter('Posts Without Index'); + + assertEntriesCount(3); + + assertInEntries('This is a YAML front matter post'); + assertInEntries('This is a JSON front matter post'); + assertInEntries('This is a TOML front matter post'); + + filter('Drafts'); + + assertEntriesCount(0); + + cy.contains('div', 'No Entries'); + }); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 6dbb9626..e32a4845 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -275,9 +275,11 @@ Cypress.Commands.add('insertEditorComponent', title => { }); Cypress.Commands.add('clickModeToggle', () => { - cy.get('button[role="switch"]') - .click() - .focused(); + cy.get('.cms-editor-visual').within(() => { + cy.get('button[role="switch"]') + .click() + .focused(); + }); }); [['insertCodeBlock', 'Code Block']].forEach(([commandName, componentTitle]) => { diff --git a/dev-test/backends/test/config.yml b/dev-test/backends/test/config.yml index 26f27a07..4f0307df 100644 --- a/dev-test/backends/test/config.yml +++ b/dev-test/backends/test/config.yml @@ -17,8 +17,19 @@ 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 + view_filters: + - label: Posts With Index + field: title + pattern: 'This is post #' + - label: Posts Without Index + field: title + pattern: front matter post + - label: Drafts + field: draft + pattern: true fields: # The fields each document in this collection have - { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' } + - { label: 'Draft', name: 'draft', widget: 'boolean', default: false } - { label: 'Publish Date', name: 'date', @@ -124,6 +135,7 @@ collections: # A list of collections the CMS should be able to edit - label: 'Object' name: 'object' widget: 'object' + collapsed: true fields: - label: 'Related Post' name: 'post' diff --git a/dev-test/backends/test/index.html b/dev-test/backends/test/index.html index 06ad4013..5b86414f 100644 --- a/dev-test/backends/test/index.html +++ b/dev-test/backends/test/index.html @@ -59,7 +59,7 @@ var slug = dateString + "-post-number-" + i + ".md"; window.repoFiles._posts[slug] = { - content: "---\ntitle: \"This is post # " + i + "\"\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# The post is number " + i + "\n\nAnd this is yet another identical post body" + content: "---\ntitle: \"This is post # " + i + `\"\ndraft: ${i % 2 === 0}` + "\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# The post is number " + i + "\n\nAnd this is yet another identical post body" } } diff --git a/dev-test/config.yml b/dev-test/config.yml index 3a7ebac4..4f0307df 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -17,8 +17,19 @@ 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 + view_filters: + - label: Posts With Index + field: title + pattern: 'This is post #' + - label: Posts Without Index + field: title + pattern: front matter post + - label: Drafts + field: draft + pattern: true fields: # The fields each document in this collection have - { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' } + - { label: 'Draft', name: 'draft', widget: 'boolean', default: false } - { label: 'Publish Date', name: 'date', diff --git a/dev-test/index.html b/dev-test/index.html index 06ad4013..5b86414f 100644 --- a/dev-test/index.html +++ b/dev-test/index.html @@ -59,7 +59,7 @@ var slug = dateString + "-post-number-" + i + ".md"; window.repoFiles._posts[slug] = { - content: "---\ntitle: \"This is post # " + i + "\"\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# The post is number " + i + "\n\nAnd this is yet another identical post body" + content: "---\ntitle: \"This is post # " + i + `\"\ndraft: ${i % 2 === 0}` + "\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# The post is number " + i + "\n\nAnd this is yet another identical post body" } } diff --git a/packages/netlify-cms-core/src/actions/config.js b/packages/netlify-cms-core/src/actions/config.js index 37d715f2..9470dcb3 100644 --- a/packages/netlify-cms-core/src/actions/config.js +++ b/packages/netlify-cms-core/src/actions/config.js @@ -107,6 +107,17 @@ export function applyDefaults(config) { collection = collection.set('sortableFields', fromJS(defaultSortable)); } + if (!collection.has('view_filters')) { + collection = collection.set('view_filters', fromJS([])); + } else { + collection = collection.set( + 'view_filters', + collection + .get('view_filters') + .map(v => v.set('id', `${v.get('field')}__${v.get('pattern')}`)), + ); + } + return collection; }), ); diff --git a/packages/netlify-cms-core/src/actions/entries.ts b/packages/netlify-cms-core/src/actions/entries.ts index d4bb725e..55a178c7 100644 --- a/packages/netlify-cms-core/src/actions/entries.ts +++ b/packages/netlify-cms-core/src/actions/entries.ts @@ -1,11 +1,11 @@ import { fromJS, List, Map, Set } from 'immutable'; -import { isEqual, orderBy } from 'lodash'; +import { isEqual } from 'lodash'; import { actions as notifActions } from 'redux-notifications'; import { serializeValues } from '../lib/serializeEntryValues'; import { currentBackend, Backend } from '../backend'; import { getIntegrationProvider } from '../integrations'; import { selectIntegration, selectPublishedSlugs } from '../reducers'; -import { selectFields, updateFieldByKey, selectSortDataPath } from '../reducers/collections'; +import { selectFields, updateFieldByKey } from '../reducers/collections'; import { selectCollectionEntriesCursor } from '../reducers/cursors'; import { Cursor, ImplementationMediaFile } from 'netlify-cms-lib-util'; import { createEntry, EntryValue } from '../valueObjects/Entry'; @@ -19,6 +19,7 @@ import { EntryFields, EntryField, SortDirection, + ViewFilter, } from '../types/redux'; import { ThunkDispatch } from 'redux-thunk'; @@ -44,6 +45,10 @@ export const SORT_ENTRIES_REQUEST = 'SORT_ENTRIES_REQUEST'; export const SORT_ENTRIES_SUCCESS = 'SORT_ENTRIES_SUCCESS'; export const SORT_ENTRIES_FAILURE = 'SORT_ENTRIES_FAILURE'; +export const FILTER_ENTRIES_REQUEST = 'FILTER_ENTRIES_REQUEST'; +export const FILTER_ENTRIES_SUCCESS = 'FILTER_ENTRIES_SUCCESS'; +export const FILTER_ENTRIES_FAILURE = 'FILTER_ENTRIES_FAILURE'; + export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY'; export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY'; export const DRAFT_DISCARD = 'DRAFT_DISCARD'; @@ -137,6 +142,16 @@ export function entriesFailed(collection: Collection, error: Error) { }; } +const getAllEntries = async (state: State, collection: Collection) => { + const backend = currentBackend(state.config); + const integration = selectIntegration(state, collection.get('name'), 'listEntries'); + const provider: Backend = integration + ? getIntegrationProvider(state.integrations, backend.getToken, integration) + : backend; + const entries = await provider.listAllEntries(collection); + return entries; +}; + export function sortByField( collection: Collection, key: string, @@ -144,8 +159,6 @@ export function sortByField( ) { return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); - const backend = currentBackend(state.config); - // if we're already fetching we update the sort key, but skip loading entries const isFetching = selectIsFetching(state.entries, collection.get('name')); dispatch({ @@ -161,22 +174,7 @@ export function sortByField( } try { - const integration = selectIntegration(state, collection.get('name'), 'listEntries'); - const provider: Backend = integration - ? getIntegrationProvider(state.integrations, backend.getToken, integration) - : backend; - - let entries = await provider.listAllEntries(collection); - - const sortFields = selectEntriesSortFields(getState().entries, collection.get('name')); - if (sortFields && sortFields.length > 0) { - const keys = sortFields.map(v => selectSortDataPath(collection, v.get('key'))); - const orders = sortFields.map(v => - v.get('direction') === SortDirection.Ascending ? 'asc' : 'desc', - ); - entries = orderBy(entries, keys, orders); - } - + const entries = await getAllEntries(state, collection); dispatch({ type: SORT_ENTRIES_SUCCESS, payload: { @@ -200,6 +198,45 @@ export function sortByField( }; } +export function filterByField(collection: Collection, filter: ViewFilter) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + 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')); + dispatch({ + type: FILTER_ENTRIES_REQUEST, + payload: { + collection: collection.get('name'), + filter, + }, + }); + if (isFetching) { + return; + } + + try { + const entries = await getAllEntries(state, collection); + dispatch({ + type: FILTER_ENTRIES_SUCCESS, + payload: { + collection: collection.get('name'), + filter, + entries, + }, + }); + } catch (error) { + dispatch({ + type: FILTER_ENTRIES_FAILURE, + payload: { + collection: collection.get('name'), + filter, + error, + }, + }); + } + }; +} + export function entryPersisting(collection: Collection, entry: EntryMap) { return { type: ENTRY_PERSIST_REQUEST, diff --git a/packages/netlify-cms-core/src/components/Collection/Collection.js b/packages/netlify-cms-core/src/components/Collection/Collection.js index 1218093e..9ac4f993 100644 --- a/packages/netlify-cms-core/src/components/Collection/Collection.js +++ b/packages/netlify-cms-core/src/components/Collection/Collection.js @@ -11,10 +11,10 @@ import CollectionTop from './CollectionTop'; import EntriesCollection from './Entries/EntriesCollection'; import EntriesSearch from './Entries/EntriesSearch'; import CollectionControls from './CollectionControls'; -import { sortByField } from 'Actions/entries'; -import { selectSortableFields } from 'Reducers/collections'; -import { selectEntriesSort } from 'Reducers/entries'; -import { VIEW_STYLE_LIST } from 'Constants/collectionViews'; +import { sortByField, filterByField } from '../../actions/entries'; +import { selectSortableFields, selectViewFilters } from '../../reducers/collections'; +import { selectEntriesSort, selectEntriesFilter } from '../../reducers/entries'; +import { VIEW_STYLE_LIST } from '../../constants/collectionViews'; const CollectionContainer = styled.div` margin: ${lengths.pageMargin}; @@ -82,7 +82,10 @@ class Collection extends React.Component { sortableFields, onSortClick, sort, + viewFilters, t, + onFilterClick, + filter, } = this.props; const newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : ''; @@ -107,12 +110,15 @@ class Collection extends React.Component { <> )} @@ -130,6 +136,8 @@ function mapStateToProps(state, ownProps) { const collection = name ? collections.get(name) : collections.first(); const sort = selectEntriesSort(state.entries, collection.get('name')); const sortableFields = selectSortableFields(collection, t); + const viewFilters = selectViewFilters(collection); + const filter = selectEntriesFilter(state.entries, collection.get('name')); return { collection, @@ -139,11 +147,14 @@ function mapStateToProps(state, ownProps) { searchTerm, sort, sortableFields, + viewFilters, + filter, }; } const mapDispatchToProps = { sortByField, + filterByField, }; const mergeProps = (stateProps, dispatchProps, ownProps) => { @@ -152,6 +163,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { ...ownProps, onSortClick: (key, direction) => dispatchProps.sortByField(stateProps.collection, key, direction), + onFilterClick: filter => dispatchProps.filterByField(stateProps.collection, filter), }; }; diff --git a/packages/netlify-cms-core/src/components/Collection/CollectionControls.js b/packages/netlify-cms-core/src/components/Collection/CollectionControls.js index e2c2c950..05e522a7 100644 --- a/packages/netlify-cms-core/src/components/Collection/CollectionControls.js +++ b/packages/netlify-cms-core/src/components/Collection/CollectionControls.js @@ -2,6 +2,7 @@ import React from 'react'; import styled from '@emotion/styled'; import ViewStyleControl from './ViewStyleControl'; import SortControl from './SortControl'; +import FilterControl from './FilterControl'; import { lengths } from 'netlify-cms-ui-default'; const CollectionControlsContainer = styled.div` @@ -18,24 +19,32 @@ const CollectionControlsContainer = styled.div` `; const CollectionControls = ({ - collection, viewStyle, onChangeViewStyle, sortableFields, onSortClick, sort, -}) => ( - - - {sortableFields.length > 0 && ( - - )} - -); + viewFilters, + onFilterClick, + t, + filter, +}) => { + return ( + + + {viewFilters.length > 0 && ( + + )} + {sortableFields.length > 0 && ( + + )} + + ); +}; export default CollectionControls; diff --git a/packages/netlify-cms-core/src/components/Collection/Entries/EntriesCollection.js b/packages/netlify-cms-core/src/components/Collection/Entries/EntriesCollection.js index bc92daec..44cabf49 100644 --- a/packages/netlify-cms-core/src/components/Collection/Entries/EntriesCollection.js +++ b/packages/netlify-cms-core/src/components/Collection/Entries/EntriesCollection.js @@ -66,7 +66,7 @@ function mapStateToProps(state, ownProps) { const { collection, viewStyle } = ownProps; const page = state.entries.getIn(['pages', collection.get('name'), 'page']); - const entries = selectEntries(state.entries, collection.get('name')); + const entries = selectEntries(state.entries, collection); const entriesLoaded = selectEntriesLoaded(state.entries, collection.get('name')); const isFetching = selectIsFetching(state.entries, collection.get('name')); diff --git a/packages/netlify-cms-core/src/components/Collection/FilterControl.js b/packages/netlify-cms-core/src/components/Collection/FilterControl.js new file mode 100644 index 00000000..c1d20e10 --- /dev/null +++ b/packages/netlify-cms-core/src/components/Collection/FilterControl.js @@ -0,0 +1,62 @@ +import React from 'react'; +import { css } from '@emotion/core'; +import styled from '@emotion/styled'; +import { translate } from 'react-polyglot'; +import { + buttons, + Dropdown, + DropdownCheckedItem, + StyledDropdownButton, + colors, +} from 'netlify-cms-ui-default'; + +const FilterButton = styled(StyledDropdownButton)` + ${buttons.button}; + ${buttons.medium}; + ${buttons.grayText}; + font-size: 14px; + + &:after { + top: 11px; + } +`; + +const FilterControl = ({ viewFilters, t, onFilterClick, filter }) => { + const hasActiveFilter = filter + ?.valueSeq() + .toJS() + .some(f => f.active === true); + + return ( + { + return ( + + {t('collection.collectionTop.filterBy')} + + ); + }} + closeOnSelection={false} + dropdownTopOverlap="30px" + dropdownPosition="left" + > + {viewFilters.map(viewFilter => { + return ( + onFilterClick(viewFilter)} + /> + ); + })} + + ); +}; + +export default translate()(FilterControl); diff --git a/packages/netlify-cms-core/src/components/Collection/SortControl.js b/packages/netlify-cms-core/src/components/Collection/SortControl.js index 6f4ce781..3b1dbfef 100644 --- a/packages/netlify-cms-core/src/components/Collection/SortControl.js +++ b/packages/netlify-cms-core/src/components/Collection/SortControl.js @@ -1,7 +1,14 @@ import React from 'react'; +import { css } from '@emotion/core'; import styled from '@emotion/styled'; import { translate } from 'react-polyglot'; -import { buttons, Dropdown, DropdownItem, StyledDropdownButton } from 'netlify-cms-ui-default'; +import { + buttons, + Dropdown, + DropdownItem, + StyledDropdownButton, + colors, +} from 'netlify-cms-ui-default'; import { SortDirection } from '../../types/redux'; const SortButton = styled(StyledDropdownButton)` @@ -40,9 +47,22 @@ const sortIconDirections = { }; const SortControl = ({ t, fields, onSortClick, sort }) => { + const hasActiveSort = sort + ?.valueSeq() + .toJS() + .some(s => s.direction !== SortDirection.None); + return ( {t('collection.collectionTop.sortBy')}} + renderButton={() => ( + + {t('collection.collectionTop.sortBy')} + + )} closeOnSelection={false} dropdownTopOverlap="30px" dropdownWidth="160px" diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index 90edb03e..caf226d0 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -21,6 +21,28 @@ const fieldsConfig = { }, }; +const viewFilters = { + type: 'array', + minItems: 1, + items: { + type: 'object', + properties: { + label: { type: 'string' }, + field: { type: 'string' }, + pattern: { + oneOf: [ + { type: 'boolean' }, + { + type: 'string', + }, + ], + }, + }, + additionalProperties: false, + required: ['label', 'field', 'pattern'], + }, +}; + /** * The schema had to be wrapped in a function to * fix a circular dependency problem for WebPack, @@ -142,6 +164,7 @@ const getConfigSchema = () => ({ type: 'string', }, }, + view_filters: viewFilters, }, required: ['name', 'label'], oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }], diff --git a/packages/netlify-cms-core/src/reducers/__tests__/entries.spec.js b/packages/netlify-cms-core/src/reducers/__tests__/entries.spec.js index 27817ecb..c94457cf 100644 --- a/packages/netlify-cms-core/src/reducers/__tests__/entries.spec.js +++ b/packages/netlify-cms-core/src/reducers/__tests__/entries.spec.js @@ -4,6 +4,7 @@ import reducer, { selectMediaFolder, selectMediaFilePath, selectMediaFilePublicPath, + selectEntries, } from '../entries'; const initialState = OrderedMap({ @@ -559,4 +560,118 @@ describe('entries', () => { ).toBe('/images/image.png'); }); }); + + describe('selectEntries', () => { + it('should return all entries', () => { + const state = fromJS({ + entities: { + 'posts.1': { slug: '1' }, + 'posts.2': { slug: '2' }, + 'posts.3': { slug: '3' }, + 'posts.4': { slug: '4' }, + }, + pages: { posts: { ids: ['1', '2', '3', '4'] } }, + }); + const collection = fromJS({ + name: 'posts', + }); + + expect(selectEntries(state, collection)).toEqual( + fromJS([{ slug: '1' }, { slug: '2' }, { slug: '3' }, { slug: '4' }]), + ); + }); + }); + + it('should return sorted entries entries by field', () => { + 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' } }, + }, + pages: { posts: { ids: ['1', '2', '3', '4'] } }, + sort: { posts: { title: { key: 'title', direction: 'Descending' } } }, + }); + const collection = fromJS({ + name: 'posts', + }); + + expect(selectEntries(state, collection)).toEqual( + fromJS([ + { slug: '4', data: { title: '4' } }, + { slug: '3', data: { title: '3' } }, + { slug: '2', data: { title: '2' } }, + { slug: '1', data: { title: '1' } }, + ]), + ); + }); + + it('should return sorted entries entries by nested field', () => { + const state = fromJS({ + entities: { + 'posts.1': { slug: '1', data: { title: '1', nested: { date: 4 } } }, + 'posts.2': { slug: '2', data: { title: '2', nested: { date: 3 } } }, + 'posts.3': { slug: '3', data: { title: '3', nested: { date: 2 } } }, + 'posts.4': { slug: '4', data: { title: '4', nested: { date: 1 } } }, + }, + pages: { posts: { ids: ['1', '2', '3', '4'] } }, + sort: { posts: { title: { key: 'nested.date', direction: 'Ascending' } } }, + }); + const collection = fromJS({ + name: 'posts', + }); + + expect(selectEntries(state, collection)).toEqual( + fromJS([ + { slug: '4', data: { title: '4', nested: { date: 1 } } }, + { slug: '3', data: { title: '3', nested: { date: 2 } } }, + { slug: '2', data: { title: '2', nested: { date: 3 } } }, + { slug: '1', data: { title: '1', nested: { date: 4 } } }, + ]), + ); + }); + + it('should return filtered entries entries by field', () => { + 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' } }, + }, + pages: { posts: { ids: ['1', '2', '3', '4'] } }, + filter: { posts: { title__1: { field: 'title', pattern: '4', active: true } } }, + }); + const collection = fromJS({ + name: 'posts', + }); + + expect(selectEntries(state, collection)).toEqual(fromJS([{ slug: '4', data: { title: '4' } }])); + }); + + it('should return filtered entries entries by nested field', () => { + const state = fromJS({ + entities: { + 'posts.1': { slug: '1', data: { title: '1', nested: { draft: true } } }, + 'posts.2': { slug: '2', data: { title: '2', nested: { draft: true } } }, + 'posts.3': { slug: '3', data: { title: '3', nested: { draft: false } } }, + 'posts.4': { slug: '4', data: { title: '4', nested: { draft: false } } }, + }, + pages: { posts: { ids: ['1', '2', '3', '4'] } }, + filter: { + posts: { 'nested.draft__false': { field: 'nested.draft', pattern: false, active: true } }, + }, + }); + const collection = fromJS({ + name: 'posts', + }); + + expect(selectEntries(state, collection)).toEqual( + fromJS([ + { slug: '3', data: { title: '3', nested: { draft: false } } }, + { slug: '4', data: { title: '4', nested: { draft: false } } }, + ]), + ); + }); }); diff --git a/packages/netlify-cms-core/src/reducers/collections.ts b/packages/netlify-cms-core/src/reducers/collections.ts index 94fc4025..c6efb67a 100644 --- a/packages/netlify-cms-core/src/reducers/collections.ts +++ b/packages/netlify-cms-core/src/reducers/collections.ts @@ -12,6 +12,7 @@ import { EntryField, State, EntryMap, + ViewFilter, } from '../types/redux'; import { selectMediaFolder } from './entries'; import { stringTemplate } from 'netlify-cms-lib-widgets'; @@ -423,6 +424,11 @@ export const selectSortDataPath = (collection: Collection, key: string) => { } }; +export const selectViewFilters = (collection: Collection) => { + const viewFilters = collection.get('view_filters').toJS() as ViewFilter[]; + return viewFilters; +}; + export const selectFieldsComments = (collection: Collection, entryMap: EntryMap) => { let fields: EntryField[] = []; if (collection.has('folder')) { diff --git a/packages/netlify-cms-core/src/reducers/entries.ts b/packages/netlify-cms-core/src/reducers/entries.ts index 18292b75..25f3c207 100644 --- a/packages/netlify-cms-core/src/reducers/entries.ts +++ b/packages/netlify-cms-core/src/reducers/entries.ts @@ -11,6 +11,9 @@ import { SORT_ENTRIES_REQUEST, SORT_ENTRIES_SUCCESS, SORT_ENTRIES_FAILURE, + FILTER_ENTRIES_REQUEST, + FILTER_ENTRIES_SUCCESS, + FILTER_ENTRIES_FAILURE, } from '../actions/entries'; import { SEARCH_ENTRIES_SUCCESS } from '../actions/search'; import { @@ -30,16 +33,23 @@ import { EntryField, CollectionFiles, EntriesSortRequestPayload, - EntriesSortSuccessPayload, EntriesSortFailurePayload, SortMap, SortObject, Sort, SortDirection, + Filter, + FilterMap, + EntriesFilterRequestPayload, + EntriesFilterFailurePayload, } from '../types/redux'; import { folderFormatter } from '../lib/formatters'; import { isAbsolutePath, basename } from 'netlify-cms-lib-util'; -import { trim, once, sortBy, set } from 'lodash'; +import { trim, once, sortBy, set, orderBy } from 'lodash'; +import { selectSortDataPath } from './collections'; +import { stringTemplate } from 'netlify-cms-lib-widgets'; + +const { keyToPathArray } = stringTemplate; let collection: string; let loadedEntries: EntryObject[]; @@ -203,8 +213,9 @@ const entries = ( return newState; } + case FILTER_ENTRIES_SUCCESS: case SORT_ENTRIES_SUCCESS: { - const payload = action.payload as EntriesSortSuccessPayload; + const payload = action.payload as { collection: string; entries: EntryObject[] }; const { collection, entries } = payload; loadedEntries = entries; const newState = state.withMutations(map => { @@ -238,6 +249,29 @@ const entries = ( return newState; } + case FILTER_ENTRIES_REQUEST: { + const payload = action.payload as EntriesFilterRequestPayload; + const { collection, filter } = payload; + const newState = state.withMutations(map => { + const current: FilterMap = map.getIn(['filter', collection, filter.id], fromJS(filter)); + map.setIn( + ['filter', collection, current.get('id')], + current.set('active', !current.get('active')), + ); + }); + return newState; + } + + case FILTER_ENTRIES_FAILURE: { + const payload = action.payload as EntriesFilterFailurePayload; + const { collection, filter } = payload; + const newState = state.withMutations(map => { + map.deleteIn(['filter', collection, filter.id]); + map.setIn(['pages', collection, 'isFetching'], false); + }); + return newState; + } + default: return state; } @@ -248,6 +282,11 @@ export const selectEntriesSort = (entries: Entries, collection: string) => { return sort?.get(collection); }; +export const selectEntriesFilter = (entries: Entries, collection: string) => { + const filter = entries.get('filter') as Filter | undefined; + return filter?.get(collection) || Map(); +}; + export const selectEntriesSortFields = (entries: Entries, collection: string) => { const sort = selectEntriesSort(entries, collection); const values = @@ -255,6 +294,17 @@ export const selectEntriesSortFields = (entries: Entries, collection: string) => ?.valueSeq() .filter(v => v?.get('direction') !== SortDirection.None) .toArray() || []; + + return values; +}; + +export const selectEntriesFilterFields = (entries: Entries, collection: string) => { + const filter = selectEntriesFilter(entries, collection); + const values = + filter + ?.valueSeq() + .filter(v => v?.get('active') === true) + .toArray() || []; return values; }; @@ -264,10 +314,39 @@ export const selectEntry = (state: Entries, collection: string, slug: string) => export const selectPublishedSlugs = (state: Entries, collection: string) => state.getIn(['pages', collection, 'ids'], List()); -export const selectEntries = (state: Entries, collection: string) => { - const slugs = selectPublishedSlugs(state, collection); - const entries = - slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as List); +export const selectEntries = (state: Entries, collection: Collection) => { + const collectionName = collection.get('name'); + const slugs = selectPublishedSlugs(state, collectionName); + let entries = + slugs && + (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as List); + + const sortFields = selectEntriesSortFields(state, collectionName); + if (sortFields && sortFields.length > 0) { + const keys = sortFields.map(v => selectSortDataPath(collection, v.get('key'))); + const orders = sortFields.map(v => + v.get('direction') === SortDirection.Ascending ? 'asc' : 'desc', + ); + entries = fromJS(orderBy(entries.toJS(), keys, orders)); + } + + const filters = selectEntriesFilterFields(state, collectionName); + if (filters && filters.length > 0) { + entries = entries + .filter(e => { + const allMatched = filters.every(f => { + const pattern = f.get('pattern'); + const field = f.get('field'); + const data = e!.get('data') || Map(); + const toMatch = data.getIn(keyToPathArray(field)); + const matched = + toMatch !== undefined && new RegExp(String(pattern)).test(String(toMatch)); + return matched; + }); + return allMatched; + }) + .toList(); + } return entries; }; diff --git a/packages/netlify-cms-core/src/reducers/index.ts b/packages/netlify-cms-core/src/reducers/index.ts index 246674b3..893f2fa5 100644 --- a/packages/netlify-cms-core/src/reducers/index.ts +++ b/packages/netlify-cms-core/src/reducers/index.ts @@ -12,7 +12,7 @@ import mediaLibrary from './mediaLibrary'; import deploys, * as fromDeploys from './deploys'; import globalUI from './globalUI'; import { Status } from '../constants/publishModes'; -import { State } from '../types/redux'; +import { State, Collection } from '../types/redux'; const reducers = { auth, @@ -38,7 +38,7 @@ export default reducers; export const selectEntry = (state: State, collection: string, slug: string) => fromEntries.selectEntry(state.entries, collection, slug); -export const selectEntries = (state: State, collection: string) => +export const selectEntries = (state: State, collection: Collection) => fromEntries.selectEntries(state.entries, collection); export const selectPublishedSlugs = (state: State, collection: string) => diff --git a/packages/netlify-cms-core/src/types/redux.ts b/packages/netlify-cms-core/src/types/redux.ts index cce075ed..242b82e2 100644 --- a/packages/netlify-cms-core/src/types/redux.ts +++ b/packages/netlify-cms-core/src/types/redux.ts @@ -64,12 +64,17 @@ export type SortMap = OrderedMap>; export type Sort = Map; +export type FilterMap = StaticallyTypedRecord; + +export type Filter = Map>; // collection.field.active + export type Entities = StaticallyTypedRecord; export type Entries = StaticallyTypedRecord<{ pages: Pages & PagesObject; entities: Entities & EntitiesObject; sort: Sort; + filter: Filter; }>; export type Deploys = StaticallyTypedRecord<{}>; @@ -134,6 +139,13 @@ export type CollectionFile = StaticallyTypedRecord<{ export type CollectionFiles = List; +export type ViewFilter = { + label: string; + field: string; + pattern: string; + id: string; +}; + type CollectionObject = { name: string; folder?: string; @@ -157,6 +169,7 @@ type CollectionObject = { label_singular?: string; label: string; sortableFields: List; + view_filters: List>; }; export type Collection = StaticallyTypedRecord; @@ -297,11 +310,18 @@ export interface EntriesSortRequestPayload extends EntryPayload { direction: string; } -export interface EntriesSortSuccessPayload extends EntriesSortRequestPayload { - entries: EntryObject[]; +export interface EntriesSortFailurePayload extends EntriesSortRequestPayload { + error: Error; } -export interface EntriesSortFailurePayload extends EntriesSortRequestPayload { +export interface EntriesFilterRequestPayload { + filter: ViewFilter; + collection: string; +} + +export interface EntriesFilterFailurePayload { + filter: ViewFilter; + collection: string; error: Error; } diff --git a/packages/netlify-cms-locales/src/en/index.js b/packages/netlify-cms-locales/src/en/index.js index 8b4f8513..4999643e 100644 --- a/packages/netlify-cms-locales/src/en/index.js +++ b/packages/netlify-cms-locales/src/en/index.js @@ -46,6 +46,7 @@ const en = { descending: 'Descending', searchResults: 'Search Results for "%{searchTerm}"', searchResultsInCollection: 'Search Results for "%{searchTerm}" in %{collection}', + filterBy: 'Filter by', }, entries: { loadingEntries: 'Loading Entries...', diff --git a/packages/netlify-cms-ui-default/src/Dropdown.js b/packages/netlify-cms-ui-default/src/Dropdown.js index 4c2dba61..e1b7cc47 100644 --- a/packages/netlify-cms-ui-default/src/Dropdown.js +++ b/packages/netlify-cms-ui-default/src/Dropdown.js @@ -48,7 +48,7 @@ const DropdownList = styled.ul` `}; `; -const StyledMenuItem = ({ isActive, ...props }) => ( +const StyledMenuItem = ({ isActive, isCheckedItem = false, ...props }) => ( ( &:not(:active) { background-color: ${isActive ? colors.activeBackground : 'inherit'}; color: ${isActive ? colors.active : 'inherit'}; + ${isCheckedItem ? 'display: flex; justify-content: start' : ''}; } &:hover { color: ${colors.active}; @@ -128,4 +129,38 @@ DropdownItem.propTypes = { className: PropTypes.string, }; -export { Dropdown as default, DropdownItem, DropdownButton, StyledDropdownButton }; +const StyledDropdownCheckbox = ({ checked, id }) => ( + +); + +const DropdownCheckedItem = ({ label, id, checked, onClick }) => { + return ( + + + {label} + + ); +}; + +DropdownCheckedItem.propTypes = { + label: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + checked: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, +}; + +export { + Dropdown as default, + DropdownItem, + DropdownCheckedItem, + DropdownButton, + StyledDropdownButton, +}; diff --git a/packages/netlify-cms-ui-default/src/index.js b/packages/netlify-cms-ui-default/src/index.js index 9ce24847..b9bb4339 100644 --- a/packages/netlify-cms-ui-default/src/index.js +++ b/packages/netlify-cms-ui-default/src/index.js @@ -1,4 +1,9 @@ -import Dropdown, { DropdownItem, DropdownButton, StyledDropdownButton } from './Dropdown'; +import Dropdown, { + DropdownItem, + DropdownCheckedItem, + DropdownButton, + StyledDropdownButton, +} from './Dropdown'; import Icon from './Icon'; import ListItemTopBar from './ListItemTopBar'; import Loader from './Loader'; @@ -29,6 +34,7 @@ import { export const NetlifyCmsUiDefault = { Dropdown, DropdownItem, + DropdownCheckedItem, DropdownButton, StyledDropdownButton, ListItemTopBar, @@ -61,6 +67,7 @@ export const NetlifyCmsUiDefault = { export { Dropdown, DropdownItem, + DropdownCheckedItem, DropdownButton, StyledDropdownButton, ListItemTopBar, diff --git a/website/content/docs/configuration-options.md b/website/content/docs/configuration-options.md index 5dfeaafb..dc117da1 100644 --- a/website/content/docs/configuration-options.md +++ b/website/content/docs/configuration-options.md @@ -210,6 +210,7 @@ The `collections` setting is the heart of your Netlify CMS configuration, as it * `editor`: see detailed description below * `summary`: see detailed description below * `sortableFields`: see detailed description below +* `view_filters`: see detailed description below The last few options require more detailed information. @@ -396,4 +397,25 @@ When `author` field can't be inferred commit author will be used. ```yaml # use dot notation for nested fields sortableFields: ['commit_date', 'title', 'commit_author', 'language.en'] -``` \ No newline at end of file +``` + +### `view_filters` + +An optional list of predefined view filters to show in the UI. + +Defaults to an empty list. + +**Example** + +```yaml + view_filters: + - label: "Alice's and Bob's Posts" + field: author + pattern: 'Alice|Bob' + - label: 'Posts published in 2020' + field: date + pattern: '2020' + - label: Drafts + field: draft + pattern: true +```