Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/decap-cms-core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ declare module 'decap-cms-core' {
view_filters?: ViewFilter[];
view_groups?: ViewGroup[];
i18n?: boolean | CmsI18nConfig;
limit?: number;

/**
* @deprecated Use sortable_fields instead
Expand Down
143 changes: 143 additions & 0 deletions packages/decap-cms-core/src/__tests__/backend.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '../backend';
import { getBackend } from '../lib/registry';
import { FOLDER, FILES } from '../constants/collectionTypes';
import { EDITORIAL_WORKFLOW } from '../constants/publishModes';

jest.mock('../lib/registry');
jest.mock('decap-cms-lib-util');
Expand Down Expand Up @@ -392,6 +393,148 @@ describe('Backend', () => {
expect(backend.entryToRaw).toHaveBeenCalledWith(collection, newEntry);
});

it('should reject new entries when collection limit is reached', async () => {
const implementation = {
init: jest.fn(() => implementation),
persistEntry: jest.fn(() => implementation),
};

const config = {
backend: {
commit_messages: 'commit-messages',
},
};
const collection = Map({
name: 'posts',
type: FOLDER,
create: true,
limit: 1,
});
const entry = Map({
data: Map({}),
newRecord: true,
});
const entryDraft = Map({
entry,
});

const user = { login: 'login', name: 'name' };
const backend = new Backend(implementation, { config, backendName: 'github' });

backend.currentUser = jest.fn().mockResolvedValue(user);
backend.invokePreSaveEvent = jest.fn().mockReturnValueOnce(entry);
backend.listAllEntries = jest.fn().mockResolvedValue([{ slug: 'existing' }]);

await expect(
backend.persistEntry({
config,
collection,
entryDraft,
assetProxies: [],
usedSlugs: List(),
}),
).rejects.toThrow('Entry limit of 1 reached for collection posts');

expect(backend.listAllEntries).toHaveBeenCalledWith(collection);
expect(implementation.persistEntry).toHaveBeenCalledTimes(0);
});

it('should include unpublished workflow entries when checking collection limits', async () => {
const implementation = {
init: jest.fn(() => implementation),
persistEntry: jest.fn(() => implementation),
unpublishedEntries: jest.fn().mockResolvedValue(['posts/draft']),
unpublishedEntry: jest.fn().mockResolvedValue({
collection: 'posts',
slug: 'draft',
status: 'draft',
diffs: [],
updatedAt: '',
}),
};

const config = {
backend: {
commit_messages: 'commit-messages',
},
publish_mode: EDITORIAL_WORKFLOW,
};
const collection = Map({
name: 'posts',
type: FOLDER,
create: true,
limit: 1,
});
const entry = Map({
data: Map({}),
newRecord: true,
});
const entryDraft = Map({
entry,
});

const backend = new Backend(implementation, { config, backendName: 'github' });

backend.invokePreSaveEvent = jest.fn().mockReturnValueOnce(entry);
backend.listAllEntries = jest.fn().mockResolvedValue([]);

await expect(
backend.persistEntry({
config,
collection,
entryDraft,
assetProxies: [],
usedSlugs: List(),
}),
).rejects.toThrow('Entry limit of 1 reached for collection posts');

expect(implementation.unpublishedEntries).toHaveBeenCalledTimes(1);
expect(implementation.persistEntry).toHaveBeenCalledTimes(0);
});

it('should use complete published entries instead of loaded slugs when checking limits', async () => {
const implementation = {
init: jest.fn(() => implementation),
persistEntry: jest.fn(() => implementation),
};

const config = {
backend: {
commit_messages: 'commit-messages',
},
};
const collection = Map({
name: 'posts',
type: FOLDER,
create: true,
limit: 2,
});
const entry = Map({
data: Map({}),
newRecord: true,
});
const entryDraft = Map({
entry,
});

const backend = new Backend(implementation, { config, backendName: 'github' });

backend.invokePreSaveEvent = jest.fn().mockReturnValueOnce(entry);
backend.listAllEntries = jest.fn().mockResolvedValue([{ slug: 'one' }, { slug: 'two' }]);

await expect(
backend.persistEntry({
config,
collection,
entryDraft,
assetProxies: [],
usedSlugs: List(['one']),
}),
).rejects.toThrow('Entry limit of 2 reached for collection posts');

expect(implementation.persistEntry).toHaveBeenCalledTimes(0);
});

it('should preserve slug when preSave event handler modifies file collection entry', async () => {
const implementation = {
init: jest.fn(() => implementation),
Expand Down
36 changes: 35 additions & 1 deletion packages/decap-cms-core/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,31 @@ export class Backend {
return uniqueSlug;
}

async entrySlugsForCollectionLimit(
collection: Collection,
config: CmsConfig,
usedSlugs: List<string>,
) {
const publishedEntries = await this.listAllEntries(collection);
let entrySlugs = Set<string>(publishedEntries.map(entry => entry.slug)).union(usedSlugs);

if (selectUseWorkflow(config)) {
const unpublishedEntryIds = await this.implementation.unpublishedEntries();
const unpublishedEntries = await Promise.all(
unpublishedEntryIds.map(id => this.implementation.unpublishedEntry({ id })),
);
const collectionName = collection.get('name');

unpublishedEntries.forEach(entry => {
if (entry.collection === collectionName) {
entrySlugs = entrySlugs.add(entry.slug);
}
});
}

return entrySlugs;
}

processEntries(loadedEntries: ImplementationEntry[], collection: Collection) {
const entries = loadedEntries.map(loadedEntry =>
createEntry(
Expand Down Expand Up @@ -1215,7 +1240,7 @@ export class Backend {
collection,
entryDraft: draft,
assetProxies,
usedSlugs,
usedSlugs = List<string>(),
unpublished = false,
status,
}: PersistArgs) {
Expand All @@ -1239,6 +1264,15 @@ export class Backend {
if (!selectAllowNewEntries(collection)) {
throw new Error('Not allowed to create new entries in this collection');
}
const limit = collection.get('limit') as number | undefined;
if (
collection.get('type') === FOLDER &&
limit !== undefined &&
limit !== null &&
(await this.entrySlugsForCollectionLimit(collection, config, usedSlugs)).size >= limit
) {
throw new Error(`Entry limit of ${limit} reached for collection ${collection.get('name')}`);
}
const slug = await this.generateUniqueSlug(
collection,
entryDraft.getIn(['entry', 'data']),
Expand Down
18 changes: 12 additions & 6 deletions packages/decap-cms-core/src/components/App/Header.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { connect } from 'react-redux';

import { SettingsDropdown } from '../UI';
import { checkBackendStatus } from '../../actions/status';
import { selectCanCreateNewEntry } from '../../reducers';

const styles = {
buttonActive: css`
Expand Down Expand Up @@ -156,6 +157,7 @@ class Header extends React.Component {
static propTypes = {
user: PropTypes.object.isRequired,
collections: ImmutablePropTypes.map.isRequired,
creatableCollections: ImmutablePropTypes.list.isRequired,
onCreateEntryClick: PropTypes.func.isRequired,
onLogoutClick: PropTypes.func.isRequired,
openMediaLibrary: PropTypes.func.isRequired,
Expand Down Expand Up @@ -196,7 +198,7 @@ class Header extends React.Component {
render() {
const {
user,
collections,
creatableCollections,
onLogoutClick,
openMediaLibrary,
hasWorkflow,
Expand All @@ -208,10 +210,6 @@ class Header extends React.Component {
showMediaButton,
} = this.props;

const creatableCollections = collections
.filter(collection => collection.get('create'))
.toList();

const shouldShowLogo = logo?.show_in_header && logo?.src;

return (
Expand Down Expand Up @@ -288,4 +286,12 @@ const mapDispatchToProps = {
checkBackendStatus,
};

export default connect(null, mapDispatchToProps)(translate()(Header));
function mapStateToProps(state, ownProps) {
return {
creatableCollections: ownProps.collections
.filter(collection => selectCanCreateNewEntry(state, collection.get('name')))
.toList(),
};
}

export default connect(mapStateToProps, mapDispatchToProps)(translate()(Header));
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
selectEntriesGroup,
selectViewStyle,
} from '../../reducers/entries';
import { selectCanCreateNewEntry } from '../../reducers';

const CollectionContainer = styled.div`
display: flex;
Expand Down Expand Up @@ -60,6 +61,9 @@ export class Collection extends React.Component {
sortableFields: PropTypes.array,
sort: ImmutablePropTypes.orderedMap,
onSortClick: PropTypes.func.isRequired,
canCreate: PropTypes.bool.isRequired,
filterTerm: PropTypes.string,
viewStyle: PropTypes.string,
};

componentDidMount() {
Expand Down Expand Up @@ -106,9 +110,10 @@ export class Collection extends React.Component {
group,
onChangeViewStyle,
viewStyle,
canCreate,
} = this.props;

let newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
let newEntryUrl = canCreate ? getNewEntryUrl(collectionName) : '';
if (newEntryUrl && filterTerm) {
newEntryUrl = getNewEntryUrl(collectionName);
if (filterTerm) {
Expand Down Expand Up @@ -174,6 +179,7 @@ function mapStateToProps(state, ownProps) {
const filter = selectEntriesFilter(state.entries, collection.get('name'));
const group = selectEntriesGroup(state.entries, collection.get('name'));
const viewStyle = selectViewStyle(state.entries);
const canCreate = selectCanCreateNewEntry(state, name);

return {
collection,
Expand All @@ -190,6 +196,7 @@ function mapStateToProps(state, ownProps) {
filter,
group,
viewStyle,
canCreate,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,26 @@ describe('Collection', () => {
view_filters: [],
view_groups: [],
});
const props = {
const baseProps = {
collections: fromJS([collection]).toOrderedMap(),
collection,
collectionName: collection.get('name'),
t: jest.fn(key => key),
onSortClick: jest.fn(),
viewStyle: 'list',
};

it('should render with collection without create url', () => {
const props = { ...baseProps, canCreate: false };
const { asFragment } = render(
<Collection {...props} collection={collection.set('create', false)} />,
);

expect(asFragment()).toMatchSnapshot();
});

it('should render with collection with create url', () => {
const props = { ...baseProps, canCreate: false };
const { asFragment } = render(
<Collection {...props} collection={collection.set('create', true)} />,
);
Expand All @@ -53,17 +57,18 @@ describe('Collection', () => {
});

it('should render with collection with create url and path', () => {
const props = { ...baseProps, canCreate: false, filterTerm: 'dir1/dir2' };
const { asFragment } = render(
<Collection {...props} collection={collection.set('create', true)} filterTerm="dir1/dir2" />,
<Collection {...props} collection={collection.set('create', true)} />,
);

expect(asFragment()).toMatchSnapshot();
});

it('should render connected component', () => {
const store = mockStore({
collections: props.collections,
entries: fromJS({}),
collections: baseProps.collections,
entries: fromJS({ pages: { entries: fromJS([]) } }),
});

const { asFragment } = renderWithRedux(<ConnectedCollection match={{ params: {} }} />, {
Expand Down
Loading
Loading