Feat: nested collections (#3716)

This commit is contained in:
Erez Rokah
2020-06-18 10:11:37 +03:00
committed by GitHub
parent b4c47caf59
commit af7bbbd9a9
89 changed files with 8269 additions and 5619 deletions

View File

@ -5,7 +5,6 @@ import { Map, List, fromJS } from 'immutable';
jest.mock('Lib/registry');
jest.mock('netlify-cms-lib-util');
jest.mock('Formats/formats');
jest.mock('../lib/urlHelper');
describe('Backend', () => {
@ -179,7 +178,7 @@ describe('Backend', () => {
const slug = 'slug';
localForage.getItem.mockReturnValue({
raw: 'content',
raw: '---\ntitle: "Hello World"\n---\n',
});
const result = await backend.getLocalDraftBackup(collection, slug);
@ -192,11 +191,12 @@ describe('Backend', () => {
slug: 'slug',
path: '',
partial: false,
raw: 'content',
data: {},
raw: '---\ntitle: "Hello World"\n---\n',
data: { title: 'Hello World' },
meta: {},
label: null,
metaData: null,
isModification: null,
status: '',
updatedOn: '',
},
});
@ -218,7 +218,7 @@ describe('Backend', () => {
const slug = 'slug';
localForage.getItem.mockReturnValue({
raw: 'content',
raw: '---\ntitle: "Hello World"\n---\n',
mediaFiles: [{ id: '1' }],
});
@ -232,11 +232,12 @@ describe('Backend', () => {
slug: 'slug',
path: '',
partial: false,
raw: 'content',
data: {},
raw: '---\ntitle: "Hello World"\n---\n',
data: { title: 'Hello World' },
meta: {},
label: null,
metaData: null,
isModification: null,
status: '',
updatedOn: '',
},
});
@ -343,22 +344,24 @@ describe('Backend', () => {
describe('unpublishedEntry', () => {
it('should return unpublished entry', async () => {
const unpublishedEntryResult = {
file: { path: 'path' },
isModification: true,
metaData: {},
mediaFiles: [{ id: '1' }],
data: 'content',
diffs: [{ path: 'src/posts/index.md', newFile: false }, { path: 'netlify.png' }],
};
const implementation = {
init: jest.fn(() => implementation),
unpublishedEntry: jest.fn().mockResolvedValue(unpublishedEntryResult),
unpublishedEntryDataFile: jest
.fn()
.mockResolvedValueOnce('---\ntitle: "Hello World"\n---\n'),
unpublishedEntryMediaFile: jest.fn().mockResolvedValueOnce({ id: '1' }),
};
const config = Map({ media_folder: 'static/images' });
const backend = new Backend(implementation, { config, backendName: 'github' });
const collection = Map({
const collection = fromJS({
name: 'posts',
folder: 'src/posts',
fields: [],
});
const state = {
@ -374,14 +377,15 @@ describe('Backend', () => {
author: '',
collection: 'posts',
slug: '',
path: 'path',
path: 'src/posts/index.md',
partial: false,
raw: 'content',
data: {},
raw: '---\ntitle: "Hello World"\n---\n',
data: { title: 'Hello World' },
meta: { path: 'src/posts/index.md' },
label: null,
metaData: {},
isModification: true,
mediaFiles: [{ id: '1', draft: true }],
status: '',
updatedOn: '',
});
});

View File

@ -5,6 +5,7 @@ import {
retrieveLocalBackup,
persistLocalBackup,
getMediaAssets,
validateMetaField,
} from '../entries';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
@ -13,6 +14,8 @@ import AssetProxy from '../../valueObjects/AssetProxy';
jest.mock('coreSrc/backend');
jest.mock('netlify-cms-lib-util');
jest.mock('../mediaLibrary');
jest.mock('../../reducers/entries');
jest.mock('../../reducers/entryDraft');
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
@ -45,14 +48,15 @@ describe('entries', () => {
author: '',
collection: undefined,
data: {},
meta: {},
isModification: null,
label: null,
mediaFiles: [],
metaData: null,
partial: false,
path: '',
raw: '',
slug: '',
status: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
@ -76,14 +80,15 @@ describe('entries', () => {
author: '',
collection: undefined,
data: { title: 'title', boolean: true },
meta: {},
isModification: null,
label: null,
mediaFiles: [],
metaData: null,
partial: false,
path: '',
raw: '',
slug: '',
status: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
@ -109,14 +114,15 @@ describe('entries', () => {
author: '',
collection: undefined,
data: { title: '<script>alert('hello')</script>' },
meta: {},
isModification: null,
label: null,
mediaFiles: [],
metaData: null,
partial: false,
path: '',
raw: '',
slug: '',
status: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
@ -383,4 +389,170 @@ describe('entries', () => {
expect(getMediaAssets({ entry })).toEqual([new AssetProxy({ path: 'path2' })]);
});
});
describe('validateMetaField', () => {
const state = {
config: fromJS({
slug: {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
},
}),
entries: fromJS([]),
};
const collection = fromJS({
folder: 'folder',
type: 'folder_based_collection',
name: 'name',
});
const t = jest.fn((key, args) => ({ key, args }));
const { selectCustomPath } = require('../../reducers/entryDraft');
const { selectEntryByPath } = require('../../reducers/entries');
beforeEach(() => {
jest.clearAllMocks();
});
it('should not return error on non meta field', () => {
expect(validateMetaField(null, null, fromJS({}), null, t)).toEqual({ error: false });
});
it('should not return error on meta path field', () => {
expect(
validateMetaField(null, null, fromJS({ meta: true, name: 'other' }), null, t),
).toEqual({ error: false });
});
it('should return error on empty path', () => {
expect(validateMetaField(null, null, fromJS({ meta: true, name: 'path' }), null, t)).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: null },
},
type: 'CUSTOM',
},
});
expect(
validateMetaField(null, null, fromJS({ meta: true, name: 'path' }), undefined, t),
).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: undefined },
},
type: 'CUSTOM',
},
});
expect(validateMetaField(null, null, fromJS({ meta: true, name: 'path' }), '', t)).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: '' },
},
type: 'CUSTOM',
},
});
});
it('should return error on invalid path', () => {
expect(
validateMetaField(state, null, fromJS({ meta: true, name: 'path' }), 'invalid path', t),
).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: 'invalid path' },
},
type: 'CUSTOM',
},
});
});
it('should return error on existing path', () => {
selectCustomPath.mockReturnValue('existing-path');
selectEntryByPath.mockReturnValue(fromJS({ path: 'existing-path' }));
expect(
validateMetaField(
{
...state,
entryDraft: fromJS({
entry: {},
}),
},
collection,
fromJS({ meta: true, name: 'path' }),
'existing-path',
t,
),
).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.pathExists',
args: { path: 'existing-path' },
},
type: 'CUSTOM',
},
});
expect(selectCustomPath).toHaveBeenCalledTimes(1);
expect(selectCustomPath).toHaveBeenCalledWith(
collection,
fromJS({ entry: { meta: { path: 'existing-path' } } }),
);
expect(selectEntryByPath).toHaveBeenCalledTimes(1);
expect(selectEntryByPath).toHaveBeenCalledWith(
state.entries,
collection.get('name'),
'existing-path',
);
});
it('should not return error on non existing path for new entry', () => {
selectCustomPath.mockReturnValue('non-existing-path');
selectEntryByPath.mockReturnValue(undefined);
expect(
validateMetaField(
{
...state,
entryDraft: fromJS({
entry: {},
}),
},
collection,
fromJS({ meta: true, name: 'path' }),
'non-existing-path',
t,
),
).toEqual({
error: false,
});
});
it('should not return error when for existing entry', () => {
selectCustomPath.mockReturnValue('existing-path');
selectEntryByPath.mockReturnValue(fromJS({ path: 'existing-path' }));
expect(
validateMetaField(
{
...state,
entryDraft: fromJS({
entry: { path: 'existing-path' },
}),
},
collection,
fromJS({ meta: true, name: 'path' }),
'existing-path',
t,
),
).toEqual({
error: false,
});
});
});
});

View File

@ -1,6 +1,6 @@
import yaml from 'yaml';
import { Map, fromJS } from 'immutable';
import { trimStart, get, isPlainObject } from 'lodash';
import { trimStart, trim, get, isPlainObject } from 'lodash';
import { authenticateUser } from 'Actions/auth';
import * as publishModes from 'Constants/publishModes';
import { validateConfig } from 'Constants/configSchema';
@ -82,11 +82,28 @@ export function applyDefaults(config) {
'fields',
traverseFields(collection.get('fields'), setDefaultPublicFolder),
);
collection = collection.set('folder', trimStart(folder, '/'));
collection = collection.set('folder', trim(folder, '/'));
if (collection.has('meta')) {
const fields = collection.get('fields');
const metaFields = [];
collection.get('meta').forEach((value, key) => {
const field = value.withMutations(map => {
map.set('name', key);
map.set('meta', true);
map.set('required', true);
});
metaFields.push(field);
});
collection = collection.set('fields', fromJS([]).concat(metaFields, fields));
} else {
collection = collection.set('meta', Map());
}
}
const files = collection.get('files');
if (files) {
collection = collection.delete('nested');
collection = collection.delete('meta');
collection = collection.set(
'files',
files.map(file => {

View File

@ -5,17 +5,24 @@ import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
import { ThunkDispatch } from 'redux-thunk';
import { Map, List } from 'immutable';
import { serializeValues } from '../lib/serializeEntryValues';
import { currentBackend } from '../backend';
import { currentBackend, slugFromCustomPath } from '../backend';
import {
selectPublishedSlugs,
selectUnpublishedSlugs,
selectEntry,
selectUnpublishedEntry,
} from '../reducers';
import { selectEditingDraft } from '../reducers/entries';
import { selectFields } from '../reducers/collections';
import { EDITORIAL_WORKFLOW, status, Status } from '../constants/publishModes';
import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util';
import { loadEntry, entryDeleted, getMediaAssets, createDraftFromEntry } from './entries';
import {
loadEntry,
entryDeleted,
getMediaAssets,
createDraftFromEntry,
loadEntries,
} from './entries';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { addAssets } from './media';
import { loadMedia } from './mediaLibrary';
@ -24,6 +31,7 @@ import ValidationErrorTypes from '../constants/validationErrorTypes';
import { Collection, EntryMap, State, Collections, EntryDraft, MediaFile } from '../types/redux';
import { AnyAction } from 'redux';
import { EntryValue } from '../valueObjects/Entry';
import { navigateToEntry } from '../routing/history';
const { notifSend } = notifActions;
@ -406,7 +414,10 @@ export function persistUnpublishedEntry(collection: Collection, existingUnpublis
}),
);
dispatch(unpublishedEntryPersisted(collection, transactionID, newSlug));
if (!existingUnpublishedEntry) return dispatch(loadUnpublishedEntry(collection, newSlug));
if (entry.get('slug') !== newSlug) {
dispatch(loadUnpublishedEntry(collection, newSlug));
navigateToEntry(collection.get('name'), newSlug);
}
} catch (error) {
dispatch(
notifSend({
@ -506,40 +517,47 @@ export function deleteUnpublishedEntry(collection: string, slug: string) {
};
}
export function publishUnpublishedEntry(collection: string, slug: string) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
export function publishUnpublishedEntry(collectionName: string, slug: string) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const collections = state.collections;
const backend = currentBackend(state.config);
const transactionID = uuid();
const entry = selectUnpublishedEntry(state, collection, slug);
dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID));
return backend
.publishUnpublishedEntry(entry)
.then(() => {
// re-load media after entry was published
dispatch(loadMedia());
dispatch(
notifSend({
message: { key: 'ui.toast.entryPublished' },
kind: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryPublished(collection, slug, transactionID));
return dispatch(loadEntry(collections.get(collection), slug));
})
.catch((error: Error) => {
dispatch(
notifSend({
message: { key: 'ui.toast.onFailToPublishEntry', details: error },
kind: 'danger',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryPublishError(collection, slug, transactionID));
});
const entry = selectUnpublishedEntry(state, collectionName, slug);
dispatch(unpublishedEntryPublishRequest(collectionName, slug, transactionID));
try {
await backend.publishUnpublishedEntry(entry);
// re-load media after entry was published
dispatch(loadMedia());
dispatch(
notifSend({
message: { key: 'ui.toast.entryPublished' },
kind: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryPublished(collectionName, slug, transactionID));
const collection = collections.get(collectionName);
if (collection.has('nested')) {
dispatch(loadEntries(collection));
const newSlug = slugFromCustomPath(collection, entry.get('path'));
loadEntry(collection, newSlug);
if (slug !== newSlug && selectEditingDraft(state.entryDraft)) {
navigateToEntry(collection.get('name'), newSlug);
}
} else {
return dispatch(loadEntry(collection, slug));
}
} catch (error) {
dispatch(
notifSend({
message: { key: 'ui.toast.onFailToPublishEntry', details: error },
kind: 'danger',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryPublishError(collectionName, slug, transactionID));
}
};
}

View File

@ -26,7 +26,10 @@ import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux';
import { waitForMediaLibraryToLoad, loadMedia } from './mediaLibrary';
import { waitUntil } from './waitUntil';
import { selectIsFetching, selectEntriesSortFields } from '../reducers/entries';
import { selectIsFetching, selectEntriesSortFields, selectEntryByPath } from '../reducers/entries';
import { selectCustomPath } from '../reducers/entryDraft';
import { navigateToEntry } from '../routing/history';
import { getProcessSegment } from '../lib/formatters';
const { notifSend } = notifActions;
@ -336,7 +339,7 @@ export function discardDraft() {
}
export function changeDraftField(
field: string,
field: EntryField,
value: string,
metadata: Record<string, unknown>,
entries: EntryMap[],
@ -520,7 +523,10 @@ export function loadEntries(collection: Collection, page = 0) {
cursor: Cursor;
pagination: number;
entries: EntryValue[];
} = await provider.listEntries(collection, page);
} = await (collection.has('nested')
? // nested collections require all entries to construct the tree
provider.listAllEntries(collection).then((entries: EntryValue[]) => ({ entries }))
: provider.listEntries(collection, page));
response = {
...response,
// The only existing backend using the pagination system is the
@ -647,7 +653,8 @@ export function createEmptyDraft(collection: Collection, search: string) {
});
const fields = collection.get('fields', List());
const dataFields = createEmptyDraftData(fields);
const dataFields = createEmptyDraftData(fields.filter(f => !f!.get('meta')).toList());
const metaFields = createEmptyDraftData(fields.filter(f => f!.get('meta') === true).toList());
const state = getState();
const backend = currentBackend(state.config);
@ -659,6 +666,8 @@ export function createEmptyDraft(collection: Collection, search: string) {
let newEntry = createEntry(collection.get('name'), '', '', {
data: dataFields,
mediaFiles: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
meta: metaFields as any,
});
newEntry = await backend.processEntry(state, collection, newEntry);
dispatch(emptyDraftCreated(newEntry));
@ -791,7 +800,7 @@ export function persistEntry(collection: Collection) {
assetProxies,
usedSlugs,
})
.then((slug: string) => {
.then((newSlug: string) => {
dispatch(
notifSend({
message: {
@ -805,8 +814,14 @@ export function persistEntry(collection: Collection) {
if (assetProxies.length > 0) {
dispatch(loadMedia());
}
dispatch(entryPersisted(collection, serializedEntry, slug));
if (serializedEntry.get('newRecord')) return dispatch(loadEntry(collection, slug));
dispatch(entryPersisted(collection, serializedEntry, newSlug));
if (collection.has('nested')) {
dispatch(loadEntries(collection));
}
if (entry.get('slug') !== newSlug) {
dispatch(loadEntry(collection, newSlug));
navigateToEntry(collection.get('name'), newSlug);
}
})
.catch((error: Error) => {
console.error(error);
@ -852,3 +867,53 @@ export function deleteEntry(collection: Collection, slug: string) {
});
};
}
const getPathError = (
path: string | undefined,
key: string,
t: (key: string, args: Record<string, unknown>) => string,
) => {
return {
error: {
type: ValidationErrorTypes.CUSTOM,
message: t(`editor.editorControlPane.widget.${key}`, {
path,
}),
},
};
};
export function validateMetaField(
state: State,
collection: Collection,
field: EntryField,
value: string | undefined,
t: (key: string, args: Record<string, unknown>) => string,
) {
if (field.get('meta') && field.get('name') === 'path') {
if (!value) {
return getPathError(value, 'invalidPath', t);
}
const sanitizedPath = (value as string)
.split('/')
.map(getProcessSegment(state.config.get('slug')))
.join('/');
if (value !== sanitizedPath) {
return getPathError(value, 'invalidPath', t);
}
const customPath = selectCustomPath(collection, fromJS({ entry: { meta: { path: value } } }));
const existingEntry = customPath
? selectEntryByPath(state.entries, collection.get('name'), customPath)
: undefined;
const existingEntryPath = existingEntry?.get('path');
const draftPath = state.entryDraft?.getIn(['entry', 'path']);
if (existingEntryPath && existingEntryPath !== draftPath) {
return getPathError(value, 'pathExists', t);
}
}
return { error: false };
}

View File

@ -1,4 +1,4 @@
import { attempt, flatten, isError, uniq } from 'lodash';
import { attempt, flatten, isError, uniq, trim, sortBy } from 'lodash';
import { List, Map, fromJS } from 'immutable';
import * as fuzzy from 'fuzzy';
import { resolveFormat } from './formats/formats';
@ -15,6 +15,7 @@ import {
selectInferedField,
selectMediaFolders,
selectFieldsComments,
selectHasMetaPath,
} from './reducers/collections';
import { createEntry, EntryValue } from './valueObjects/Entry';
import { sanitizeChar } from './lib/urlHelper';
@ -34,6 +35,7 @@ import {
Config as ImplementationConfig,
blobToFileObj,
} from 'netlify-cms-lib-util';
import { basename, join, extname, dirname } from 'path';
import { status } from './constants/publishModes';
import { stringTemplate } from 'netlify-cms-lib-widgets';
import {
@ -49,6 +51,8 @@ import {
} from './types/redux';
import AssetProxy from './valueObjects/AssetProxy';
import { FOLDER, FILES } from './constants/collectionTypes';
import { selectCustomPath } from './reducers/entryDraft';
import { UnpublishedEntry } from 'netlify-cms-lib-util/src/implementation';
const { extractTemplateVars, dateParsers } = stringTemplate;
@ -103,6 +107,13 @@ const sortByScore = (a: fuzzy.FilterResult<EntryValue>, b: fuzzy.FilterResult<En
return 0;
};
export const slugFromCustomPath = (collection: Collection, customPath: string) => {
const folderPath = collection.get('folder', '') as string;
const entryPath = customPath.toLowerCase().replace(folderPath.toLowerCase(), '');
const slug = join(dirname(trim(entryPath, '/')), basename(entryPath, extname(customPath)));
return slug;
};
interface AuthStore {
retrieve: () => User;
store: (user: User) => void;
@ -153,6 +164,14 @@ type Implementation = BackendImplementation & {
init: (config: ImplementationConfig, options: ImplementationInitOptions) => Implementation;
};
const prepareMetaPath = (path: string, collection: Collection) => {
if (!selectHasMetaPath(collection)) {
return path;
}
const dir = dirname(path);
return dir.substr(collection.get('folder')!.length + 1) || '/';
};
export class Backend {
implementation: Implementation;
backendName: string;
@ -261,12 +280,14 @@ export class Backend {
async entryExist(collection: Collection, path: string, slug: string, useWorkflow: boolean) {
const unpublishedEntry =
useWorkflow &&
(await this.implementation.unpublishedEntry(collection.get('name'), slug).catch(error => {
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
return Promise.resolve(false);
}
return Promise.reject(error);
}));
(await this.implementation
.unpublishedEntry({ collection: collection.get('name'), slug })
.catch(error => {
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
return Promise.resolve(false);
}
return Promise.reject(error);
}));
if (unpublishedEntry) return unpublishedEntry;
@ -285,9 +306,15 @@ export class Backend {
entryData: Map<string, unknown>,
config: Config,
usedSlugs: List<string>,
customPath: string | undefined,
) {
const slugConfig = config.get('slug');
const slug: string = slugFormatter(collection, entryData, slugConfig);
let slug: string;
if (customPath) {
slug = slugFromCustomPath(collection, customPath);
} else {
slug = slugFormatter(collection, entryData, slugConfig);
}
let i = 1;
let uniqueSlug = slug;
@ -334,12 +361,17 @@ export class Backend {
let listMethod: () => Promise<ImplementationEntry[]>;
const collectionType = collection.get('type');
if (collectionType === FOLDER) {
listMethod = () =>
this.implementation.entriesByFolder(
listMethod = () => {
const depth =
collection.get('nested')?.get('depth') ||
getPathDepth(collection.get('path', '') as string);
return this.implementation.entriesByFolder(
collection.get('folder') as string,
extension,
getPathDepth(collection.get('path', '') as string),
depth,
);
};
} else if (collectionType === FILES) {
const files = collection
.get('files')!
@ -379,12 +411,12 @@ export class Backend {
async listAllEntries(collection: Collection) {
if (collection.get('folder') && this.implementation.allEntriesByFolder) {
const extension = selectFolderEntryExtension(collection);
const depth =
collection.get('nested')?.get('depth') ||
getPathDepth(collection.get('path', '') as string);
return this.implementation
.allEntriesByFolder(
collection.get('folder') as string,
extension,
getPathDepth(collection.get('path', '') as string),
)
.allEntriesByFolder(collection.get('folder') as string, extension, depth)
.then(entries => this.processEntries(entries, collection));
}
@ -491,7 +523,12 @@ export class Backend {
const label = selectFileEntryLabel(collection, slug);
const entry: EntryValue = this.entryWithFormat(collection)(
createEntry(collection.get('name'), slug, path, { raw, label, mediaFiles }),
createEntry(collection.get('name'), slug, path, {
raw,
label,
mediaFiles,
meta: { path: prepareMetaPath(path, collection) },
}),
);
return { entry };
@ -548,6 +585,7 @@ export class Backend {
raw: loadedEntry.data,
label,
mediaFiles: [],
meta: { path: prepareMetaPath(loadedEntry.file.path, collection) },
});
entry = this.entryWithFormat(collection)(entry);
@ -586,35 +624,93 @@ export class Backend {
};
}
unpublishedEntries(collections: Collections) {
return this.implementation.unpublishedEntries!()
.then(entries =>
entries.map(loadedEntry => {
const collectionName = loadedEntry.metaData!.collection;
async processUnpublishedEntry(
collection: Collection,
entryData: UnpublishedEntry,
withMediaFiles: boolean,
) {
const { slug } = entryData;
let extension: string;
if (collection.get('type') === FILES) {
const file = collection.get('files')!.find(f => f?.get('name') === slug);
extension = extname(file.get('file'));
} else {
extension = selectFolderEntryExtension(collection);
}
const dataFiles = sortBy(
entryData.diffs.filter(d => d.path.endsWith(extension)),
f => f.path.length,
);
// if the unpublished entry has no diffs, return the original
let data = '';
let newFile = false;
let path = slug;
if (dataFiles.length <= 0) {
const loadedEntry = await this.implementation.getEntry(
selectEntryPath(collection, slug) as string,
);
data = loadedEntry.data;
path = loadedEntry.file.path;
} else {
const entryFile = dataFiles[0];
data = await this.implementation.unpublishedEntryDataFile(
collection.get('name'),
entryData.slug,
entryFile.path,
entryFile.id,
);
newFile = entryFile.newFile;
path = entryFile.path;
}
const mediaFiles: MediaFile[] = [];
if (withMediaFiles) {
const nonDataFiles = entryData.diffs.filter(d => !d.path.endsWith(extension));
const files = await Promise.all(
nonDataFiles.map(f =>
this.implementation!.unpublishedEntryMediaFile(
collection.get('name'),
slug,
f.path,
f.id,
),
),
);
mediaFiles.push(...files.map(f => ({ ...f, draft: true })));
}
const entry = createEntry(collection.get('name'), slug, path, {
raw: data,
isModification: !newFile,
label: collection && selectFileEntryLabel(collection, slug),
mediaFiles,
updatedOn: entryData.updatedAt,
status: entryData.status,
meta: { path: prepareMetaPath(path, collection) },
});
const entryWithFormat = this.entryWithFormat(collection)(entry);
return entryWithFormat;
}
async unpublishedEntries(collections: Collections) {
const ids = await this.implementation.unpublishedEntries!();
const entries = (
await Promise.all(
ids.map(async id => {
const entryData = await this.implementation.unpublishedEntry({ id });
const collectionName = entryData.collection;
const collection = collections.find(c => c.get('name') === collectionName);
const entry = createEntry(collectionName, loadedEntry.slug, loadedEntry.file.path, {
raw: loadedEntry.data,
isModification: loadedEntry.isModification,
label: collection && selectFileEntryLabel(collection, loadedEntry.slug!),
});
entry.metaData = loadedEntry.metaData;
if (!collection) {
console.warn(`Missing collection '${collectionName}' for unpublished entry '${id}'`);
return null;
}
const entry = await this.processUnpublishedEntry(collection, entryData, false);
return entry;
}),
)
.then(entries => ({
pagination: 0,
entries: entries.reduce((acc, entry) => {
const collection = collections.get(entry.collection);
if (collection) {
acc.push(this.entryWithFormat(collection)(entry) as EntryValue);
} else {
console.warn(
`Missing collection '${entry.collection}' for entry with path '${entry.path}'`,
);
}
return acc;
}, [] as EntryValue[]),
}));
).filter(Boolean) as EntryValue[];
return { pagination: 0, entries };
}
async processEntry(state: State, collection: Collection, entry: EntryValue) {
@ -633,19 +729,12 @@ export class Backend {
}
async unpublishedEntry(state: State, collection: Collection, slug: string) {
const loadedEntry = await this.implementation!.unpublishedEntry!(
collection.get('name') as string,
const entryData = await this.implementation!.unpublishedEntry!({
collection: collection.get('name') as string,
slug,
);
let entry = createEntry(collection.get('name'), loadedEntry.slug, loadedEntry.file.path, {
raw: loadedEntry.data,
isModification: loadedEntry.isModification,
metaData: loadedEntry.metaData,
mediaFiles: loadedEntry.mediaFiles?.map(file => ({ ...file, draft: true })) || [],
});
entry = this.entryWithFormat(collection)(entry);
let entry = await this.processUnpublishedEntry(collection, entryData, true);
entry = await this.processEntry(state, collection, entry);
return entry;
}
@ -738,12 +827,17 @@ export class Backend {
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
const useWorkflow = selectUseWorkflow(config);
let entryObj: {
path: string;
slug: string;
raw: string;
newPath?: string;
};
const customPath = selectCustomPath(collection, entryDraft);
if (newEntry) {
if (!selectAllowNewEntries(collection)) {
throw new Error('Not allowed to create new entries in this collection');
@ -753,9 +847,9 @@ export class Backend {
entryDraft.getIn(['entry', 'data']),
config,
usedSlugs,
customPath,
);
const path = selectEntryPath(collection, slug) as string;
const path = customPath || (selectEntryPath(collection, slug) as string);
entryObj = {
path,
slug,
@ -775,12 +869,13 @@ export class Backend {
asset.path = newPath;
});
} else {
const path = entryDraft.getIn(['entry', 'path']);
const slug = entryDraft.getIn(['entry', 'slug']);
entryObj = {
path,
slug,
path: entryDraft.getIn(['entry', 'path']),
// for workflow entries we refresh the slug on publish
slug: customPath && !useWorkflow ? slugFromCustomPath(collection, customPath) : slug,
raw: this.entryToRaw(collection, entryDraft.get('entry')),
newPath: customPath,
};
}
@ -798,8 +893,6 @@ export class Backend {
user.useOpenAuthoring,
);
const useWorkflow = selectUseWorkflow(config);
const collectionName = collection.get('name');
const updatedOptions = { unpublished, status };

View File

@ -234,6 +234,11 @@ class App extends React.Component {
collections={collections}
render={props => <Collection {...props} isSearchResults isSingleSearchResult />}
/>
<RouteInCollection
collections={collections}
path="/collections/:name/filter/:filterTerm*"
render={props => <Collection {...props} />}
/>
<Route
path="/search/:searchTerm"
render={props => <Collection {...props} isSearchResults />}

View File

@ -33,7 +33,7 @@ const SearchResultHeading = styled.h1`
${components.cardTopHeading};
`;
class Collection extends React.Component {
export class Collection extends React.Component {
static propTypes = {
searchTerm: PropTypes.string,
collectionName: PropTypes.string,
@ -51,8 +51,14 @@ class Collection extends React.Component {
};
renderEntriesCollection = () => {
const { collection } = this.props;
return <EntriesCollection collection={collection} viewStyle={this.state.viewStyle} />;
const { collection, filterTerm } = this.props;
return (
<EntriesCollection
collection={collection}
viewStyle={this.state.viewStyle}
filterTerm={filterTerm}
/>
);
};
renderEntriesSearch = () => {
@ -83,11 +89,19 @@ class Collection extends React.Component {
onSortClick,
sort,
viewFilters,
filterTerm,
t,
onFilterClick,
filter,
} = this.props;
const newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
let newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
if (newEntryUrl && filterTerm) {
newEntryUrl = getNewEntryUrl(collectionName);
if (filterTerm) {
newEntryUrl = `${newEntryUrl}?path=${filterTerm}`;
}
}
const searchResultKey =
'collection.collectionTop.searchResults' + (isSingleSearchResult ? 'InCollection' : '');
@ -98,6 +112,7 @@ class Collection extends React.Component {
collections={collections}
collection={(!isSearchResults || isSingleSearchResult) && collection}
searchTerm={searchTerm}
filterTerm={filterTerm}
/>
<CollectionMain>
{isSearchResults ? (
@ -132,7 +147,7 @@ class Collection extends React.Component {
function mapStateToProps(state, ownProps) {
const { collections } = state;
const { isSearchResults, match, t } = ownProps;
const { name, searchTerm } = match.params;
const { name, searchTerm = '', filterTerm = '' } = match.params;
const collection = name ? collections.get(name) : collections.first();
const sort = selectEntriesSort(state.entries, collection.get('name'));
const sortableFields = selectSortableFields(collection, t);
@ -145,6 +160,7 @@ function mapStateToProps(state, ownProps) {
collectionName: name,
isSearchResults,
searchTerm,
filterTerm,
sort,
sortableFields,
viewFilters,

View File

@ -12,7 +12,7 @@ import { selectEntries, selectEntriesLoaded, selectIsFetching } from '../../../r
import { selectCollectionEntriesCursor } from 'Reducers/cursors';
import Entries from './Entries';
class EntriesCollection extends React.Component {
export class EntriesCollection extends React.Component {
static propTypes = {
collection: ImmutablePropTypes.map.isRequired,
page: PropTypes.number,
@ -62,11 +62,36 @@ class EntriesCollection extends React.Component {
}
}
export const filterNestedEntries = (path, collectionFolder, entries) => {
const filtered = entries.filter(e => {
const entryPath = e.get('path').substring(collectionFolder.length + 1);
if (!entryPath.startsWith(path)) {
return false;
}
// only show immediate children
if (path) {
// non root path
const trimmed = entryPath.substring(path.length + 1);
return trimmed.split('/').length === 2;
} else {
// root path
return entryPath.split('/').length <= 2;
}
});
return filtered;
};
function mapStateToProps(state, ownProps) {
const { collection, viewStyle } = ownProps;
const { collection, viewStyle, filterTerm } = ownProps;
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
const entries = selectEntries(state.entries, collection);
let entries = selectEntries(state.entries, collection);
if (collection.has('nested')) {
const collectionFolder = collection.get('folder');
entries = filterNestedEntries(filterTerm || '', collectionFolder, entries);
}
const entriesLoaded = selectEntriesLoaded(state.entries, collection.get('name'));
const isFetching = selectIsFetching(state.entries, collection.get('name'));

View File

@ -0,0 +1,153 @@
import React from 'react';
import ConnectedEntriesCollection, {
EntriesCollection,
filterNestedEntries,
} from '../EntriesCollection';
import { render } from '@testing-library/react';
import { fromJS } from 'immutable';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
jest.mock('../Entries', () => 'mock-entries');
const middlewares = [];
const mockStore = configureStore(middlewares);
const renderWithRedux = (component, { store } = {}) => {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return render(component, { wrapper: Wrapper });
};
const toEntriesState = (collection, entriesArray) => {
const entries = entriesArray.reduce(
(acc, entry) => {
acc.entities[`${collection.get('name')}.${entry.slug}`] = entry;
acc.pages[collection.get('name')].ids.push(entry.slug);
return acc;
},
{ pages: { [collection.get('name')]: { ids: [] } }, entities: {} },
);
return fromJS(entries);
};
describe('filterNestedEntries', () => {
it('should return only immediate children for non root path', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir1/dir2/index', path: 'src/pages/dir1/dir2/index.md', data: { title: 'File 2' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
];
const entries = fromJS(entriesArray);
expect(filterNestedEntries('dir3', 'src/pages', entries).toJS()).toEqual([
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
]);
});
it('should return immediate children and root for root path', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir1/dir2/index', path: 'src/pages/dir1/dir2/index.md', data: { title: 'File 2' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
];
const entries = fromJS(entriesArray);
expect(filterNestedEntries('', 'src/pages', entries).toJS()).toEqual([
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
]);
});
});
describe('EntriesCollection', () => {
const collection = fromJS({ name: 'pages', label: 'Pages', folder: 'src/pages' });
const props = {
t: jest.fn(),
loadEntries: jest.fn(),
traverseCollectionCursor: jest.fn(),
isFetching: false,
cursor: {},
collection,
};
it('should render with entries', () => {
const entries = fromJS([{ slug: 'index' }]);
const { asFragment } = render(<EntriesCollection {...props} entries={entries} />);
expect(asFragment()).toMatchSnapshot();
});
it('should render connected component', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir2/index', path: 'src/pages/dir2/index.md', data: { title: 'File 2' } },
];
const store = mockStore({
entries: toEntriesState(collection, entriesArray),
cursors: fromJS({}),
});
const { asFragment } = renderWithRedux(<ConnectedEntriesCollection collection={collection} />, {
store,
});
expect(asFragment()).toMatchSnapshot();
});
it('should render show only immediate children for nested collection', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir1/dir2/index', path: 'src/pages/dir1/dir2/index.md', data: { title: 'File 2' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
];
const store = mockStore({
entries: toEntriesState(collection, entriesArray),
cursors: fromJS({}),
});
const { asFragment } = renderWithRedux(
<ConnectedEntriesCollection collection={collection.set('nested', fromJS({ depth: 10 }))} />,
{
store,
},
);
expect(asFragment()).toMatchSnapshot();
});
it('should render apply filter term for nested collections', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir1/dir2/index', path: 'src/pages/dir1/dir2/index.md', data: { title: 'File 2' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
];
const store = mockStore({
entries: toEntriesState(collection, entriesArray),
cursors: fromJS({}),
});
const { asFragment } = renderWithRedux(
<ConnectedEntriesCollection
collection={collection.set('nested', fromJS({ depth: 10 }))}
filterTerm="dir3/dir4"
/>,
{
store,
},
);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,49 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EntriesCollection should render apply filter term for nested collections 1`] = `
<DocumentFragment>
<mock-entries
collectionname="Pages"
collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\", \\"nested\\": Map { \\"depth\\": 10 } }"
cursor="[object Object]"
entries="List []"
isfetching="false"
/>
</DocumentFragment>
`;
exports[`EntriesCollection should render connected component 1`] = `
<DocumentFragment>
<mock-entries
collectionname="Pages"
collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\" }"
cursor="[object Object]"
entries="List [ Map { \\"slug\\": \\"index\\", \\"path\\": \\"src/pages/index.md\\", \\"data\\": Map { \\"title\\": \\"Root\\" } }, Map { \\"slug\\": \\"dir1/index\\", \\"path\\": \\"src/pages/dir1/index.md\\", \\"data\\": Map { \\"title\\": \\"File 1\\" } }, Map { \\"slug\\": \\"dir2/index\\", \\"path\\": \\"src/pages/dir2/index.md\\", \\"data\\": Map { \\"title\\": \\"File 2\\" } } ]"
isfetching="false"
/>
</DocumentFragment>
`;
exports[`EntriesCollection should render show only immediate children for nested collection 1`] = `
<DocumentFragment>
<mock-entries
collectionname="Pages"
collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\", \\"nested\\": Map { \\"depth\\": 10 } }"
cursor="[object Object]"
entries="List [ Map { \\"slug\\": \\"index\\", \\"path\\": \\"src/pages/index.md\\", \\"data\\": Map { \\"title\\": \\"Root\\" } }, Map { \\"slug\\": \\"dir1/index\\", \\"path\\": \\"src/pages/dir1/index.md\\", \\"data\\": Map { \\"title\\": \\"File 1\\" } }, Map { \\"slug\\": \\"dir3/index\\", \\"path\\": \\"src/pages/dir3/index.md\\", \\"data\\": Map { \\"title\\": \\"File 3\\" } } ]"
isfetching="false"
/>
</DocumentFragment>
`;
exports[`EntriesCollection should render with entries 1`] = `
<DocumentFragment>
<mock-entries
collectionname="Pages"
collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\" }"
cursor="[object Object]"
entries="List [ Map { \\"slug\\": \\"index\\" } ]"
isfetching="false"
/>
</DocumentFragment>
`;

View File

@ -0,0 +1,308 @@
import React from 'react';
import { List } from 'immutable';
import { css } from '@emotion/core';
import styled from '@emotion/styled';
import { connect } from 'react-redux';
import { NavLink } from 'react-router-dom';
import { dirname, sep } from 'path';
import { stringTemplate } from 'netlify-cms-lib-widgets';
import { selectEntryCollectionTitle } from '../../reducers/collections';
import { selectEntries } from '../../reducers/entries';
import { Icon, colors, components } from 'netlify-cms-ui-default';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { sortBy } from 'lodash';
const { addFileTemplateFields } = stringTemplate;
const NodeTitleContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
const NodeTitle = styled.div`
margin-right: 4px;
`;
const Caret = styled.div`
position: relative;
top: 2px;
`;
const CaretDown = styled(Caret)`
${components.caretDown};
color: currentColor;
`;
const CaretRight = styled(Caret)`
${components.caretRight};
color: currentColor;
left: 2px;
`;
const TreeNavLink = styled(NavLink)`
display: flex;
font-size: 14px;
font-weight: 500;
align-items: center;
padding: 8px;
padding-left: ${props => props.depth * 20 + 12}px;
border-left: 2px solid #fff;
${Icon} {
margin-right: 8px;
flex-shrink: 0;
}
${props => css`
&:hover,
&:active,
&.${props.activeClassName} {
color: ${colors.active};
background-color: ${colors.activeBackground};
border-left-color: #4863c6;
}
`};
`;
const getNodeTitle = node => {
const title = node.isRoot
? node.title
: node.children.find(c => !c.isDir && c.title)?.title || node.title;
return title;
};
const TreeNode = props => {
const { collection, treeData, depth = 0, onToggle } = props;
const collectionName = collection.get('name');
const sortedData = sortBy(treeData, getNodeTitle);
return sortedData.map(node => {
const leaf = node.children.length <= 1 && !node.children[0]?.isDir && depth > 0;
if (leaf) {
return null;
}
let to = `/collections/${collectionName}`;
if (depth > 0) {
to = `${to}/filter${node.path}`;
}
const title = getNodeTitle(node);
const hasChildren = depth === 0 || node.children.some(c => c.children.some(c => c.isDir));
return (
<React.Fragment key={node.path}>
<TreeNavLink
exact
to={to}
activeClassName="sidebar-active"
onClick={() => onToggle({ node, expanded: !node.expanded })}
depth={depth}
data-testid={node.path}
>
<Icon type="write" />
<NodeTitleContainer>
<NodeTitle>{title}</NodeTitle>
{hasChildren && (node.expanded ? <CaretDown /> : <CaretRight />)}
</NodeTitleContainer>
</TreeNavLink>
{node.expanded && (
<TreeNode
collection={collection}
depth={depth + 1}
treeData={node.children}
onToggle={onToggle}
/>
)}
</React.Fragment>
);
});
};
TreeNode.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
depth: PropTypes.number,
treeData: PropTypes.array.isRequired,
onToggle: PropTypes.func.isRequired,
};
export const walk = (treeData, callback) => {
const traverse = children => {
for (const child of children) {
callback(child);
traverse(child.children);
}
};
return traverse(treeData);
};
export const getTreeData = (collection, entries) => {
const collectionFolder = collection.get('folder');
const rootFolder = '/';
const entriesObj = entries
.toJS()
.map(e => ({ ...e, path: e.path.substring(collectionFolder.length) }));
const dirs = entriesObj.reduce((acc, entry) => {
let dir = dirname(entry.path);
while (!acc[dir] && dir && dir !== rootFolder) {
const parts = dir.split(sep);
acc[dir] = parts.pop();
dir = parts.length && parts.join(sep);
}
return acc;
}, {});
if (collection.getIn(['nested', 'summary'])) {
collection = collection.set('summary', collection.getIn(['nested', 'summary']));
} else {
collection = collection.delete('summary');
}
const flatData = [
{
title: collection.get('label'),
path: rootFolder,
isDir: true,
isRoot: true,
},
...Object.entries(dirs).map(([key, value]) => ({
title: value,
path: key,
isDir: true,
isRoot: false,
})),
...entriesObj.map((e, index) => {
let entryMap = entries.get(index);
entryMap = entryMap.set(
'data',
addFileTemplateFields(entryMap.get('path'), entryMap.get('data')),
);
const title = selectEntryCollectionTitle(collection, entryMap);
return {
...e,
title,
isDir: false,
isRoot: false,
};
}),
];
const parentsToChildren = flatData.reduce((acc, node) => {
const parent = node.path === rootFolder ? '' : dirname(node.path);
if (acc[parent]) {
acc[parent].push(node);
} else {
acc[parent] = [node];
}
return acc;
}, {});
const reducer = (acc, value) => {
const node = value;
let children = [];
if (parentsToChildren[node.path]) {
children = parentsToChildren[node.path].reduce(reducer, []);
}
acc.push({ ...node, children });
return acc;
};
const treeData = parentsToChildren[''].reduce(reducer, []);
return treeData;
};
export const updateNode = (treeData, node, callback) => {
let stop = false;
const updater = nodes => {
if (stop) {
return nodes;
}
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].path === node.path) {
nodes[i] = callback(node);
stop = true;
return nodes;
}
}
nodes.forEach(node => updater(node.children));
return nodes;
};
return updater([...treeData]);
};
export class NestedCollection extends React.Component {
static propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entries: ImmutablePropTypes.list.isRequired,
filterTerm: PropTypes.string,
};
constructor(props) {
super(props);
this.state = {
treeData: getTreeData(this.props.collection, this.props.entries),
selected: null,
useFilter: true,
};
}
componentDidUpdate(prevProps) {
const { collection, entries, filterTerm } = this.props;
if (
collection !== prevProps.collection ||
entries !== prevProps.entries ||
filterTerm !== prevProps.filterTerm
) {
const expanded = {};
walk(this.state.treeData, node => {
if (node.expanded) {
expanded[node.path] = true;
}
});
const treeData = getTreeData(collection, entries);
const path = `/${filterTerm}`;
walk(treeData, node => {
if (expanded[node.path] || (this.state.useFilter && path.startsWith(node.path))) {
node.expanded = true;
}
});
this.setState({ treeData });
}
}
onToggle = ({ node, expanded }) => {
if (!this.state.selected || this.state.selected.path === node.path || expanded) {
const treeData = updateNode(this.state.treeData, node, node => ({
...node,
expanded,
}));
this.setState({ treeData, selected: node, useFilter: false });
} else {
// don't collapse non selected nodes when clicked
this.setState({ selected: node, useFilter: false });
}
};
render() {
const { treeData } = this.state;
const { collection } = this.props;
return <TreeNode collection={collection} treeData={treeData} onToggle={this.onToggle} />;
}
}
function mapStateToProps(state, ownProps) {
const { collection } = ownProps;
const entries = selectEntries(state.entries, collection) || List();
return { entries };
}
export default connect(mapStateToProps, null)(NestedCollection);

View File

@ -8,6 +8,7 @@ import { NavLink } from 'react-router-dom';
import { Icon, components, colors } from 'netlify-cms-ui-default';
import { searchCollections } from 'Actions/collections';
import CollectionSearch from './CollectionSearch';
import NestedCollection from './NestedCollection';
const styles = {
sidebarNavLinkActive: css`
@ -64,23 +65,35 @@ const SidebarNavLink = styled(NavLink)`
`};
`;
class Sidebar extends React.Component {
export class Sidebar extends React.Component {
static propTypes = {
collections: ImmutablePropTypes.orderedMap.isRequired,
collection: ImmutablePropTypes.map,
searchTerm: PropTypes.string,
filterTerm: PropTypes.string,
t: PropTypes.func.isRequired,
};
static defaultProps = {
searchTerm: '',
};
renderLink = collection => {
renderLink = (collection, filterTerm) => {
const collectionName = collection.get('name');
if (collection.has('nested')) {
return (
<li key={collectionName}>
<NestedCollection
collection={collection}
filterTerm={filterTerm}
data-testid={collectionName}
/>
</li>
);
}
return (
<li key={collectionName}>
<SidebarNavLink to={`/collections/${collectionName}`} activeClassName="sidebar-active">
<SidebarNavLink
to={`/collections/${collectionName}`}
activeClassName="sidebar-active"
data-testid={collectionName}
>
<Icon type="write" />
{collection.get('label')}
</SidebarNavLink>
@ -89,7 +102,8 @@ class Sidebar extends React.Component {
};
render() {
const { collections, collection, searchTerm, t } = this.props;
const { collections, collection, searchTerm, t, filterTerm } = this.props;
return (
<SidebarContainer>
<SidebarHeading>{t('collection.sidebar.collections')}</SidebarHeading>
@ -103,7 +117,7 @@ class Sidebar extends React.Component {
{collections
.toList()
.filter(collection => collection.get('hide') !== true)
.map(this.renderLink)}
.map(collection => this.renderLink(collection, filterTerm))}
</SidebarNavList>
</SidebarContainer>
);

View File

@ -0,0 +1,68 @@
import React from 'react';
import ConnectedCollection, { Collection } from '../Collection';
import { render } from '@testing-library/react';
import { fromJS } from 'immutable';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
jest.mock('../Entries/EntriesCollection', () => 'mock-entries-collection');
jest.mock('../CollectionTop', () => 'mock-collection-top');
jest.mock('../CollectionControls', () => 'mock-collection-controls');
jest.mock('../Sidebar', () => 'mock-sidebar');
const middlewares = [];
const mockStore = configureStore(middlewares);
const renderWithRedux = (component, { store } = {}) => {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return render(component, { wrapper: Wrapper });
};
describe('Collection', () => {
const collection = fromJS({ name: 'pages', sortableFields: [], view_filters: [] });
const props = {
collections: fromJS([collection]).toOrderedMap(),
collection,
collectionName: collection.get('name'),
t: jest.fn(key => key),
onSortClick: jest.fn(),
};
it('should render with collection without create url', () => {
const { asFragment } = render(
<Collection {...props} collection={collection.set('create', false)} />,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render with collection with create url', () => {
const { asFragment } = render(
<Collection {...props} collection={collection.set('create', true)} />,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render with collection with create url and path', () => {
const { asFragment } = render(
<Collection {...props} collection={collection.set('create', true)} filterTerm="dir1/dir2" />,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render connected component', () => {
const store = mockStore({
collections: props.collections,
entries: fromJS({}),
});
const { asFragment } = renderWithRedux(<ConnectedCollection match={{ params: {} }} />, {
store,
});
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,440 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import ConnectedNestedCollection, {
NestedCollection,
getTreeData,
walk,
updateNode,
} from '../NestedCollection';
import { render, fireEvent } from '@testing-library/react';
import { fromJS } from 'immutable';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
jest.mock('netlify-cms-ui-default', () => {
const actual = jest.requireActual('netlify-cms-ui-default');
return {
...actual,
Icon: 'mocked-icon',
};
});
const middlewares = [];
const mockStore = configureStore(middlewares);
const renderWithRedux = (component, { store } = {}) => {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return render(component, { wrapper: Wrapper });
};
describe('NestedCollection', () => {
const collection = fromJS({
name: 'pages',
label: 'Pages',
folder: 'src/pages',
fields: [{ name: 'title', widget: 'string' }],
});
it('should render correctly with no entries', () => {
const entries = fromJS([]);
const { asFragment, getByTestId } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
expect(getByTestId('/')).toHaveTextContent('Pages');
expect(getByTestId('/')).toHaveAttribute('href', '/collections/pages');
expect(asFragment()).toMatchSnapshot();
});
it('should render correctly with nested entries', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/b/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/b/a/index.md', data: { title: 'File 4' } },
]);
const { asFragment, getByTestId } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
// expand the tree
fireEvent.click(getByTestId('/'));
expect(getByTestId('/a')).toHaveTextContent('File 1');
expect(getByTestId('/a')).toHaveAttribute('href', '/collections/pages/filter/a');
expect(getByTestId('/b')).toHaveTextContent('File 2');
expect(getByTestId('/b')).toHaveAttribute('href', '/collections/pages/filter/b');
expect(asFragment()).toMatchSnapshot();
});
it('should keep expanded nodes on re-render', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/b/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/b/a/index.md', data: { title: 'File 4' } },
]);
const { getByTestId, rerender } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
fireEvent.click(getByTestId('/'));
fireEvent.click(getByTestId('/a'));
expect(getByTestId('/a')).toHaveTextContent('File 1');
const newEntries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/b/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/b/a/index.md', data: { title: 'File 4' } },
{ path: 'src/pages/c/index.md', data: { title: 'File 5' } },
{ path: 'src/pages/c/a/index.md', data: { title: 'File 6' } },
]);
rerender(
<MemoryRouter>
<NestedCollection collection={collection} entries={newEntries} />
</MemoryRouter>,
);
expect(getByTestId('/a')).toHaveTextContent('File 1');
});
it('should expand nodes based on filterTerm', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/a/index.md', data: { title: 'File 3' } },
]);
const { getByTestId, queryByTestId, rerender } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
expect(queryByTestId('/a/a')).toBeNull();
rerender(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} filterTerm={'a/a'} />
</MemoryRouter>,
);
expect(getByTestId('/a/a')).toHaveTextContent('File 2');
});
it('should ignore filterTerm once a user toggles an node', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/a/index.md', data: { title: 'File 3' } },
]);
const { getByTestId, queryByTestId, rerender } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
rerender(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} filterTerm={'a/a'} />
</MemoryRouter>,
);
expect(getByTestId('/a/a')).toHaveTextContent('File 2');
fireEvent.click(getByTestId('/a'));
rerender(
<MemoryRouter>
<NestedCollection
collection={collection}
entries={fromJS(entries.toJS())}
filterTerm={'a/a'}
/>
</MemoryRouter>,
);
expect(queryByTestId('/a/a')).toBeNull();
});
it('should not collapse an unselected node when clicked', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/a/a/a/a/index.md', data: { title: 'File 4' } },
]);
const { getByTestId } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
fireEvent.click(getByTestId('/'));
fireEvent.click(getByTestId('/a'));
fireEvent.click(getByTestId('/a/a'));
expect(getByTestId('/a/a')).toHaveTextContent('File 2');
fireEvent.click(getByTestId('/a'));
expect(getByTestId('/a/a')).toHaveTextContent('File 2');
});
it('should collapse a selected node when clicked', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/a/a/a/a/index.md', data: { title: 'File 4' } },
]);
const { getByTestId, queryByTestId } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
fireEvent.click(getByTestId('/'));
fireEvent.click(getByTestId('/a'));
fireEvent.click(getByTestId('/a/a'));
expect(getByTestId('/a/a/a')).toHaveTextContent('File 3');
fireEvent.click(getByTestId('/a/a'));
expect(queryByTestId('/a/a/a')).toBeNull();
});
it('should render connected component', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'a/index', path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ slug: 'b/index', path: 'src/pages/b/index.md', data: { title: 'File 2' } },
{ slug: 'a/a/index', path: 'src/pages/a/a/index.md', data: { title: 'File 3' } },
{ slug: 'b/a/index', path: 'src/pages/b/a/index.md', data: { title: 'File 4' } },
];
const entries = entriesArray.reduce(
(acc, entry) => {
acc.entities[`${collection.get('name')}.${entry.slug}`] = entry;
acc.pages[collection.get('name')].ids.push(entry.slug);
return acc;
},
{ pages: { [collection.get('name')]: { ids: [] } }, entities: {} },
);
const store = mockStore({ entries: fromJS(entries) });
const { asFragment, getByTestId } = renderWithRedux(
<MemoryRouter>
<ConnectedNestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
{ store },
);
// expand the root
fireEvent.click(getByTestId('/'));
expect(getByTestId('/a')).toHaveTextContent('File 1');
expect(getByTestId('/a')).toHaveAttribute('href', '/collections/pages/filter/a');
expect(getByTestId('/b')).toHaveTextContent('File 2');
expect(getByTestId('/b')).toHaveAttribute('href', '/collections/pages/filter/b');
expect(asFragment()).toMatchSnapshot();
});
describe('getTreeData', () => {
it('should return nested tree data from entries', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/intro/index.md', data: { title: 'intro index' } },
{ path: 'src/pages/intro/category/index.md', data: { title: 'intro category index' } },
{ path: 'src/pages/compliance/index.md', data: { title: 'compliance index' } },
]);
const treeData = getTreeData(collection, entries);
expect(treeData).toEqual([
{
title: 'Pages',
path: '/',
isDir: true,
isRoot: true,
children: [
{
title: 'intro',
path: '/intro',
isDir: true,
isRoot: false,
children: [
{
title: 'category',
path: '/intro/category',
isDir: true,
isRoot: false,
children: [
{
path: '/intro/category/index.md',
data: { title: 'intro category index' },
title: 'intro category index',
isDir: false,
isRoot: false,
children: [],
},
],
},
{
path: '/intro/index.md',
data: { title: 'intro index' },
title: 'intro index',
isDir: false,
isRoot: false,
children: [],
},
],
},
{
title: 'compliance',
path: '/compliance',
isDir: true,
isRoot: false,
children: [
{
path: '/compliance/index.md',
data: { title: 'compliance index' },
title: 'compliance index',
isDir: false,
isRoot: false,
children: [],
},
],
},
{
path: '/index.md',
data: { title: 'Root' },
title: 'Root',
isDir: false,
isRoot: false,
children: [],
},
],
},
]);
});
it('should ignore collection summary', () => {
const entries = fromJS([{ path: 'src/pages/index.md', data: { title: 'Root' } }]);
const treeData = getTreeData(collection, entries);
expect(treeData).toEqual([
{
title: 'Pages',
path: '/',
isDir: true,
isRoot: true,
children: [
{
path: '/index.md',
data: { title: 'Root' },
title: 'Root',
isDir: false,
isRoot: false,
children: [],
},
],
},
]);
});
it('should use nested collection summary for title', () => {
const entries = fromJS([{ path: 'src/pages/index.md', data: { title: 'Root' } }]);
const treeData = getTreeData(
collection.setIn(['nested', 'summary'], '{{filename}}'),
entries,
);
expect(treeData).toEqual([
{
title: 'Pages',
path: '/',
isDir: true,
isRoot: true,
children: [
{
path: '/index.md',
data: { title: 'Root' },
title: 'index',
isDir: false,
isRoot: false,
children: [],
},
],
},
]);
});
});
describe('walk', () => {
it('should visit every tree node', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/dir1/index.md', data: { title: 'Dir1 File' } },
{ path: 'src/pages/dir2/index.md', data: { title: 'Dir2 File' } },
]);
const treeData = getTreeData(collection, entries);
const callback = jest.fn();
walk(treeData, callback);
expect(callback).toHaveBeenCalledTimes(6);
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/index.md' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/dir1' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/dir2' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/dir1/index.md' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/dir2/index.md' }));
});
});
describe('updateNode', () => {
it('should update node', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/dir1/index.md', data: { title: 'Dir1 File' } },
{ path: 'src/pages/dir2/index.md', data: { title: 'Dir2 File' } },
]);
const treeData = getTreeData(collection, entries);
expect(treeData[0].children[0].children[0].expanded).toBeUndefined();
const callback = jest.fn(node => ({ ...node, expanded: true }));
const node = { path: '/dir1/index.md' };
updateNode(treeData, node, callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(node);
expect(treeData[0].children[0].children[0].expanded).toEqual(true);
});
});
});

View File

@ -0,0 +1,74 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Sidebar } from '../Sidebar';
import { render } from '@testing-library/react';
import { fromJS } from 'immutable';
jest.mock('netlify-cms-ui-default', () => {
const actual = jest.requireActual('netlify-cms-ui-default');
return {
...actual,
Icon: 'mocked-icon',
};
});
jest.mock('../NestedCollection', () => 'nested-collection');
jest.mock('../CollectionSearch', () => 'collection-search');
jest.mock('Actions/collections');
describe('Sidebar', () => {
const props = {
searchTerm: '',
t: jest.fn(key => key),
};
it('should render sidebar with a simple collection', () => {
const collections = fromJS([{ name: 'posts', label: 'Posts' }]).toOrderedMap();
const { asFragment, getByTestId } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} />
</MemoryRouter>,
);
expect(getByTestId('posts')).toHaveTextContent('Posts');
expect(getByTestId('posts')).toHaveAttribute('href', '/collections/posts');
expect(asFragment()).toMatchSnapshot();
});
it('should not render a hidden collection', () => {
const collections = fromJS([{ name: 'posts', label: 'Posts', hide: true }]).toOrderedMap();
const { queryByTestId } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} />
</MemoryRouter>,
);
expect(queryByTestId('posts')).toBeNull();
});
it('should render sidebar with a nested collection', () => {
const collections = fromJS([
{ name: 'posts', label: 'Posts', nested: { depth: 10 } },
]).toOrderedMap();
const { asFragment } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} />
</MemoryRouter>,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render nested collection with filterTerm', () => {
const collections = fromJS([
{ name: 'posts', label: 'Posts', nested: { depth: 10 } },
]).toOrderedMap();
const { asFragment } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} filterTerm="dir1/dir2" />
</MemoryRouter>,
);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,153 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Collection should render connected component 1`] = `
<DocumentFragment>
.emotion-2 {
margin: 28px 18px;
}
.emotion-0 {
padding-left: 280px;
}
<div
class="emotion-2 emotion-3"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] } }"
filterterm=""
searchterm=""
/>
<main
class="emotion-0 emotion-1"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] }"
newentryurl=""
/>
<mock-collection-controls
filter="Map {}"
sortablefields=""
viewfilters=""
viewstyle="VIEW_STYLE_LIST"
/>
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] }"
filterterm=""
viewstyle="VIEW_STYLE_LIST"
/>
</main>
</div>
</DocumentFragment>
`;
exports[`Collection should render with collection with create url 1`] = `
<DocumentFragment>
.emotion-2 {
margin: 28px 18px;
}
.emotion-0 {
padding-left: 280px;
}
<div
class="emotion-2 emotion-3"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] } }"
/>
<main
class="emotion-0 emotion-1"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
newentryurl="/collections/pages/new"
/>
<mock-collection-controls
viewstyle="VIEW_STYLE_LIST"
/>
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
viewstyle="VIEW_STYLE_LIST"
/>
</main>
</div>
</DocumentFragment>
`;
exports[`Collection should render with collection with create url and path 1`] = `
<DocumentFragment>
.emotion-2 {
margin: 28px 18px;
}
.emotion-0 {
padding-left: 280px;
}
<div
class="emotion-2 emotion-3"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] } }"
filterterm="dir1/dir2"
/>
<main
class="emotion-0 emotion-1"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
newentryurl="/collections/pages/new?path=dir1/dir2"
/>
<mock-collection-controls
viewstyle="VIEW_STYLE_LIST"
/>
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": true }"
filterterm="dir1/dir2"
viewstyle="VIEW_STYLE_LIST"
/>
</main>
</div>
</DocumentFragment>
`;
exports[`Collection should render with collection without create url 1`] = `
<DocumentFragment>
.emotion-2 {
margin: 28px 18px;
}
.emotion-0 {
padding-left: 280px;
}
<div
class="emotion-2 emotion-3"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": false }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [] } }"
/>
<main
class="emotion-0 emotion-1"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": false }"
newentryurl=""
/>
<mock-collection-controls
viewstyle="VIEW_STYLE_LIST"
/>
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortableFields\\": List [], \\"view_filters\\": List [], \\"create\\": false }"
viewstyle="VIEW_STYLE_LIST"
/>
</main>
</div>
</DocumentFragment>
`;

View File

@ -0,0 +1,549 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NestedCollection should render connected component 1`] = `
<DocumentFragment>
.emotion-6 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 12px;
border-left: 2px solid #fff;
}
.emotion-6 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-6:hover,
.emotion-6:active,
.emotion-6.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-2 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-top: 6px solid currentColor;
border-bottom: 0;
color: currentColor;
}
<a
class="emotion-6 emotion-7"
data-testid="/"
depth="0"
href="/collections/pages"
>
<mocked-icon
type="write"
/>
<div
class="emotion-4 emotion-5"
>
<div
class="emotion-0 emotion-1"
>
Pages
</div>
<div
class="emotion-2 emotion-3"
/>
</div>
</a>
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 32px;
border-left: 2px solid #fff;
}
.emotion-4 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-4:hover,
.emotion-4:active,
.emotion-4.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
<a
class="emotion-4 emotion-5"
data-testid="/a"
depth="1"
href="/collections/pages/filter/a"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-0 emotion-1"
>
File 1
</div>
</div>
</a>
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 32px;
border-left: 2px solid #fff;
}
.emotion-4 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-4:hover,
.emotion-4:active,
.emotion-4.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
<a
class="emotion-4 emotion-5"
data-testid="/b"
depth="1"
href="/collections/pages/filter/b"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-0 emotion-1"
>
File 2
</div>
</div>
</a>
</DocumentFragment>
`;
exports[`NestedCollection should render correctly with nested entries 1`] = `
<DocumentFragment>
.emotion-6 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 12px;
border-left: 2px solid #fff;
}
.emotion-6 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-6:hover,
.emotion-6:active,
.emotion-6.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-2 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-top: 6px solid currentColor;
border-bottom: 0;
color: currentColor;
}
<a
aria-current="page"
class="emotion-6 emotion-7 sidebar-active"
data-testid="/"
depth="0"
href="/collections/pages"
>
<mocked-icon
type="write"
/>
<div
class="emotion-4 emotion-5"
>
<div
class="emotion-0 emotion-1"
>
Pages
</div>
<div
class="emotion-2 emotion-3"
/>
</div>
</a>
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 32px;
border-left: 2px solid #fff;
}
.emotion-4 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-4:hover,
.emotion-4:active,
.emotion-4.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
<a
class="emotion-4 emotion-5"
data-testid="/a"
depth="1"
href="/collections/pages/filter/a"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-0 emotion-1"
>
File 1
</div>
</div>
</a>
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 32px;
border-left: 2px solid #fff;
}
.emotion-4 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-4:hover,
.emotion-4:active,
.emotion-4.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
<a
class="emotion-4 emotion-5"
data-testid="/b"
depth="1"
href="/collections/pages/filter/b"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-0 emotion-1"
>
File 2
</div>
</div>
</a>
</DocumentFragment>
`;
exports[`NestedCollection should render correctly with no entries 1`] = `
<DocumentFragment>
.emotion-6 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 12px;
border-left: 2px solid #fff;
}
.emotion-6 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-6:hover,
.emotion-6:active,
.emotion-6.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-0 {
margin-right: 4px;
}
.emotion-2 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-left: 6px solid currentColor;
border-right: 0;
color: currentColor;
left: 2px;
}
<a
class="emotion-6 emotion-7"
data-testid="/"
depth="0"
href="/collections/pages"
>
<mocked-icon
type="write"
/>
<div
class="emotion-4 emotion-5"
>
<div
class="emotion-0 emotion-1"
>
Pages
</div>
<div
class="emotion-2 emotion-3"
/>
</div>
</a>
</DocumentFragment>
`;

View File

@ -0,0 +1,216 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sidebar should render nested collection with filterTerm 1`] = `
<DocumentFragment>
.emotion-4 {
box-shadow: 0 2px 6px 0 rgba(68,74,87,0.05),0 1px 3px 0 rgba(68,74,87,0.1);
border-radius: 5px;
background-color: #fff;
width: 250px;
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
.emotion-0 {
font-size: 23px;
font-weight: 600;
padding: 0;
margin: 18px 12px 12px;
color: #313d3e;
}
.emotion-2 {
margin: 16px 0 0;
list-style: none;
overflow: auto;
}
<aside
class="emotion-4 emotion-5"
>
<h2
class="emotion-0 emotion-1"
>
collection.sidebar.collections
</h2>
<collection-search
collections="OrderedMap { 0: Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\", \\"nested\\": Map { \\"depth\\": 10 } } }"
searchterm=""
/>
<ul
class="emotion-2 emotion-3"
>
<li>
<nested-collection
collection="Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\", \\"nested\\": Map { \\"depth\\": 10 } }"
data-testid="posts"
filterterm="dir1/dir2"
/>
</li>
</ul>
</aside>
</DocumentFragment>
`;
exports[`Sidebar should render sidebar with a nested collection 1`] = `
<DocumentFragment>
.emotion-4 {
box-shadow: 0 2px 6px 0 rgba(68,74,87,0.05),0 1px 3px 0 rgba(68,74,87,0.1);
border-radius: 5px;
background-color: #fff;
width: 250px;
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
.emotion-0 {
font-size: 23px;
font-weight: 600;
padding: 0;
margin: 18px 12px 12px;
color: #313d3e;
}
.emotion-2 {
margin: 16px 0 0;
list-style: none;
overflow: auto;
}
<aside
class="emotion-4 emotion-5"
>
<h2
class="emotion-0 emotion-1"
>
collection.sidebar.collections
</h2>
<collection-search
collections="OrderedMap { 0: Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\", \\"nested\\": Map { \\"depth\\": 10 } } }"
searchterm=""
/>
<ul
class="emotion-2 emotion-3"
>
<li>
<nested-collection
collection="Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\", \\"nested\\": Map { \\"depth\\": 10 } }"
data-testid="posts"
/>
</li>
</ul>
</aside>
</DocumentFragment>
`;
exports[`Sidebar should render sidebar with a simple collection 1`] = `
<DocumentFragment>
.emotion-6 {
box-shadow: 0 2px 6px 0 rgba(68,74,87,0.05),0 1px 3px 0 rgba(68,74,87,0.1);
border-radius: 5px;
background-color: #fff;
width: 250px;
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
.emotion-0 {
font-size: 23px;
font-weight: 600;
padding: 0;
margin: 18px 12px 12px;
color: #313d3e;
}
.emotion-4 {
margin: 16px 0 0;
list-style: none;
overflow: auto;
}
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px 12px;
border-left: 2px solid #fff;
z-index: -1;
}
.emotion-2 mocked-icon {
margin-right: 8px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-2:hover,
.emotion-2:active,
.emotion-2.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
<aside
class="emotion-6 emotion-7"
>
<h2
class="emotion-0 emotion-1"
>
collection.sidebar.collections
</h2>
<collection-search
collections="OrderedMap { 0: Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\" } }"
searchterm=""
/>
<ul
class="emotion-4 emotion-5"
>
<li>
<a
class="emotion-2 emotion-3"
data-testid="posts"
href="/collections/posts"
>
<mocked-icon
type="write"
/>
Posts
</a>
</li>
</ul>
</aside>
</DocumentFragment>
`;

View File

@ -34,12 +34,7 @@ import { selectFields } from 'Reducers/collections';
import { status, EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import EditorInterface from './EditorInterface';
import withWorkflow from './withWorkflow';
const navigateCollection = collectionPath => history.push(`/collections/${collectionPath}`);
const navigateToCollection = collectionName => navigateCollection(collectionName);
const navigateToNewEntry = collectionName => navigateCollection(`${collectionName}/new`);
const navigateToEntry = (collectionName, slug) =>
navigateCollection(`${collectionName}/entries/${slug}`);
import { navigateToCollection, navigateToNewEntry } from '../../routing/history';
export class Editor extends React.Component {
static propTypes = {
@ -169,16 +164,6 @@ export class Editor extends React.Component {
}
componentDidUpdate(prevProps) {
/**
* If the old slug is empty and the new slug is not, a new entry was just
* saved, and we need to update navigation to the correct url using the
* slug.
*/
const newSlug = this.props.entryDraft && this.props.entryDraft.getIn(['entry', 'slug']);
if (!prevProps.slug && newSlug && this.props.newEntry) {
navigateToEntry(prevProps.collection.get('name'), newSlug);
}
if (!prevProps.localBackup && this.props.localBackup) {
const confirmLoadBackup = window.confirm(this.props.t('editor.editor.confirmLoadBackup'));
if (confirmLoadBackup) {
@ -453,7 +438,7 @@ function mapStateToProps(state, ownProps) {
const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]);
const unPublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
const publishedEntry = selectEntry(state, collectionName, slug);
const currentStatus = unPublishedEntry && unPublishedEntry.getIn(['metaData', 'status']);
const currentStatus = unPublishedEntry && unPublishedEntry.get('status');
const deployPreview = selectDeployPreview(state, collectionName, slug);
const localBackup = entryDraft.get('localBackup');
const draftKey = entryDraft.get('key');

View File

@ -20,6 +20,7 @@ import {
removeMediaControl,
} from 'Actions/mediaLibrary';
import Widget from './Widget';
import { validateMetaField } from '../../../actions/entries';
/**
* This is a necessary bridge as we are still passing classnames to widgets
@ -116,6 +117,8 @@ class EditorControl extends React.Component {
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
parentIds: PropTypes.arrayOf(PropTypes.string),
entry: ImmutablePropTypes.map.isRequired,
collection: ImmutablePropTypes.map.isRequired,
};
static defaultProps = {
@ -171,6 +174,7 @@ class EditorControl extends React.Component {
isNewEditorComponent,
parentIds,
t,
validateMetaField,
} = this.props;
const widgetName = field.get('widget');
@ -248,7 +252,7 @@ class EditorControl extends React.Component {
value={value}
mediaPaths={mediaPaths}
metadata={metadata}
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
onChange={(newValue, newMetadata) => onChange(field, newValue, newMetadata)}
onValidate={onValidate && partial(onValidate, this.uniqueFieldId)}
onOpenMediaLibrary={openMediaLibrary}
onClearMediaControl={clearMediaControl}
@ -277,6 +281,7 @@ class EditorControl extends React.Component {
isNewEditorComponent={isNewEditorComponent}
parentIds={parentIds}
t={t}
validateMetaField={validateMetaField}
/>
{fieldHint && (
<ControlHint active={isSelected || this.state.styleActive} error={hasErrors}>
@ -311,10 +316,11 @@ const mapStateToProps = state => {
isFetching: state.search.get('isFetching'),
queryHits: state.search.get('queryHits'),
config: state.config,
collection,
entry,
collection,
isLoadingAsset,
loadEntry,
validateMetaField: (field, value, t) => validateMetaField(state, collection, field, value, t),
};
};

View File

@ -50,21 +50,27 @@ export default class ControlPane extends React.Component {
return (
<ControlPaneContainer>
{fields.map((field, i) =>
field.get('widget') === 'hidden' ? null : (
{fields.map((field, i) => {
return field.get('widget') === 'hidden' ? null : (
<EditorControl
key={i}
field={field}
value={entry.getIn(['data', field.get('name')])}
value={
field.get('meta')
? entry.getIn(['meta', field.get('name')])
: entry.getIn(['data', field.get('name')])
}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
onChange={onChange}
onValidate={onValidate}
processControlRef={this.controlRef.bind(this)}
controlRef={this.controlRef}
entry={entry}
collection={collection}
/>
),
)}
);
})}
</ControlPaneContainer>
);
}

View File

@ -59,6 +59,7 @@ export default class Widget extends Component {
onValidateObject: PropTypes.func,
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
entry: ImmutablePropTypes.map.isRequired,
};
shouldComponentUpdate(nextProps) {
@ -104,8 +105,11 @@ export default class Widget extends Component {
const field = this.props.field;
const errors = [];
const validations = [this.validatePresence, this.validatePattern];
if (field.get('meta')) {
validations.push(this.props.validateMetaField);
}
validations.forEach(func => {
const response = func(field, value);
const response = func(field, value, this.props.t);
if (response.error) errors.push(response.error);
});
if (skipWrapped) {
@ -114,6 +118,7 @@ export default class Widget extends Component {
const wrappedError = this.validateWrappedControl(field);
if (wrappedError.error) errors.push(wrappedError.error);
}
this.props.onValidate(errors);
};
@ -211,8 +216,8 @@ export default class Widget extends Component {
/**
* Change handler for fields that are nested within another field.
*/
onChangeObject = (fieldName, newValue, newMetadata) => {
const newObjectValue = this.getObjectValue().set(fieldName, newValue);
onChangeObject = (field, newValue, newMetadata) => {
const newObjectValue = this.getObjectValue().set(field.get('name'), newValue);
return this.props.onChange(
newObjectValue,
newMetadata && { [this.props.field.get('name')]: newMetadata },

View File

@ -77,6 +77,9 @@ export class PreviewPane extends React.Component {
// custom preview templates, where the field object can't be passed in.
let field = fields && fields.find(f => f.get('name') === name);
let value = values && values.get(field.get('name'));
if (field.get('meta')) {
value = this.props.entry.getIn(['meta', field.get('name')]);
}
const nestedFields = field.get('fields');
const singleField = field.get('field');
const metadata = fieldsMetaData && fieldsMetaData.get(field.get('name'), Map());

View File

@ -5,7 +5,7 @@ import { css } from '@emotion/core';
import styled from '@emotion/styled';
import { translate } from 'react-polyglot';
import { Map } from 'immutable';
import { Link } from 'react-router-dom';
import history from 'Routing/history';
import {
Icon,
Dropdown,
@ -80,7 +80,7 @@ const ToolbarSubSectionLast = styled(ToolbarSubSectionFirst)`
justify-content: flex-end;
`;
const ToolbarSectionBackLink = styled(Link)`
const ToolbarSectionBackLink = styled.a`
${styles.toolbarSection};
border-right-width: 1px;
font-weight: normal;
@ -568,7 +568,15 @@ class EditorToolbar extends React.Component {
return (
<ToolbarContainer>
<ToolbarSectionBackLink to={`/collections/${collection.get('name')}`}>
<ToolbarSectionBackLink
onClick={() => {
if (history.length > 0) {
history.goBack();
} else {
history.push(`/collections/${collection.get('name')}`);
}
}}
>
<BackArrow></BackArrow>
<div>
<BackCollection>

View File

@ -204,13 +204,13 @@ class WorkflowList extends React.Component {
return (
<div>
{entries.map(entry => {
const timestamp = moment(entry.getIn(['metaData', 'timeStamp'])).format(
const timestamp = moment(entry.get('updatedOn')).format(
t('workflow.workflow.dateFormat'),
);
const slug = entry.get('slug');
const editLink = `collections/${entry.getIn(['metaData', 'collection'])}/entries/${slug}`;
const ownStatus = entry.getIn(['metaData', 'status']);
const collectionName = entry.getIn(['metaData', 'collection']);
const collectionName = entry.get('collection');
const editLink = `collections/${collectionName}/entries/${slug}`;
const ownStatus = entry.get('status');
const collection = collections.find(
collection => collection.get('name') === collectionName,
);

View File

@ -316,5 +316,47 @@ describe('config', () => {
}).not.toThrow();
});
});
it('should throw if collection meta is not a plain object', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ meta: [] }] }));
}).toThrowError("'collections[0].meta' should be object");
});
it('should throw if collection meta is an empty object', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ meta: {} }] }));
}).toThrowError("'collections[0].meta' should NOT have fewer than 1 properties");
});
it('should throw if collection meta is an empty object', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ meta: { path: {} } }] }));
}).toThrowError("'collections[0].meta.path' should have required property 'label'");
expect(() => {
validateConfig(
merge({}, validConfig, { collections: [{ meta: { path: { label: 'Label' } } }] }),
);
}).toThrowError("'collections[0].meta.path' should have required property 'widget'");
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [{ meta: { path: { label: 'Label', widget: 'widget' } } }],
}),
);
}).toThrowError("'collections[0].meta.path' should have required property 'index_file'");
});
it('should allow collection meta to have a path configuration', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [
{ meta: { path: { label: 'Path', widget: 'string', index_file: 'index' } } },
],
}),
);
}).not.toThrow();
});
});
});

View File

@ -183,6 +183,30 @@ const getConfigSchema = () => ({
},
},
view_filters: viewFilters,
nested: {
type: 'object',
properties: {
depth: { type: 'number', minimum: 1, maximum: 1000 },
summary: { type: 'string' },
},
required: ['depth'],
},
meta: {
type: 'object',
properties: {
path: {
type: 'object',
properties: {
label: { type: 'string' },
widget: { type: 'string' },
index_file: { type: 'string' },
},
required: ['label', 'widget', 'index_file'],
},
},
additionalProperties: false,
minProperties: 1,
},
},
required: ['name', 'label'],
oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }],

View File

@ -103,7 +103,7 @@ export const prepareSlug = (slug: string) => {
);
};
const getProcessSegment = (slugConfig: SlugConfig) =>
export const getProcessSegment = (slugConfig: SlugConfig) =>
flow([value => String(value), prepareSlug, partialRight(sanitizeSlug, slugConfig)]);
export const slugFormatter = (

View File

@ -449,4 +449,13 @@ export const selectFieldsComments = (collection: Collection, entryMap: EntryMap)
return comments;
};
export const selectHasMetaPath = (collection: Collection) => {
return (
collection.has('folder') &&
collection.get('type') === FOLDER &&
collection.has('meta') &&
collection.get('meta')?.has('path')
);
};
export default collections;

View File

@ -98,12 +98,7 @@ const unpublishedEntries = (state = Map(), action: EditorialWorkflowAction) => {
// Update Optimistically
return state.withMutations(map => {
map.setIn(
[
'entities',
`${action.payload!.collection}.${action.payload!.slug}`,
'metaData',
'status',
],
['entities', `${action.payload!.collection}.${action.payload!.slug}`, 'status'],
action.payload!.newStatus,
);
map.setIn(
@ -148,7 +143,7 @@ export const selectUnpublishedEntry = (
export const selectUnpublishedEntriesByStatus = (state: EditorialWorkflow, status: string) => {
if (!state) return null;
const entities = state.get('entities') as Entities;
return entities.filter(entry => entry.getIn(['metaData', 'status']) === status).valueSeq();
return entities.filter(entry => entry.get('status') === status).valueSeq();
};
export const selectUnpublishedSlugs = (state: EditorialWorkflow, collection: string) => {

View File

@ -351,6 +351,14 @@ export const selectEntries = (state: Entries, collection: Collection) => {
return entries;
};
export const selectEntryByPath = (state: Entries, collection: string, path: string) => {
const slugs = selectPublishedSlugs(state, collection);
const entries =
slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as List<EntryMap>);
return entries && entries.find(e => e?.get('path') === path);
};
export const selectEntriesLoaded = (state: Entries, collection: string) => {
return !!state.getIn(['pages', collection]);
};

View File

@ -22,6 +22,9 @@ import {
UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
UNPUBLISHED_ENTRY_PERSIST_FAILURE,
} from 'Actions/editorialWorkflow';
import { get } from 'lodash';
import { selectFolderEntryExtension, selectHasMetaPath } from './collections';
import { join } from 'path';
const initialState = Map({
entry: Map(),
@ -87,10 +90,22 @@ const entryDraftReducer = (state = Map(), action) => {
}
case DRAFT_CHANGE_FIELD: {
return state.withMutations(state => {
state.setIn(['entry', 'data', action.payload.field], action.payload.value);
state.mergeDeepIn(['fieldsMetaData'], fromJS(action.payload.metadata));
const { field, value, metadata, entries } = action.payload;
const name = field.get('name');
const meta = field.get('meta');
if (meta) {
state.setIn(['entry', 'meta', name], value);
} else {
state.setIn(['entry', 'data', name], value);
}
state.mergeDeepIn(['fieldsMetaData'], fromJS(metadata));
const newData = state.getIn(['entry', 'data']);
state.set('hasChanged', !action.payload.entries.some(e => newData.equals(e.get('data'))));
const newMeta = state.getIn(['entry', 'meta']);
state.set(
'hasChanged',
!entries.some(e => newData.equals(e.get('data'))) ||
!entries.some(e => newMeta.equals(e.get('meta'))),
);
});
}
case DRAFT_VALIDATION_ERRORS:
@ -161,4 +176,16 @@ const entryDraftReducer = (state = Map(), action) => {
}
};
export const selectCustomPath = (collection, entryDraft) => {
if (!selectHasMetaPath(collection)) {
return;
}
const meta = entryDraft.getIn(['entry', 'meta']);
const path = meta && meta.get('path');
const indexFile = get(collection.toJS(), ['meta', 'path', 'index_file']);
const extension = selectFolderEntryExtension(collection);
const customPath = path && join(collection.get('folder'), path, `${indexFile}.${extension}`);
return customPath;
};
export default entryDraftReducer;

View File

@ -0,0 +1,44 @@
jest.mock('history');
describe('history', () => {
const { createHashHistory } = require('history');
const history = { push: jest.fn(), replace: jest.fn() };
createHashHistory.mockReturnValue(history);
beforeEach(() => {
jest.clearAllMocks();
});
describe('navigateToCollection', () => {
it('should push route', () => {
const { navigateToCollection } = require('../history');
navigateToCollection('posts');
expect(history.push).toHaveBeenCalledTimes(1);
expect(history.push).toHaveBeenCalledWith('/collections/posts');
});
});
describe('navigateToNewEntry', () => {
it('should replace route', () => {
const { navigateToNewEntry } = require('../history');
navigateToNewEntry('posts');
expect(history.replace).toHaveBeenCalledTimes(1);
expect(history.replace).toHaveBeenCalledWith('/collections/posts/new');
});
});
describe('navigateToEntry', () => {
it('should replace route', () => {
const { navigateToEntry } = require('../history');
navigateToEntry('posts', 'index');
expect(history.replace).toHaveBeenCalledTimes(1);
expect(history.replace).toHaveBeenCalledWith('/collections/posts/entries/index');
});
});
});

View File

@ -2,4 +2,11 @@ import { createHashHistory } from 'history';
const history = createHashHistory();
export const navigateToCollection = collectionName =>
history.push(`/collections/${collectionName}`);
export const navigateToNewEntry = collectionName =>
history.replace(`/collections/${collectionName}/new`);
export const navigateToEntry = (collectionName, slug) =>
history.replace(`/collections/${collectionName}/entries/${slug}`);
export default history;

View File

@ -93,9 +93,10 @@ export type EntryObject = {
collection: string;
mediaFiles: List<MediaFileMap>;
newRecord: boolean;
metaData: { status: string };
author?: string;
updatedOn?: string;
status: string;
meta: StaticallyTypedRecord<{ path: string }>;
};
export type EntryMap = StaticallyTypedRecord<EntryObject>;
@ -107,6 +108,7 @@ export type FieldsErrors = StaticallyTypedRecord<{ [field: string]: { type: stri
export type EntryDraft = StaticallyTypedRecord<{
entry: Entry;
fieldsErrors: FieldsErrors;
fieldsMetaData?: Map<string, Map<string, string>>;
}>;
export type EntryField = StaticallyTypedRecord<{
@ -119,6 +121,7 @@ export type EntryField = StaticallyTypedRecord<{
media_folder?: string;
public_folder?: string;
comment?: string;
meta?: boolean;
}>;
export type EntryFields = List<EntryField>;
@ -145,6 +148,17 @@ export type ViewFilter = {
pattern: string;
id: string;
};
type NestedObject = { depth: number };
type Nested = StaticallyTypedRecord<NestedObject>;
type PathObject = { label: string; widget: string; index_file: string };
type MetaObject = {
path?: StaticallyTypedRecord<PathObject>;
};
type Meta = StaticallyTypedRecord<MetaObject>;
type CollectionObject = {
name: string;
@ -170,6 +184,8 @@ type CollectionObject = {
label: string;
sortableFields: List<string>;
view_filters: List<StaticallyTypedRecord<ViewFilter>>;
nested?: Nested;
meta?: Meta;
};
export type Collection = StaticallyTypedRecord<CollectionObject>;
@ -332,6 +348,10 @@ export interface EntriesFilterFailurePayload {
error: Error;
}
export interface EntriesMoveSuccessPayload extends EntryPayload {
entries: EntryObject[];
}
export interface EntriesAction extends Action<string> {
payload:
| EntryRequestPayload

View File

@ -26,6 +26,9 @@ export default class AssetProxy {
async toBase64(): Promise<string> {
const blob = await fetch(this.url).then(response => response.blob());
if (blob.size <= 0) {
return '';
}
const result = await new Promise<string>(resolve => {
const fr = new FileReader();
fr.onload = (readerEvt): void => {

View File

@ -7,11 +7,12 @@ interface Options {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any;
label?: string | null;
metaData?: unknown | null;
isModification?: boolean | null;
mediaFiles?: MediaFile[] | null;
author?: string;
updatedOn?: string;
status?: string;
meta?: { path?: string };
}
export interface EntryValue {
@ -23,11 +24,12 @@ export interface EntryValue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
label: string | null;
metaData: unknown | null;
isModification: boolean | null;
mediaFiles: MediaFile[];
author: string;
updatedOn: string;
status?: string;
meta: { path?: string };
}
export function createEntry(collection: string, slug = '', path = '', options: Options = {}) {
@ -39,11 +41,12 @@ export function createEntry(collection: string, slug = '', path = '', options: O
raw: options.raw || '',
data: options.data || {},
label: options.label || null,
metaData: options.metaData || null,
isModification: isBoolean(options.isModification) ? options.isModification : null,
mediaFiles: options.mediaFiles || [],
author: options.author || '',
updatedOn: options.updatedOn || '',
status: options.status || '',
meta: options.meta || {},
};
return returnObj;