feat: add filter to collection view (#3741)
This commit is contained in:
parent
d3aaf4ddb3
commit
c28cc0c9e7
105
cypress/integration/view_filters_spec.js
Normal file
105
cypress/integration/view_filters_spec.js
Normal 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');
|
||||
});
|
||||
});
|
@ -275,9 +275,11 @@ Cypress.Commands.add('insertEditorComponent', title => {
|
||||
});
|
||||
|
||||
Cypress.Commands.add('clickModeToggle', () => {
|
||||
cy.get('button[role="switch"]')
|
||||
.click()
|
||||
.focused();
|
||||
cy.get('.cms-editor-visual').within(() => {
|
||||
cy.get('button[role="switch"]')
|
||||
.click()
|
||||
.focused();
|
||||
});
|
||||
});
|
||||
|
||||
[['insertCodeBlock', 'Code Block']].forEach(([commandName, componentTitle]) => {
|
||||
|
@ -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'
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}),
|
||||
);
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
}) => (
|
||||
<CollectionControlsContainer>
|
||||
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
|
||||
{sortableFields.length > 0 && (
|
||||
<SortControl
|
||||
fields={sortableFields}
|
||||
collection={collection}
|
||||
sort={sort}
|
||||
onSortClick={onSortClick}
|
||||
/>
|
||||
)}
|
||||
</CollectionControlsContainer>
|
||||
);
|
||||
viewFilters,
|
||||
onFilterClick,
|
||||
t,
|
||||
filter,
|
||||
}) => {
|
||||
return (
|
||||
<CollectionControlsContainer>
|
||||
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
|
||||
{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;
|
||||
|
@ -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'));
|
||||
|
||||
|
@ -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);
|
@ -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"
|
||||
|
@ -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'] }],
|
||||
|
@ -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 } } },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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')) {
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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) =>
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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...',
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
```
|
||||
|
Loading…
x
Reference in New Issue
Block a user