Feat: nested collections (#3716)
This commit is contained in:
@ -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: '',
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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 => {
|
||||
|
@ -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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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 };
|
||||
|
@ -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 />}
|
||||
|
@ -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,
|
||||
|
@ -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'));
|
||||
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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>
|
||||
`;
|
@ -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);
|
@ -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>
|
||||
);
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
@ -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>
|
||||
`;
|
@ -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>
|
||||
`;
|
@ -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>
|
||||
`;
|
@ -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');
|
||||
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 },
|
||||
|
@ -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());
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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'] }],
|
||||
|
@ -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 = (
|
||||
|
@ -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;
|
||||
|
@ -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) => {
|
||||
|
@ -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]);
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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 => {
|
||||
|
@ -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;
|
||||
|
Reference in New Issue
Block a user