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