feat: Add group by to collection view (Issue 3614) (#4486)

This commit is contained in:
Kancer (Nilay) Gökırmak 2020-11-08 17:33:09 +01:00 committed by GitHub
parent 519cb2d4c2
commit e52e29034e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 537 additions and 110 deletions

View File

@ -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);
};

View File

@ -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);
});
});

View File

@ -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 }

View File

@ -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 }

View File

@ -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: [],
},
],
});

View File

@ -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']));

View File

@ -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<State, {}, AnyAction>, 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,

View File

@ -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),
};
};

View File

@ -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 (
<CollectionControlsContainer>
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
{viewGroups.length > 0 && (
<GroupControl viewGroups={viewGroups} onGroupClick={onGroupClick} t={t} group={group} />
)}
{viewFilters.length > 0 && (
<FilterControl
viewFilters={viewFilters}

View File

@ -0,0 +1,27 @@
import React from 'react';
import { css } from '@emotion/core';
import styled from '@emotion/styled';
import { buttons, StyledDropdownButton, colors } from 'netlify-cms-ui-default';
const Button = styled(StyledDropdownButton)`
${buttons.button};
${buttons.medium};
${buttons.grayText};
font-size: 14px;
&:after {
top: 11px;
}
`;
export const ControlButton = ({ active, title }) => {
return (
<Button
css={css`
color: ${active ? colors.active : undefined};
`}
>
{title}
</Button>
);
};

View File

@ -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 (
<GroupContainer key={group.id} id={group.id}>
<GroupHeading>{title}</GroupHeading>
<EntriesToRender entries={getGroupEntries(entries, group.paths)} />
</GroupContainer>
);
});
};
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,8 +89,9 @@ 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;
const EntriesToRender = ({ entries }) => {
return (
<Entries
collections={collection}
@ -59,6 +104,13 @@ export class EntriesCollection extends React.Component {
page={page}
/>
);
};
if (groups && groups.length > 0) {
return withGroups(groups, entries, EntriesToRender, t);
}
return <EntriesToRender entries={entries} />;
}
}
@ -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);

View File

@ -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 }) => {
<Dropdown
renderButton={() => {
return (
<FilterButton
css={css`
color: ${hasActiveFilter ? colors.active : undefined};
`}
>
{t('collection.collectionTop.filterBy')}
</FilterButton>
<ControlButton active={hasActiveFilter} title={t('collection.collectionTop.filterBy')} />
);
}}
closeOnSelection={false}

View File

@ -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 (
<Dropdown
renderButton={() => {
return (
<ControlButton active={hasActiveGroup} title={t('collection.collectionTop.groupBy')} />
);
}}
closeOnSelection={false}
dropdownTopOverlap="30px"
dropdownWidth="160px"
dropdownPosition="left"
>
{viewGroups.map(viewGroup => {
return (
<DropdownItem
key={viewGroup.id}
label={viewGroup.label}
onClick={() => onGroupClick(viewGroup)}
isActive={group.getIn([viewGroup.id, 'active'], false)}
/>
);
})}
</Dropdown>
);
};
export default translate()(GroupControl);

View File

@ -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 (
<Dropdown
renderButton={() => (
<SortButton
css={css`
color: ${hasActiveSort ? colors.active : undefined};
`}
>
{t('collection.collectionTop.sortBy')}
</SortButton>
)}
renderButton={() => {
return (
<ControlButton active={hasActiveSort} title={t('collection.collectionTop.sortBy')} />
);
}}
closeOnSelection={false}
dropdownTopOverlap="30px"
dropdownWidth="160px"

View File

@ -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,

View File

@ -14,8 +14,8 @@ exports[`Collection should render connected component 1`] = `
class="emotion-2 emotion-3"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [] }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [] } }"
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] } }"
filterterm=""
searchterm=""
/>
@ -23,16 +23,18 @@ exports[`Collection should render connected component 1`] = `
class="emotion-0 emotion-1"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [] }"
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] }"
newentryurl=""
/>
<mock-collection-controls
filter="Map {}"
group="Map {}"
sortablefields=""
viewfilters=""
viewgroups=""
/>
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [] }"
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] }"
filterterm=""
/>
</main>
@ -54,19 +56,19 @@ exports[`Collection should render with collection with create url 1`] = `
class="emotion-2 emotion-3"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [] } }"
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": true }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] } }"
/>
<main
class="emotion-0 emotion-1"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": true }"
newentryurl="/collections/pages/new"
/>
<mock-collection-controls />
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": true }"
/>
</main>
</div>
@ -87,20 +89,20 @@ exports[`Collection should render with collection with create url and path 1`] =
class="emotion-2 emotion-3"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [] } }"
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": true }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] } }"
filterterm="dir1/dir2"
/>
<main
class="emotion-0 emotion-1"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": true }"
newentryurl="/collections/pages/new?path=dir1/dir2"
/>
<mock-collection-controls />
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": true }"
filterterm="dir1/dir2"
/>
</main>
@ -122,19 +124,19 @@ exports[`Collection should render with collection without create url 1`] = `
class="emotion-2 emotion-3"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"create\\": false }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [] } }"
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": false }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] } }"
/>
<main
class="emotion-0 emotion-1"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"create\\": false }"
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": false }"
newentryurl=""
/>
<mock-collection-controls />
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"create\\": false }"
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": false }"
/>
</main>
</div>

View File

@ -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: {

View File

@ -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')) {

View File

@ -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]);
}

View File

@ -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<string>());
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<EntryMap>);
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 =

View File

@ -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<string, SortMap>;
export type FilterMap = StaticallyTypedRecord<ViewFilter & { active: boolean }>;
export type GroupMap = StaticallyTypedRecord<ViewGroup & { active: boolean }>;
export type Filter = Map<string, Map<string, FilterMap>>; // collection.field.active
export type Group = Map<string, Map<string, GroupMap>>; // collection.field.active
export type GroupOfEntries = {
id: string;
label: string;
value: string | boolean | undefined;
paths: Set<string>;
};
export type Entities = StaticallyTypedRecord<EntitiesObject>;
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<NestedObject>;
@ -194,6 +214,7 @@ type CollectionObject = {
label: string;
sortable_fields: List<string>;
view_filters: List<StaticallyTypedRecord<ViewFilter>>;
view_groups: List<StaticallyTypedRecord<ViewGroup>>;
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;
}

View File

@ -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',