feat: Add group by
to collection view (Issue 3614) (#4486)
This commit is contained in:
parent
519cb2d4c2
commit
e52e29034e
@ -1,10 +1,11 @@
|
|||||||
import { login } from '../utils/steps';
|
import { login } from '../utils/steps';
|
||||||
|
|
||||||
const filter = term => {
|
const filter = term => {
|
||||||
cy.get('[class*=FilterButton]').click();
|
cy.contains('span', 'Filter by').click();
|
||||||
cy.contains(term).click();
|
cy.contains(term).click();
|
||||||
cy.contains('Contents').click();
|
cy.contains('Contents').click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const assertEntriesCount = count => {
|
const assertEntriesCount = count => {
|
||||||
cy.get('[class*=ListCardLink]').should('have.length', count);
|
cy.get('[class*=ListCardLink]').should('have.length', count);
|
||||||
};
|
};
|
||||||
|
71
cypress/integration/view_groups_spec.js
Normal file
71
cypress/integration/view_groups_spec.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
@ -27,6 +27,12 @@ collections: # A list of collections the CMS should be able to edit
|
|||||||
- label: Drafts
|
- label: Drafts
|
||||||
field: draft
|
field: draft
|
||||||
pattern: true
|
pattern: true
|
||||||
|
view_groups:
|
||||||
|
- label: Year
|
||||||
|
field: date
|
||||||
|
pattern: \d{4}
|
||||||
|
- label: Drafts
|
||||||
|
field: draft
|
||||||
fields: # The fields each document in this collection have
|
fields: # The fields each document in this collection have
|
||||||
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
|
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
|
||||||
- { label: 'Draft', name: 'draft', widget: 'boolean', default: false }
|
- { label: 'Draft', name: 'draft', widget: 'boolean', default: false }
|
||||||
|
@ -27,6 +27,12 @@ collections: # A list of collections the CMS should be able to edit
|
|||||||
- label: Drafts
|
- label: Drafts
|
||||||
field: draft
|
field: draft
|
||||||
pattern: true
|
pattern: true
|
||||||
|
view_groups:
|
||||||
|
- label: Year
|
||||||
|
field: date
|
||||||
|
pattern: \d{4}
|
||||||
|
- label: Drafts
|
||||||
|
field: draft
|
||||||
fields: # The fields each document in this collection have
|
fields: # The fields each document in this collection have
|
||||||
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
|
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
|
||||||
- { label: 'Draft', name: 'draft', widget: 'boolean', default: false }
|
- { label: 'Draft', name: 'draft', widget: 'boolean', default: false }
|
||||||
|
@ -528,6 +528,7 @@ describe('config', () => {
|
|||||||
meta: {},
|
meta: {},
|
||||||
publish: true,
|
publish: true,
|
||||||
view_filters: [],
|
view_filters: [],
|
||||||
|
view_groups: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sortable_fields: [],
|
sortable_fields: [],
|
||||||
@ -557,6 +558,7 @@ describe('config', () => {
|
|||||||
],
|
],
|
||||||
publish: true,
|
publish: true,
|
||||||
view_filters: [],
|
view_filters: [],
|
||||||
|
view_groups: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
@ -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 = {
|
const defaults = {
|
||||||
publish_mode: publishModes.SIMPLE,
|
publish_mode: publishModes.SIMPLE,
|
||||||
};
|
};
|
||||||
@ -256,16 +269,8 @@ export function applyDefaults(config) {
|
|||||||
collection = collection.set('sortable_fields', fromJS(defaultSortable));
|
collection = collection.set('sortable_fields', fromJS(defaultSortable));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!collection.has('view_filters')) {
|
collection = setViewPatternsDefaults('view_filters', collection);
|
||||||
collection = collection.set('view_filters', fromJS([]));
|
collection = setViewPatternsDefaults('view_groups', collection);
|
||||||
} else {
|
|
||||||
collection = collection.set(
|
|
||||||
'view_filters',
|
|
||||||
collection
|
|
||||||
.get('view_filters')
|
|
||||||
.map(v => v.set('id', `${v.get('field')}__${v.get('pattern')}`)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (map.hasIn(['editor', 'preview']) && !collection.has('editor')) {
|
if (map.hasIn(['editor', 'preview']) && !collection.has('editor')) {
|
||||||
collection = collection.setIn(['editor', 'preview'], map.getIn(['editor', 'preview']));
|
collection = collection.setIn(['editor', 'preview'], map.getIn(['editor', 'preview']));
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
EntryField,
|
EntryField,
|
||||||
SortDirection,
|
SortDirection,
|
||||||
ViewFilter,
|
ViewFilter,
|
||||||
|
ViewGroup,
|
||||||
Entry,
|
Entry,
|
||||||
} from '../types/redux';
|
} 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_SUCCESS = 'FILTER_ENTRIES_SUCCESS';
|
||||||
export const FILTER_ENTRIES_FAILURE = 'FILTER_ENTRIES_FAILURE';
|
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_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY';
|
||||||
export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY';
|
export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY';
|
||||||
export const DRAFT_DISCARD = 'DRAFT_DISCARD';
|
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) {
|
export function changeViewStyle(viewStyle: string) {
|
||||||
return {
|
return {
|
||||||
type: CHANGE_VIEW_STYLE,
|
type: CHANGE_VIEW_STYLE,
|
||||||
|
@ -11,9 +11,18 @@ import CollectionTop from './CollectionTop';
|
|||||||
import EntriesCollection from './Entries/EntriesCollection';
|
import EntriesCollection from './Entries/EntriesCollection';
|
||||||
import EntriesSearch from './Entries/EntriesSearch';
|
import EntriesSearch from './Entries/EntriesSearch';
|
||||||
import CollectionControls from './CollectionControls';
|
import CollectionControls from './CollectionControls';
|
||||||
import { sortByField, filterByField, changeViewStyle } from '../../actions/entries';
|
import { sortByField, filterByField, changeViewStyle, groupByField } from '../../actions/entries';
|
||||||
import { selectSortableFields, selectViewFilters } from '../../reducers/collections';
|
import {
|
||||||
import { selectEntriesSort, selectEntriesFilter, selectViewStyle } from '../../reducers/entries';
|
selectSortableFields,
|
||||||
|
selectViewFilters,
|
||||||
|
selectViewGroups,
|
||||||
|
} from '../../reducers/collections';
|
||||||
|
import {
|
||||||
|
selectEntriesSort,
|
||||||
|
selectEntriesFilter,
|
||||||
|
selectEntriesGroup,
|
||||||
|
selectViewStyle,
|
||||||
|
} from '../../reducers/entries';
|
||||||
|
|
||||||
const CollectionContainer = styled.div`
|
const CollectionContainer = styled.div`
|
||||||
margin: ${lengths.pageMargin};
|
margin: ${lengths.pageMargin};
|
||||||
@ -74,10 +83,13 @@ export class Collection extends React.Component {
|
|||||||
onSortClick,
|
onSortClick,
|
||||||
sort,
|
sort,
|
||||||
viewFilters,
|
viewFilters,
|
||||||
|
viewGroups,
|
||||||
filterTerm,
|
filterTerm,
|
||||||
t,
|
t,
|
||||||
onFilterClick,
|
onFilterClick,
|
||||||
|
onGroupClick,
|
||||||
filter,
|
filter,
|
||||||
|
group,
|
||||||
onChangeViewStyle,
|
onChangeViewStyle,
|
||||||
viewStyle,
|
viewStyle,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
@ -118,9 +130,12 @@ export class Collection extends React.Component {
|
|||||||
onSortClick={onSortClick}
|
onSortClick={onSortClick}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
viewFilters={viewFilters}
|
viewFilters={viewFilters}
|
||||||
|
viewGroups={viewGroups}
|
||||||
t={t}
|
t={t}
|
||||||
onFilterClick={onFilterClick}
|
onFilterClick={onFilterClick}
|
||||||
|
onGroupClick={onGroupClick}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
|
group={group}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -139,7 +154,9 @@ function mapStateToProps(state, ownProps) {
|
|||||||
const sort = selectEntriesSort(state.entries, collection.get('name'));
|
const sort = selectEntriesSort(state.entries, collection.get('name'));
|
||||||
const sortableFields = selectSortableFields(collection, t);
|
const sortableFields = selectSortableFields(collection, t);
|
||||||
const viewFilters = selectViewFilters(collection);
|
const viewFilters = selectViewFilters(collection);
|
||||||
|
const viewGroups = selectViewGroups(collection);
|
||||||
const filter = selectEntriesFilter(state.entries, collection.get('name'));
|
const filter = selectEntriesFilter(state.entries, collection.get('name'));
|
||||||
|
const group = selectEntriesGroup(state.entries, collection.get('name'));
|
||||||
const viewStyle = selectViewStyle(state.entries);
|
const viewStyle = selectViewStyle(state.entries);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -152,7 +169,9 @@ function mapStateToProps(state, ownProps) {
|
|||||||
sort,
|
sort,
|
||||||
sortableFields,
|
sortableFields,
|
||||||
viewFilters,
|
viewFilters,
|
||||||
|
viewGroups,
|
||||||
filter,
|
filter,
|
||||||
|
group,
|
||||||
viewStyle,
|
viewStyle,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -161,6 +180,7 @@ const mapDispatchToProps = {
|
|||||||
sortByField,
|
sortByField,
|
||||||
filterByField,
|
filterByField,
|
||||||
changeViewStyle,
|
changeViewStyle,
|
||||||
|
groupByField,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mergeProps = (stateProps, dispatchProps, ownProps) => {
|
const mergeProps = (stateProps, dispatchProps, ownProps) => {
|
||||||
@ -170,6 +190,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
|
|||||||
onSortClick: (key, direction) =>
|
onSortClick: (key, direction) =>
|
||||||
dispatchProps.sortByField(stateProps.collection, key, direction),
|
dispatchProps.sortByField(stateProps.collection, key, direction),
|
||||||
onFilterClick: filter => dispatchProps.filterByField(stateProps.collection, filter),
|
onFilterClick: filter => dispatchProps.filterByField(stateProps.collection, filter),
|
||||||
|
onGroupClick: group => dispatchProps.groupByField(stateProps.collection, group),
|
||||||
onChangeViewStyle: viewStyle => dispatchProps.changeViewStyle(viewStyle),
|
onChangeViewStyle: viewStyle => dispatchProps.changeViewStyle(viewStyle),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -4,6 +4,7 @@ import ViewStyleControl from './ViewStyleControl';
|
|||||||
import SortControl from './SortControl';
|
import SortControl from './SortControl';
|
||||||
import FilterControl from './FilterControl';
|
import FilterControl from './FilterControl';
|
||||||
import { lengths } from 'netlify-cms-ui-default';
|
import { lengths } from 'netlify-cms-ui-default';
|
||||||
|
import GroupControl from './GroupControl';
|
||||||
|
|
||||||
const CollectionControlsContainer = styled.div`
|
const CollectionControlsContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -25,13 +26,19 @@ const CollectionControls = ({
|
|||||||
onSortClick,
|
onSortClick,
|
||||||
sort,
|
sort,
|
||||||
viewFilters,
|
viewFilters,
|
||||||
|
viewGroups,
|
||||||
onFilterClick,
|
onFilterClick,
|
||||||
|
onGroupClick,
|
||||||
t,
|
t,
|
||||||
filter,
|
filter,
|
||||||
|
group,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<CollectionControlsContainer>
|
<CollectionControlsContainer>
|
||||||
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
|
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
|
||||||
|
{viewGroups.length > 0 && (
|
||||||
|
<GroupControl viewGroups={viewGroups} onGroupClick={onGroupClick} t={t} group={group} />
|
||||||
|
)}
|
||||||
{viewFilters.length > 0 && (
|
{viewFilters.length > 0 && (
|
||||||
<FilterControl
|
<FilterControl
|
||||||
viewFilters={viewFilters}
|
viewFilters={viewFilters}
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -2,21 +2,65 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { translate } from 'react-polyglot';
|
||||||
import { partial } from 'lodash';
|
import { partial } from 'lodash';
|
||||||
import { Cursor } from 'netlify-cms-lib-util';
|
import { Cursor } from 'netlify-cms-lib-util';
|
||||||
|
import { colors } from 'netlify-cms-ui-default';
|
||||||
import {
|
import {
|
||||||
loadEntries as actionLoadEntries,
|
loadEntries as actionLoadEntries,
|
||||||
traverseCollectionCursor as actionTraverseCollectionCursor,
|
traverseCollectionCursor as actionTraverseCollectionCursor,
|
||||||
} from 'Actions/entries';
|
} from 'Actions/entries';
|
||||||
import { selectEntries, selectEntriesLoaded, selectIsFetching } from '../../../reducers/entries';
|
import {
|
||||||
|
selectEntries,
|
||||||
|
selectEntriesLoaded,
|
||||||
|
selectIsFetching,
|
||||||
|
selectGroups,
|
||||||
|
} from '../../../reducers/entries';
|
||||||
import { selectCollectionEntriesCursor } from 'Reducers/cursors';
|
import { selectCollectionEntriesCursor } from 'Reducers/cursors';
|
||||||
import Entries from './Entries';
|
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 {
|
export class EntriesCollection extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
collection: ImmutablePropTypes.map.isRequired,
|
collection: ImmutablePropTypes.map.isRequired,
|
||||||
page: PropTypes.number,
|
page: PropTypes.number,
|
||||||
entries: ImmutablePropTypes.list,
|
entries: ImmutablePropTypes.list,
|
||||||
|
groups: PropTypes.array,
|
||||||
isFetching: PropTypes.bool.isRequired,
|
isFetching: PropTypes.bool.isRequired,
|
||||||
viewStyle: PropTypes.string,
|
viewStyle: PropTypes.string,
|
||||||
cursor: PropTypes.object.isRequired,
|
cursor: PropTypes.object.isRequired,
|
||||||
@ -45,20 +89,28 @@ export class EntriesCollection extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
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 }) => {
|
||||||
<Entries
|
return (
|
||||||
collections={collection}
|
<Entries
|
||||||
entries={entries}
|
collections={collection}
|
||||||
isFetching={isFetching}
|
entries={entries}
|
||||||
collectionName={collection.get('label')}
|
isFetching={isFetching}
|
||||||
viewStyle={viewStyle}
|
collectionName={collection.get('label')}
|
||||||
cursor={cursor}
|
viewStyle={viewStyle}
|
||||||
handleCursorActions={partial(this.handleCursorActions, cursor)}
|
cursor={cursor}
|
||||||
page={page}
|
handleCursorActions={partial(this.handleCursorActions, cursor)}
|
||||||
/>
|
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']);
|
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
|
||||||
|
|
||||||
let entries = selectEntries(state.entries, collection);
|
let entries = selectEntries(state.entries, collection);
|
||||||
|
const groups = selectGroups(state.entries, collection);
|
||||||
|
|
||||||
if (collection.has('nested')) {
|
if (collection.has('nested')) {
|
||||||
const collectionFolder = collection.get('folder');
|
const collectionFolder = collection.get('folder');
|
||||||
@ -98,7 +151,7 @@ function mapStateToProps(state, ownProps) {
|
|||||||
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name'));
|
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name'));
|
||||||
const cursor = Cursor.create(rawCursor).clearData();
|
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 = {
|
const mapDispatchToProps = {
|
||||||
@ -106,4 +159,6 @@ const mapDispatchToProps = {
|
|||||||
traverseCollectionCursor: actionTraverseCollectionCursor,
|
traverseCollectionCursor: actionTraverseCollectionCursor,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(EntriesCollection);
|
const ConnectedEntriesCollection = connect(mapStateToProps, mapDispatchToProps)(EntriesCollection);
|
||||||
|
|
||||||
|
export default translate()(ConnectedEntriesCollection);
|
||||||
|
@ -1,25 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { css } from '@emotion/core';
|
|
||||||
import styled from '@emotion/styled';
|
|
||||||
import { translate } from 'react-polyglot';
|
import { translate } from 'react-polyglot';
|
||||||
import {
|
import { Dropdown, DropdownCheckedItem } from 'netlify-cms-ui-default';
|
||||||
buttons,
|
import { ControlButton } from './ControlButton';
|
||||||
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 FilterControl = ({ viewFilters, t, onFilterClick, filter }) => {
|
||||||
const hasActiveFilter = filter
|
const hasActiveFilter = filter
|
||||||
@ -31,13 +13,7 @@ const FilterControl = ({ viewFilters, t, onFilterClick, filter }) => {
|
|||||||
<Dropdown
|
<Dropdown
|
||||||
renderButton={() => {
|
renderButton={() => {
|
||||||
return (
|
return (
|
||||||
<FilterButton
|
<ControlButton active={hasActiveFilter} title={t('collection.collectionTop.filterBy')} />
|
||||||
css={css`
|
|
||||||
color: ${hasActiveFilter ? colors.active : undefined};
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{t('collection.collectionTop.filterBy')}
|
|
||||||
</FilterButton>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
closeOnSelection={false}
|
closeOnSelection={false}
|
||||||
|
@ -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);
|
@ -1,26 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { css } from '@emotion/core';
|
|
||||||
import styled from '@emotion/styled';
|
|
||||||
import { translate } from 'react-polyglot';
|
import { translate } from 'react-polyglot';
|
||||||
import {
|
import { Dropdown, DropdownItem } from 'netlify-cms-ui-default';
|
||||||
buttons,
|
|
||||||
Dropdown,
|
|
||||||
DropdownItem,
|
|
||||||
StyledDropdownButton,
|
|
||||||
colors,
|
|
||||||
} from 'netlify-cms-ui-default';
|
|
||||||
import { SortDirection } from '../../types/redux';
|
import { SortDirection } from '../../types/redux';
|
||||||
|
import { ControlButton } from './ControlButton';
|
||||||
const SortButton = styled(StyledDropdownButton)`
|
|
||||||
${buttons.button};
|
|
||||||
${buttons.medium};
|
|
||||||
${buttons.grayText};
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
top: 11px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function nextSortDirection(direction) {
|
function nextSortDirection(direction) {
|
||||||
switch (direction) {
|
switch (direction) {
|
||||||
@ -54,15 +36,11 @@ const SortControl = ({ t, fields, onSortClick, sort }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
renderButton={() => (
|
renderButton={() => {
|
||||||
<SortButton
|
return (
|
||||||
css={css`
|
<ControlButton active={hasActiveSort} title={t('collection.collectionTop.sortBy')} />
|
||||||
color: ${hasActiveSort ? colors.active : undefined};
|
);
|
||||||
`}
|
}}
|
||||||
>
|
|
||||||
{t('collection.collectionTop.sortBy')}
|
|
||||||
</SortButton>
|
|
||||||
)}
|
|
||||||
closeOnSelection={false}
|
closeOnSelection={false}
|
||||||
dropdownTopOverlap="30px"
|
dropdownTopOverlap="30px"
|
||||||
dropdownWidth="160px"
|
dropdownWidth="160px"
|
||||||
|
@ -21,7 +21,12 @@ const renderWithRedux = (component, { store } = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('Collection', () => {
|
describe('Collection', () => {
|
||||||
const collection = fromJS({ name: 'pages', sortable_fields: [], view_filters: [] });
|
const collection = fromJS({
|
||||||
|
name: 'pages',
|
||||||
|
sortable_fields: [],
|
||||||
|
view_filters: [],
|
||||||
|
view_groups: [],
|
||||||
|
});
|
||||||
const props = {
|
const props = {
|
||||||
collections: fromJS([collection]).toOrderedMap(),
|
collections: fromJS([collection]).toOrderedMap(),
|
||||||
collection,
|
collection,
|
||||||
|
@ -14,8 +14,8 @@ exports[`Collection should render connected component 1`] = `
|
|||||||
class="emotion-2 emotion-3"
|
class="emotion-2 emotion-3"
|
||||||
>
|
>
|
||||||
<mock-sidebar
|
<mock-sidebar
|
||||||
collection="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 [] } }"
|
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] } }"
|
||||||
filterterm=""
|
filterterm=""
|
||||||
searchterm=""
|
searchterm=""
|
||||||
/>
|
/>
|
||||||
@ -23,16 +23,18 @@ exports[`Collection should render connected component 1`] = `
|
|||||||
class="emotion-0 emotion-1"
|
class="emotion-0 emotion-1"
|
||||||
>
|
>
|
||||||
<mock-collection-top
|
<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=""
|
newentryurl=""
|
||||||
/>
|
/>
|
||||||
<mock-collection-controls
|
<mock-collection-controls
|
||||||
filter="Map {}"
|
filter="Map {}"
|
||||||
|
group="Map {}"
|
||||||
sortablefields=""
|
sortablefields=""
|
||||||
viewfilters=""
|
viewfilters=""
|
||||||
|
viewgroups=""
|
||||||
/>
|
/>
|
||||||
<mock-entries-collection
|
<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=""
|
filterterm=""
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
@ -54,19 +56,19 @@ exports[`Collection should render with collection with create url 1`] = `
|
|||||||
class="emotion-2 emotion-3"
|
class="emotion-2 emotion-3"
|
||||||
>
|
>
|
||||||
<mock-sidebar
|
<mock-sidebar
|
||||||
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 }"
|
||||||
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [] } }"
|
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] } }"
|
||||||
/>
|
/>
|
||||||
<main
|
<main
|
||||||
class="emotion-0 emotion-1"
|
class="emotion-0 emotion-1"
|
||||||
>
|
>
|
||||||
<mock-collection-top
|
<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"
|
newentryurl="/collections/pages/new"
|
||||||
/>
|
/>
|
||||||
<mock-collection-controls />
|
<mock-collection-controls />
|
||||||
<mock-entries-collection
|
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@ -87,20 +89,20 @@ exports[`Collection should render with collection with create url and path 1`] =
|
|||||||
class="emotion-2 emotion-3"
|
class="emotion-2 emotion-3"
|
||||||
>
|
>
|
||||||
<mock-sidebar
|
<mock-sidebar
|
||||||
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 }"
|
||||||
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [] } }"
|
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] } }"
|
||||||
filterterm="dir1/dir2"
|
filterterm="dir1/dir2"
|
||||||
/>
|
/>
|
||||||
<main
|
<main
|
||||||
class="emotion-0 emotion-1"
|
class="emotion-0 emotion-1"
|
||||||
>
|
>
|
||||||
<mock-collection-top
|
<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"
|
newentryurl="/collections/pages/new?path=dir1/dir2"
|
||||||
/>
|
/>
|
||||||
<mock-collection-controls />
|
<mock-collection-controls />
|
||||||
<mock-entries-collection
|
<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"
|
filterterm="dir1/dir2"
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
@ -122,19 +124,19 @@ exports[`Collection should render with collection without create url 1`] = `
|
|||||||
class="emotion-2 emotion-3"
|
class="emotion-2 emotion-3"
|
||||||
>
|
>
|
||||||
<mock-sidebar
|
<mock-sidebar
|
||||||
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 }"
|
||||||
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [] } }"
|
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] } }"
|
||||||
/>
|
/>
|
||||||
<main
|
<main
|
||||||
class="emotion-0 emotion-1"
|
class="emotion-0 emotion-1"
|
||||||
>
|
>
|
||||||
<mock-collection-top
|
<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=""
|
newentryurl=""
|
||||||
/>
|
/>
|
||||||
<mock-collection-controls />
|
<mock-collection-controls />
|
||||||
<mock-entries-collection
|
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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
|
* The schema had to be wrapped in a function to
|
||||||
* fix a circular dependency problem for WebPack,
|
* fix a circular dependency problem for WebPack,
|
||||||
@ -234,6 +249,7 @@ const getConfigSchema = () => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
view_filters: viewFilters,
|
view_filters: viewFilters,
|
||||||
|
view_groups: viewGroups,
|
||||||
nested: {
|
nested: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
State,
|
State,
|
||||||
EntryMap,
|
EntryMap,
|
||||||
ViewFilter,
|
ViewFilter,
|
||||||
|
ViewGroup,
|
||||||
} from '../types/redux';
|
} from '../types/redux';
|
||||||
import { selectMediaFolder } from './entries';
|
import { selectMediaFolder } from './entries';
|
||||||
import { stringTemplate } from 'netlify-cms-lib-widgets';
|
import { stringTemplate } from 'netlify-cms-lib-widgets';
|
||||||
@ -430,6 +431,11 @@ export const selectViewFilters = (collection: Collection) => {
|
|||||||
return viewFilters;
|
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) => {
|
export const selectFieldsComments = (collection: Collection, entryMap: EntryMap) => {
|
||||||
let fields: EntryField[] = [];
|
let fields: EntryField[] = [];
|
||||||
if (collection.has('folder')) {
|
if (collection.has('folder')) {
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import { fromJS } from 'immutable';
|
import { fromJS } from 'immutable';
|
||||||
import { Cursor } from 'netlify-cms-lib-util';
|
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
|
// Since pagination can be used for a variety of views (collections
|
||||||
// and searches are the most common examples), we namespace cursors by
|
// 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,
|
Cursor.create(action.payload.cursor).store,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case FILTER_ENTRIES_SUCCESS:
|
||||||
|
case GROUP_ENTRIES_SUCCESS:
|
||||||
case SORT_ENTRIES_SUCCESS: {
|
case SORT_ENTRIES_SUCCESS: {
|
||||||
return state.deleteIn(['cursorsByType', 'collectionEntries', action.payload.collection]);
|
return state.deleteIn(['cursorsByType', 'collectionEntries', action.payload.collection]);
|
||||||
}
|
}
|
||||||
|
@ -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 { dirname, join } from 'path';
|
||||||
import {
|
import {
|
||||||
ENTRY_REQUEST,
|
ENTRY_REQUEST,
|
||||||
@ -14,6 +14,9 @@ import {
|
|||||||
FILTER_ENTRIES_REQUEST,
|
FILTER_ENTRIES_REQUEST,
|
||||||
FILTER_ENTRIES_SUCCESS,
|
FILTER_ENTRIES_SUCCESS,
|
||||||
FILTER_ENTRIES_FAILURE,
|
FILTER_ENTRIES_FAILURE,
|
||||||
|
GROUP_ENTRIES_REQUEST,
|
||||||
|
GROUP_ENTRIES_SUCCESS,
|
||||||
|
GROUP_ENTRIES_FAILURE,
|
||||||
CHANGE_VIEW_STYLE,
|
CHANGE_VIEW_STYLE,
|
||||||
} from '../actions/entries';
|
} from '../actions/entries';
|
||||||
import { SEARCH_ENTRIES_SUCCESS } from '../actions/search';
|
import { SEARCH_ENTRIES_SUCCESS } from '../actions/search';
|
||||||
@ -40,14 +43,19 @@ import {
|
|||||||
Sort,
|
Sort,
|
||||||
SortDirection,
|
SortDirection,
|
||||||
Filter,
|
Filter,
|
||||||
|
Group,
|
||||||
FilterMap,
|
FilterMap,
|
||||||
|
GroupMap,
|
||||||
EntriesFilterRequestPayload,
|
EntriesFilterRequestPayload,
|
||||||
EntriesFilterFailurePayload,
|
EntriesFilterFailurePayload,
|
||||||
ChangeViewStylePayload,
|
ChangeViewStylePayload,
|
||||||
|
EntriesGroupRequestPayload,
|
||||||
|
EntriesGroupFailurePayload,
|
||||||
|
GroupOfEntries,
|
||||||
} from '../types/redux';
|
} from '../types/redux';
|
||||||
import { folderFormatter } from '../lib/formatters';
|
import { folderFormatter } from '../lib/formatters';
|
||||||
import { isAbsolutePath, basename } from 'netlify-cms-lib-util';
|
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 { selectSortDataPath } from './collections';
|
||||||
import { stringTemplate } from 'netlify-cms-lib-widgets';
|
import { stringTemplate } from 'netlify-cms-lib-widgets';
|
||||||
import { VIEW_STYLE_LIST } from '../constants/collectionViews';
|
import { VIEW_STYLE_LIST } from '../constants/collectionViews';
|
||||||
@ -239,6 +247,7 @@ const entries = (
|
|||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case GROUP_ENTRIES_SUCCESS:
|
||||||
case FILTER_ENTRIES_SUCCESS:
|
case FILTER_ENTRIES_SUCCESS:
|
||||||
case SORT_ENTRIES_SUCCESS: {
|
case SORT_ENTRIES_SUCCESS: {
|
||||||
const payload = action.payload as { collection: string; entries: EntryObject[] };
|
const payload = action.payload as { collection: string; entries: EntryObject[] };
|
||||||
@ -298,6 +307,30 @@ const entries = (
|
|||||||
return newState;
|
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: {
|
case CHANGE_VIEW_STYLE: {
|
||||||
const payload = (action.payload as unknown) as ChangeViewStylePayload;
|
const payload = (action.payload as unknown) as ChangeViewStylePayload;
|
||||||
const { style } = payload;
|
const { style } = payload;
|
||||||
@ -323,6 +356,17 @@ export const selectEntriesFilter = (entries: Entries, collection: string) => {
|
|||||||
return filter?.get(collection) || Map();
|
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) => {
|
export const selectEntriesSortFields = (entries: Entries, collection: string) => {
|
||||||
const sort = selectEntriesSort(entries, collection);
|
const sort = selectEntriesSort(entries, collection);
|
||||||
const values =
|
const values =
|
||||||
@ -354,12 +398,17 @@ export const selectEntry = (state: Entries, collection: string, slug: string) =>
|
|||||||
export const selectPublishedSlugs = (state: Entries, collection: string) =>
|
export const selectPublishedSlugs = (state: Entries, collection: string) =>
|
||||||
state.getIn(['pages', collection, 'ids'], List<string>());
|
state.getIn(['pages', collection, 'ids'], List<string>());
|
||||||
|
|
||||||
export const selectEntries = (state: Entries, collection: Collection) => {
|
const getPublishedEntries = (state: Entries, collectionName: string) => {
|
||||||
const collectionName = collection.get('name');
|
|
||||||
const slugs = selectPublishedSlugs(state, collectionName);
|
const slugs = selectPublishedSlugs(state, collectionName);
|
||||||
let entries =
|
const entries =
|
||||||
slugs &&
|
slugs &&
|
||||||
(slugs.map(slug => selectEntry(state, collectionName, slug as string)) as List<EntryMap>);
|
(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);
|
const sortFields = selectEntriesSortFields(state, collectionName);
|
||||||
if (sortFields && sortFields.length > 0) {
|
if (sortFields && sortFields.length > 0) {
|
||||||
@ -391,6 +440,75 @@ export const selectEntries = (state: Entries, collection: Collection) => {
|
|||||||
return entries;
|
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) => {
|
export const selectEntryByPath = (state: Entries, collection: string, path: string) => {
|
||||||
const slugs = selectPublishedSlugs(state, collection);
|
const slugs = selectPublishedSlugs(state, collection);
|
||||||
const entries =
|
const entries =
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Action } from 'redux';
|
import { Action } from 'redux';
|
||||||
import { StaticallyTypedRecord } from './immutable';
|
import { StaticallyTypedRecord } from './immutable';
|
||||||
import { Map, List, OrderedMap } from 'immutable';
|
import { Map, List, OrderedMap, Set } from 'immutable';
|
||||||
import AssetProxy from '../valueObjects/AssetProxy';
|
import AssetProxy from '../valueObjects/AssetProxy';
|
||||||
import { MediaFile as BackendMediaFile } from '../backend';
|
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 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 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 Entities = StaticallyTypedRecord<EntitiesObject>;
|
||||||
|
|
||||||
export type Entries = StaticallyTypedRecord<{
|
export type Entries = StaticallyTypedRecord<{
|
||||||
@ -75,6 +86,7 @@ export type Entries = StaticallyTypedRecord<{
|
|||||||
entities: Entities & EntitiesObject;
|
entities: Entities & EntitiesObject;
|
||||||
sort: Sort;
|
sort: Sort;
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
|
group: Group;
|
||||||
viewStyle: string;
|
viewStyle: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@ -152,6 +164,14 @@ export type ViewFilter = {
|
|||||||
pattern: string;
|
pattern: string;
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ViewGroup = {
|
||||||
|
label: string;
|
||||||
|
field: string;
|
||||||
|
pattern: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
type NestedObject = { depth: number };
|
type NestedObject = { depth: number };
|
||||||
|
|
||||||
type Nested = StaticallyTypedRecord<NestedObject>;
|
type Nested = StaticallyTypedRecord<NestedObject>;
|
||||||
@ -194,6 +214,7 @@ type CollectionObject = {
|
|||||||
label: string;
|
label: string;
|
||||||
sortable_fields: List<string>;
|
sortable_fields: List<string>;
|
||||||
view_filters: List<StaticallyTypedRecord<ViewFilter>>;
|
view_filters: List<StaticallyTypedRecord<ViewFilter>>;
|
||||||
|
view_groups: List<StaticallyTypedRecord<ViewGroup>>;
|
||||||
nested?: Nested;
|
nested?: Nested;
|
||||||
meta?: Meta;
|
meta?: Meta;
|
||||||
i18n: i18n;
|
i18n: i18n;
|
||||||
@ -359,6 +380,17 @@ export interface EntriesFilterFailurePayload {
|
|||||||
error: Error;
|
error: Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntriesGroupRequestPayload {
|
||||||
|
group: ViewGroup;
|
||||||
|
collection: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntriesGroupFailurePayload {
|
||||||
|
group: ViewGroup;
|
||||||
|
collection: string;
|
||||||
|
error: Error;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChangeViewStylePayload {
|
export interface ChangeViewStylePayload {
|
||||||
style: string;
|
style: string;
|
||||||
}
|
}
|
||||||
|
@ -47,6 +47,7 @@ const en = {
|
|||||||
searchResults: 'Search Results for "%{searchTerm}"',
|
searchResults: 'Search Results for "%{searchTerm}"',
|
||||||
searchResultsInCollection: 'Search Results for "%{searchTerm}" in %{collection}',
|
searchResultsInCollection: 'Search Results for "%{searchTerm}" in %{collection}',
|
||||||
filterBy: 'Filter by',
|
filterBy: 'Filter by',
|
||||||
|
groupBy: 'Group by',
|
||||||
},
|
},
|
||||||
entries: {
|
entries: {
|
||||||
loadingEntries: 'Loading Entries...',
|
loadingEntries: 'Loading Entries...',
|
||||||
@ -54,6 +55,10 @@ const en = {
|
|||||||
longerLoading: 'This might take several minutes',
|
longerLoading: 'This might take several minutes',
|
||||||
noEntries: 'No Entries',
|
noEntries: 'No Entries',
|
||||||
},
|
},
|
||||||
|
groups: {
|
||||||
|
other: 'Other',
|
||||||
|
negateLabel: 'Not %{label}',
|
||||||
|
},
|
||||||
defaultFields: {
|
defaultFields: {
|
||||||
author: {
|
author: {
|
||||||
label: 'Author',
|
label: 'Author',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user