feat: add filter to collection view (#3741)

This commit is contained in:
Shashank Bairy R 2020-05-24 17:37:08 +00:00 committed by GitHub
parent d3aaf4ddb3
commit c28cc0c9e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 652 additions and 63 deletions

View File

@ -0,0 +1,105 @@
import { login } from '../utils/steps';
const filter = term => {
cy.get('[class*=FilterButton]').click();
cy.contains(term).click();
cy.contains('Contents').click();
};
const assertEntriesCount = count => {
cy.get('[class*=ListCardLink]').should('have.length', count);
};
const assertInEntries = text => {
cy.get('[class*=ListCardLink]').within(() => {
cy.contains('h2', text);
});
};
const assertNotInEntries = text => {
cy.get('[class*=ListCardLink]').within(() => {
cy.contains('h2', text).should('not.exist');
});
};
describe('View Filter', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
beforeEach(() => {
login();
});
it('can apply string filter', () => {
// enable filter
filter('Posts With Index');
assertEntriesCount(20);
for (let i = 1; i <= 20; i++) {
assertInEntries(`This is post # ${i} --`);
}
assertNotInEntries('This is a YAML front matter post');
assertNotInEntries('This is a JSON front matter post');
assertNotInEntries('This is a TOML front matter post');
// disable filter
filter('Posts With Index');
assertEntriesCount(23);
for (let i = 1; i <= 20; i++) {
assertInEntries(`This is post # ${i} --`);
}
assertInEntries('This is a YAML front matter post');
assertInEntries('This is a JSON front matter post');
assertInEntries('This is a TOML front matter post');
});
it('can apply boolean filter', () => {
// enable filter
filter('Drafts');
assertEntriesCount(10);
for (let i = 1; i <= 20; i++) {
const draft = i % 2 === 0;
if (draft) {
assertInEntries(`This is post # ${i} --`);
} else {
assertNotInEntries(`This is post # ${i} --`);
}
}
assertNotInEntries('This is a YAML front matter post');
assertNotInEntries('This is a JSON front matter post');
assertNotInEntries('This is a TOML front matter post');
// disable filter
filter('Drafts');
assertEntriesCount(23);
for (let i = 1; i <= 20; i++) {
assertInEntries(`This is post # ${i} --`);
}
assertInEntries('This is a YAML front matter post');
assertInEntries('This is a JSON front matter post');
assertInEntries('This is a TOML front matter post');
});
it('can apply multiple filters', () => {
// enable filter
filter('Posts Without Index');
assertEntriesCount(3);
assertInEntries('This is a YAML front matter post');
assertInEntries('This is a JSON front matter post');
assertInEntries('This is a TOML front matter post');
filter('Drafts');
assertEntriesCount(0);
cy.contains('div', 'No Entries');
});
});

View File

@ -275,10 +275,12 @@ Cypress.Commands.add('insertEditorComponent', title => {
});
Cypress.Commands.add('clickModeToggle', () => {
cy.get('.cms-editor-visual').within(() => {
cy.get('button[role="switch"]')
.click()
.focused();
});
});
[['insertCodeBlock', 'Code Block']].forEach(([commandName, componentTitle]) => {
Cypress.Commands.add(commandName, () => {

View File

@ -17,8 +17,19 @@ collections: # A list of collections the CMS should be able to edit
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
create: true # Allow users to create new documents in this collection
view_filters:
- label: Posts With Index
field: title
pattern: 'This is post #'
- label: Posts Without Index
field: title
pattern: front matter post
- label: Drafts
field: draft
pattern: true
fields: # The fields each document in this collection have
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
- { label: 'Draft', name: 'draft', widget: 'boolean', default: false }
- {
label: 'Publish Date',
name: 'date',
@ -124,6 +135,7 @@ collections: # A list of collections the CMS should be able to edit
- label: 'Object'
name: 'object'
widget: 'object'
collapsed: true
fields:
- label: 'Related Post'
name: 'post'

View File

@ -59,7 +59,7 @@
var slug = dateString + "-post-number-" + i + ".md";
window.repoFiles._posts[slug] = {
content: "---\ntitle: \"This is post # " + i + "\"\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# The post is number " + i + "\n\nAnd this is yet another identical post body"
content: "---\ntitle: \"This is post # " + i + `\"\ndraft: ${i % 2 === 0}` + "\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# The post is number " + i + "\n\nAnd this is yet another identical post body"
}
}

View File

@ -17,8 +17,19 @@ collections: # A list of collections the CMS should be able to edit
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
create: true # Allow users to create new documents in this collection
view_filters:
- label: Posts With Index
field: title
pattern: 'This is post #'
- label: Posts Without Index
field: title
pattern: front matter post
- label: Drafts
field: draft
pattern: true
fields: # The fields each document in this collection have
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
- { label: 'Draft', name: 'draft', widget: 'boolean', default: false }
- {
label: 'Publish Date',
name: 'date',

View File

@ -59,7 +59,7 @@
var slug = dateString + "-post-number-" + i + ".md";
window.repoFiles._posts[slug] = {
content: "---\ntitle: \"This is post # " + i + "\"\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# The post is number " + i + "\n\nAnd this is yet another identical post body"
content: "---\ntitle: \"This is post # " + i + `\"\ndraft: ${i % 2 === 0}` + "\ndate: " + dateString + "T00:99:99.999Z\n---\n\n# The post is number " + i + "\n\nAnd this is yet another identical post body"
}
}

View File

@ -107,6 +107,17 @@ export function applyDefaults(config) {
collection = collection.set('sortableFields', fromJS(defaultSortable));
}
if (!collection.has('view_filters')) {
collection = collection.set('view_filters', fromJS([]));
} else {
collection = collection.set(
'view_filters',
collection
.get('view_filters')
.map(v => v.set('id', `${v.get('field')}__${v.get('pattern')}`)),
);
}
return collection;
}),
);

View File

@ -1,11 +1,11 @@
import { fromJS, List, Map, Set } from 'immutable';
import { isEqual, orderBy } from 'lodash';
import { isEqual } from 'lodash';
import { actions as notifActions } from 'redux-notifications';
import { serializeValues } from '../lib/serializeEntryValues';
import { currentBackend, Backend } from '../backend';
import { getIntegrationProvider } from '../integrations';
import { selectIntegration, selectPublishedSlugs } from '../reducers';
import { selectFields, updateFieldByKey, selectSortDataPath } from '../reducers/collections';
import { selectFields, updateFieldByKey } from '../reducers/collections';
import { selectCollectionEntriesCursor } from '../reducers/cursors';
import { Cursor, ImplementationMediaFile } from 'netlify-cms-lib-util';
import { createEntry, EntryValue } from '../valueObjects/Entry';
@ -19,6 +19,7 @@ import {
EntryFields,
EntryField,
SortDirection,
ViewFilter,
} from '../types/redux';
import { ThunkDispatch } from 'redux-thunk';
@ -44,6 +45,10 @@ export const SORT_ENTRIES_REQUEST = 'SORT_ENTRIES_REQUEST';
export const SORT_ENTRIES_SUCCESS = 'SORT_ENTRIES_SUCCESS';
export const SORT_ENTRIES_FAILURE = 'SORT_ENTRIES_FAILURE';
export const FILTER_ENTRIES_REQUEST = 'FILTER_ENTRIES_REQUEST';
export const FILTER_ENTRIES_SUCCESS = 'FILTER_ENTRIES_SUCCESS';
export const FILTER_ENTRIES_FAILURE = 'FILTER_ENTRIES_FAILURE';
export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY';
export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY';
export const DRAFT_DISCARD = 'DRAFT_DISCARD';
@ -137,6 +142,16 @@ export function entriesFailed(collection: Collection, error: Error) {
};
}
const getAllEntries = async (state: State, collection: Collection) => {
const backend = currentBackend(state.config);
const integration = selectIntegration(state, collection.get('name'), 'listEntries');
const provider: Backend = integration
? getIntegrationProvider(state.integrations, backend.getToken, integration)
: backend;
const entries = await provider.listAllEntries(collection);
return entries;
};
export function sortByField(
collection: Collection,
key: string,
@ -144,8 +159,6 @@ export function sortByField(
) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
// if we're already fetching we update the sort key, but skip loading entries
const isFetching = selectIsFetching(state.entries, collection.get('name'));
dispatch({
@ -161,22 +174,7 @@ export function sortByField(
}
try {
const integration = selectIntegration(state, collection.get('name'), 'listEntries');
const provider: Backend = integration
? getIntegrationProvider(state.integrations, backend.getToken, integration)
: backend;
let entries = await provider.listAllEntries(collection);
const sortFields = selectEntriesSortFields(getState().entries, collection.get('name'));
if (sortFields && sortFields.length > 0) {
const keys = sortFields.map(v => selectSortDataPath(collection, v.get('key')));
const orders = sortFields.map(v =>
v.get('direction') === SortDirection.Ascending ? 'asc' : 'desc',
);
entries = orderBy(entries, keys, orders);
}
const entries = await getAllEntries(state, collection);
dispatch({
type: SORT_ENTRIES_SUCCESS,
payload: {
@ -200,6 +198,45 @@ export function sortByField(
};
}
export function filterByField(collection: Collection, filter: ViewFilter) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
// if we're already fetching we update the filter key, but skip loading entries
const isFetching = selectIsFetching(state.entries, collection.get('name'));
dispatch({
type: FILTER_ENTRIES_REQUEST,
payload: {
collection: collection.get('name'),
filter,
},
});
if (isFetching) {
return;
}
try {
const entries = await getAllEntries(state, collection);
dispatch({
type: FILTER_ENTRIES_SUCCESS,
payload: {
collection: collection.get('name'),
filter,
entries,
},
});
} catch (error) {
dispatch({
type: FILTER_ENTRIES_FAILURE,
payload: {
collection: collection.get('name'),
filter,
error,
},
});
}
};
}
export function entryPersisting(collection: Collection, entry: EntryMap) {
return {
type: ENTRY_PERSIST_REQUEST,

View File

@ -11,10 +11,10 @@ import CollectionTop from './CollectionTop';
import EntriesCollection from './Entries/EntriesCollection';
import EntriesSearch from './Entries/EntriesSearch';
import CollectionControls from './CollectionControls';
import { sortByField } from 'Actions/entries';
import { selectSortableFields } from 'Reducers/collections';
import { selectEntriesSort } from 'Reducers/entries';
import { VIEW_STYLE_LIST } from 'Constants/collectionViews';
import { sortByField, filterByField } from '../../actions/entries';
import { selectSortableFields, selectViewFilters } from '../../reducers/collections';
import { selectEntriesSort, selectEntriesFilter } from '../../reducers/entries';
import { VIEW_STYLE_LIST } from '../../constants/collectionViews';
const CollectionContainer = styled.div`
margin: ${lengths.pageMargin};
@ -82,7 +82,10 @@ class Collection extends React.Component {
sortableFields,
onSortClick,
sort,
viewFilters,
t,
onFilterClick,
filter,
} = this.props;
const newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
@ -107,12 +110,15 @@ class Collection extends React.Component {
<>
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} />
<CollectionControls
collection={collection}
viewStyle={this.state.viewStyle}
onChangeViewStyle={this.handleChangeViewStyle}
sortableFields={sortableFields}
onSortClick={onSortClick}
sort={sort}
viewFilters={viewFilters}
t={t}
onFilterClick={onFilterClick}
filter={filter}
/>
</>
)}
@ -130,6 +136,8 @@ function mapStateToProps(state, ownProps) {
const collection = name ? collections.get(name) : collections.first();
const sort = selectEntriesSort(state.entries, collection.get('name'));
const sortableFields = selectSortableFields(collection, t);
const viewFilters = selectViewFilters(collection);
const filter = selectEntriesFilter(state.entries, collection.get('name'));
return {
collection,
@ -139,11 +147,14 @@ function mapStateToProps(state, ownProps) {
searchTerm,
sort,
sortableFields,
viewFilters,
filter,
};
}
const mapDispatchToProps = {
sortByField,
filterByField,
};
const mergeProps = (stateProps, dispatchProps, ownProps) => {
@ -152,6 +163,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
...ownProps,
onSortClick: (key, direction) =>
dispatchProps.sortByField(stateProps.collection, key, direction),
onFilterClick: filter => dispatchProps.filterByField(stateProps.collection, filter),
};
};

View File

@ -2,6 +2,7 @@ import React from 'react';
import styled from '@emotion/styled';
import ViewStyleControl from './ViewStyleControl';
import SortControl from './SortControl';
import FilterControl from './FilterControl';
import { lengths } from 'netlify-cms-ui-default';
const CollectionControlsContainer = styled.div`
@ -18,24 +19,32 @@ const CollectionControlsContainer = styled.div`
`;
const CollectionControls = ({
collection,
viewStyle,
onChangeViewStyle,
sortableFields,
onSortClick,
sort,
}) => (
viewFilters,
onFilterClick,
t,
filter,
}) => {
return (
<CollectionControlsContainer>
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
{sortableFields.length > 0 && (
<SortControl
fields={sortableFields}
collection={collection}
sort={sort}
onSortClick={onSortClick}
{viewFilters.length > 0 && (
<FilterControl
viewFilters={viewFilters}
onFilterClick={onFilterClick}
t={t}
filter={filter}
/>
)}
{sortableFields.length > 0 && (
<SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} />
)}
</CollectionControlsContainer>
);
};
export default CollectionControls;

View File

@ -66,7 +66,7 @@ function mapStateToProps(state, ownProps) {
const { collection, viewStyle } = ownProps;
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
const entries = selectEntries(state.entries, collection.get('name'));
const entries = selectEntries(state.entries, collection);
const entriesLoaded = selectEntriesLoaded(state.entries, collection.get('name'));
const isFetching = selectIsFetching(state.entries, collection.get('name'));

View File

@ -0,0 +1,62 @@
import React from 'react';
import { css } from '@emotion/core';
import styled from '@emotion/styled';
import { translate } from 'react-polyglot';
import {
buttons,
Dropdown,
DropdownCheckedItem,
StyledDropdownButton,
colors,
} from 'netlify-cms-ui-default';
const FilterButton = styled(StyledDropdownButton)`
${buttons.button};
${buttons.medium};
${buttons.grayText};
font-size: 14px;
&:after {
top: 11px;
}
`;
const FilterControl = ({ viewFilters, t, onFilterClick, filter }) => {
const hasActiveFilter = filter
?.valueSeq()
.toJS()
.some(f => f.active === true);
return (
<Dropdown
renderButton={() => {
return (
<FilterButton
css={css`
color: ${hasActiveFilter ? colors.active : undefined};
`}
>
{t('collection.collectionTop.filterBy')}
</FilterButton>
);
}}
closeOnSelection={false}
dropdownTopOverlap="30px"
dropdownPosition="left"
>
{viewFilters.map(viewFilter => {
return (
<DropdownCheckedItem
key={viewFilter.id}
label={viewFilter.label}
id={viewFilter.id}
checked={filter.getIn([viewFilter.id, 'active'], false)}
onClick={() => onFilterClick(viewFilter)}
/>
);
})}
</Dropdown>
);
};
export default translate()(FilterControl);

View File

@ -1,7 +1,14 @@
import React from 'react';
import { css } from '@emotion/core';
import styled from '@emotion/styled';
import { translate } from 'react-polyglot';
import { buttons, Dropdown, DropdownItem, StyledDropdownButton } from 'netlify-cms-ui-default';
import {
buttons,
Dropdown,
DropdownItem,
StyledDropdownButton,
colors,
} from 'netlify-cms-ui-default';
import { SortDirection } from '../../types/redux';
const SortButton = styled(StyledDropdownButton)`
@ -40,9 +47,22 @@ const sortIconDirections = {
};
const SortControl = ({ t, fields, onSortClick, sort }) => {
const hasActiveSort = sort
?.valueSeq()
.toJS()
.some(s => s.direction !== SortDirection.None);
return (
<Dropdown
renderButton={() => <SortButton>{t('collection.collectionTop.sortBy')}</SortButton>}
renderButton={() => (
<SortButton
css={css`
color: ${hasActiveSort ? colors.active : undefined};
`}
>
{t('collection.collectionTop.sortBy')}
</SortButton>
)}
closeOnSelection={false}
dropdownTopOverlap="30px"
dropdownWidth="160px"

View File

@ -21,6 +21,28 @@ const fieldsConfig = {
},
};
const viewFilters = {
type: 'array',
minItems: 1,
items: {
type: 'object',
properties: {
label: { type: 'string' },
field: { type: 'string' },
pattern: {
oneOf: [
{ type: 'boolean' },
{
type: 'string',
},
],
},
},
additionalProperties: false,
required: ['label', 'field', 'pattern'],
},
};
/**
* The schema had to be wrapped in a function to
* fix a circular dependency problem for WebPack,
@ -142,6 +164,7 @@ const getConfigSchema = () => ({
type: 'string',
},
},
view_filters: viewFilters,
},
required: ['name', 'label'],
oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }],

View File

@ -4,6 +4,7 @@ import reducer, {
selectMediaFolder,
selectMediaFilePath,
selectMediaFilePublicPath,
selectEntries,
} from '../entries';
const initialState = OrderedMap({
@ -559,4 +560,118 @@ describe('entries', () => {
).toBe('/images/image.png');
});
});
describe('selectEntries', () => {
it('should return all entries', () => {
const state = fromJS({
entities: {
'posts.1': { slug: '1' },
'posts.2': { slug: '2' },
'posts.3': { slug: '3' },
'posts.4': { slug: '4' },
},
pages: { posts: { ids: ['1', '2', '3', '4'] } },
});
const collection = fromJS({
name: 'posts',
});
expect(selectEntries(state, collection)).toEqual(
fromJS([{ slug: '1' }, { slug: '2' }, { slug: '3' }, { slug: '4' }]),
);
});
});
it('should return sorted entries entries by field', () => {
const state = fromJS({
entities: {
'posts.1': { slug: '1', data: { title: '1' } },
'posts.2': { slug: '2', data: { title: '2' } },
'posts.3': { slug: '3', data: { title: '3' } },
'posts.4': { slug: '4', data: { title: '4' } },
},
pages: { posts: { ids: ['1', '2', '3', '4'] } },
sort: { posts: { title: { key: 'title', direction: 'Descending' } } },
});
const collection = fromJS({
name: 'posts',
});
expect(selectEntries(state, collection)).toEqual(
fromJS([
{ slug: '4', data: { title: '4' } },
{ slug: '3', data: { title: '3' } },
{ slug: '2', data: { title: '2' } },
{ slug: '1', data: { title: '1' } },
]),
);
});
it('should return sorted entries entries by nested field', () => {
const state = fromJS({
entities: {
'posts.1': { slug: '1', data: { title: '1', nested: { date: 4 } } },
'posts.2': { slug: '2', data: { title: '2', nested: { date: 3 } } },
'posts.3': { slug: '3', data: { title: '3', nested: { date: 2 } } },
'posts.4': { slug: '4', data: { title: '4', nested: { date: 1 } } },
},
pages: { posts: { ids: ['1', '2', '3', '4'] } },
sort: { posts: { title: { key: 'nested.date', direction: 'Ascending' } } },
});
const collection = fromJS({
name: 'posts',
});
expect(selectEntries(state, collection)).toEqual(
fromJS([
{ slug: '4', data: { title: '4', nested: { date: 1 } } },
{ slug: '3', data: { title: '3', nested: { date: 2 } } },
{ slug: '2', data: { title: '2', nested: { date: 3 } } },
{ slug: '1', data: { title: '1', nested: { date: 4 } } },
]),
);
});
it('should return filtered entries entries by field', () => {
const state = fromJS({
entities: {
'posts.1': { slug: '1', data: { title: '1' } },
'posts.2': { slug: '2', data: { title: '2' } },
'posts.3': { slug: '3', data: { title: '3' } },
'posts.4': { slug: '4', data: { title: '4' } },
},
pages: { posts: { ids: ['1', '2', '3', '4'] } },
filter: { posts: { title__1: { field: 'title', pattern: '4', active: true } } },
});
const collection = fromJS({
name: 'posts',
});
expect(selectEntries(state, collection)).toEqual(fromJS([{ slug: '4', data: { title: '4' } }]));
});
it('should return filtered entries entries by nested field', () => {
const state = fromJS({
entities: {
'posts.1': { slug: '1', data: { title: '1', nested: { draft: true } } },
'posts.2': { slug: '2', data: { title: '2', nested: { draft: true } } },
'posts.3': { slug: '3', data: { title: '3', nested: { draft: false } } },
'posts.4': { slug: '4', data: { title: '4', nested: { draft: false } } },
},
pages: { posts: { ids: ['1', '2', '3', '4'] } },
filter: {
posts: { 'nested.draft__false': { field: 'nested.draft', pattern: false, active: true } },
},
});
const collection = fromJS({
name: 'posts',
});
expect(selectEntries(state, collection)).toEqual(
fromJS([
{ slug: '3', data: { title: '3', nested: { draft: false } } },
{ slug: '4', data: { title: '4', nested: { draft: false } } },
]),
);
});
});

View File

@ -12,6 +12,7 @@ import {
EntryField,
State,
EntryMap,
ViewFilter,
} from '../types/redux';
import { selectMediaFolder } from './entries';
import { stringTemplate } from 'netlify-cms-lib-widgets';
@ -423,6 +424,11 @@ export const selectSortDataPath = (collection: Collection, key: string) => {
}
};
export const selectViewFilters = (collection: Collection) => {
const viewFilters = collection.get('view_filters').toJS() as ViewFilter[];
return viewFilters;
};
export const selectFieldsComments = (collection: Collection, entryMap: EntryMap) => {
let fields: EntryField[] = [];
if (collection.has('folder')) {

View File

@ -11,6 +11,9 @@ import {
SORT_ENTRIES_REQUEST,
SORT_ENTRIES_SUCCESS,
SORT_ENTRIES_FAILURE,
FILTER_ENTRIES_REQUEST,
FILTER_ENTRIES_SUCCESS,
FILTER_ENTRIES_FAILURE,
} from '../actions/entries';
import { SEARCH_ENTRIES_SUCCESS } from '../actions/search';
import {
@ -30,16 +33,23 @@ import {
EntryField,
CollectionFiles,
EntriesSortRequestPayload,
EntriesSortSuccessPayload,
EntriesSortFailurePayload,
SortMap,
SortObject,
Sort,
SortDirection,
Filter,
FilterMap,
EntriesFilterRequestPayload,
EntriesFilterFailurePayload,
} from '../types/redux';
import { folderFormatter } from '../lib/formatters';
import { isAbsolutePath, basename } from 'netlify-cms-lib-util';
import { trim, once, sortBy, set } from 'lodash';
import { trim, once, sortBy, set, orderBy } from 'lodash';
import { selectSortDataPath } from './collections';
import { stringTemplate } from 'netlify-cms-lib-widgets';
const { keyToPathArray } = stringTemplate;
let collection: string;
let loadedEntries: EntryObject[];
@ -203,8 +213,9 @@ const entries = (
return newState;
}
case FILTER_ENTRIES_SUCCESS:
case SORT_ENTRIES_SUCCESS: {
const payload = action.payload as EntriesSortSuccessPayload;
const payload = action.payload as { collection: string; entries: EntryObject[] };
const { collection, entries } = payload;
loadedEntries = entries;
const newState = state.withMutations(map => {
@ -238,6 +249,29 @@ const entries = (
return newState;
}
case FILTER_ENTRIES_REQUEST: {
const payload = action.payload as EntriesFilterRequestPayload;
const { collection, filter } = payload;
const newState = state.withMutations(map => {
const current: FilterMap = map.getIn(['filter', collection, filter.id], fromJS(filter));
map.setIn(
['filter', collection, current.get('id')],
current.set('active', !current.get('active')),
);
});
return newState;
}
case FILTER_ENTRIES_FAILURE: {
const payload = action.payload as EntriesFilterFailurePayload;
const { collection, filter } = payload;
const newState = state.withMutations(map => {
map.deleteIn(['filter', collection, filter.id]);
map.setIn(['pages', collection, 'isFetching'], false);
});
return newState;
}
default:
return state;
}
@ -248,6 +282,11 @@ export const selectEntriesSort = (entries: Entries, collection: string) => {
return sort?.get(collection);
};
export const selectEntriesFilter = (entries: Entries, collection: string) => {
const filter = entries.get('filter') as Filter | undefined;
return filter?.get(collection) || Map();
};
export const selectEntriesSortFields = (entries: Entries, collection: string) => {
const sort = selectEntriesSort(entries, collection);
const values =
@ -255,6 +294,17 @@ export const selectEntriesSortFields = (entries: Entries, collection: string) =>
?.valueSeq()
.filter(v => v?.get('direction') !== SortDirection.None)
.toArray() || [];
return values;
};
export const selectEntriesFilterFields = (entries: Entries, collection: string) => {
const filter = selectEntriesFilter(entries, collection);
const values =
filter
?.valueSeq()
.filter(v => v?.get('active') === true)
.toArray() || [];
return values;
};
@ -264,10 +314,39 @@ export const selectEntry = (state: Entries, collection: string, slug: string) =>
export const selectPublishedSlugs = (state: Entries, collection: string) =>
state.getIn(['pages', collection, 'ids'], List<string>());
export const selectEntries = (state: Entries, collection: string) => {
const slugs = selectPublishedSlugs(state, collection);
const entries =
slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as List<EntryMap>);
export const selectEntries = (state: Entries, collection: Collection) => {
const collectionName = collection.get('name');
const slugs = selectPublishedSlugs(state, collectionName);
let entries =
slugs &&
(slugs.map(slug => selectEntry(state, collectionName, slug as string)) as List<EntryMap>);
const sortFields = selectEntriesSortFields(state, collectionName);
if (sortFields && sortFields.length > 0) {
const keys = sortFields.map(v => selectSortDataPath(collection, v.get('key')));
const orders = sortFields.map(v =>
v.get('direction') === SortDirection.Ascending ? 'asc' : 'desc',
);
entries = fromJS(orderBy(entries.toJS(), keys, orders));
}
const filters = selectEntriesFilterFields(state, collectionName);
if (filters && filters.length > 0) {
entries = entries
.filter(e => {
const allMatched = filters.every(f => {
const pattern = f.get('pattern');
const field = f.get('field');
const data = e!.get('data') || Map();
const toMatch = data.getIn(keyToPathArray(field));
const matched =
toMatch !== undefined && new RegExp(String(pattern)).test(String(toMatch));
return matched;
});
return allMatched;
})
.toList();
}
return entries;
};

View File

@ -12,7 +12,7 @@ import mediaLibrary from './mediaLibrary';
import deploys, * as fromDeploys from './deploys';
import globalUI from './globalUI';
import { Status } from '../constants/publishModes';
import { State } from '../types/redux';
import { State, Collection } from '../types/redux';
const reducers = {
auth,
@ -38,7 +38,7 @@ export default reducers;
export const selectEntry = (state: State, collection: string, slug: string) =>
fromEntries.selectEntry(state.entries, collection, slug);
export const selectEntries = (state: State, collection: string) =>
export const selectEntries = (state: State, collection: Collection) =>
fromEntries.selectEntries(state.entries, collection);
export const selectPublishedSlugs = (state: State, collection: string) =>

View File

@ -64,12 +64,17 @@ export type SortMap = OrderedMap<string, StaticallyTypedRecord<SortObject>>;
export type Sort = Map<string, SortMap>;
export type FilterMap = StaticallyTypedRecord<ViewFilter & { active: boolean }>;
export type Filter = Map<string, Map<string, FilterMap>>; // collection.field.active
export type Entities = StaticallyTypedRecord<EntitiesObject>;
export type Entries = StaticallyTypedRecord<{
pages: Pages & PagesObject;
entities: Entities & EntitiesObject;
sort: Sort;
filter: Filter;
}>;
export type Deploys = StaticallyTypedRecord<{}>;
@ -134,6 +139,13 @@ export type CollectionFile = StaticallyTypedRecord<{
export type CollectionFiles = List<CollectionFile>;
export type ViewFilter = {
label: string;
field: string;
pattern: string;
id: string;
};
type CollectionObject = {
name: string;
folder?: string;
@ -157,6 +169,7 @@ type CollectionObject = {
label_singular?: string;
label: string;
sortableFields: List<string>;
view_filters: List<StaticallyTypedRecord<ViewFilter>>;
};
export type Collection = StaticallyTypedRecord<CollectionObject>;
@ -297,11 +310,18 @@ export interface EntriesSortRequestPayload extends EntryPayload {
direction: string;
}
export interface EntriesSortSuccessPayload extends EntriesSortRequestPayload {
entries: EntryObject[];
export interface EntriesSortFailurePayload extends EntriesSortRequestPayload {
error: Error;
}
export interface EntriesSortFailurePayload extends EntriesSortRequestPayload {
export interface EntriesFilterRequestPayload {
filter: ViewFilter;
collection: string;
}
export interface EntriesFilterFailurePayload {
filter: ViewFilter;
collection: string;
error: Error;
}

View File

@ -46,6 +46,7 @@ const en = {
descending: 'Descending',
searchResults: 'Search Results for "%{searchTerm}"',
searchResultsInCollection: 'Search Results for "%{searchTerm}" in %{collection}',
filterBy: 'Filter by',
},
entries: {
loadingEntries: 'Loading Entries...',

View File

@ -48,7 +48,7 @@ const DropdownList = styled.ul`
`};
`;
const StyledMenuItem = ({ isActive, ...props }) => (
const StyledMenuItem = ({ isActive, isCheckedItem = false, ...props }) => (
<MenuItem
css={css`
${components.dropdownItem};
@ -58,6 +58,7 @@ const StyledMenuItem = ({ isActive, ...props }) => (
&:not(:active) {
background-color: ${isActive ? colors.activeBackground : 'inherit'};
color: ${isActive ? colors.active : 'inherit'};
${isCheckedItem ? 'display: flex; justify-content: start' : ''};
}
&:hover {
color: ${colors.active};
@ -128,4 +129,38 @@ DropdownItem.propTypes = {
className: PropTypes.string,
};
export { Dropdown as default, DropdownItem, DropdownButton, StyledDropdownButton };
const StyledDropdownCheckbox = ({ checked, id }) => (
<input
readOnly
type="checkbox"
css={css`
margin-right: 10px;
`}
checked={checked}
id={id}
/>
);
const DropdownCheckedItem = ({ label, id, checked, onClick }) => {
return (
<StyledMenuItem isCheckedItem={true} isActive={checked} onClick={onClick}>
<StyledDropdownCheckbox checked={checked} id={id} />
<span htmlFor={id}>{label}</span>
</StyledMenuItem>
);
};
DropdownCheckedItem.propTypes = {
label: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
checked: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
};
export {
Dropdown as default,
DropdownItem,
DropdownCheckedItem,
DropdownButton,
StyledDropdownButton,
};

View File

@ -1,4 +1,9 @@
import Dropdown, { DropdownItem, DropdownButton, StyledDropdownButton } from './Dropdown';
import Dropdown, {
DropdownItem,
DropdownCheckedItem,
DropdownButton,
StyledDropdownButton,
} from './Dropdown';
import Icon from './Icon';
import ListItemTopBar from './ListItemTopBar';
import Loader from './Loader';
@ -29,6 +34,7 @@ import {
export const NetlifyCmsUiDefault = {
Dropdown,
DropdownItem,
DropdownCheckedItem,
DropdownButton,
StyledDropdownButton,
ListItemTopBar,
@ -61,6 +67,7 @@ export const NetlifyCmsUiDefault = {
export {
Dropdown,
DropdownItem,
DropdownCheckedItem,
DropdownButton,
StyledDropdownButton,
ListItemTopBar,

View File

@ -210,6 +210,7 @@ The `collections` setting is the heart of your Netlify CMS configuration, as it
* `editor`: see detailed description below
* `summary`: see detailed description below
* `sortableFields`: see detailed description below
* `view_filters`: see detailed description below
The last few options require more detailed information.
@ -397,3 +398,24 @@ When `author` field can't be inferred commit author will be used.
# use dot notation for nested fields
sortableFields: ['commit_date', 'title', 'commit_author', 'language.en']
```
### `view_filters`
An optional list of predefined view filters to show in the UI.
Defaults to an empty list.
**Example**
```yaml
view_filters:
- label: "Alice's and Bob's Posts"
field: author
pattern: 'Alice|Bob'
- label: 'Posts published in 2020'
field: date
pattern: '2020'
- label: Drafts
field: draft
pattern: true
```