diff --git a/cypress/integration/view_filters_spec.js b/cypress/integration/view_filters_spec.js index cdcf1f46..69cd8211 100644 --- a/cypress/integration/view_filters_spec.js +++ b/cypress/integration/view_filters_spec.js @@ -1,10 +1,11 @@ import { login } from '../utils/steps'; const filter = term => { - cy.get('[class*=FilterButton]').click(); + cy.contains('span', 'Filter by').click(); cy.contains(term).click(); cy.contains('Contents').click(); }; + const assertEntriesCount = count => { cy.get('[class*=ListCardLink]').should('have.length', count); }; diff --git a/cypress/integration/view_groups_spec.js b/cypress/integration/view_groups_spec.js new file mode 100644 index 00000000..385caf9b --- /dev/null +++ b/cypress/integration/view_groups_spec.js @@ -0,0 +1,71 @@ +import { login } from '../utils/steps'; + +const group = term => { + cy.contains('span', 'Group by').click(); + cy.contains(term).click(); + cy.contains('Contents').click(); +}; + +const assertGroupsCount = count => { + cy.get('[class*=GroupContainer]').should('have.length', count); +}; + +const assertEachGroupCount = (id, count) => { + cy.get(`[id='${id}']`).within(() => { + assertEntriesCount(count); + }); +}; + +const assertEntriesCount = count => { + cy.get('[class*=ListCardLink]').should('have.length', count); +}; + +const assertInEntries = text => { + cy.get('[class*=ListCardLink]').within(() => { + cy.contains('h2', text); + }); +}; + +describe('View Group', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + beforeEach(() => { + login(); + }); + + it('can apply string group', () => { + // enable group + group('Year'); + + assertGroupsCount(2); + assertEachGroupCount('Year2020', 20); + assertEachGroupCount('Year2015', 3); + + //disable group + group('Year'); + + 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'); + + //enable group + group('Drafts'); + + assertEntriesCount(23); + assertGroupsCount(3); + assertEachGroupCount('Draftstrue', 10); + assertEachGroupCount('Draftsfalse', 10); + assertEachGroupCount('missing_value', 3); + }); +}); diff --git a/dev-test/backends/test/config.yml b/dev-test/backends/test/config.yml index b9a3f720..91999b72 100644 --- a/dev-test/backends/test/config.yml +++ b/dev-test/backends/test/config.yml @@ -27,6 +27,12 @@ collections: # A list of collections the CMS should be able to edit - label: Drafts field: draft pattern: true + view_groups: + - label: Year + field: date + pattern: \d{4} + - label: Drafts + field: draft 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 } diff --git a/dev-test/config.yml b/dev-test/config.yml index 46ae0230..06ed38c8 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -27,6 +27,12 @@ collections: # A list of collections the CMS should be able to edit - label: Drafts field: draft pattern: true + view_groups: + - label: Year + field: date + pattern: \d{4} + - label: Drafts + field: draft 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 } diff --git a/packages/netlify-cms-core/src/actions/__tests__/config.spec.js b/packages/netlify-cms-core/src/actions/__tests__/config.spec.js index 2267149c..3420c585 100644 --- a/packages/netlify-cms-core/src/actions/__tests__/config.spec.js +++ b/packages/netlify-cms-core/src/actions/__tests__/config.spec.js @@ -528,6 +528,7 @@ describe('config', () => { meta: {}, publish: true, view_filters: [], + view_groups: [], }, { sortable_fields: [], @@ -557,6 +558,7 @@ describe('config', () => { ], publish: true, view_filters: [], + view_groups: [], }, ], }); diff --git a/packages/netlify-cms-core/src/actions/config.js b/packages/netlify-cms-core/src/actions/config.js index 4769ae1e..85956425 100644 --- a/packages/netlify-cms-core/src/actions/config.js +++ b/packages/netlify-cms-core/src/actions/config.js @@ -112,6 +112,19 @@ const throwOnMissingDefaultLocale = i18n => { } }; +const setViewPatternsDefaults = (key, collection) => { + if (!collection.has(key)) { + collection = collection.set(key, fromJS([])); + } else { + collection = collection.set( + key, + collection.get(key).map(v => v.set('id', `${v.get('field')}__${v.get('pattern')}`)), + ); + } + + return collection; +}; + const defaults = { publish_mode: publishModes.SIMPLE, }; @@ -256,16 +269,8 @@ export function applyDefaults(config) { collection = collection.set('sortable_fields', 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')}`)), - ); - } + collection = setViewPatternsDefaults('view_filters', collection); + collection = setViewPatternsDefaults('view_groups', collection); if (map.hasIn(['editor', 'preview']) && !collection.has('editor')) { collection = collection.setIn(['editor', 'preview'], map.getIn(['editor', 'preview'])); diff --git a/packages/netlify-cms-core/src/actions/entries.ts b/packages/netlify-cms-core/src/actions/entries.ts index f6820355..6cc32d4c 100644 --- a/packages/netlify-cms-core/src/actions/entries.ts +++ b/packages/netlify-cms-core/src/actions/entries.ts @@ -20,6 +20,7 @@ import { EntryField, SortDirection, ViewFilter, + ViewGroup, Entry, } from '../types/redux'; @@ -54,6 +55,10 @@ 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 GROUP_ENTRIES_REQUEST = 'GROUP_ENTRIES_REQUEST'; +export const GROUP_ENTRIES_SUCCESS = 'GROUP_ENTRIES_SUCCESS'; +export const GROUP_ENTRIES_FAILURE = 'GROUP_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'; @@ -244,6 +249,44 @@ export function filterByField(collection: Collection, filter: ViewFilter) { }; } +export function groupByField(collection: Collection, group: ViewGroup) { + return async (dispatch: ThunkDispatch, getState: () => State) => { + const state = getState(); + const isFetching = selectIsFetching(state.entries, collection.get('name')); + dispatch({ + type: GROUP_ENTRIES_REQUEST, + payload: { + collection: collection.get('name'), + group, + }, + }); + if (isFetching) { + return; + } + + try { + const entries = await getAllEntries(state, collection); + dispatch({ + type: GROUP_ENTRIES_SUCCESS, + payload: { + collection: collection.get('name'), + group, + entries, + }, + }); + } catch (error) { + dispatch({ + type: GROUP_ENTRIES_FAILURE, + payload: { + collection: collection.get('name'), + group, + error, + }, + }); + } + }; +} + export function changeViewStyle(viewStyle: string) { return { type: CHANGE_VIEW_STYLE, diff --git a/packages/netlify-cms-core/src/components/Collection/Collection.js b/packages/netlify-cms-core/src/components/Collection/Collection.js index a8a6f989..5b014502 100644 --- a/packages/netlify-cms-core/src/components/Collection/Collection.js +++ b/packages/netlify-cms-core/src/components/Collection/Collection.js @@ -11,9 +11,18 @@ import CollectionTop from './CollectionTop'; import EntriesCollection from './Entries/EntriesCollection'; import EntriesSearch from './Entries/EntriesSearch'; import CollectionControls from './CollectionControls'; -import { sortByField, filterByField, changeViewStyle } from '../../actions/entries'; -import { selectSortableFields, selectViewFilters } from '../../reducers/collections'; -import { selectEntriesSort, selectEntriesFilter, selectViewStyle } from '../../reducers/entries'; +import { sortByField, filterByField, changeViewStyle, groupByField } from '../../actions/entries'; +import { + selectSortableFields, + selectViewFilters, + selectViewGroups, +} from '../../reducers/collections'; +import { + selectEntriesSort, + selectEntriesFilter, + selectEntriesGroup, + selectViewStyle, +} from '../../reducers/entries'; const CollectionContainer = styled.div` margin: ${lengths.pageMargin}; @@ -74,10 +83,13 @@ export class Collection extends React.Component { onSortClick, sort, viewFilters, + viewGroups, filterTerm, t, onFilterClick, + onGroupClick, filter, + group, onChangeViewStyle, viewStyle, } = this.props; @@ -118,9 +130,12 @@ export class Collection extends React.Component { onSortClick={onSortClick} sort={sort} viewFilters={viewFilters} + viewGroups={viewGroups} t={t} onFilterClick={onFilterClick} + onGroupClick={onGroupClick} filter={filter} + group={group} /> )} @@ -139,7 +154,9 @@ function mapStateToProps(state, ownProps) { const sort = selectEntriesSort(state.entries, collection.get('name')); const sortableFields = selectSortableFields(collection, t); const viewFilters = selectViewFilters(collection); + const viewGroups = selectViewGroups(collection); const filter = selectEntriesFilter(state.entries, collection.get('name')); + const group = selectEntriesGroup(state.entries, collection.get('name')); const viewStyle = selectViewStyle(state.entries); return { @@ -152,7 +169,9 @@ function mapStateToProps(state, ownProps) { sort, sortableFields, viewFilters, + viewGroups, filter, + group, viewStyle, }; } @@ -161,6 +180,7 @@ const mapDispatchToProps = { sortByField, filterByField, changeViewStyle, + groupByField, }; const mergeProps = (stateProps, dispatchProps, ownProps) => { @@ -170,6 +190,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { onSortClick: (key, direction) => dispatchProps.sortByField(stateProps.collection, key, direction), onFilterClick: filter => dispatchProps.filterByField(stateProps.collection, filter), + onGroupClick: group => dispatchProps.groupByField(stateProps.collection, group), onChangeViewStyle: viewStyle => dispatchProps.changeViewStyle(viewStyle), }; }; diff --git a/packages/netlify-cms-core/src/components/Collection/CollectionControls.js b/packages/netlify-cms-core/src/components/Collection/CollectionControls.js index 05e522a7..006ffdc6 100644 --- a/packages/netlify-cms-core/src/components/Collection/CollectionControls.js +++ b/packages/netlify-cms-core/src/components/Collection/CollectionControls.js @@ -4,6 +4,7 @@ import ViewStyleControl from './ViewStyleControl'; import SortControl from './SortControl'; import FilterControl from './FilterControl'; import { lengths } from 'netlify-cms-ui-default'; +import GroupControl from './GroupControl'; const CollectionControlsContainer = styled.div` display: flex; @@ -25,13 +26,19 @@ const CollectionControls = ({ onSortClick, sort, viewFilters, + viewGroups, onFilterClick, + onGroupClick, t, filter, + group, }) => { return ( + {viewGroups.length > 0 && ( + + )} {viewFilters.length > 0 && ( { + return ( + + ); +}; 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 83878893..f8acf94e 100644 --- a/packages/netlify-cms-core/src/components/Collection/Entries/EntriesCollection.js +++ b/packages/netlify-cms-core/src/components/Collection/Entries/EntriesCollection.js @@ -2,21 +2,65 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; +import styled from '@emotion/styled'; +import { translate } from 'react-polyglot'; import { partial } from 'lodash'; import { Cursor } from 'netlify-cms-lib-util'; +import { colors } from 'netlify-cms-ui-default'; import { loadEntries as actionLoadEntries, traverseCollectionCursor as actionTraverseCollectionCursor, } from 'Actions/entries'; -import { selectEntries, selectEntriesLoaded, selectIsFetching } from '../../../reducers/entries'; +import { + selectEntries, + selectEntriesLoaded, + selectIsFetching, + selectGroups, +} from '../../../reducers/entries'; import { selectCollectionEntriesCursor } from 'Reducers/cursors'; import Entries from './Entries'; +const GroupHeading = styled.h2` + font-size: 23px; + font-weight: 600; + color: ${colors.textLead}; +`; + +const GroupContainer = styled.div``; + +const getGroupEntries = (entries, paths) => { + return entries.filter(entry => paths.has(entry.get('path'))); +}; + +const getGroupTitle = (group, t) => { + const { label, value } = group; + if (value === undefined) { + return t('collection.groups.other'); + } + if (typeof value === 'boolean') { + return value ? label : t('collection.groups.negateLabel', { label }); + } + return `${label} ${value}`.trim(); +}; + +const withGroups = (groups, entries, EntriesToRender, t) => { + return groups.map(group => { + const title = getGroupTitle(group, t); + return ( + + {title} + + + ); + }); +}; + export class EntriesCollection extends React.Component { static propTypes = { collection: ImmutablePropTypes.map.isRequired, page: PropTypes.number, entries: ImmutablePropTypes.list, + groups: PropTypes.array, isFetching: PropTypes.bool.isRequired, viewStyle: PropTypes.string, cursor: PropTypes.object.isRequired, @@ -45,20 +89,28 @@ export class EntriesCollection extends React.Component { }; render() { - const { collection, entries, isFetching, viewStyle, cursor, page } = this.props; + const { collection, entries, groups, isFetching, viewStyle, cursor, page, t } = this.props; - return ( - - ); + const EntriesToRender = ({ entries }) => { + return ( + + ); + }; + + if (groups && groups.length > 0) { + return withGroups(groups, entries, EntriesToRender, t); + } + + return ; } } @@ -87,6 +139,7 @@ function mapStateToProps(state, ownProps) { const page = state.entries.getIn(['pages', collection.get('name'), 'page']); let entries = selectEntries(state.entries, collection); + const groups = selectGroups(state.entries, collection); if (collection.has('nested')) { const collectionFolder = collection.get('folder'); @@ -98,7 +151,7 @@ function mapStateToProps(state, ownProps) { const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name')); const cursor = Cursor.create(rawCursor).clearData(); - return { collection, page, entries, entriesLoaded, isFetching, viewStyle, cursor }; + return { collection, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor }; } const mapDispatchToProps = { @@ -106,4 +159,6 @@ const mapDispatchToProps = { traverseCollectionCursor: actionTraverseCollectionCursor, }; -export default connect(mapStateToProps, mapDispatchToProps)(EntriesCollection); +const ConnectedEntriesCollection = connect(mapStateToProps, mapDispatchToProps)(EntriesCollection); + +export default translate()(ConnectedEntriesCollection); diff --git a/packages/netlify-cms-core/src/components/Collection/FilterControl.js b/packages/netlify-cms-core/src/components/Collection/FilterControl.js index c1d20e10..40cf3c71 100644 --- a/packages/netlify-cms-core/src/components/Collection/FilterControl.js +++ b/packages/netlify-cms-core/src/components/Collection/FilterControl.js @@ -1,25 +1,7 @@ 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; - } -`; +import { Dropdown, DropdownCheckedItem } from 'netlify-cms-ui-default'; +import { ControlButton } from './ControlButton'; const FilterControl = ({ viewFilters, t, onFilterClick, filter }) => { const hasActiveFilter = filter @@ -31,13 +13,7 @@ const FilterControl = ({ viewFilters, t, onFilterClick, filter }) => { { return ( - - {t('collection.collectionTop.filterBy')} - + ); }} closeOnSelection={false} diff --git a/packages/netlify-cms-core/src/components/Collection/GroupControl.js b/packages/netlify-cms-core/src/components/Collection/GroupControl.js new file mode 100644 index 00000000..b6b5537b --- /dev/null +++ b/packages/netlify-cms-core/src/components/Collection/GroupControl.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { translate } from 'react-polyglot'; +import { Dropdown, DropdownItem } from 'netlify-cms-ui-default'; +import { ControlButton } from './ControlButton'; + +const GroupControl = ({ viewGroups, t, onGroupClick, group }) => { + const hasActiveGroup = group + ?.valueSeq() + .toJS() + .some(f => f.active === true); + + return ( + { + return ( + + ); + }} + closeOnSelection={false} + dropdownTopOverlap="30px" + dropdownWidth="160px" + dropdownPosition="left" + > + {viewGroups.map(viewGroup => { + return ( + onGroupClick(viewGroup)} + isActive={group.getIn([viewGroup.id, 'active'], false)} + /> + ); + })} + + ); +}; + +export default translate()(GroupControl); diff --git a/packages/netlify-cms-core/src/components/Collection/SortControl.js b/packages/netlify-cms-core/src/components/Collection/SortControl.js index 3b1dbfef..fb1095c3 100644 --- a/packages/netlify-cms-core/src/components/Collection/SortControl.js +++ b/packages/netlify-cms-core/src/components/Collection/SortControl.js @@ -1,26 +1,8 @@ import React from 'react'; -import { css } from '@emotion/core'; -import styled from '@emotion/styled'; import { translate } from 'react-polyglot'; -import { - buttons, - Dropdown, - DropdownItem, - StyledDropdownButton, - colors, -} from 'netlify-cms-ui-default'; +import { Dropdown, DropdownItem } from 'netlify-cms-ui-default'; import { SortDirection } from '../../types/redux'; - -const SortButton = styled(StyledDropdownButton)` - ${buttons.button}; - ${buttons.medium}; - ${buttons.grayText}; - font-size: 14px; - - &:after { - top: 11px; - } -`; +import { ControlButton } from './ControlButton'; function nextSortDirection(direction) { switch (direction) { @@ -54,15 +36,11 @@ const SortControl = ({ t, fields, onSortClick, sort }) => { return ( ( - - {t('collection.collectionTop.sortBy')} - - )} + renderButton={() => { + return ( + + ); + }} closeOnSelection={false} dropdownTopOverlap="30px" dropdownWidth="160px" diff --git a/packages/netlify-cms-core/src/components/Collection/__tests__/Collection.spec.js b/packages/netlify-cms-core/src/components/Collection/__tests__/Collection.spec.js index 38f83a44..e8f24e48 100644 --- a/packages/netlify-cms-core/src/components/Collection/__tests__/Collection.spec.js +++ b/packages/netlify-cms-core/src/components/Collection/__tests__/Collection.spec.js @@ -21,7 +21,12 @@ const renderWithRedux = (component, { store } = {}) => { }; describe('Collection', () => { - const collection = fromJS({ name: 'pages', sortable_fields: [], view_filters: [] }); + const collection = fromJS({ + name: 'pages', + sortable_fields: [], + view_filters: [], + view_groups: [], + }); const props = { collections: fromJS([collection]).toOrderedMap(), collection, diff --git a/packages/netlify-cms-core/src/components/Collection/__tests__/__snapshots__/Collection.spec.js.snap b/packages/netlify-cms-core/src/components/Collection/__tests__/__snapshots__/Collection.spec.js.snap index 50aa5d67..ac958e9a 100644 --- a/packages/netlify-cms-core/src/components/Collection/__tests__/__snapshots__/Collection.spec.js.snap +++ b/packages/netlify-cms-core/src/components/Collection/__tests__/__snapshots__/Collection.spec.js.snap @@ -14,8 +14,8 @@ exports[`Collection should render connected component 1`] = ` class="emotion-2 emotion-3" > @@ -23,16 +23,18 @@ exports[`Collection should render connected component 1`] = ` class="emotion-0 emotion-1" > @@ -54,19 +56,19 @@ exports[`Collection should render with collection with create url 1`] = ` class="emotion-2 emotion-3" >
@@ -87,20 +89,20 @@ exports[`Collection should render with collection with create url and path 1`] = class="emotion-2 emotion-3" >
@@ -122,19 +124,19 @@ exports[`Collection should render with collection without create url 1`] = ` class="emotion-2 emotion-3" >
diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index cd1d7fd0..e7bef049 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -97,6 +97,21 @@ const viewFilters = { }, }; +const viewGroups = { + type: 'array', + minItems: 1, + items: { + type: 'object', + properties: { + label: { type: 'string' }, + field: { type: 'string' }, + pattern: { type: 'string' }, + }, + additionalProperties: false, + required: ['label', 'field'], + }, +}; + /** * The schema had to be wrapped in a function to * fix a circular dependency problem for WebPack, @@ -234,6 +249,7 @@ const getConfigSchema = () => ({ }, }, view_filters: viewFilters, + view_groups: viewGroups, nested: { type: 'object', properties: { diff --git a/packages/netlify-cms-core/src/reducers/collections.ts b/packages/netlify-cms-core/src/reducers/collections.ts index d4e36213..9986bc55 100644 --- a/packages/netlify-cms-core/src/reducers/collections.ts +++ b/packages/netlify-cms-core/src/reducers/collections.ts @@ -13,6 +13,7 @@ import { State, EntryMap, ViewFilter, + ViewGroup, } from '../types/redux'; import { selectMediaFolder } from './entries'; import { stringTemplate } from 'netlify-cms-lib-widgets'; @@ -430,6 +431,11 @@ export const selectViewFilters = (collection: Collection) => { return viewFilters; }; +export const selectViewGroups = (collection: Collection) => { + const viewGroups = collection.get('view_groups').toJS() as ViewGroup[]; + return viewGroups; +}; + export const selectFieldsComments = (collection: Collection, entryMap: EntryMap) => { let fields: EntryField[] = []; if (collection.has('folder')) { diff --git a/packages/netlify-cms-core/src/reducers/cursors.js b/packages/netlify-cms-core/src/reducers/cursors.js index ac6a9cd7..4a43cd26 100644 --- a/packages/netlify-cms-core/src/reducers/cursors.js +++ b/packages/netlify-cms-core/src/reducers/cursors.js @@ -1,6 +1,11 @@ import { fromJS } from 'immutable'; import { Cursor } from 'netlify-cms-lib-util'; -import { ENTRIES_SUCCESS, SORT_ENTRIES_SUCCESS } from 'Actions/entries'; +import { + ENTRIES_SUCCESS, + SORT_ENTRIES_SUCCESS, + FILTER_ENTRIES_SUCCESS, + GROUP_ENTRIES_SUCCESS, +} from 'Actions/entries'; // Since pagination can be used for a variety of views (collections // and searches are the most common examples), we namespace cursors by @@ -16,6 +21,8 @@ const cursors = (state = fromJS({ cursorsByType: { collectionEntries: {} } }), a Cursor.create(action.payload.cursor).store, ); } + case FILTER_ENTRIES_SUCCESS: + case GROUP_ENTRIES_SUCCESS: case SORT_ENTRIES_SUCCESS: { return state.deleteIn(['cursorsByType', 'collectionEntries', action.payload.collection]); } diff --git a/packages/netlify-cms-core/src/reducers/entries.ts b/packages/netlify-cms-core/src/reducers/entries.ts index 55ed1f8d..70e1fa1e 100644 --- a/packages/netlify-cms-core/src/reducers/entries.ts +++ b/packages/netlify-cms-core/src/reducers/entries.ts @@ -1,4 +1,4 @@ -import { Map, List, fromJS, OrderedMap } from 'immutable'; +import { Map, List, fromJS, OrderedMap, Set } from 'immutable'; import { dirname, join } from 'path'; import { ENTRY_REQUEST, @@ -14,6 +14,9 @@ import { FILTER_ENTRIES_REQUEST, FILTER_ENTRIES_SUCCESS, FILTER_ENTRIES_FAILURE, + GROUP_ENTRIES_REQUEST, + GROUP_ENTRIES_SUCCESS, + GROUP_ENTRIES_FAILURE, CHANGE_VIEW_STYLE, } from '../actions/entries'; import { SEARCH_ENTRIES_SUCCESS } from '../actions/search'; @@ -40,14 +43,19 @@ import { Sort, SortDirection, Filter, + Group, FilterMap, + GroupMap, EntriesFilterRequestPayload, EntriesFilterFailurePayload, ChangeViewStylePayload, + EntriesGroupRequestPayload, + EntriesGroupFailurePayload, + GroupOfEntries, } from '../types/redux'; import { folderFormatter } from '../lib/formatters'; import { isAbsolutePath, basename } from 'netlify-cms-lib-util'; -import { trim, once, sortBy, set, orderBy } from 'lodash'; +import { trim, once, sortBy, set, orderBy, groupBy } from 'lodash'; import { selectSortDataPath } from './collections'; import { stringTemplate } from 'netlify-cms-lib-widgets'; import { VIEW_STYLE_LIST } from '../constants/collectionViews'; @@ -239,6 +247,7 @@ const entries = ( return newState; } + case GROUP_ENTRIES_SUCCESS: case FILTER_ENTRIES_SUCCESS: case SORT_ENTRIES_SUCCESS: { const payload = action.payload as { collection: string; entries: EntryObject[] }; @@ -298,6 +307,30 @@ const entries = ( return newState; } + case GROUP_ENTRIES_REQUEST: { + const payload = action.payload as EntriesGroupRequestPayload; + const { collection, group } = payload; + const newState = state.withMutations(map => { + const current: GroupMap = map.getIn(['group', collection, group.id], fromJS(group)); + map.deleteIn(['group', collection]); + map.setIn( + ['group', collection, current.get('id')], + current.set('active', !current.get('active')), + ); + }); + return newState; + } + + case GROUP_ENTRIES_FAILURE: { + const payload = action.payload as EntriesGroupFailurePayload; + const { collection, group } = payload; + const newState = state.withMutations(map => { + map.deleteIn(['group', collection, group.id]); + map.setIn(['pages', collection, 'isFetching'], false); + }); + return newState; + } + case CHANGE_VIEW_STYLE: { const payload = (action.payload as unknown) as ChangeViewStylePayload; const { style } = payload; @@ -323,6 +356,17 @@ export const selectEntriesFilter = (entries: Entries, collection: string) => { return filter?.get(collection) || Map(); }; +export const selectEntriesGroup = (entries: Entries, collection: string) => { + const group = entries.get('group') as Group | undefined; + return group?.get(collection) || Map(); +}; + +export const selectEntriesGroupField = (entries: Entries, collection: string) => { + const groups = selectEntriesGroup(entries, collection); + const value = groups?.valueSeq().find(v => v?.get('active') === true); + return value; +}; + export const selectEntriesSortFields = (entries: Entries, collection: string) => { const sort = selectEntriesSort(entries, collection); const values = @@ -354,12 +398,17 @@ 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: Collection) => { - const collectionName = collection.get('name'); +const getPublishedEntries = (state: Entries, collectionName: string) => { const slugs = selectPublishedSlugs(state, collectionName); - let entries = + const entries = slugs && (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as List); + return entries; +}; + +export const selectEntries = (state: Entries, collection: Collection) => { + const collectionName = collection.get('name'); + let entries = getPublishedEntries(state, collectionName); const sortFields = selectEntriesSortFields(state, collectionName); if (sortFields && sortFields.length > 0) { @@ -391,6 +440,75 @@ export const selectEntries = (state: Entries, collection: Collection) => { return entries; }; +const getGroup = (entry: EntryMap, selectedGroup: GroupMap) => { + const label = selectedGroup.get('label'); + const field = selectedGroup.get('field'); + + const fieldData = entry.getIn(['data', ...keyToPathArray(field)]); + if (fieldData === undefined) { + return { + id: 'missing_value', + label, + value: fieldData, + }; + } + + const dataAsString = String(fieldData); + if (selectedGroup.has('pattern')) { + const pattern = selectedGroup.get('pattern'); + let value = ''; + try { + const regex = new RegExp(pattern); + const matched = dataAsString.match(regex); + if (matched) { + value = matched[0]; + } + } catch (e) { + console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e); + } + return { + id: `${label}${value}`, + label, + value, + }; + } + + return { + id: `${label}${fieldData}`, + label, + value: typeof fieldData === 'boolean' ? fieldData : dataAsString, + }; +}; + +export const selectGroups = (state: Entries, collection: Collection) => { + const collectionName = collection.get('name'); + const entries = getPublishedEntries(state, collectionName); + + const selectedGroup = selectEntriesGroupField(state, collectionName); + if (selectedGroup === undefined) { + return []; + } + + let groups: Record< + string, + { id: string; label: string; value: string | boolean | undefined } + > = {}; + const groupedEntries = groupBy(entries.toArray(), entry => { + const group = getGroup(entry, selectedGroup); + groups = { ...groups, [group.id]: group }; + return group.id; + }); + + const groupsArray: GroupOfEntries[] = Object.entries(groupedEntries).map(([id, entries]) => { + return { + ...groups[id], + paths: Set(entries.map(entry => entry.get('path'))), + }; + }); + + return groupsArray; +}; + export const selectEntryByPath = (state: Entries, collection: string, path: string) => { const slugs = selectPublishedSlugs(state, collection); const entries = diff --git a/packages/netlify-cms-core/src/types/redux.ts b/packages/netlify-cms-core/src/types/redux.ts index 6d290f6a..f4487cd3 100644 --- a/packages/netlify-cms-core/src/types/redux.ts +++ b/packages/netlify-cms-core/src/types/redux.ts @@ -1,6 +1,6 @@ import { Action } from 'redux'; import { StaticallyTypedRecord } from './immutable'; -import { Map, List, OrderedMap } from 'immutable'; +import { Map, List, OrderedMap, Set } from 'immutable'; import AssetProxy from '../valueObjects/AssetProxy'; import { MediaFile as BackendMediaFile } from '../backend'; @@ -66,8 +66,19 @@ export type Sort = Map; export type FilterMap = StaticallyTypedRecord; +export type GroupMap = StaticallyTypedRecord; + export type Filter = Map>; // collection.field.active +export type Group = Map>; // collection.field.active + +export type GroupOfEntries = { + id: string; + label: string; + value: string | boolean | undefined; + paths: Set; +}; + export type Entities = StaticallyTypedRecord; export type Entries = StaticallyTypedRecord<{ @@ -75,6 +86,7 @@ export type Entries = StaticallyTypedRecord<{ entities: Entities & EntitiesObject; sort: Sort; filter: Filter; + group: Group; viewStyle: string; }>; @@ -152,6 +164,14 @@ export type ViewFilter = { pattern: string; id: string; }; + +export type ViewGroup = { + label: string; + field: string; + pattern: string; + id: string; +}; + type NestedObject = { depth: number }; type Nested = StaticallyTypedRecord; @@ -194,6 +214,7 @@ type CollectionObject = { label: string; sortable_fields: List; view_filters: List>; + view_groups: List>; nested?: Nested; meta?: Meta; i18n: i18n; @@ -359,6 +380,17 @@ export interface EntriesFilterFailurePayload { error: Error; } +export interface EntriesGroupRequestPayload { + group: ViewGroup; + collection: string; +} + +export interface EntriesGroupFailurePayload { + group: ViewGroup; + collection: string; + error: Error; +} + export interface ChangeViewStylePayload { style: string; } diff --git a/packages/netlify-cms-locales/src/en/index.js b/packages/netlify-cms-locales/src/en/index.js index a6c7e021..e2ed293d 100644 --- a/packages/netlify-cms-locales/src/en/index.js +++ b/packages/netlify-cms-locales/src/en/index.js @@ -47,6 +47,7 @@ const en = { searchResults: 'Search Results for "%{searchTerm}"', searchResultsInCollection: 'Search Results for "%{searchTerm}" in %{collection}', filterBy: 'Filter by', + groupBy: 'Group by', }, entries: { loadingEntries: 'Loading Entries...', @@ -54,6 +55,10 @@ const en = { longerLoading: 'This might take several minutes', noEntries: 'No Entries', }, + groups: { + other: 'Other', + negateLabel: 'Not %{label}', + }, defaultFields: { author: { label: 'Author',