begin scaffolding for lerna

This commit is contained in:
Shawn Erquhart
2018-07-03 15:47:15 -04:00
parent 26f7c38a9f
commit 768fcbaa1d
320 changed files with 50292 additions and 464 deletions

View File

@ -0,0 +1,45 @@
import Immutable from 'immutable';
import { authenticating, authenticate, authError, logout } from 'Actions/auth';
import auth from '../auth';
describe('auth', () => {
it('should handle an empty state', () => {
expect(
auth(undefined, {})
).toEqual(
null
);
});
it('should handle an authentication request', () => {
expect(
auth(undefined, authenticating())
).toEqual(
Immutable.Map({ isFetching: true })
);
});
it('should handle authentication', () => {
expect(
auth(undefined, authenticate({ email: 'joe@example.com' }))
).toEqual(
Immutable.fromJS({ user: { email: 'joe@example.com' } })
);
});
it('should handle an authentication error', () => {
expect(
auth(undefined, authError(new Error('Bad credentials')))
).toEqual(
Immutable.Map({
error: 'Error: Bad credentials',
})
);
});
it('should handle logout', () => {
const initialState = Immutable.fromJS({ user: { email: 'joe@example.com' } });
const newState = auth(initialState, logout());
expect(newState.get('user')).toBeUndefined();
});
});

View File

@ -0,0 +1,36 @@
import { OrderedMap, fromJS } from 'immutable';
import { configLoaded } from 'Actions/config';
import collections from '../collections';
describe('collections', () => {
it('should handle an empty state', () => {
expect(
collections(undefined, {})
).toEqual(
null
);
});
it('should load the collections from the config', () => {
expect(
collections(undefined, configLoaded(fromJS({
collections: [
{
name: 'posts',
folder: '_posts',
fields: [{ name: 'title', widget: 'string' }],
},
],
})))
).toEqual(
OrderedMap({
posts: fromJS({
name: 'posts',
folder: '_posts',
fields: [{ name: 'title', widget: 'string' }],
type: 'folder_based_collection',
}),
})
);
});
});

View File

@ -0,0 +1,37 @@
import { Map } from 'immutable';
import { configLoaded, configLoading, configFailed } from 'Actions/config';
import config from 'Reducers/config';
describe('config', () => {
it('should handle an empty state', () => {
expect(
config(undefined, {})
).toEqual(
Map({ isFetching: true })
);
});
it('should handle an update', () => {
expect(
config(Map({ a: 'b', c: 'd' }), configLoaded(Map({ a: 'changed', e: 'new' })))
).toEqual(
Map({ a: 'changed', e: 'new' })
);
});
it('should mark the config as loading', () => {
expect(
config(undefined, configLoading())
).toEqual(
Map({ isFetching: true })
);
});
it('should handle an error', () => {
expect(
config(Map(), configFailed(new Error('Config could not be loaded')))
).toEqual(
Map({ error: 'Error: Config could not be loaded' })
);
});
});

View File

@ -0,0 +1,45 @@
import { Map, OrderedMap, fromJS } from 'immutable';
import * as actions from 'Actions/entries';
import reducer from '../entries';
const initialState = OrderedMap({
posts: Map({ name: 'posts' }),
});
describe('entries', () => {
it('should mark entries as fetching', () => {
expect(
reducer(initialState, actions.entriesLoading(Map({ name: 'posts' })))
).toEqual(
OrderedMap(fromJS({
posts: { name: 'posts' },
pages: {
posts: { isFetching: true },
},
}))
);
});
it('should handle loaded entries', () => {
const entries = [{ slug: 'a', path: '' }, { slug: 'b', title: 'B' }];
expect(
reducer(initialState, actions.entriesLoaded(Map({ name: 'posts' }), entries, 0))
).toEqual(
OrderedMap(fromJS(
{
posts: { name: 'posts' },
entities: {
'posts.a': { slug: 'a', path: '', isFetching: false },
'posts.b': { slug: 'b', title: 'B', isFetching: false },
},
pages: {
posts: {
page: 0,
ids: ['a', 'b'],
},
},
}
))
);
});
});

View File

@ -0,0 +1,139 @@
import { Map, List, fromJS } from 'immutable';
import * as actions from 'Actions/entries';
import reducer from '../entryDraft';
let initialState = Map({
entry: Map(),
mediaFiles: List(),
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
});
const entry = {
collection: 'posts',
slug: 'slug',
path: 'content/blog/art-and-wine-festival.md',
partial: false,
raw: '',
data: {},
metaData: null,
};
describe('entryDraft reducer', () => {
describe('DRAFT_CREATE_FROM_ENTRY', () => {
it('should create draft from the entry', () => {
expect(
reducer(
initialState,
actions.createDraftFromEntry(fromJS(entry))
)
).toEqual(
fromJS({
entry: {
...entry,
newRecord: false,
},
mediaFiles: [],
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
})
);
});
});
describe('DRAFT_CREATE_EMPTY', () => {
it('should create a new draft ', () => {
expect(
reducer(
initialState,
actions.emptyDraftCreated(fromJS(entry))
)
).toEqual(
fromJS({
entry: {
...entry,
newRecord: true,
},
mediaFiles: [],
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
})
);
});
});
describe('DRAFT_DISCARD', () => {
it('should discard the draft and return initial state', () => {
expect(reducer(initialState, actions.discardDraft()))
.toEqual(initialState);
});
});
describe('DRAFT_CHANGE', () => {
it.skip('should update the draft', () => {
const newEntry = {
...entry,
raw: 'updated',
};
expect(reducer(initialState, actions.changeDraft(newEntry)))
.toEqual(fromJS({
entry: {
...entry,
raw: 'updated',
},
mediaFiles: [],
hasChanged: true,
}));
});
});
describe('persisting', () => {
beforeEach(() => {
initialState = fromJS({
entities: {
'posts.slug': {
collection: 'posts',
slug: 'slug',
path: 'content/blog/art-and-wine-festival.md',
partial: false,
raw: '',
data: {},
metaData: null,
},
},
pages: {},
});
});
it('should handle persisting request', () => {
const newState = reducer(
initialState,
actions.entryPersisting(Map({ name: 'posts' }), Map({ slug: 'slug' }))
);
expect(newState.getIn(['entry', 'isPersisting'])).toBe(true);
});
it('should handle persisting success', () => {
let newState = reducer(initialState,
actions.entryPersisting(Map({ name: 'posts' }), Map({ slug: 'slug' }))
);
newState = reducer(newState,
actions.entryPersisted(Map({ name: 'posts' }), Map({ slug: 'slug' }))
);
expect(newState.getIn(['entry', 'isPersisting'])).toBeUndefined();
});
it('should handle persisting error', () => {
let newState = reducer(initialState,
actions.entryPersisting(Map({ name: 'posts' }), Map({ slug: 'slug' }))
);
newState = reducer(newState,
actions.entryPersistFail(Map({ name: 'posts' }), Map({ slug: 'slug' }), 'Error message')
);
expect(newState.getIn(['entry', 'isPersisting'])).toBeUndefined();
});
});
});

View File

@ -0,0 +1,21 @@
import Immutable from 'immutable';
import { AUTH_REQUEST, AUTH_SUCCESS, AUTH_FAILURE, AUTH_REQUEST_DONE, LOGOUT } from 'Actions/auth';
const auth = (state = null, action) => {
switch (action.type) {
case AUTH_REQUEST:
return Immutable.Map({ isFetching: true });
case AUTH_SUCCESS:
return Immutable.fromJS({ user: action.payload });
case AUTH_FAILURE:
return Immutable.Map({ error: action.payload && action.payload.toString() });
case AUTH_REQUEST_DONE:
return state.remove('isFetching');
case LOGOUT:
return state.remove('user').remove('isFetching');
default:
return state;
}
};
export default auth;

View File

@ -0,0 +1,131 @@
import { List } from 'immutable';
import { has, get, escapeRegExp } from 'lodash';
import consoleError from 'Lib/consoleError';
import { CONFIG_SUCCESS } from 'Actions/config';
import { FILES, FOLDER } from 'Constants/collectionTypes';
import { INFERABLE_FIELDS, IDENTIFIER_FIELDS } from 'Constants/fieldInference';
import { formatToExtension } from 'Formats/formats';
const collections = (state = null, action) => {
switch (action.type) {
case CONFIG_SUCCESS:
const configCollections = action.payload ? action.payload.get('collections') : List();
return configCollections
.toOrderedMap()
.map(collection => {
if (collection.has('folder')) {
return collection.set('type', FOLDER);
}
if (collection.has('files')) {
return collection.set('type', FILES);
}
})
.mapKeys((key, collection) => collection.get('name'));
default:
return state;
}
};
const selectors = {
[FOLDER]: {
entryExtension(collection) {
return (collection.get('extension') || formatToExtension(collection.get('format') || 'frontmatter')).replace(/^\./, '');
},
fields(collection) {
return collection.get('fields');
},
entryPath(collection, slug) {
return `${ collection.get('folder').replace(/\/$/, '') }/${ slug }.${ this.entryExtension(collection) }`;
},
entrySlug(collection, path) {
return path.split('/').pop().replace(new RegExp(`\.${ escapeRegExp(this.entryExtension(collection)) }$`), '');
},
listMethod() {
return 'entriesByFolder';
},
allowNewEntries(collection) {
return collection.get('create');
},
allowDeletion(collection) {
return collection.get('delete', true);
},
templateName(collection) {
return collection.get('name');
},
},
[FILES]: {
fileForEntry(collection, slug) {
const files = collection.get('files');
return files.filter(f => f.get('name') === slug).get(0);
},
fields(collection, slug) {
const file = this.fileForEntry(collection, slug);
return file && file.get('fields');
},
entryPath(collection, slug) {
const file = this.fileForEntry(collection, slug);
return file && file.get('file');
},
entrySlug(collection, path) {
const file = collection.get('files').filter(f => f.get('file') === path).get(0);
return file && file.get('name');
},
listMethod() {
return 'entriesByFiles';
},
allowNewEntries() {
return false;
},
allowDeletion(collection) {
return collection.get('delete', true);
},
templateName(collection, slug) {
return slug;
},
},
};
export const selectFields = (collection, slug) => selectors[collection.get('type')].fields(collection, slug);
export const selectFolderEntryExtension = (collection) => selectors[FOLDER].entryExtension(collection);
export const selectEntryPath = (collection, slug) => selectors[collection.get('type')].entryPath(collection, slug);
export const selectEntrySlug = (collection, path) => selectors[collection.get('type')].entrySlug(collection, path);
export const selectListMethod = collection => selectors[collection.get('type')].listMethod();
export const selectAllowNewEntries = collection => selectors[collection.get('type')].allowNewEntries(collection);
export const selectAllowDeletion = collection => selectors[collection.get('type')].allowDeletion(collection);
export const selectTemplateName = (collection, slug) => selectors[collection.get('type')].templateName(collection, slug);
export const selectIdentifier = collection => {
const fieldNames = collection.get('fields').map(field => field.get('name'));
return IDENTIFIER_FIELDS.find(id => fieldNames.find(name => name.toLowerCase().trim() === id));
};
export const selectInferedField = (collection, fieldName) => {
const inferableField = INFERABLE_FIELDS[fieldName];
const fields = collection.get('fields');
let field;
// If colllection has no fields or fieldName is not defined within inferables list, return null
if (!fields || !inferableField) return null;
// Try to return a field of the specified type with one of the synonyms
const mainTypeFields = fields.filter(f => f.get('widget', 'string') === inferableField.type).map(f => f.get('name'));
field = mainTypeFields.filter(f => inferableField.synonyms.indexOf(f) !== -1);
if (field && field.size > 0) return field.first();
// Try to return a field for each of the specified secondary types
const secondaryTypeFields = fields.filter(f => inferableField.secondaryTypes.indexOf(f.get('widget', 'string')) !== -1).map(f => f.get('name'));
field = secondaryTypeFields.filter(f => inferableField.synonyms.indexOf(f) !== -1);
if (field && field.size > 0) return field.first();
// Try to return the first field of the specified type
if (inferableField.fallbackToFirstField && mainTypeFields.size > 0) return mainTypeFields.first();
// Coundn't infer the field. Show error and return null.
if (inferableField.showError) {
consoleError(
`The Field ${ fieldName } is missing for the collection “${ collection.get('name') }`,
`Netlify CMS tries to infer the entry ${ fieldName } automatically, but one couldn\'t be found for entries of the collection “${ collection.get('name') }”. Please check your site configuration.`
);
}
return null;
};
export default collections;

View File

@ -0,0 +1,11 @@
import { combineReducers } from 'redux';
import { routerReducer } from 'react-router-redux';
import { reducer as notifReducer } from 'redux-notifications';
import optimist from 'redux-optimist';
import reducers from '.';
export default optimist(combineReducers({
...reducers,
notifs: notifReducer,
routing: routerReducer,
}));

View File

@ -0,0 +1,24 @@
import { Map } from 'immutable';
import { CONFIG_REQUEST, CONFIG_SUCCESS, CONFIG_FAILURE, CONFIG_MERGE } from 'Actions/config';
const config = (state = Map({ isFetching: true }), action) => {
switch (action.type) {
case CONFIG_MERGE:
return state.mergeDeep(action.payload);
case CONFIG_REQUEST:
return state.set('isFetching', true);
case CONFIG_SUCCESS:
/**
* The loadConfig action merges any existing config into the loaded config
* before firing this action (so the resulting config can be validated),
* so we don't have to merge it here.
*/
return action.payload.delete('isFetching');
case CONFIG_FAILURE:
return Map({ error: action.payload.toString() });
default:
return state;
}
};
export default config;

View File

@ -0,0 +1,27 @@
import { fromJS, Map } from 'immutable';
import Cursor from "ValueObjects/Cursor";
import {
ENTRIES_SUCCESS,
} from 'Actions/entries';
// Since pagination can be used for a variety of views (collections
// and searches are the most common examples), we namespace cursors by
// their type before storing them in the state.
export const selectCollectionEntriesCursor = (state, collectionName) =>
new Cursor(state.getIn(["cursorsByType", "collectionEntries", collectionName]));
const cursors = (state = fromJS({ cursorsByType: { collectionEntries: {} } }), action) => {
switch (action.type) {
case ENTRIES_SUCCESS: {
return state.setIn(
["cursorsByType", "collectionEntries", action.payload.collection],
Cursor.create(action.payload.cursor).store
);
}
default:
return state;
}
};
export default cursors;

View File

@ -0,0 +1,106 @@
import { Map, List, fromJS } from 'immutable';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import {
UNPUBLISHED_ENTRY_REQUEST,
UNPUBLISHED_ENTRY_REDIRECT,
UNPUBLISHED_ENTRY_SUCCESS,
UNPUBLISHED_ENTRIES_REQUEST,
UNPUBLISHED_ENTRIES_SUCCESS,
UNPUBLISHED_ENTRY_PERSIST_REQUEST,
UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST,
UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE,
UNPUBLISHED_ENTRY_PUBLISH_REQUEST,
UNPUBLISHED_ENTRY_PUBLISH_SUCCESS,
UNPUBLISHED_ENTRY_PUBLISH_FAILURE,
UNPUBLISHED_ENTRY_DELETE_REQUEST,
UNPUBLISHED_ENTRY_DELETE_SUCCESS,
UNPUBLISHED_ENTRY_DELETE_FAILURE,
} from 'Actions/editorialWorkflow';
import { CONFIG_SUCCESS } from 'Actions/config';
const unpublishedEntries = (state = Map(), action) => {
switch (action.type) {
case CONFIG_SUCCESS:
const publishMode = action.payload && action.payload.get('publish_mode');
if (publishMode === EDITORIAL_WORKFLOW) {
// Editorial workflow state is explicetelly initiated after the config.
return Map({ entities: Map(), pages: Map() });
}
return state;
case UNPUBLISHED_ENTRY_REQUEST:
return state.setIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`, 'isFetching'], true);
case UNPUBLISHED_ENTRY_REDIRECT:
return state.deleteIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`]);
case UNPUBLISHED_ENTRY_SUCCESS:
return state.setIn(
['entities', `${ action.payload.collection }.${ action.payload.entry.slug }`],
fromJS(action.payload.entry)
);
case UNPUBLISHED_ENTRIES_REQUEST:
return state.setIn(['pages', 'isFetching'], true);
case UNPUBLISHED_ENTRIES_SUCCESS:
return state.withMutations((map) => {
action.payload.entries.forEach(entry => (
map.setIn(['entities', `${ entry.collection }.${ entry.slug }`], fromJS(entry).set('isFetching', false))
));
map.set('pages', Map({
...action.payload.pages,
ids: List(action.payload.entries.map(entry => entry.slug)),
}));
});
case UNPUBLISHED_ENTRY_PERSIST_REQUEST:
// Update Optimistically
return state.withMutations((map) => {
map.setIn(['entities', `${ action.payload.collection }.${ action.payload.entry.get('slug') }`], fromJS(action.payload.entry));
map.setIn(['entities', `${ action.payload.collection }.${ action.payload.entry.get('slug') }`, 'isPersisting'], true);
map.updateIn(['pages', 'ids'], List(), list => list.push(action.payload.entry.get('slug')));
});
case UNPUBLISHED_ENTRY_PERSIST_SUCCESS:
// Update Optimistically
return state.deleteIn(['entities', `${ action.payload.collection }.${ action.payload.entry.get('slug') }`, 'isPersisting']);
case UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST:
// Update Optimistically
return state.withMutations((map) => {
map.setIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`, 'metaData', 'status'], action.payload.newStatus);
map.setIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`, 'isUpdatingStatus'], true);
});
case UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS:
case UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE:
return state.setIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`, 'isUpdatingStatus'], false);
case UNPUBLISHED_ENTRY_PUBLISH_REQUEST:
return state.setIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`, 'isPublishing'], true);
case UNPUBLISHED_ENTRY_PUBLISH_SUCCESS:
case UNPUBLISHED_ENTRY_PUBLISH_FAILURE:
return state.withMutations(map => {
map.deleteIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`]);
});
case UNPUBLISHED_ENTRY_DELETE_SUCCESS:
return state.deleteIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`]);
default:
return state;
}
};
export const selectUnpublishedEntry = (state, collection, slug) => state && state.getIn(['entities', `${ collection }.${ slug }`]);
export const selectUnpublishedEntriesByStatus = (state, status) => {
if (!state) return null;
return state.get('entities').filter(entry => entry.getIn(['metaData', 'status']) === status).valueSeq();
};
export default unpublishedEntries;

View File

@ -0,0 +1,90 @@
import { Map, List, fromJS } from 'immutable';
import {
ENTRY_REQUEST,
ENTRY_SUCCESS,
ENTRY_FAILURE,
ENTRIES_REQUEST,
ENTRIES_SUCCESS,
ENTRIES_FAILURE,
ENTRY_DELETE_SUCCESS,
} from 'Actions/entries';
import { SEARCH_ENTRIES_SUCCESS } from 'Actions/search';
let collection;
let loadedEntries;
let append;
let page;
const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
switch (action.type) {
case ENTRY_REQUEST:
return state.setIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`, 'isFetching'], true);
case ENTRY_SUCCESS:
return state.setIn(
['entities', `${ action.payload.collection }.${ action.payload.entry.slug }`],
fromJS(action.payload.entry)
);
case ENTRIES_REQUEST:
return state.setIn(['pages', action.payload.collection, 'isFetching'], true);
case ENTRIES_SUCCESS:
collection = action.payload.collection;
loadedEntries = action.payload.entries;
append = action.payload.append;
page = action.payload.page;
return state.withMutations((map) => {
loadedEntries.forEach(entry => (
map.setIn(['entities', `${ collection }.${ entry.slug }`], fromJS(entry).set('isFetching', false))
));
const ids = List(loadedEntries.map(entry => entry.slug));
map.setIn(['pages', collection], Map({
page,
ids: append
? map.getIn(['pages', collection, 'ids'], List()).concat(ids)
: ids,
}));
});
case ENTRIES_FAILURE:
return state.setIn(['pages', action.meta.collection, 'isFetching'], false);
case ENTRY_FAILURE:
return state.withMutations((map) => {
map.setIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`, 'isFetching'], false);
map.setIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`, 'error'], action.payload.error.message);
});
case SEARCH_ENTRIES_SUCCESS:
loadedEntries = action.payload.entries;
return state.withMutations((map) => {
loadedEntries.forEach(entry => (
map.setIn(['entities', `${ entry.collection }.${ entry.slug }`], fromJS(entry).set('isFetching', false))
));
});
case ENTRY_DELETE_SUCCESS:
return state.withMutations((map) => {
map.deleteIn(['entities', `${ action.payload.collectionName }.${ action.payload.entrySlug }`]);
map.updateIn(['pages', action.payload.collectionName, 'ids'],
ids => ids.filter(id => id !== action.payload.entrySlug));
});
default:
return state;
}
};
export const selectEntry = (state, collection, slug) => (
state.getIn(['entities', `${ collection }.${ slug }`])
);
export const selectEntries = (state, collection) => {
const slugs = state.getIn(['pages', collection, 'ids']);
return slugs && slugs.map(slug => selectEntry(state, collection, slug));
};
export default entries;

View File

@ -0,0 +1,112 @@
import { Map, List, fromJS } from 'immutable';
import {
DRAFT_CREATE_FROM_ENTRY,
DRAFT_CREATE_EMPTY,
DRAFT_DISCARD,
DRAFT_CHANGE_FIELD,
DRAFT_VALIDATION_ERRORS,
ENTRY_PERSIST_REQUEST,
ENTRY_PERSIST_SUCCESS,
ENTRY_PERSIST_FAILURE,
ENTRY_DELETE_SUCCESS,
} from 'Actions/entries';
import {
UNPUBLISHED_ENTRY_PERSIST_REQUEST,
UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
UNPUBLISHED_ENTRY_PERSIST_FAILURE,
} from 'Actions/editorialWorkflow';
import {
ADD_ASSET,
REMOVE_ASSET,
} from 'Actions/media';
const initialState = Map({
entry: Map(),
mediaFiles: List(),
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
});
const entryDraftReducer = (state = Map(), action) => {
switch (action.type) {
case DRAFT_CREATE_FROM_ENTRY:
// Existing Entry
return state.withMutations((state) => {
state.set('entry', action.payload.entry);
state.setIn(['entry', 'newRecord'], false);
state.set('mediaFiles', List());
// An existing entry may already have metadata. If we surfed away and back to its
// editor page, the metadata will have been fetched already, so we shouldn't
// clear it as to not break relation lists.
state.set('fieldsMetaData', action.payload.metadata || Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', false);
});
case DRAFT_CREATE_EMPTY:
// New Entry
return state.withMutations((state) => {
state.set('entry', fromJS(action.payload));
state.setIn(['entry', 'newRecord'], true);
state.set('mediaFiles', List());
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', false);
});
case DRAFT_DISCARD:
return initialState;
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));
state.set('hasChanged', true);
});
case DRAFT_VALIDATION_ERRORS:
if (action.payload.errors.length === 0) {
return state.deleteIn(['fieldsErrors', action.payload.field]);
} else {
return state.setIn(['fieldsErrors', action.payload.field], action.payload.errors);
}
case ENTRY_PERSIST_REQUEST:
case UNPUBLISHED_ENTRY_PERSIST_REQUEST: {
return state.setIn(['entry', 'isPersisting'], true);
}
case ENTRY_PERSIST_FAILURE:
case UNPUBLISHED_ENTRY_PERSIST_FAILURE: {
return state.deleteIn(['entry', 'isPersisting']);
}
case ENTRY_PERSIST_SUCCESS:
case UNPUBLISHED_ENTRY_PERSIST_SUCCESS:
return state.withMutations((state) => {
state.deleteIn(['entry', 'isPersisting']);
state.set('hasChanged', false);
if (!state.getIn(['entry', 'slug'])) {
state.setIn(['entry', 'slug'], action.payload.slug);
}
});
case ENTRY_DELETE_SUCCESS:
return state.withMutations((state) => {
state.deleteIn(['entry', 'isPersisting']);
state.set('hasChanged', false);
});
case ADD_ASSET:
if (state.has('mediaFiles')) {
return state.update('mediaFiles', list => list.push(action.payload.public_path));
}
return state;
case REMOVE_ASSET:
return state.update('mediaFiles', list => list.filterNot(path => path === action.payload));
default:
return state;
}
};
export default entryDraftReducer;

View File

@ -0,0 +1,18 @@
import { Map } from 'immutable';
/*
* Reducer for some global UI state that we want to share between components
* */
const globalUI = (state = Map({ isFetching: false }), action) => {
// Generic, global loading indicator
if ((action.type.indexOf('REQUEST') > -1)) {
return state.set('isFetching', true);
} else if (
(action.type.indexOf('SUCCESS') > -1) ||
(action.type.indexOf('FAILURE') > -1)
) {
return state.set('isFetching', false);
}
return state;
};
export default globalUI;

View File

@ -0,0 +1,55 @@
import auth from './auth';
import config from './config';
import integrations, * as fromIntegrations from './integrations';
import entries, * as fromEntries from './entries';
import cursors from './cursors';
import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow';
import entryDraft from './entryDraft';
import collections from './collections';
import search from './search';
import mediaLibrary from './mediaLibrary';
import medias, * as fromMedias from './medias';
import globalUI from './globalUI';
const reducers = {
auth,
config,
collections,
search,
integrations,
entries,
cursors,
editorialWorkflow,
entryDraft,
mediaLibrary,
medias,
globalUI,
};
export default reducers;
/*
* Selectors
*/
export const selectEntry = (state, collection, slug) =>
fromEntries.selectEntry(state.entries, collection, slug);
export const selectEntries = (state, collection) =>
fromEntries.selectEntries(state.entries, collection);
export const selectSearchedEntries = (state) => {
const searchItems = state.search.get('entryIds');
return searchItems && searchItems.map(({ collection, slug }) => fromEntries.selectEntry(state.entries, collection, slug));
};
export const selectUnpublishedEntry = (state, collection, slug) =>
fromEditorialWorkflow.selectUnpublishedEntry(state.editorialWorkflow, collection, slug);
export const selectUnpublishedEntriesByStatus = (state, status) =>
fromEditorialWorkflow.selectUnpublishedEntriesByStatus(state.editorialWorkflow, status);
export const selectIntegration = (state, collection, hook) =>
fromIntegrations.selectIntegration(state.integrations, collection, hook);
export const getAsset = (state, path) =>
fromMedias.getAsset(state.config.get('public_folder'), state.medias, path);

View File

@ -0,0 +1,36 @@
import { fromJS, List } from 'immutable';
import { CONFIG_SUCCESS } from 'Actions/config';
const integrations = (state = null, action) => {
switch (action.type) {
case CONFIG_SUCCESS:
const integrations = action.payload.get('integrations', List()).toJS() || [];
const newState = integrations.reduce((acc, integration) => {
const { hooks, collections, provider, ...providerData } = integration;
acc.providers[provider] = { ...providerData };
if (!collections) {
hooks.forEach((hook) => {
acc.hooks[hook] = provider;
});
return acc;
}
const integrationCollections = collections === "*" ? action.payload.collections.map(collection => collection.name) : collections;
integrationCollections.forEach((collection) => {
hooks.forEach((hook) => {
acc.hooks[collection] ? acc.hooks[collection][hook] = provider : acc.hooks[collection] = { [hook]: provider };
});
});
return acc;
}, { providers:{}, hooks: {} });
return fromJS(newState);
default:
return state;
}
};
export const selectIntegration = (state, collection, hook) => (
collection? state.getIn(['hooks', collection, hook], false) : state.getIn(['hooks', hook], false)
);
export default integrations;

View File

@ -0,0 +1,130 @@
import { get } from 'lodash';
import { Map } from 'immutable';
import uuid from 'uuid/v4';
import {
MEDIA_LIBRARY_OPEN,
MEDIA_LIBRARY_CLOSE,
MEDIA_INSERT,
MEDIA_REMOVE_INSERTED,
MEDIA_LOAD_REQUEST,
MEDIA_LOAD_SUCCESS,
MEDIA_LOAD_FAILURE,
MEDIA_PERSIST_REQUEST,
MEDIA_PERSIST_SUCCESS,
MEDIA_PERSIST_FAILURE,
MEDIA_DELETE_REQUEST,
MEDIA_DELETE_SUCCESS,
MEDIA_DELETE_FAILURE,
} from 'Actions/mediaLibrary';
const mediaLibrary = (state = Map({ isVisible: false, controlMedia: Map() }), action) => {
const privateUploadChanged = state.get('privateUpload') !== get(action, ['payload', 'privateUpload']);
switch (action.type) {
case MEDIA_LIBRARY_OPEN: {
const { controlID, forImage, privateUpload } = action.payload || {};
if (privateUploadChanged) {
return Map({
isVisible: true,
forImage,
controlID,
canInsert: !!controlID,
privateUpload,
controlMedia: Map(),
});
}
return state.withMutations(map => {
map.set('isVisible', true);
map.set('forImage', forImage);
map.set('controlID', controlID);
map.set('canInsert', !!controlID);
map.set('privateUpload', privateUpload);
});
}
case MEDIA_LIBRARY_CLOSE:
return state.set('isVisible', false);
case MEDIA_INSERT: {
const controlID = state.get('controlID');
const mediaPath = get(action, ['payload', 'mediaPath']);
return state.setIn(['controlMedia', controlID], mediaPath);
}
case MEDIA_REMOVE_INSERTED: {
const controlID = get(action, ['payload', 'controlID']);
return state.setIn(['controlMedia', controlID], '');
}
case MEDIA_LOAD_REQUEST:
return state.withMutations(map => {
map.set('isLoading', true);
map.set('isPaginating', action.payload.page > 1);
});
case MEDIA_LOAD_SUCCESS: {
const { files = [], page, canPaginate, dynamicSearch, dynamicSearchQuery, privateUpload } = action.payload;
if (privateUploadChanged) {
return state;
}
const filesWithKeys = files.map(file => ({ ...file, key: uuid() }));
return state.withMutations(map => {
map.set('isLoading', false);
map.set('isPaginating', false);
map.set('page', page);
map.set('hasNextPage', canPaginate && files.length > 0);
map.set('dynamicSearch', dynamicSearch);
map.set('dynamicSearchQuery', dynamicSearchQuery);
map.set('dynamicSearchActive', !!dynamicSearchQuery);
if (page && page > 1) {
const updatedFiles = map.get('files').concat(filesWithKeys);
map.set('files', updatedFiles);
} else {
map.set('files', filesWithKeys);
}
});
}
case MEDIA_LOAD_FAILURE:
if (privateUploadChanged) {
return state;
}
return state.set('isLoading', false);
case MEDIA_PERSIST_REQUEST:
return state.set('isPersisting', true);
case MEDIA_PERSIST_SUCCESS: {
const { file } = action.payload;
if (privateUploadChanged) {
return state;
}
return state.withMutations(map => {
const fileWithKey = { ...file, key: uuid() };
const updatedFiles = [fileWithKey, ...map.get('files')];
map.set('files', updatedFiles);
map.set('isPersisting', false);
});
}
case MEDIA_PERSIST_FAILURE:
if (privateUploadChanged) {
return state;
}
return state.set('isPersisting', false);
case MEDIA_DELETE_REQUEST:
return state.set('isDeleting', true);
case MEDIA_DELETE_SUCCESS: {
const { key } = action.payload.file;
if (privateUploadChanged) {
return state;
}
return state.withMutations(map => {
const updatedFiles = map.get('files').filter(file => file.key !== key);
map.set('files', updatedFiles);
map.set('isDeleting', false);
});
}
case MEDIA_DELETE_FAILURE:
if (privateUploadChanged) {
return state;
}
return state.set('isDeleting', false);
default:
return state;
}
};
export default mediaLibrary;

View File

@ -0,0 +1,34 @@
import { Map } from 'immutable';
import { resolvePath } from 'netlify-cms-lib-util';
import { ADD_ASSET, REMOVE_ASSET } from 'Actions/media';
import AssetProxy from 'ValueObjects/AssetProxy';
const medias = (state = Map(), action) => {
switch (action.type) {
case ADD_ASSET:
return state.set(action.payload.public_path, action.payload);
case REMOVE_ASSET:
return state.delete(action.payload);
default:
return state;
}
};
export default medias;
const memoizedProxies = {};
export const getAsset = (publicFolder, state, path) => {
// No path provided, skip
if (!path) return null;
let proxy = state.get(path) || memoizedProxies[path];
if (proxy) {
// There is already an AssetProxy in memmory for this path. Use it.
return proxy;
}
// Create a new AssetProxy (for consistency) and return it.
proxy = memoizedProxies[path] = new AssetProxy(resolvePath(path, publicFolder), null, true);
return proxy;
};

View File

@ -0,0 +1,67 @@
import { Map, List } from 'immutable';
import {
SEARCH_ENTRIES_REQUEST,
SEARCH_ENTRIES_SUCCESS,
QUERY_REQUEST,
QUERY_SUCCESS,
SEARCH_CLEAR,
} from 'Actions/search';
let loadedEntries;
let response;
let page;
let searchTerm;
const defaultState = Map({ isFetching: false, term: null, page: 0, entryIds: List([]), queryHits: Map({}) });
const entries = (state = defaultState, action) => {
switch (action.type) {
case SEARCH_CLEAR:
return defaultState;
case SEARCH_ENTRIES_REQUEST:
if (action.payload.searchTerm !== state.get('term')) {
return state.withMutations((map) => {
map.set('isFetching', true);
map.set('term', action.payload.searchTerm);
});
}
return state;
case SEARCH_ENTRIES_SUCCESS:
loadedEntries = action.payload.entries;
page = action.payload.page;
searchTerm = action.payload.searchTerm;
return state.withMutations((map) => {
const entryIds = List(loadedEntries.map(entry => ({ collection: entry.collection, slug: entry.slug })));
map.set('isFetching', false);
map.set('page', page);
map.set('term', searchTerm);
map.set('entryIds', (!page || isNaN(page) || page === 0) ? entryIds : map.get('entryIds', List()).concat(entryIds));
});
case QUERY_REQUEST:
if (action.payload.searchTerm !== state.get('term')) {
return state.withMutations((map) => {
map.set('isFetching', action.payload.namespace);
map.set('term', action.payload.searchTerm);
});
}
return state;
case QUERY_SUCCESS:
searchTerm = action.payload.searchTerm;
response = action.payload.response;
return state.withMutations((map) => {
map.set('isFetching', false);
map.set('term', searchTerm);
map.mergeIn(['queryHits'], Map({ [action.payload.namespace]: response.hits }));
});
default:
return state;
}
};
export default entries;