chore: add code formatting and linting (#952)

This commit is contained in:
Caleb
2018-08-07 14:46:54 -06:00
committed by Shawn Erquhart
parent 32e0a9b2b5
commit f801b19221
265 changed files with 5988 additions and 4481 deletions

View File

@ -6,20 +6,23 @@ module.exports = {
plugins: [
...babelConfig.plugins,
'react-hot-loader/babel',
['module-resolver', {
root: path.join(__dirname, 'src/components'),
alias: {
src: path.join(__dirname, 'src'),
Actions: path.join(__dirname, 'src/actions/'),
Constants: path.join(__dirname, 'src/constants/'),
Formats: path.join(__dirname, 'src/formats/'),
Integrations: path.join(__dirname, 'src/integrations/'),
Lib: path.join(__dirname, 'src/lib/'),
Reducers: path.join(__dirname, 'src/reducers/'),
Redux: path.join(__dirname, 'src/redux/'),
Routing: path.join(__dirname, 'src/routing/'),
ValueObjects: path.join(__dirname, 'src/valueObjects/'),
}
}],
[
'module-resolver',
{
root: path.join(__dirname, 'src/components'),
alias: {
src: path.join(__dirname, 'src'),
Actions: path.join(__dirname, 'src/actions/'),
Constants: path.join(__dirname, 'src/constants/'),
Formats: path.join(__dirname, 'src/formats/'),
Integrations: path.join(__dirname, 'src/integrations/'),
Lib: path.join(__dirname, 'src/lib/'),
Reducers: path.join(__dirname, 'src/reducers/'),
Redux: path.join(__dirname, 'src/redux/'),
Routing: path.join(__dirname, 'src/routing/'),
ValueObjects: path.join(__dirname, 'src/valueObjects/'),
},
},
],
],
};

View File

@ -9,11 +9,7 @@ describe('config', () => {
media_folder: 'path/to/media',
public_folder: '/path/to/media',
});
expect(
applyDefaults(config)
).toEqual(
config.set('publish_mode', 'simple')
);
expect(applyDefaults(config)).toEqual(config.set('publish_mode', 'simple'));
});
it('should set publish_mode from config', () => {
@ -23,36 +19,44 @@ describe('config', () => {
media_folder: 'path/to/media',
public_folder: '/path/to/media',
});
expect(
applyDefaults(config)
).toEqual(
config
);
expect(applyDefaults(config)).toEqual(config);
});
it('should set public_folder based on media_folder if not set', () => {
expect(applyDefaults(fromJS({
foo: 'bar',
media_folder: 'path/to/media',
}))).toEqual(fromJS({
foo: 'bar',
publish_mode: 'simple',
media_folder: 'path/to/media',
public_folder: '/path/to/media',
}));
expect(
applyDefaults(
fromJS({
foo: 'bar',
media_folder: 'path/to/media',
}),
),
).toEqual(
fromJS({
foo: 'bar',
publish_mode: 'simple',
media_folder: 'path/to/media',
public_folder: '/path/to/media',
}),
);
});
it('should not overwrite public_folder if set', () => {
expect(applyDefaults(fromJS({
foo: 'bar',
media_folder: 'path/to/media',
public_folder: '/publib/path',
}))).toEqual(fromJS({
foo: 'bar',
publish_mode: 'simple',
media_folder: 'path/to/media',
public_folder: '/publib/path',
}));
expect(
applyDefaults(
fromJS({
foo: 'bar',
media_folder: 'path/to/media',
public_folder: '/publib/path',
}),
),
).toEqual(
fromJS({
foo: 'bar',
publish_mode: 'simple',
media_folder: 'path/to/media',
public_folder: '/publib/path',
}),
);
});
});
});
});

View File

@ -48,15 +48,16 @@ export function authenticateUser() {
const state = getState();
const backend = currentBackend(state.config);
dispatch(authenticating());
return backend.currentUser()
.then((user) => {
return backend
.currentUser()
.then(user => {
if (user) {
dispatch(authenticate(user));
} else {
dispatch(doneAuthenticating());
}
})
.catch((error) => {
.catch(error => {
dispatch(authError(error));
dispatch(logoutUser());
});
@ -69,16 +70,19 @@ export function loginUser(credentials) {
const backend = currentBackend(state.config);
dispatch(authenticating());
return backend.authenticate(credentials)
.then((user) => {
return backend
.authenticate(credentials)
.then(user => {
dispatch(authenticate(user));
})
.catch((error) => {
dispatch(notifSend({
message: `${ error.message }`,
kind: 'warning',
dismissAfter: 8000,
}));
.catch(error => {
dispatch(
notifSend({
message: `${error.message}`,
kind: 'warning',
dismissAfter: 8000,
}),
);
dispatch(authError(error));
});
};

View File

@ -1,15 +1,14 @@
import yaml from "js-yaml";
import { Map, fromJS } from "immutable";
import { trimStart, flow, get } from "lodash";
import { authenticateUser } from "Actions/auth";
import * as publishModes from "Constants/publishModes";
import yaml from 'js-yaml';
import { Map, fromJS } from 'immutable';
import { trimStart, get } from 'lodash';
import { authenticateUser } from 'Actions/auth';
import * as publishModes from 'Constants/publishModes';
import { validateConfig } from 'Constants/configSchema';
export const CONFIG_REQUEST = "CONFIG_REQUEST";
export const CONFIG_SUCCESS = "CONFIG_SUCCESS";
export const CONFIG_FAILURE = "CONFIG_FAILURE";
export const CONFIG_MERGE = "CONFIG_MERGE";
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
export const CONFIG_FAILURE = 'CONFIG_FAILURE';
export const CONFIG_MERGE = 'CONFIG_MERGE';
const getConfigUrl = () => {
const validTypes = { 'text/yaml': 'yaml', 'application/x-yaml': 'yaml' };
@ -21,7 +20,7 @@ const getConfigUrl = () => {
return link;
}
return 'config.yml';
}
};
const defaults = {
publish_mode: publishModes.SIMPLE,
@ -48,8 +47,8 @@ function mergePreloadedConfig(preloadedConfig, loadedConfig) {
function parseConfig(data) {
const config = yaml.safeLoad(data);
if (typeof CMS_ENV === "string" && config[CMS_ENV]) {
Object.keys(config[CMS_ENV]).forEach((key) => {
if (typeof CMS_ENV === 'string' && config[CMS_ENV]) {
Object.keys(config[CMS_ENV]).forEach(key => {
config[key] = config[CMS_ENV][key];
});
}
@ -60,12 +59,12 @@ async function getConfig(file, isPreloaded) {
const response = await fetch(file, { credentials: 'same-origin' });
if (response.status !== 200) {
if (isPreloaded) return parseConfig('');
throw new Error(`Failed to load config.yml (${ response.status })`);
throw new Error(`Failed to load config.yml (${response.status})`);
}
const contentType = response.headers.get('Content-Type') || 'Not-Found';
const isYaml = contentType.indexOf('yaml') !== -1;
if (!isYaml) {
console.log(`Response for ${ file } was not yaml. (Content-Type: ${ contentType })`);
console.log(`Response for ${file} was not yaml. (Content-Type: ${contentType})`);
if (isPreloaded) return parseConfig('');
}
return parseConfig(await response.text());
@ -87,13 +86,13 @@ export function configLoading() {
export function configFailed(err) {
return {
type: CONFIG_FAILURE,
error: "Error loading config",
error: 'Error loading config',
payload: err,
};
}
export function configDidLoad(config) {
return (dispatch) => {
return dispatch => {
dispatch(configLoaded(config));
};
}
@ -124,10 +123,9 @@ export function loadConfig() {
dispatch(configDidLoad(config));
dispatch(authenticateUser());
}
catch(err) {
} catch (err) {
dispatch(configFailed(err));
throw(err)
throw err;
}
};
}

View File

@ -56,7 +56,7 @@ function unpublishedEntryLoading(collection, slug) {
function unpublishedEntryLoaded(collection, entry) {
return {
type: UNPUBLISHED_ENTRY_SUCCESS,
payload: {
payload: {
collection: collection.get('name'),
entry,
},
@ -66,7 +66,7 @@ function unpublishedEntryLoaded(collection, entry) {
function unpublishedEntryRedirected(collection, slug) {
return {
type: UNPUBLISHED_ENTRY_REDIRECT,
payload: {
payload: {
collection: collection.get('name'),
slug,
},
@ -97,7 +97,6 @@ function unpublishedEntriesFailed(error) {
};
}
function unpublishedEntryPersisting(collection, entry, transactionID) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_REQUEST,
@ -112,7 +111,7 @@ function unpublishedEntryPersisting(collection, entry, transactionID) {
function unpublishedEntryPersisted(collection, entry, transactionID, slug) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
payload: {
payload: {
collection: collection.get('name'),
entry,
slug,
@ -130,10 +129,16 @@ function unpublishedEntryPersistedFail(error, transactionID) {
};
}
function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus, transactionID) {
function unpublishedEntryStatusChangeRequest(
collection,
slug,
oldStatus,
newStatus,
transactionID,
) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST,
payload: {
payload: {
collection,
slug,
oldStatus,
@ -143,10 +148,16 @@ function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newSta
};
}
function unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus, transactionID) {
function unpublishedEntryStatusChangePersisted(
collection,
slug,
oldStatus,
newStatus,
transactionID,
) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
payload: {
payload: {
collection,
slug,
oldStatus,
@ -223,20 +234,23 @@ export function loadUnpublishedEntry(collection, slug) {
const state = getState();
const backend = currentBackend(state.config);
dispatch(unpublishedEntryLoading(collection, slug));
backend.unpublishedEntry(collection, slug)
.then(entry => dispatch(unpublishedEntryLoaded(collection, entry)))
.catch((error) => {
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
dispatch(unpublishedEntryRedirected(collection, slug));
dispatch(loadEntry(collection, slug));
} else {
dispatch(notifSend({
message: `Error loading entry: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
}
});
backend
.unpublishedEntry(collection, slug)
.then(entry => dispatch(unpublishedEntryLoaded(collection, entry)))
.catch(error => {
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
dispatch(unpublishedEntryRedirected(collection, slug));
dispatch(loadEntry(collection, slug));
} else {
dispatch(
notifSend({
message: `Error loading entry: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
}
});
};
}
@ -246,16 +260,19 @@ export function loadUnpublishedEntries(collections) {
if (state.config.get('publish_mode') !== EDITORIAL_WORKFLOW) return;
const backend = currentBackend(state.config);
dispatch(unpublishedEntriesLoading());
backend.unpublishedEntries(collections)
backend
.unpublishedEntries(collections)
.then(response => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination)))
.catch(error => {
dispatch(notifSend({
message: `Error loading entries: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(
notifSend({
message: `Error loading entries: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntriesFailed(error));
Promise.reject(error)
Promise.reject(error);
});
};
}
@ -268,17 +285,20 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
// Early return if draft contains validation errors
if (!fieldsErrors.isEmpty()) {
const hasPresenceErrors = fieldsErrors
.some(errors => errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE));
const hasPresenceErrors = fieldsErrors.some(errors =>
errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE),
);
if (hasPresenceErrors) {
dispatch(notifSend({
message: 'Oops, you\'ve missed a required field. Please complete before saving.',
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(
notifSend({
message: "Oops, you've missed a required field. Please complete before saving.",
kind: 'danger',
dismissAfter: 8000,
}),
);
}
return Promise.reject()
return Promise.reject();
}
const backend = currentBackend(state.config);
@ -296,7 +316,9 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
dispatch(unpublishedEntryPersisting(collection, serializedEntry, transactionID));
const persistAction = existingUnpublishedEntry ? backend.persistUnpublishedEntry : backend.persistEntry;
const persistAction = existingUnpublishedEntry
? backend.persistUnpublishedEntry
: backend.persistEntry;
const persistCallArgs = [
backend,
state.config,
@ -308,19 +330,22 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
try {
const newSlug = await persistAction.call(...persistCallArgs);
dispatch(notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(
notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryPersisted(collection, serializedEntry, transactionID, newSlug));
}
catch(error) {
dispatch(notifSend({
message: `Failed to persist entry: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
} catch (error) {
dispatch(
notifSend({
message: `Failed to persist entry: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
return Promise.reject(dispatch(unpublishedEntryPersistedFail(error, transactionID)));
}
};
@ -331,24 +356,39 @@ export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newSta
const state = getState();
const backend = currentBackend(state.config);
const transactionID = uuid();
dispatch(unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus, transactionID));
backend.updateUnpublishedEntryStatus(collection, slug, newStatus)
.then(() => {
dispatch(notifSend({
message: 'Entry status updated',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus, transactionID));
})
.catch((error) => {
dispatch(notifSend({
message: `Failed to update status: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(unpublishedEntryStatusChangeError(collection, slug, transactionID));
});
dispatch(
unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus, transactionID),
);
backend
.updateUnpublishedEntryStatus(collection, slug, newStatus)
.then(() => {
dispatch(
notifSend({
message: 'Entry status updated',
kind: 'success',
dismissAfter: 4000,
}),
);
dispatch(
unpublishedEntryStatusChangePersisted(
collection,
slug,
oldStatus,
newStatus,
transactionID,
),
);
})
.catch(error => {
dispatch(
notifSend({
message: `Failed to update status: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryStatusChangeError(collection, slug, transactionID));
});
};
}
@ -358,23 +398,28 @@ export function deleteUnpublishedEntry(collection, slug) {
const backend = currentBackend(state.config);
const transactionID = uuid();
dispatch(unpublishedEntryDeleteRequest(collection, slug, transactionID));
return backend.deleteUnpublishedEntry(collection, slug)
.then(() => {
dispatch(notifSend({
message: 'Unpublished changes deleted',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(unpublishedEntryDeleted(collection, slug, transactionID));
})
.catch((error) => {
dispatch(notifSend({
message: `Failed to delete unpublished changes: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(unpublishedEntryDeleteError(collection, slug, transactionID));
});
return backend
.deleteUnpublishedEntry(collection, slug)
.then(() => {
dispatch(
notifSend({
message: 'Unpublished changes deleted',
kind: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryDeleted(collection, slug, transactionID));
})
.catch(error => {
dispatch(
notifSend({
message: `Failed to delete unpublished changes: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryDeleteError(collection, slug, transactionID));
});
};
}
@ -384,22 +429,27 @@ export function publishUnpublishedEntry(collection, slug) {
const backend = currentBackend(state.config);
const transactionID = uuid();
dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID));
return backend.publishUnpublishedEntry(collection, slug)
.then(() => {
dispatch(notifSend({
message: 'Entry published',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(unpublishedEntryPublished(collection, slug, transactionID));
})
.catch((error) => {
dispatch(notifSend({
message: `Failed to publish: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(unpublishedEntryPublishError(collection, slug, transactionID));
});
return backend
.publishUnpublishedEntry(collection, slug)
.then(() => {
dispatch(
notifSend({
message: 'Entry published',
kind: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryPublished(collection, slug, transactionID));
})
.catch(error => {
dispatch(
notifSend({
message: `Failed to publish: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryPublishError(collection, slug, transactionID));
});
};
}

View File

@ -188,7 +188,6 @@ export function createDraftFromEntry(entry, metadata) {
};
}
export function discardDraft() {
return {
type: DRAFT_DISCARD,
@ -216,7 +215,6 @@ export function changeDraftFieldValidation(field, errors) {
};
}
/*
* Exported Thunk Action Creators
*/
@ -226,31 +224,33 @@ export function loadEntry(collection, slug) {
const state = getState();
const backend = currentBackend(state.config);
dispatch(entryLoading(collection, slug));
return backend.getEntry(collection, slug)
return backend
.getEntry(collection, slug)
.then(loadedEntry => {
return dispatch(entryLoaded(collection, loadedEntry))
return dispatch(entryLoaded(collection, loadedEntry));
})
.catch((error) => {
.catch(error => {
console.error(error);
dispatch(notifSend({
message: `Failed to load entry: ${ error.message }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(
notifSend({
message: `Failed to load entry: ${error.message}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
dispatch(entryLoadError(error, collection, slug));
});
};
}
const appendActions = fromJS({
["append_next"]: { action: "next", append: true },
['append_next']: { action: 'next', append: true },
});
const addAppendActionsToCursor = cursor => Cursor
.create(cursor)
.updateStore("actions", actions => actions.union(
appendActions.filter(v => actions.has(v.get("action"))).keySeq()
));
const addAppendActionsToCursor = cursor =>
Cursor.create(cursor).updateStore('actions', actions =>
actions.union(appendActions.filter(v => actions.has(v.get('action'))).keySeq()),
);
export function loadEntries(collection, page = 0) {
return (dispatch, getState) => {
@ -260,46 +260,59 @@ export function loadEntries(collection, page = 0) {
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, collection.get('name'), 'listEntries');
const provider = integration ? getIntegrationProvider(state.integrations, backend.getToken, integration) : backend;
const provider = integration
? getIntegrationProvider(state.integrations, backend.getToken, integration)
: backend;
const append = !!(page && !isNaN(page) && page > 0);
dispatch(entriesLoading(collection));
provider.listEntries(collection, page)
.then(response => ({
...response,
provider
.listEntries(collection, page)
.then(response => ({
...response,
// The only existing backend using the pagination system is the
// Algolia integration, which is also the only integration used
// to list entries. Thus, this checking for an integration can
// determine whether or not this is using the old integer-based
// pagination API. Other backends will simply store an empty
// cursor, which behaves identically to no cursor at all.
cursor: integration
? Cursor.create({ actions: ["next"], meta: { usingOldPaginationAPI: true }, data: { nextPage: page + 1 } })
: Cursor.create(response.cursor),
}))
.then(response => dispatch(entriesLoaded(
collection,
response.cursor.meta.get('usingOldPaginationAPI')
? response.entries.reverse()
: response.entries,
response.pagination,
addAppendActionsToCursor(response.cursor),
append,
)))
.catch(err => {
dispatch(notifSend({
message: `Failed to load entries: ${ err }`,
kind: 'danger',
dismissAfter: 8000,
}));
return Promise.reject(dispatch(entriesFailed(collection, err)));
});
// The only existing backend using the pagination system is the
// Algolia integration, which is also the only integration used
// to list entries. Thus, this checking for an integration can
// determine whether or not this is using the old integer-based
// pagination API. Other backends will simply store an empty
// cursor, which behaves identically to no cursor at all.
cursor: integration
? Cursor.create({
actions: ['next'],
meta: { usingOldPaginationAPI: true },
data: { nextPage: page + 1 },
})
: Cursor.create(response.cursor),
}))
.then(response =>
dispatch(
entriesLoaded(
collection,
response.cursor.meta.get('usingOldPaginationAPI')
? response.entries.reverse()
: response.entries,
response.pagination,
addAppendActionsToCursor(response.cursor),
append,
),
),
)
.catch(err => {
dispatch(
notifSend({
message: `Failed to load entries: ${err}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
return Promise.reject(dispatch(entriesFailed(collection, err)));
});
};
}
function traverseCursor(backend, cursor, action) {
if (!cursor.actions.has(action)) {
throw new Error(`The current cursor does not support the pagination action "${ action }".`);
throw new Error(`The current cursor does not support the pagination action "${action}".`);
}
return backend.traverseCursor(cursor, action);
}
@ -307,7 +320,7 @@ function traverseCursor(backend, cursor, action) {
export function traverseCollectionCursor(collection, action) {
return async (dispatch, getState) => {
const state = getState();
if (state.entries.getIn(['pages', `${ collection.get('name') }`, 'isFetching',])) {
if (state.entries.getIn(['pages', `${collection.get('name')}`, 'isFetching'])) {
return;
}
const backend = currentBackend(state.config);
@ -319,8 +332,8 @@ export function traverseCollectionCursor(collection, action) {
// Handle cursors representing pages in the old, integer-based
// pagination API
if (cursor.meta.get("usingOldPaginationAPI", false)) {
return dispatch(loadEntries(collection, cursor.data.get("nextPage")));
if (cursor.meta.get('usingOldPaginationAPI', false)) {
return dispatch(loadEntries(collection, cursor.data.get('nextPage')));
}
try {
@ -328,23 +341,27 @@ export function traverseCollectionCursor(collection, action) {
const { entries, cursor: newCursor } = await traverseCursor(backend, cursor, realAction);
// Pass null for the old pagination argument - this will
// eventually be removed.
return dispatch(entriesLoaded(collection, entries, null, addAppendActionsToCursor(newCursor), append));
return dispatch(
entriesLoaded(collection, entries, null, addAppendActionsToCursor(newCursor), append),
);
} catch (err) {
console.error(err);
dispatch(notifSend({
message: `Failed to persist entry: ${ err }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(
notifSend({
message: `Failed to persist entry: ${err}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
return Promise.reject(dispatch(entriesFailed(collection, err)));
}
}
};
}
export function createEmptyDraft(collection) {
return (dispatch) => {
return dispatch => {
const dataFields = {};
collection.get('fields', List()).forEach((field) => {
collection.get('fields', List()).forEach(field => {
dataFields[field.get('name')] = field.get('default');
});
const newEntry = createEntry(collection.get('name'), '', '', { data: dataFields });
@ -360,15 +377,18 @@ export function persistEntry(collection) {
// Early return if draft contains validation errors
if (!fieldsErrors.isEmpty()) {
const hasPresenceErrors = fieldsErrors
.some(errors => errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE));
const hasPresenceErrors = fieldsErrors.some(errors =>
errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE),
);
if (hasPresenceErrors) {
dispatch(notifSend({
message: 'Oops, you\'ve missed a required field. Please complete before saving.',
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(
notifSend({
message: "Oops, you've missed a required field. Please complete before saving.",
kind: 'danger',
dismissAfter: 8000,
}),
);
}
return Promise.reject();
@ -390,20 +410,24 @@ export function persistEntry(collection) {
return backend
.persistEntry(state.config, collection, serializedEntryDraft, assetProxies.toJS())
.then(slug => {
dispatch(notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(entryPersisted(collection, serializedEntry, slug))
dispatch(
notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}),
);
dispatch(entryPersisted(collection, serializedEntry, slug));
})
.catch((error) => {
.catch(error => {
console.error(error);
dispatch(notifSend({
message: `Failed to persist entry: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(
notifSend({
message: `Failed to persist entry: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
return Promise.reject(dispatch(entryPersistFail(collection, serializedEntry, error)));
});
};
@ -415,18 +439,21 @@ export function deleteEntry(collection, slug) {
const backend = currentBackend(state.config);
dispatch(entryDeleting(collection, slug));
return backend.deleteEntry(state.config, collection, slug)
.then(() => {
return dispatch(entryDeleted(collection, slug));
})
.catch((error) => {
dispatch(notifSend({
message: `Failed to delete entry: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
console.error(error);
return Promise.reject(dispatch(entryDeleteFail(collection, slug, error)));
});
return backend
.deleteEntry(state.config, collection, slug)
.then(() => {
return dispatch(entryDeleted(collection, slug));
})
.catch(error => {
dispatch(
notifSend({
message: `Failed to delete entry: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
console.error(error);
return Promise.reject(dispatch(entryDeleteFail(collection, slug, error)));
});
};
}

View File

@ -4,7 +4,7 @@ import { createAssetProxy } from 'ValueObjects/AssetProxy';
import { selectIntegration } from 'Reducers';
import { getIntegrationProvider } from 'Integrations';
import { addAsset } from './media';
import { sanitizeSlug } from "Lib/urlHelper";
import { sanitizeSlug } from 'Lib/urlHelper';
const { notifSend } = notifActions;
@ -57,18 +57,20 @@ export function loadMedia(opts = {}) {
privateUpload,
};
return dispatch(mediaLoaded(files, mediaLoadedOpts));
}
catch(error) {
} catch (error) {
return dispatch(mediaLoadFailed({ privateUpload }));
}
}
dispatch(mediaLoading(page));
return new Promise(resolve => {
setTimeout(() => resolve(
backend.getMedia()
.then(files => dispatch(mediaLoaded(files)))
.catch((error) => dispatch(error.status === 404 ? mediaLoaded() : mediaLoadFailed()))
));
setTimeout(() =>
resolve(
backend
.getMedia()
.then(files => dispatch(mediaLoaded(files)))
.catch(error => dispatch(error.status === 404 ? mediaLoaded() : mediaLoadFailed())),
),
);
}, delay);
};
}
@ -107,14 +109,15 @@ export function persistMedia(file, opts = {}) {
return dispatch(mediaPersisted(asset));
}
return dispatch(mediaPersisted(assetProxy.asset, { privateUpload }));
}
catch(error) {
} catch (error) {
console.error(error);
dispatch(notifSend({
message: `Failed to persist media: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(
notifSend({
message: `Failed to persist media: ${error}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
return dispatch(mediaPersistFailed({ privateUpload }));
}
};
@ -129,32 +132,38 @@ export function deleteMedia(file, opts = {}) {
if (integration) {
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
dispatch(mediaDeleting());
return provider.delete(file.id)
return provider
.delete(file.id)
.then(() => {
return dispatch(mediaDeleted(file, { privateUpload }));
})
.catch(error => {
console.error(error);
dispatch(notifSend({
message: `Failed to delete media: ${ error.message }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(
notifSend({
message: `Failed to delete media: ${error.message}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
return dispatch(mediaDeleteFailed({ privateUpload }));
});
}
dispatch(mediaDeleting());
return backend.deleteMedia(state.config, file.path)
return backend
.deleteMedia(state.config, file.path)
.then(() => {
return dispatch(mediaDeleted(file));
})
.catch(error => {
console.error(error);
dispatch(notifSend({
message: `Failed to delete media: ${ error.message }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(
notifSend({
message: `Failed to delete media: ${error.message}`,
kind: 'danger',
dismissAfter: 8000,
}),
);
return dispatch(mediaDeleteFailed());
});
};
@ -164,13 +173,13 @@ export function mediaLoading(page) {
return {
type: MEDIA_LOAD_REQUEST,
payload: { page },
}
};
}
export function mediaLoaded(files, opts = {}) {
return {
type: MEDIA_LOAD_SUCCESS,
payload: { files, ...opts }
payload: { files, ...opts },
};
}

View File

@ -93,7 +93,6 @@ export function clearSearch() {
return { type: SEARCH_CLEAR };
}
/*
* Exported Thunk Action Creators
*/
@ -106,16 +105,22 @@ export function searchEntries(searchTerm, page = 0) {
const state = getState();
const backend = currentBackend(state.config);
const allCollections = state.collections.keySeq().toArray();
const collections = allCollections.filter(collection => selectIntegration(state, collection, 'search'));
const collections = allCollections.filter(collection =>
selectIntegration(state, collection, 'search'),
);
const integration = selectIntegration(state, collections[0], 'search');
const searchPromise = integration
? getIntegrationProvider(state.integrations, backend.getToken, integration).search(collections, searchTerm, page)
? getIntegrationProvider(state.integrations, backend.getToken, integration).search(
collections,
searchTerm,
page,
)
: backend.search(state.collections.valueSeq().toArray(), searchTerm);
return searchPromise.then(
response => dispatch(searchSuccess(searchTerm, response.entries, response.pagination)),
error => dispatch(searchFailure(searchTerm, error))
error => dispatch(searchFailure(searchTerm, error)),
);
};
}
@ -129,16 +134,22 @@ export function query(namespace, collectionName, searchFields, searchTerm) {
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, collectionName, 'search');
const collection = state.collections.find(collection => collection.get('name') === collectionName);
const collection = state.collections.find(
collection => collection.get('name') === collectionName,
);
const queryPromise = integration
? getIntegrationProvider(state.integrations, backend.getToken, integration)
.searchBy(searchFields.map(f => `data.${ f }`), collectionName, searchTerm)
? getIntegrationProvider(state.integrations, backend.getToken, integration).searchBy(
searchFields.map(f => `data.${f}`),
collectionName,
searchTerm,
)
: backend.query(collection, searchFields, searchTerm);
return queryPromise.then(
response => dispatch(querySuccess(namespace, collectionName, searchFields, searchTerm, response)),
error => dispatch(queryFailure(namespace, collectionName, searchFields, searchTerm, error))
response =>
dispatch(querySuccess(namespace, collectionName, searchFields, searchTerm, response)),
error => dispatch(queryFailure(namespace, collectionName, searchFields, searchTerm, error)),
);
};
}

View File

@ -1,7 +1,7 @@
import { attempt, flatten, isError } from 'lodash';
import { Map } from 'immutable';
import fuzzy from 'fuzzy';
import { resolveFormat } from "Formats/formats";
import { resolveFormat } from 'Formats/formats';
import { selectIntegration } from 'Reducers/integrations';
import {
selectListMethod,
@ -12,15 +12,15 @@ import {
selectFolderEntryExtension,
selectIdentifier,
selectInferedField,
} from "Reducers/collections";
import { createEntry } from "ValueObjects/Entry";
import { sanitizeSlug } from "Lib/urlHelper";
} from 'Reducers/collections';
import { createEntry } from 'ValueObjects/Entry';
import { sanitizeSlug } from 'Lib/urlHelper';
import { getBackend } from 'Lib/registry';
import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from 'netlify-cms-lib-util';
import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes';
class LocalStorageAuthStore {
storageKey = "netlify-cms-user";
storageKey = 'netlify-cms-user';
retrieve() {
const data = window.localStorage.getItem(this.storageKey);
@ -37,39 +37,40 @@ class LocalStorageAuthStore {
}
const slugFormatter = (collection, entryData, slugConfig) => {
const template = collection.get('slug') || "{{slug}}";
const template = collection.get('slug') || '{{slug}}';
const date = new Date();
const identifier = entryData.get(selectIdentifier(collection));
if (!identifier) {
throw new Error("Collection must have a field name that is a valid entry identifier");
throw new Error('Collection must have a field name that is a valid entry identifier');
}
const slug = template.replace(/\{\{([^}]+)\}\}/g, (_, field) => {
switch (field) {
case "year":
return date.getFullYear();
case "month":
return (`0${ date.getMonth() + 1 }`).slice(-2);
case "day":
return (`0${ date.getDate() }`).slice(-2);
case "hour":
return (`0${ date.getHours() }`).slice(-2);
case "minute":
return (`0${ date.getMinutes() }`).slice(-2);
case "second":
return (`0${ date.getSeconds() }`).slice(-2);
case "slug":
return identifier.trim();
default:
return entryData.get(field, "").trim();
}
})
// Convert slug to lower-case
.toLocaleLowerCase()
const slug = template
.replace(/\{\{([^}]+)\}\}/g, (_, field) => {
switch (field) {
case 'year':
return date.getFullYear();
case 'month':
return `0${date.getMonth() + 1}`.slice(-2);
case 'day':
return `0${date.getDate()}`.slice(-2);
case 'hour':
return `0${date.getHours()}`.slice(-2);
case 'minute':
return `0${date.getMinutes()}`.slice(-2);
case 'second':
return `0${date.getSeconds()}`.slice(-2);
case 'slug':
return identifier.trim();
default:
return entryData.get(field, '').trim();
}
})
// Convert slug to lower-case
.toLocaleLowerCase()
// Replace periods with dashes.
.replace(/[.]/g, '-');
// Replace periods with dashes.
.replace(/[.]/g, '-');
return sanitizeSlug(slug, slugConfig);
};
@ -79,11 +80,13 @@ const commitMessageTemplates = Map({
update: 'Update {{collection}} “{{slug}}”',
delete: 'Delete {{collection}} “{{slug}}”',
uploadMedia: 'Upload “{{path}}”',
deleteMedia: 'Delete “{{path}}”'
deleteMedia: 'Delete “{{path}}”',
});
const commitMessageFormatter = (type, config, { slug, path, collection }) => {
const templates = commitMessageTemplates.merge(config.getIn(['backend', 'commit_messages'], Map()));
const templates = commitMessageTemplates.merge(
config.getIn(['backend', 'commit_messages'], Map()),
);
const messageTemplate = templates.get(type);
return messageTemplate.replace(/\{\{([^}]+)\}\}/g, (_, variable) => {
switch (variable) {
@ -94,16 +97,17 @@ const commitMessageFormatter = (type, config, { slug, path, collection }) => {
case 'collection':
return collection.get('label');
default:
console.warn(`Ignoring unknown variable “${ variable }” in commit message template.`);
console.warn(`Ignoring unknown variable “${variable}” in commit message template.`);
return '';
}
});
}
};
const extractSearchFields = searchFields => entry => searchFields.reduce((acc, field) => {
const f = entry.data[field];
return f ? `${acc} ${f}` : acc;
}, "");
const extractSearchFields = searchFields => entry =>
searchFields.reduce((acc, field) => {
const f = entry.data[field];
return f ? `${acc} ${f}` : acc;
}, '');
const sortByScore = (a, b) => {
if (a.score > b.score) return -1;
@ -114,23 +118,25 @@ const sortByScore = (a, b) => {
class Backend {
constructor(implementation, { backendName, authStore = null, config } = {}) {
this.implementation = implementation.init(config, {
useWorkflow: config.getIn(["publish_mode"]) === EDITORIAL_WORKFLOW,
useWorkflow: config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW,
updateUserCredentials: this.updateUserCredentials,
initialWorkflowStatus: status.first(),
});
this.backendName = backendName;
this.authStore = authStore;
if (this.implementation === null) {
throw new Error("Cannot instantiate a Backend with no implementation");
throw new Error('Cannot instantiate a Backend with no implementation');
}
}
currentUser() {
if (this.user) { return this.user; }
if (this.user) {
return this.user;
}
const stored = this.authStore && this.authStore.retrieve();
if (stored && stored.backendName === this.backendName) {
return Promise.resolve(this.implementation.restoreUser(stored)).then((user) => {
const newUser = {...user, backendName: this.backendName};
return Promise.resolve(this.implementation.restoreUser(stored)).then(user => {
const newUser = { ...user, backendName: this.backendName };
// return confirmed/rehydrated user object instead of stored
this.authStore.store(newUser);
return newUser;
@ -153,9 +159,11 @@ class Backend {
}
authenticate(credentials) {
return this.implementation.authenticate(credentials).then((user) => {
const newUser = {...user, backendName: this.backendName};
if (this.authStore) { this.authStore.store(newUser); }
return this.implementation.authenticate(credentials).then(user => {
const newUser = { ...user, backendName: this.backendName };
if (this.authStore) {
this.authStore.store(newUser);
}
return newUser;
});
}
@ -172,12 +180,14 @@ class Backend {
processEntries(loadedEntries, collection) {
const collectionFilter = collection.get('filter');
const entries = loadedEntries.map(loadedEntry => createEntry(
collection.get("name"),
selectEntrySlug(collection, loadedEntry.file.path),
loadedEntry.file.path,
{ raw: loadedEntry.data || '', label: loadedEntry.file.label }
));
const entries = loadedEntries.map(loadedEntry =>
createEntry(
collection.get('name'),
selectEntrySlug(collection, loadedEntry.file.path),
loadedEntry.file.path,
{ raw: loadedEntry.data || '', label: loadedEntry.file.label },
),
);
const formattedEntries = entries.map(this.entryWithFormat(collection));
// If this collection has a "filter" property, filter entries accordingly
const filteredEntries = collectionFilter
@ -186,23 +196,21 @@ class Backend {
return filteredEntries;
}
listEntries(collection) {
const listMethod = this.implementation[selectListMethod(collection)];
const extension = selectFolderEntryExtension(collection);
return listMethod.call(this.implementation, collection, extension)
.then(loadedEntries => ({
entries: this.processEntries(loadedEntries, collection),
/*
return listMethod.call(this.implementation, collection, extension).then(loadedEntries => ({
entries: this.processEntries(loadedEntries, collection),
/*
Wrap cursors so we can tell which collection the cursor is
from. This is done to prevent traverseCursor from requiring a
`collection` argument.
*/
cursor: Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({
cursorType: "collectionEntries",
collection,
}),
}));
cursor: Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({
cursorType: 'collectionEntries',
collection,
}),
}));
}
// The same as listEntries, except that if a cursor with the "next"
@ -211,17 +219,18 @@ class Backend {
// returns all the collected entries. Used to retrieve all entries
// for local searches and queries.
async listAllEntries(collection) {
if (collection.get("folder") && this.implementation.allEntriesByFolder) {
if (collection.get('folder') && this.implementation.allEntriesByFolder) {
const extension = selectFolderEntryExtension(collection);
return this.implementation.allEntriesByFolder(collection, extension)
.then(entries => this.processEntries(entries, collection));
return this.implementation
.allEntriesByFolder(collection, extension)
.then(entries => this.processEntries(entries, collection));
}
const response = await this.listEntries(collection);
const { entries } = response;
let { cursor } = response;
while (cursor && cursor.actions.includes("next")) {
const { entries: newEntries, cursor: newCursor } = await this.traverseCursor(cursor, "next");
while (cursor && cursor.actions.includes('next')) {
const { entries: newEntries, cursor: newCursor } = await this.traverseCursor(cursor, 'next');
entries.push(...newEntries);
cursor = newCursor;
}
@ -233,31 +242,37 @@ class Backend {
// collection, load it, search, and call onCollectionResults with
// its results.
const errors = [];
const collectionEntriesRequests = collections.map(async collection => {
// TODO: pass search fields in as an argument
const searchFields = [
selectInferedField(collection, 'title'),
selectInferedField(collection, 'shortTitle'),
selectInferedField(collection, 'author'),
];
const collectionEntries = await this.listAllEntries(collection);
return fuzzy.filter(searchTerm, collectionEntries, {
extract: extractSearchFields(searchFields),
});
}).map(p => p.catch(err => errors.push(err) && []));
const collectionEntriesRequests = collections
.map(async collection => {
// TODO: pass search fields in as an argument
const searchFields = [
selectInferedField(collection, 'title'),
selectInferedField(collection, 'shortTitle'),
selectInferedField(collection, 'author'),
];
const collectionEntries = await this.listAllEntries(collection);
return fuzzy.filter(searchTerm, collectionEntries, {
extract: extractSearchFields(searchFields),
});
})
.map(p => p.catch(err => errors.push(err) && []));
const entries = await Promise.all(collectionEntriesRequests).then(arrs => flatten(arrs));
if (errors.length > 0) {
throw new Error({ message: "Errors ocurred while searching entries locally!", errors });
throw new Error({ message: 'Errors ocurred while searching entries locally!', errors });
}
const hits = entries.filter(({ score }) => score > 5).sort(sortByScore).map(f => f.original);
const hits = entries
.filter(({ score }) => score > 5)
.sort(sortByScore)
.map(f => f.original);
return { entries: hits };
}
async query(collection, searchFields, searchTerm) {
const entries = await this.listAllEntries(collection);
const hits = fuzzy.filter(searchTerm, entries, { extract: extractSearchFields(searchFields) })
const hits = fuzzy
.filter(searchTerm, entries, { extract: extractSearchFields(searchFields) })
.filter(entry => entry.score > 5)
.sort(sortByScore)
.map(f => f.original);
@ -267,26 +282,29 @@ class Backend {
traverseCursor(cursor, action) {
const [data, unwrappedCursor] = cursor.unwrapData();
// TODO: stop assuming all cursors are for collections
const collection = data.get("collection");
return this.implementation.traverseCursor(unwrappedCursor, action)
const collection = data.get('collection');
return this.implementation
.traverseCursor(unwrappedCursor, action)
.then(async ({ entries, cursor: newCursor }) => ({
entries: this.processEntries(entries, collection),
cursor: Cursor.create(newCursor).wrapData({
cursorType: "collectionEntries",
cursorType: 'collectionEntries',
collection,
}),
}));
}
getEntry(collection, slug) {
return this.implementation.getEntry(collection, slug, selectEntryPath(collection, slug))
.then(loadedEntry => this.entryWithFormat(collection, slug)(createEntry(
collection.get("name"),
slug,
loadedEntry.file.path,
{ raw: loadedEntry.data, label: loadedEntry.file.label }
))
);
return this.implementation
.getEntry(collection, slug, selectEntryPath(collection, slug))
.then(loadedEntry =>
this.entryWithFormat(collection, slug)(
createEntry(collection.get('name'), slug, loadedEntry.file.path, {
raw: loadedEntry.data,
label: loadedEntry.file.label,
}),
),
);
}
getMedia() {
@ -294,7 +312,7 @@ class Backend {
}
entryWithFormat(collectionOrEntity) {
return (entry) => {
return entry => {
const format = resolveFormat(collectionOrEntity, entry);
if (entry && entry.raw !== undefined) {
const data = (format && attempt(format.fromFile.bind(format, entry.raw))) || {};
@ -306,87 +324,93 @@ class Backend {
}
unpublishedEntries(collections) {
return this.implementation.unpublishedEntries()
.then(loadedEntries => loadedEntries.filter(entry => entry !== null))
.then(entries => (
entries.map((loadedEntry) => {
const entry = createEntry(
loadedEntry.metaData.collection,
loadedEntry.slug,
loadedEntry.file.path,
{
raw: loadedEntry.data,
isModification: loadedEntry.isModification,
return this.implementation
.unpublishedEntries()
.then(loadedEntries => loadedEntries.filter(entry => entry !== null))
.then(entries =>
entries.map(loadedEntry => {
const entry = createEntry(
loadedEntry.metaData.collection,
loadedEntry.slug,
loadedEntry.file.path,
{
raw: loadedEntry.data,
isModification: loadedEntry.isModification,
},
);
entry.metaData = loadedEntry.metaData;
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));
}
);
entry.metaData = loadedEntry.metaData;
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));
}
return acc;
}, []),
}));
return acc;
}, []),
}));
}
unpublishedEntry(collection, slug) {
return this.implementation.unpublishedEntry(collection, slug)
.then((loadedEntry) => {
const entry = createEntry(
"draft",
loadedEntry.slug,
loadedEntry.file.path,
{
return this.implementation
.unpublishedEntry(collection, slug)
.then(loadedEntry => {
const entry = createEntry('draft', loadedEntry.slug, loadedEntry.file.path, {
raw: loadedEntry.data,
isModification: loadedEntry.isModification,
});
entry.metaData = loadedEntry.metaData;
return entry;
})
.then(this.entryWithFormat(collection, slug));
entry.metaData = loadedEntry.metaData;
return entry;
})
.then(this.entryWithFormat(collection, slug));
}
persistEntry(config, collection, entryDraft, MediaFiles, integrations, options = {}) {
const newEntry = entryDraft.getIn(["entry", "newRecord"]) || false;
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
const parsedData = {
title: entryDraft.getIn(["entry", "data", "title"], "No Title"),
description: entryDraft.getIn(["entry", "data", "description"], "No Description!"),
title: entryDraft.getIn(['entry', 'data', 'title'], 'No Title'),
description: entryDraft.getIn(['entry', 'data', 'description'], 'No Description!'),
};
let entryObj;
if (newEntry) {
if (!selectAllowNewEntries(collection)) {
throw (new Error("Not allowed to create new entries in this collection"));
throw new Error('Not allowed to create new entries in this collection');
}
const slug = slugFormatter(collection, entryDraft.getIn(["entry", "data"]), config.get("slug"));
const slug = slugFormatter(
collection,
entryDraft.getIn(['entry', 'data']),
config.get('slug'),
);
const path = selectEntryPath(collection, slug);
entryObj = {
path,
slug,
raw: this.entryToRaw(collection, entryDraft.get("entry")),
raw: this.entryToRaw(collection, entryDraft.get('entry')),
};
} else {
const path = entryDraft.getIn(["entry", "path"]);
const slug = entryDraft.getIn(["entry", "slug"]);
const path = entryDraft.getIn(['entry', 'path']);
const slug = entryDraft.getIn(['entry', 'slug']);
entryObj = {
path,
slug,
raw: this.entryToRaw(collection, entryDraft.get("entry")),
raw: this.entryToRaw(collection, entryDraft.get('entry')),
};
}
const commitMessage = commitMessageFormatter(newEntry ? 'create' : 'update', config, { collection, slug: entryObj.slug, path: entryObj.path });
const commitMessage = commitMessageFormatter(newEntry ? 'create' : 'update', config, {
collection,
slug: entryObj.slug,
path: entryObj.path,
});
const useWorkflow = config.getIn(["publish_mode"]) === EDITORIAL_WORKFLOW;
const useWorkflow = config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW;
const collectionName = collection.get("name");
const collectionName = collection.get('name');
/**
* Determine whether an asset store integration is in use.
@ -399,11 +423,10 @@ class Backend {
commitMessage,
collectionName,
useWorkflow,
...updatedOptions
...updatedOptions,
};
return this.implementation.persistEntry(entryObj, MediaFiles, opts)
.then(() => entryObj.slug);
return this.implementation.persistEntry(entryObj, MediaFiles, opts).then(() => entryObj.slug);
}
persistMedia(config, file) {
@ -417,7 +440,7 @@ class Backend {
const path = selectEntryPath(collection, slug);
if (!selectAllowDeletion(collection)) {
throw (new Error("Not allowed to delete entries in this collection"));
throw new Error('Not allowed to delete entries in this collection');
}
const commitMessage = commitMessageFormatter('delete', config, { collection, slug, path });
@ -448,52 +471,60 @@ class Backend {
entryToRaw(collection, entry) {
const format = resolveFormat(collection, entry.toJS());
const fieldsOrder = this.fieldsOrder(collection, entry);
return format && format.toFile(entry.get("data").toJS(), fieldsOrder);
return format && format.toFile(entry.get('data').toJS(), fieldsOrder);
}
fieldsOrder(collection, entry) {
const fields = collection.get('fields');
if (fields) {
return collection.get('fields').map(f => f.get('name')).toArray();
return collection
.get('fields')
.map(f => f.get('name'))
.toArray();
}
const files = collection.get('files');
const file = (files || []).filter(f => f.get("name") === entry.get("slug")).get(0);
const file = (files || []).filter(f => f.get('name') === entry.get('slug')).get(0);
if (file == null) {
throw new Error(`No file found for ${ entry.get("slug") } in ${ collection.get('name') }`);
throw new Error(`No file found for ${entry.get('slug')} in ${collection.get('name')}`);
}
return file.get('fields').map(f => f.get('name')).toArray();
return file
.get('fields')
.map(f => f.get('name'))
.toArray();
}
filterEntries(collection, filterRule) {
return collection.entries.filter(entry => (
entry.data[filterRule.get('field')] === filterRule.get('value')
));
return collection.entries.filter(
entry => entry.data[filterRule.get('field')] === filterRule.get('value'),
);
}
}
export function resolveBackend(config) {
const name = config.getIn(["backend", "name"]);
const name = config.getIn(['backend', 'name']);
if (name == null) {
throw new Error("No backend defined in configuration");
throw new Error('No backend defined in configuration');
}
const authStore = new LocalStorageAuthStore();
if (!getBackend(name)) {
throw new Error(`Backend not found: ${ name }`);
throw new Error(`Backend not found: ${name}`);
} else {
return new Backend(getBackend(name), { backendName: name, authStore, config });
}
}
export const currentBackend = (function () {
export const currentBackend = (function() {
let backend = null;
return (config) => {
if (backend) { return backend; }
if (config.get("backend")) {
return backend = resolveBackend(config);
return config => {
if (backend) {
return backend;
}
if (config.get('backend')) {
return (backend = resolveBackend(config));
}
};
}());
})();

View File

@ -7,7 +7,7 @@ import history from 'Routing/history';
import configureStore from 'Redux/configureStore';
import { mergeConfig } from 'Actions/config';
import { setStore } from 'ValueObjects/AssetProxy';
import { ErrorBoundary } from 'UI'
import { ErrorBoundary } from 'UI';
import App from 'App/App';
import 'EditorWidgets';
import 'what-input';
@ -73,7 +73,7 @@ function bootstrap(opts = {}) {
<ErrorBoundary>
<Provider store={store}>
<ConnectedRouter history={history}>
<Route component={App}/>
<Route component={App} />
</ConnectedRouter>
</Provider>
</ErrorBoundary>

View File

@ -36,20 +36,19 @@ const AppMainContainer = styled.div`
min-width: 800px;
max-width: 1440px;
margin: 0 auto;
`
`;
const ErrorContainer = styled.div`
margin: 20px;
`
`;
const ErrorCodeBlock = styled.pre`
margin-left: 20px;
font-size: 15px;
line-height: 1.5;
`
`;
class App extends React.Component {
static propTypes = {
auth: ImmutablePropTypes.map,
config: ImmutablePropTypes.map,
@ -89,24 +88,26 @@ class App extends React.Component {
const backend = currentBackend(this.props.config);
if (backend == null) {
return <div><h1>Waiting for backend...</h1></div>;
return (
<div>
<h1>Waiting for backend...</h1>
</div>
);
}
return (
<div>
<Notifs CustomComponent={Toast} />
{
React.createElement(backend.authComponent(), {
onLogin: this.handleLogin.bind(this),
error: auth && auth.get('error'),
isFetching: auth && auth.get('isFetching'),
siteId: this.props.config.getIn(["backend", "site_domain"]),
base_url: this.props.config.getIn(["backend", "base_url"], null),
authEndpoint: this.props.config.getIn(["backend", "auth_endpoint"]),
config: this.props.config,
clearHash: () => history.replace('/'),
})
}
{React.createElement(backend.authComponent(), {
onLogin: this.handleLogin.bind(this),
error: auth && auth.get('error'),
isFetching: auth && auth.get('isFetching'),
siteId: this.props.config.getIn(['backend', 'site_domain']),
base_url: this.props.config.getIn(['backend', 'base_url'], null),
authEndpoint: this.props.config.getIn(['backend', 'auth_endpoint']),
config: this.props.config,
clearHash: () => history.replace('/'),
})}
</div>
);
}
@ -159,19 +160,25 @@ class App extends React.Component {
displayUrl={config.get('display_url')}
/>
<AppMainContainer>
{ isFetching && <TopBarProgress /> }
{isFetching && <TopBarProgress />}
<div>
<Switch>
<Redirect exact from="/" to={defaultPath} />
<Redirect exact from="/search/" to={defaultPath} />
{ hasWorkflow ? <Route path="/workflow" component={Workflow}/> : null }
{hasWorkflow ? <Route path="/workflow" component={Workflow} /> : null}
<Route exact path="/collections/:name" component={Collection} />
<Route path="/collections/:name/new" render={props => <Editor {...props} newRecord />} />
<Route
path="/collections/:name/new"
render={props => <Editor {...props} newRecord />}
/>
<Route path="/collections/:name/entries/:slug" component={Editor} />
<Route path="/search/:searchTerm" render={props => <Collection {...props} isSearchResults />} />
<Route
path="/search/:searchTerm"
render={props => <Collection {...props} isSearchResults />}
/>
<Route component={NotFoundPage} />
</Switch>
<MediaLibrary/>
<MediaLibrary />
</div>
</AppMainContainer>
</div>
@ -200,5 +207,8 @@ function mapDispatchToProps(dispatch) {
}
export default hot(module)(
connect(mapStateToProps, mapDispatchToProps)(App)
connect(
mapStateToProps,
mapDispatchToProps,
)(App),
);

View File

@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React from "react";
import ImmutablePropTypes from "react-immutable-proptypes";
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled, { css } from 'react-emotion';
import { NavLink } from 'react-router-dom';
import {
@ -33,7 +33,7 @@ const AppHeader = styled.div`
background-color: ${colors.foreground};
z-index: 300;
height: ${lengths.topBarHeight};
`
`;
const AppHeaderContent = styled.div`
display: flex;
@ -69,15 +69,15 @@ const AppHeaderButton = styled.button`
${styles.buttonActive};
}
}
`}
`
`};
`;
const AppHeaderNavLink = AppHeaderButton.withComponent(NavLink);
const AppHeaderActions = styled.div`
display: inline-flex;
align-items: center;
`
`;
const AppHeaderQuickNewButton = styled(StyledDropdownButton)`
${buttons.button};
@ -88,7 +88,7 @@ const AppHeaderQuickNewButton = styled(StyledDropdownButton)`
&:after {
top: 11px;
}
`
`;
export default class Header extends React.Component {
static propTypes = {
@ -99,7 +99,7 @@ export default class Header extends React.Component {
displayUrl: PropTypes.string,
};
handleCreatePostClick = (collectionName) => {
handleCreatePostClick = collectionName => {
const { onCreateEntryClick } = this.props;
if (onCreateEntryClick) {
onCreateEntryClick(collectionName);
@ -126,19 +126,17 @@ export default class Header extends React.Component {
activeClassName="header-link-active"
isActive={(match, location) => location.pathname.startsWith('/collections/')}
>
<Icon type="page"/>
<Icon type="page" />
Content
</AppHeaderNavLink>
{
hasWorkflow
? <AppHeaderNavLink to="/workflow" activeClassName="header-link-active">
<Icon type="workflow"/>
Workflow
</AppHeaderNavLink>
: null
}
{hasWorkflow ? (
<AppHeaderNavLink to="/workflow" activeClassName="header-link-active">
<Icon type="workflow" />
Workflow
</AppHeaderNavLink>
) : null}
<AppHeaderButton onClick={openMediaLibrary}>
<Icon type="media-alt"/>
<Icon type="media-alt" />
Media
</AppHeaderButton>
</nav>
@ -149,15 +147,16 @@ export default class Header extends React.Component {
dropdownWidth="160px"
dropdownPosition="left"
>
{
collections.filter(collection => collection.get('create')).toList().map(collection =>
{collections
.filter(collection => collection.get('create'))
.toList()
.map(collection => (
<DropdownItem
key={collection.get("name")}
label={collection.get("label_singular") || collection.get("label")}
key={collection.get('name')}
label={collection.get('label_singular') || collection.get('label')}
onClick={() => this.handleCreatePostClick(collection.get('name'))}
/>
)
}
))}
</Dropdown>
<SettingsDropdown
displayUrl={displayUrl}

View File

@ -2,7 +2,6 @@ import React from 'react';
import styled from 'react-emotion';
import { lengths } from 'netlify-cms-ui-default';
const NotFoundContainer = styled.div`
margin: ${lengths.pageMargin};
`;

View File

@ -12,11 +12,11 @@ import { VIEW_STYLE_LIST } from 'Constants/collectionViews';
const CollectionContainer = styled.div`
margin: ${lengths.pageMargin};
`
`;
const CollectionMain = styled.main`
padding-left: 280px;
`
`;
class Collection extends React.Component {
static propTypes = {
@ -30,40 +30,40 @@ class Collection extends React.Component {
renderEntriesCollection = () => {
const { name, collection } = this.props;
return <EntriesCollection collection={collection} name={name} viewStyle={this.state.viewStyle}/>
return (
<EntriesCollection collection={collection} name={name} viewStyle={this.state.viewStyle} />
);
};
renderEntriesSearch = () => {
const { searchTerm, collections } = this.props;
return <EntriesSearch collections={collections} searchTerm={searchTerm} />
return <EntriesSearch collections={collections} searchTerm={searchTerm} />;
};
handleChangeViewStyle = (viewStyle) => {
handleChangeViewStyle = viewStyle => {
if (this.state.viewStyle !== viewStyle) {
this.setState({ viewStyle });
}
}
};
render() {
const { collection, collections, collectionName, isSearchResults, searchTerm } = this.props;
const newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
return (
<CollectionContainer>
<Sidebar collections={collections} searchTerm={searchTerm}/>
<Sidebar collections={collections} searchTerm={searchTerm} />
<CollectionMain>
{
isSearchResults
? null
: <CollectionTop
collectionLabel={collection.get('label')}
collectionLabelSingular={collection.get('label_singular')}
collectionDescription={collection.get('description')}
newEntryUrl={newEntryUrl}
viewStyle={this.state.viewStyle}
onChangeViewStyle={this.handleChangeViewStyle}
/>
}
{ isSearchResults ? this.renderEntriesSearch() : this.renderEntriesCollection() }
{isSearchResults ? null : (
<CollectionTop
collectionLabel={collection.get('label')}
collectionLabelSingular={collection.get('label_singular')}
collectionDescription={collection.get('description')}
newEntryUrl={newEntryUrl}
viewStyle={this.state.viewStyle}
onChangeViewStyle={this.handleChangeViewStyle}
/>
)}
{isSearchResults ? this.renderEntriesSearch() : this.renderEntriesCollection()}
</CollectionMain>
</CollectionContainer>
);

View File

@ -7,18 +7,18 @@ import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
const CollectionTopContainer = styled.div`
${components.cardTop};
`
`;
const CollectionTopRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
`
`;
const CollectionTopHeading = styled.h1`
${components.cardTopHeading};
`
`;
const CollectionTopNewButton = styled(Link)`
${buttons.button};
@ -27,28 +27,28 @@ const CollectionTopNewButton = styled(Link)`
${buttons.gray};
padding: 0 30px;
`
`;
const CollectionTopDescription = styled.p`
${components.cardTopDescription};
`
`;
const ViewControls = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 24px;
`
`;
const ViewControlsText = styled.span`
font-size: 14px;
color: ${colors.text};
margin-right: 12px;
`
`;
const ViewControlsButton = styled.button`
${buttons.button};
color: ${props => props.isActive ? colors.active : '#b3b9c4'};
color: ${props => (props.isActive ? colors.active : '#b3b9c4')};
background-color: transparent;
display: block;
padding: 0;
@ -61,7 +61,7 @@ const ViewControlsButton = styled.button`
${Icon} {
display: block;
}
`
`;
const CollectionTop = ({
collectionLabel,
@ -75,32 +75,28 @@ const CollectionTop = ({
<CollectionTopContainer>
<CollectionTopRow>
<CollectionTopHeading>{collectionLabel}</CollectionTopHeading>
{
newEntryUrl
? <CollectionTopNewButton to={newEntryUrl}>
{`New ${collectionLabelSingular || collectionLabel}`}
</CollectionTopNewButton>
: null
}
{newEntryUrl ? (
<CollectionTopNewButton to={newEntryUrl}>
{`New ${collectionLabelSingular || collectionLabel}`}
</CollectionTopNewButton>
) : null}
</CollectionTopRow>
{
collectionDescription
? <CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
: null
}
{collectionDescription ? (
<CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
) : null}
<ViewControls>
<ViewControlsText>View as:</ViewControlsText>
<ViewControlsButton
isActive={viewStyle === VIEW_STYLE_LIST}
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
>
<Icon type="list"/>
<Icon type="list" />
</ViewControlsButton>
<ViewControlsButton
isActive={viewStyle === VIEW_STYLE_GRID}
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
>
<Icon type="grid"/>
<Icon type="grid" />
</ViewControlsButton>
</ViewControls>
</CollectionTopContainer>
@ -110,7 +106,7 @@ const CollectionTop = ({
CollectionTop.propTypes = {
collectionLabel: PropTypes.string.isRequired,
collectionDescription: PropTypes.string,
newEntryUrl: PropTypes.string
newEntryUrl: PropTypes.string,
};
export default CollectionTop;

View File

@ -13,11 +13,7 @@ const Entries = ({
cursor,
handleCursorActions,
}) => {
const loadingMessages = [
'Loading Entries',
'Caching Entries',
'This might take several minutes',
];
const loadingMessages = ['Loading Entries', 'Caching Entries', 'This might take several minutes'];
if (entries) {
return (
@ -37,7 +33,7 @@ const Entries = ({
}
return <div className="nc-collectionPage-noEntries">No Entries</div>;
}
};
Entries.propTypes = {
collections: ImmutablePropTypes.map.isRequired,

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { partial } from 'lodash';
import { Cursor } from 'netlify-cms-lib-util'
import { Cursor } from 'netlify-cms-lib-util';
import {
loadEntries as actionLoadEntries,
traverseCollectionCursor as actionTraverseCollectionCursor,
@ -43,7 +43,7 @@ class EntriesCollection extends React.Component {
traverseCollectionCursor(collection, action);
};
render () {
render() {
const { collection, entries, publicFolder, isFetching, viewStyle, cursor } = this.props;
return (
@ -70,7 +70,7 @@ function mapStateToProps(state, ownProps) {
const entries = selectEntries(state, collection.get('name'));
const isFetching = state.entries.getIn(['pages', collection.get('name'), 'isFetching'], false);
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get("name"));
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name'));
const cursor = Cursor.create(rawCursor).clearData();
return { publicFolder, collection, page, entries, isFetching, viewStyle, cursor };
@ -81,4 +81,7 @@ const mapDispatchToProps = {
traverseCollectionCursor: actionTraverseCollectionCursor,
};
export default connect(mapStateToProps, mapDispatchToProps)(EntriesCollection);
export default connect(
mapStateToProps,
mapDispatchToProps,
)(EntriesCollection);

View File

@ -6,7 +6,7 @@ import { Cursor } from 'netlify-cms-lib-util';
import { selectSearchedEntries } from 'Reducers';
import {
searchEntries as actionSearchEntries,
clearSearch as actionClearSearch
clearSearch as actionClearSearch,
} from 'Actions/search';
import Entries from './Entries';
@ -40,19 +40,19 @@ class EntriesSearch extends React.Component {
getCursor = () => {
const { page } = this.props;
return Cursor.create({
actions: isNaN(page) ? [] : ["append_next"],
actions: isNaN(page) ? [] : ['append_next'],
});
};
handleCursorActions = (action) => {
handleCursorActions = action => {
const { page, searchTerm, searchEntries } = this.props;
if (action === "append_next") {
if (action === 'append_next') {
const nextPage = page + 1;
searchEntries(searchTerm, nextPage);
}
};
render () {
render() {
const { collections, entries, publicFolder, isFetching } = this.props;
return (
<Entries
@ -83,4 +83,7 @@ const mapDispatchToProps = {
clearSearch: actionClearSearch,
};
export default connect(mapStateToProps, mapDispatchToProps)(EntriesSearch);
export default connect(
mapStateToProps,
mapDispatchToProps,
)(EntriesSearch);

View File

@ -10,7 +10,7 @@ const ListCard = styled.li`
width: ${lengths.topCardWidth};
margin-left: 12px;
margin-bottom: 16px;
`
`;
const ListCardLink = styled(Link)`
display: block;
@ -19,7 +19,7 @@ const ListCardLink = styled(Link)`
&:hover {
background-color: ${colors.foreground};
}
`
`;
const GridCard = styled.li`
${components.card};
@ -28,29 +28,30 @@ const GridCard = styled.li`
overflow: hidden;
margin-left: 12px;
margin-bottom: 16px;
`
`;
const GridCardLink = styled(Link)`
display: block;
&, &:hover {
&,
&:hover {
background-color: ${colors.foreground};
color: ${colors.text};
}
`
`;
const CollectionLabel = styled.h2`
font-size: 12px;
color: ${colors.textLead};
text-transform: uppercase;
`
`;
const ListCardTitle = styled.h2`
margin-bottom: 0;
`
`;
const CardHeading = styled.h2`
margin: 0 0 2px;
`
`;
const CardBody = styled.div`
padding: 16px 22px;
@ -69,7 +70,7 @@ const CardBody = styled.div`
width: 140%;
box-shadow: inset 0 -15px 24px ${colorsRaw.white};
}
`
`;
const CardImage = styled.div`
background-image: url(${props => props.url});
@ -77,7 +78,7 @@ const CardImage = styled.div`
background-size: cover;
background-repeat: no-repeat;
height: 150px;
`
`;
const EntryCard = ({
collection,
@ -92,7 +93,7 @@ const EntryCard = ({
const path = `/collections/${collection.get('name')}/entries/${entry.get('slug')}`;
let image = entry.getIn(['data', inferedFields.imageField]);
image = resolvePath(image, publicFolder);
if(image) {
if (image) {
image = encodeURI(image);
}
@ -100,8 +101,8 @@ const EntryCard = ({
return (
<ListCard>
<ListCardLink to={path}>
{ collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null }
<ListCardTitle>{ title }</ListCardTitle>
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
<ListCardTitle>{title}</ListCardTitle>
</ListCardLink>
</ListCard>
);
@ -112,14 +113,14 @@ const EntryCard = ({
<GridCard>
<GridCardLink to={path}>
<CardBody hasImage={image}>
{ collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null }
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
<CardHeading>{title}</CardHeading>
</CardBody>
{ image ? <CardImage url={image}/> : null }
{image ? <CardImage url={image} /> : null}
</GridCardLink>
</GridCard>
);
}
}
};
export default EntryCard;

View File

@ -13,23 +13,21 @@ const CardsGrid = styled.ul`
flex-flow: row wrap;
list-style-type: none;
margin-left: -12px;
`
`;
export default class EntryListing extends React.Component {
static propTypes = {
publicFolder: PropTypes.string.isRequired,
collections: PropTypes.oneOfType([
ImmutablePropTypes.map,
ImmutablePropTypes.iterable,
]).isRequired,
collections: PropTypes.oneOfType([ImmutablePropTypes.map, ImmutablePropTypes.iterable])
.isRequired,
entries: ImmutablePropTypes.list,
viewStyle: PropTypes.string,
};
handleLoadMore = () => {
const { cursor, handleCursorActions } = this.props;
if (Cursor.create(cursor).actions.has("append_next")) {
handleCursorActions("append_next");
if (Cursor.create(cursor).actions.has('append_next')) {
handleCursorActions('append_next');
}
};
@ -39,7 +37,8 @@ export default class EntryListing extends React.Component {
const imageField = selectInferedField(collection, 'image');
const fields = selectFields(collection);
const inferedFields = [titleField, descriptionField, imageField];
const remainingFields = fields && fields.filter(f => inferedFields.indexOf(f.get('name')) === -1);
const remainingFields =
fields && fields.filter(f => inferedFields.indexOf(f.get('name')) === -1);
return { titleField, descriptionField, imageField, remainingFields };
};
@ -68,11 +67,9 @@ export default class EntryListing extends React.Component {
return (
<div>
<CardsGrid>
{
Map.isMap(collections)
? this.renderCardsForSingleCollection()
: this.renderCardsForMultipleCollections()
}
{Map.isMap(collections)
? this.renderCardsForSingleCollection()
: this.renderCardsForMultipleCollections()}
<Waypoint onEnter={this.handleLoadMore} />
</CardsGrid>
</div>

View File

@ -20,7 +20,7 @@ const SidebarContainer = styled.aside`
position: fixed;
max-height: calc(100vh - 112px);
overflow: auto;
`
`;
const SidebarHeading = styled.h2`
font-size: 23px;
@ -28,7 +28,7 @@ const SidebarHeading = styled.h2`
padding: 0;
margin: 18px 12px 12px;
color: ${colors.textLead};
`
`;
const SearchContainer = styled.div`
display: flex;
@ -46,7 +46,7 @@ const SearchContainer = styled.div`
align-items: center;
pointer-events: none;
}
`
`;
const SearchInput = styled.input`
background-color: #eff0f4;
@ -61,7 +61,7 @@ const SearchInput = styled.input`
outline: none;
box-shadow: inset 0 0 0 2px ${colorsRaw.blue};
}
`
`;
const SidebarNavLink = styled(NavLink)`
display: flex;
@ -77,19 +77,16 @@ const SidebarNavLink = styled(NavLink)`
&.${props.activeClassName} {
${styles.sidebarNavLinkActive};
}
`}
&:first-of-type {
`} &:first-of-type {
margin-top: 16px;
}
${Icon} {
margin-right: 8px;
}
`
`;
export default class Sidebar extends React.Component {
static propTypes = {
collections: ImmutablePropTypes.orderedMap.isRequired,
};
@ -104,13 +101,12 @@ export default class Sidebar extends React.Component {
to={`/collections/${collectionName}`}
activeClassName="sidebar-active"
>
<Icon type="write"/>
<Icon type="write" />
{collection.get('label')}
</SidebarNavLink>
);
};
render() {
const { collections } = this.props;
const { query } = this.state;
@ -119,7 +115,7 @@ export default class Sidebar extends React.Component {
<SidebarContainer>
<SidebarHeading>Collections</SidebarHeading>
<SearchContainer>
<Icon type="search" size="small"/>
<Icon type="search" size="small" />
<SearchInput
onChange={e => this.setState({ query: e.target.value })}
onKeyDown={e => e.key === 'Enter' && searchCollections(query)}

View File

@ -19,7 +19,7 @@ import {
import {
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
deleteUnpublishedEntry
deleteUnpublishedEntry,
} from 'Actions/editorialWorkflow';
import { deserializeValues } from 'Lib/serializeEntryValues';
import { selectEntry, selectUnpublishedEntry, getAsset } from 'Reducers';
@ -29,10 +29,11 @@ import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import EditorInterface from './EditorInterface';
import withWorkflow from './withWorkflow';
const navigateCollection = (collectionPath) => history.push(`/collections/${collectionPath}`);
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}`);
const navigateToEntry = (collectionName, slug) =>
navigateCollection(`${collectionName}/entries/${slug}`);
class Editor extends React.Component {
static propTypes = {
@ -83,7 +84,7 @@ class Editor extends React.Component {
const leaveMessage = 'Are you sure you want to leave this page?';
this.exitBlocker = (event) => {
this.exitBlocker = event => {
if (this.props.entryDraft.get('hasChanged')) {
// This message is ignored in most browsers, but its presence
// triggers the confirmation dialog
@ -100,14 +101,18 @@ class Editor extends React.Component {
const isPersisting = this.props.entryDraft.getIn(['entry', 'isPersisting']);
const newRecord = this.props.entryDraft.getIn(['entry', 'newRecord']);
const newEntryPath = `/collections/${collection.get('name')}/new`;
if (isPersisting && newRecord && this.props.location.pathname === newEntryPath && action === 'PUSH') {
if (
isPersisting &&
newRecord &&
this.props.location.pathname === newEntryPath &&
action === 'PUSH'
) {
return;
}
if (this.props.hasChanged) {
return leaveMessage;
}
};
const unblock = history.block(navigationBlocker);
@ -119,7 +124,10 @@ class Editor extends React.Component {
const newEntryPath = `/collections/${collection.get('name')}/new`;
const entriesPath = `/collections/${collection.get('name')}/entries/`;
const { pathname } = location;
if (pathname.startsWith(newEntryPath) || pathname.startsWith(entriesPath) && action === 'PUSH') {
if (
pathname.startsWith(newEntryPath) ||
(pathname.startsWith(entriesPath) && action === 'PUSH')
) {
return;
}
unblock();
@ -147,7 +155,6 @@ class Editor extends React.Component {
const { entry, newEntry, fields, collection } = this.props;
if (entry && !entry.get('isFetching') && !entry.get('error')) {
/**
* Deserialize entry values for widgets with registered serializers before
* creating the entry draft.
@ -170,34 +177,54 @@ class Editor extends React.Component {
if (entry) this.props.createDraftFromEntry(entry, metadata);
};
handleChangeStatus = (newStatusName) => {
const { entryDraft, updateUnpublishedEntryStatus, collection, slug, currentStatus } = this.props;
handleChangeStatus = newStatusName => {
const {
entryDraft,
updateUnpublishedEntryStatus,
collection,
slug,
currentStatus,
} = this.props;
if (entryDraft.get('hasChanged')) {
window.alert('You have unsaved changes, please save before updating status.');
return;
}
const newStatus = status.get(newStatusName);
updateUnpublishedEntryStatus(collection.get('name'), slug, currentStatus, newStatus);
}
};
handlePersistEntry = async (opts = {}) => {
const { createNew = false } = opts;
const { persistEntry, collection, currentStatus, hasWorkflow, loadEntry, slug, createEmptyDraft } = this.props;
const {
persistEntry,
collection,
currentStatus,
hasWorkflow,
loadEntry,
slug,
createEmptyDraft,
} = this.props;
await persistEntry(collection)
await persistEntry(collection);
if (createNew) {
navigateToNewEntry(collection.get('name'));
createEmptyDraft(collection);
}
else if (slug && hasWorkflow && !currentStatus) {
} else if (slug && hasWorkflow && !currentStatus) {
loadEntry(collection, slug);
}
};
handlePublishEntry = async (opts = {}) => {
const { createNew = false } = opts;
const { publishUnpublishedEntry, entryDraft, collection, slug, currentStatus, loadEntry } = this.props;
const {
publishUnpublishedEntry,
entryDraft,
collection,
slug,
currentStatus,
loadEntry,
} = this.props;
if (currentStatus !== status.last()) {
window.alert('Please update status to "Ready" before publishing.');
return;
@ -212,8 +239,7 @@ class Editor extends React.Component {
if (createNew) {
navigateToNewEntry(collection.get('name'));
}
else {
} else {
loadEntry(collection, slug);
}
};
@ -221,7 +247,11 @@ class Editor extends React.Component {
handleDeleteEntry = () => {
const { entryDraft, newEntry, collection, deleteEntry, slug } = this.props;
if (entryDraft.get('hasChanged')) {
if (!window.confirm('Are you sure you want to delete this published entry, as well as your unsaved changes from the current session?')) {
if (
!window.confirm(
'Are you sure you want to delete this published entry, as well as your unsaved changes from the current session?',
)
) {
return;
}
} else if (!window.confirm('Are you sure you want to delete this published entry?')) {
@ -238,10 +268,26 @@ class Editor extends React.Component {
};
handleDeleteUnpublishedChanges = async () => {
const { entryDraft, collection, slug, deleteUnpublishedEntry, loadEntry, isModification } = this.props;
if (entryDraft.get('hasChanged') && !window.confirm('This will delete all unpublished changes to this entry, as well as your unsaved changes from the current session. Do you still want to delete?')) {
const {
entryDraft,
collection,
slug,
deleteUnpublishedEntry,
loadEntry,
isModification,
} = this.props;
if (
entryDraft.get('hasChanged') &&
!window.confirm(
'This will delete all unpublished changes to this entry, as well as your unsaved changes from the current session. Do you still want to delete?',
)
) {
return;
} else if (!window.confirm('All unpublished changes to this entry will be deleted. Do you still want to delete?')) {
} else if (
!window.confirm(
'All unpublished changes to this entry will be deleted. Do you still want to delete?',
)
) {
return;
}
await deleteUnpublishedEntry(collection.get('name'), slug);
@ -274,10 +320,16 @@ class Editor extends React.Component {
} = this.props;
if (entry && entry.get('error')) {
return <div><h3>{ entry.get('error') }</h3></div>;
} else if (entryDraft == null
|| entryDraft.get('entry') === undefined
|| (entry && entry.get('isFetching'))) {
return (
<div>
<h3>{entry.get('error')}</h3>
</div>
);
} else if (
entryDraft == null ||
entryDraft.get('entry') === undefined ||
(entry && entry.get('isFetching'))
) {
return <Loader active>Loading entry...</Loader>;
}
@ -325,7 +377,7 @@ function mapStateToProps(state, ownProps) {
const displayUrl = config.get('display_url');
const hasWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
const isModification = entryDraft.getIn(['entry', 'isModification']);
const collectionEntriesLoaded = !!entries.getIn(['entities', collectionName])
const collectionEntriesLoaded = !!entries.getIn(['entities', collectionName]);
const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']);
return {
@ -363,5 +415,5 @@ export default connect(
publishUnpublishedEntry,
deleteUnpublishedEntry,
logoutUser,
}
},
)(withWorkflow(Editor));

View File

@ -89,7 +89,7 @@ const ControlContainer = styled.div`
&:first-child {
margin-top: 36px;
}
`
`;
const ControlErrorsList = styled.ul`
list-style-type: none;
@ -101,9 +101,7 @@ const ControlErrorsList = styled.ul`
position: relative;
font-weight: 600;
top: 20px;
`
`;
class EditorControl extends React.Component {
state = {
@ -138,13 +136,14 @@ class EditorControl extends React.Component {
return (
<ControlContainer>
<ControlErrorsList>
{
errors && errors.map(error =>
error.message &&
typeof error.message === 'string' &&
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>{error.message}</li>
)
}
{errors &&
errors.map(
error =>
error.message &&
typeof error.message === 'string' && (
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>{error.message}</li>
),
)}
</ControlErrorsList>
<label
className={cx(

View File

@ -12,7 +12,7 @@ const ControlPaneContainer = styled.div`
p {
font-size: 16px;
}
`
`;
export default class ControlPane extends React.Component {
componentValidate = {};
@ -23,9 +23,9 @@ export default class ControlPane extends React.Component {
};
validate = () => {
this.props.fields.forEach((field) => {
this.props.fields.forEach(field => {
if (field.get('widget') === 'hidden') return;
this.componentValidate[field.get("name")]();
this.componentValidate[field.get('name')]();
});
};
@ -50,17 +50,20 @@ export default class ControlPane extends React.Component {
return (
<ControlPaneContainer>
{fields.map((field, i) => field.get('widget') === 'hidden' ? null :
<EditorControl
key={i}
field={field}
value={entry.getIn(['data', field.get('name')])}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
onChange={onChange}
onValidate={onValidate}
processControlRef={this.processControlRef}
/>
{fields.map(
(field, i) =>
field.get('widget') === 'hidden' ? null : (
<EditorControl
key={i}
field={field}
value={entry.getIn(['data', field.get('name')])}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
onChange={onChange}
onValidate={onValidate}
processControlRef={this.processControlRef}
/>
),
)}
</ControlPaneContainer>
);

View File

@ -1,17 +1,16 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ImmutablePropTypes from "react-immutable-proptypes";
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Map } from 'immutable';
import ValidationErrorTypes from 'Constants/validationErrorTypes';
const truthy = () => ({ error: false });
const isEmpty = value => (
const isEmpty = value =>
value === null ||
value === undefined ||
(value.hasOwnProperty('length') && value.length === 0) ||
(value.constructor === Object && Object.keys(value).length === 0)
);
(value.constructor === Object && Object.keys(value).length === 0);
export default class Widget extends Component {
static propTypes = {
@ -44,10 +43,7 @@ export default class Widget extends Component {
isFetching: PropTypes.bool,
query: PropTypes.func.isRequired,
clearSearch: PropTypes.func.isRequired,
queryHits: PropTypes.oneOfType([
PropTypes.array,
PropTypes.object,
]),
queryHits: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
};
shouldComponentUpdate(nextProps) {
@ -57,9 +53,11 @@ export default class Widget extends Component {
if (this.wrappedControlShouldComponentUpdate) {
return this.wrappedControlShouldComponentUpdate(nextProps);
}
return this.props.value !== nextProps.value
|| this.props.classNameWrapper !== nextProps.classNameWrapper
|| this.props.hasActiveStyle !== nextProps.hasActiveStyle;
return (
this.props.value !== nextProps.value ||
this.props.classNameWrapper !== nextProps.classNameWrapper ||
this.props.hasActiveStyle !== nextProps.hasActiveStyle
);
}
processInnerControlRef = ref => {
@ -87,7 +85,7 @@ export default class Widget extends Component {
const { field, value } = this.props;
const errors = [];
const validations = [this.validatePresence, this.validatePattern];
validations.forEach((func) => {
validations.forEach(func => {
const response = func(field, value);
if (response.error) errors.push(response.error);
});
@ -105,7 +103,7 @@ export default class Widget extends Component {
if (isRequired && isEmpty(value)) {
const error = {
type: ValidationErrorTypes.PRESENCE,
message: `${ field.get('label', field.get('name')) } is required.`,
message: `${field.get('label', field.get('name'))} is required.`,
};
return { error };
@ -123,7 +121,10 @@ export default class Widget extends Component {
if (pattern && !RegExp(pattern.first()).test(value)) {
const error = {
type: ValidationErrorTypes.PATTERN,
message: `${ field.get('label', field.get('name')) } didn't match the pattern: ${ pattern.last() }`,
message: `${field.get(
'label',
field.get('name'),
)} didn't match the pattern: ${pattern.last()}`,
};
return { error };
@ -132,29 +133,31 @@ export default class Widget extends Component {
return { error: false };
};
validateWrappedControl = (field) => {
validateWrappedControl = field => {
const response = this.wrappedControlValid();
if (typeof response === "boolean") {
if (typeof response === 'boolean') {
const isValid = response;
return { error: (!isValid) };
return { error: !isValid };
} else if (response.hasOwnProperty('error')) {
return response;
} else if (response instanceof Promise) {
response.then(
() => { this.validate({ error: false }); },
(err) => {
() => {
this.validate({ error: false });
},
err => {
const error = {
type: ValidationErrorTypes.CUSTOM,
message: `${ field.get('label', field.get('name')) } - ${ err }.`,
message: `${field.get('label', field.get('name'))} - ${err}.`,
};
this.validate({ error });
}
},
);
const error = {
type: ValidationErrorTypes.CUSTOM,
message: `${ field.get('label', field.get('name')) } is processing.`,
message: `${field.get('label', field.get('name'))} is processing.`,
};
return { error };

View File

@ -23,7 +23,7 @@ const styles = {
height: 100%;
overflow-y: auto;
`,
}
};
injectGlobal`
/**
@ -51,7 +51,7 @@ injectGlobal`
}
}
`
`;
const StyledSplitPane = styled(SplitPane)`
${styles.splitPane};
@ -62,11 +62,11 @@ const StyledSplitPane = styled(SplitPane)`
.Pane {
height: 100%;
}
`
`;
const NoPreviewContainer = styled.div`
${styles.splitPane};
`
`;
const EditorContainer = styled.div`
width: 100%;
@ -78,39 +78,39 @@ const EditorContainer = styled.div`
overflow: hidden;
padding-top: 66px;
background-color: ${colors.background};
`
`;
const Editor = styled.div`
max-width: 1600px;
height: 100%;
margin: 0 auto;
position: relative;
`
`;
const PreviewPaneContainer = styled.div`
height: 100%;
overflow-y: auto;
pointer-events: ${props => props.blockEntry ? 'none' : 'auto'};
`
pointer-events: ${props => (props.blockEntry ? 'none' : 'auto')};
`;
const ControlPaneContainer = styled(PreviewPaneContainer)`
padding: 0 16px;
position: relative;
overflow-x: hidden;
`
`;
const ViewControls = styled.div`
position: absolute;
top: 10px;
right: 10px;
z-index: 299;
`
`;
class EditorInterface extends Component {
state = {
showEventBlocker: false,
previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== "false",
scrollSyncEnabled: localStorage.getItem(SCROLL_SYNC_ENABLED) !== "false",
previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== 'false',
scrollSyncEnabled: localStorage.getItem(SCROLL_SYNC_ENABLED) !== 'false',
};
handleSplitPaneDragStart = () => {
@ -185,7 +185,7 @@ class EditorInterface extends Component {
fieldsErrors={fieldsErrors}
onChange={onChange}
onValidate={onValidate}
ref={c => this.controlPaneRef = c}
ref={c => (this.controlPaneRef = c)}
/>
</ControlPaneContainer>
);
@ -255,11 +255,11 @@ class EditorInterface extends Component {
icon="scroll"
/>
</ViewControls>
{
collectionPreviewEnabled && this.state.previewVisible
? editorWithPreview
: <NoPreviewContainer>{editor}</NoPreviewContainer>
}
{collectionPreviewEnabled && this.state.previewVisible ? (
editorWithPreview
) : (
<NoPreviewContainer>{editor}</NoPreviewContainer>
)}
</Editor>
</EditorContainer>
);

View File

@ -8,8 +8,8 @@ function isVisible(field) {
}
const PreviewContainer = styled.div`
font-family: Roboto, "Helvetica Neue", HelveticaNeue, Helvetica, Arial, sans-serif;
`
font-family: Roboto, 'Helvetica Neue', HelveticaNeue, Helvetica, Arial, sans-serif;
`;
/**
* Use a stateful component so that child components can effectively utilize

View File

@ -19,10 +19,9 @@ const PreviewPaneFrame = styled(Frame)`
border: none;
background: #fff;
border-radius: ${lengths.borderRadius};
`
`;
export default class PreviewPane extends React.Component {
getWidget = (field, value, props) => {
const { fieldsMetaData, getAsset, entry } = props;
const widget = resolveWidget(field.get('widget'));
@ -77,8 +76,16 @@ export default class PreviewPane extends React.Component {
const labelledWidgets = ['string', 'text', 'number'];
if (Object.keys(this.inferedFields).indexOf(name) !== -1) {
value = this.inferedFields[name].defaultPreview(value);
} else if (value && labelledWidgets.indexOf(field.get('widget')) !== -1 && value.toString().length < 50) {
value = <div><strong>{field.get('label')}:</strong> {value}</div>;
} else if (
value &&
labelledWidgets.indexOf(field.get('widget')) !== -1 &&
value.toString().length < 50
) {
value = (
<div>
<strong>{field.get('label')}:</strong> {value}
</div>
);
}
return value ? this.getWidget(field, value, this.props) : null;
@ -109,7 +116,7 @@ export default class PreviewPane extends React.Component {
*
* TODO: see if widgetFor can now provide this functionality for preview templates
*/
widgetsFor = (name) => {
widgetsFor = name => {
const { fields, entry } = this.props;
const field = fields.find(f => f.get('name') === name);
const nestedFields = field && field.get('fields');
@ -117,14 +124,23 @@ export default class PreviewPane extends React.Component {
if (List.isList(value)) {
return value.map(val => {
const widgets = nestedFields && Map(nestedFields.map((f, i) => [f.get('name'), <div key={i}>{this.getWidget(f, val, this.props)}</div>]));
const widgets =
nestedFields &&
Map(
nestedFields.map((f, i) => [
f.get('name'),
<div key={i}>{this.getWidget(f, val, this.props)}</div>,
]),
);
return Map({ data: val, widgets });
});
}
return Map({
data: value,
widgets: nestedFields && Map(nestedFields.map(f => [f.get('name'), this.getWidget(f, value, this.props)])),
widgets:
nestedFields &&
Map(nestedFields.map(f => [f.get('name'), this.getWidget(f, value, this.props)])),
});
};
@ -136,8 +152,7 @@ export default class PreviewPane extends React.Component {
}
const previewComponent =
getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) ||
EditorPreview;
getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) || EditorPreview;
this.inferFields();
@ -147,16 +162,15 @@ export default class PreviewPane extends React.Component {
widgetsFor: this.widgetsFor,
};
const styleEls = getPreviewStyles()
.map((style, i) => {
if (style.raw) {
return <style key={i}>{style.value}</style>
}
return <link key={i} href={style.value} type="text/css" rel="stylesheet" />;
});
const styleEls = getPreviewStyles().map((style, i) => {
if (style.raw) {
return <style key={i}>{style.value}</style>;
}
return <link key={i} href={style.value} type="text/css" rel="stylesheet" />;
});
if (!collection) {
<PreviewPaneFrame head={styleEls}/>
<PreviewPaneFrame head={styleEls} />;
}
const initialContent = `
@ -170,7 +184,7 @@ export default class PreviewPane extends React.Component {
return (
<ErrorBoundary>
<PreviewPaneFrame head={styleEls} initialContent={initialContent}>
<EditorPreviewContent {...{ previewComponent, previewProps }}/>
<EditorPreviewContent {...{ previewComponent, previewProps }} />
</PreviewPaneFrame>
</ErrorBoundary>
);

View File

@ -1,7 +1,6 @@
import React from 'react';
class PreviewHOC extends React.Component {
/**
* Only re-render on value change, but always re-render objects and lists.
* Their child widgets will each also be wrapped with this component, and

View File

@ -16,12 +16,14 @@ const EditorToggleButton = styled.button`
height: 40px;
padding: 0;
margin-bottom: 12px;
`
`;
const EditorToggle = ({ enabled, active, onClick, icon }) => !enabled ? null :
<EditorToggleButton onClick={onClick} isActive={active}>
<Icon type={icon} size="large"/>
</EditorToggleButton>;
const EditorToggle = ({ enabled, active, onClick, icon }) =>
!enabled ? null : (
<EditorToggleButton onClick={onClick} isActive={active}>
<Icon type={icon} size="large" />
</EditorToggleButton>
);
EditorToggle.propTypes = {
enabled: PropTypes.bool,

View File

@ -27,12 +27,11 @@ const styles = {
align-items: center;
border: 0 solid ${colors.textFieldBorder};
`,
}
};
const ToolbarContainer = styled.div`
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05),
0 1px 3px 0 rgba(68, 74, 87, 0.10),
0 2px 54px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05), 0 1px 3px 0 rgba(68, 74, 87, 0.1),
0 2px 54px rgba(0, 0, 0, 0.1);
position: fixed;
top: 0;
left: 0;
@ -43,7 +42,7 @@ const ToolbarContainer = styled.div`
height: 66px;
display: flex;
justify-content: space-between;
`
`;
const ToolbarSectionMain = styled.div`
${styles.toolbarSection};
@ -51,15 +50,15 @@ const ToolbarSectionMain = styled.div`
display: flex;
justify-content: space-between;
padding: 0 10px;
`
`;
const ToolbarSubSectionFirst = styled.div`
display: flex;
`
`;
const ToolbarSubSectionLast = styled(ToolbarSubSectionFirst)`
justify-content: flex-end;
`
`;
const ToolbarSectionBackLink = styled(Link)`
${styles.toolbarSection};
@ -69,15 +68,15 @@ const ToolbarSectionBackLink = styled(Link)`
&:hover,
&:focus {
background-color: #F1F2F4;
background-color: #f1f2f4;
}
`
`;
const ToolbarSectionMeta = styled.div`
${styles.toolbarSection};
border-left-width: 1px;
padding: 0 7px;
`
`;
const ToolbarDropdown = styled(Dropdown)`
${styles.buttonMargin};
@ -85,23 +84,23 @@ const ToolbarDropdown = styled(Dropdown)`
${Icon} {
color: ${colorsRaw.teal};
}
`
`;
const BackArrow = styled.div`
color: ${colors.textLead};
font-size: 21px;
font-weight: 600;
margin-right: 16px;
`
`;
const BackCollection = styled.div`
color: ${colors.textLead};
font-size: 14px;
`
`;
const BackStatus = styled.div`
margin-top: 6px;
`
`;
const BackStatusUnchanged = styled(BackStatus)`
${components.textBadgeSuccess};
@ -117,26 +116,26 @@ const BackStatusUnchanged = styled(BackStatus)`
content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg' width='15' height='11'><path fill='#005614' fill-rule='nonzero' d='M4.016 11l-.648-.946a6.202 6.202 0 0 0-.157-.22 9.526 9.526 0 0 1-.096-.133l-.511-.7a7.413 7.413 0 0 0-.162-.214l-.102-.134-.265-.346a26.903 26.903 0 0 0-.543-.687l-.11-.136c-.143-.179-.291-.363-.442-.54l-.278-.332a8.854 8.854 0 0 0-.192-.225L.417 6.28l-.283-.324L0 5.805l1.376-1.602c.04.027.186.132.186.132l.377.272.129.095c.08.058.16.115.237.175l.37.28c.192.142.382.292.565.436l.162.126c.27.21.503.398.714.574l.477.393c.078.064.156.127.23.194l.433.375.171-.205A50.865 50.865 0 0 1 8.18 4.023a35.163 35.163 0 0 1 2.382-2.213c.207-.174.42-.349.635-.518l.328-.255.333-.245c.072-.055.146-.107.221-.159l.117-.083c.11-.077.225-.155.341-.23.163-.11.334-.217.503-.32l1.158 1.74a11.908 11.908 0 0 0-.64.55l-.065.06c-.07.062-.139.125-.207.192l-.258.249-.26.265c-.173.176-.345.357-.512.539a32.626 32.626 0 0 0-1.915 2.313 52.115 52.115 0 0 0-2.572 3.746l-.392.642-.19.322-.233.382H4.016z'/></svg>");
}
`
`;
const BackStatusChanged = styled(BackStatus)`
${components.textBadgeDanger};
`
`;
const ToolbarButton = styled.button`
${buttons.button};
${buttons.default};
${styles.buttonMargin};
display: block;
`
`;
const DeleteButton = styled(ToolbarButton)`
${buttons.lightRed};
`
`;
const SaveButton = styled(ToolbarButton)`
${buttons.lightBlue};
`
`;
const StatusPublished = styled.div`
${styles.buttonMargin};
@ -149,22 +148,22 @@ const StatusPublished = styled.div`
cursor: default;
font-size: 14px;
font-weight: 500;
`
`;
const PublishButton = styled(StyledDropdownButton)`
background-color: ${colorsRaw.teal};
`
`;
const StatusButton = styled(StyledDropdownButton)`
background-color: ${colorsRaw.tealLight};
color: ${colorsRaw.teal};
`
`;
const StatusDropdownItem = styled(DropdownItem)`
${Icon} {
color: ${colors.infoText};
}
`
`;
export default class EditorToolbar extends React.Component {
static propTypes = {
@ -196,14 +195,19 @@ export default class EditorToolbar extends React.Component {
renderSimpleSaveControls = () => {
const { showDelete, onDelete } = this.props;
return (
<div>
{ showDelete ? <DeleteButton onClick={onDelete}>Delete entry</DeleteButton> : null }
</div>
<div>{showDelete ? <DeleteButton onClick={onDelete}>Delete entry</DeleteButton> : null}</div>
);
};
renderSimplePublishControls = () => {
const { collection, onPersist, onPersistAndNew, isPersisting, hasChanged, isNewEntry } = this.props;
const {
collection,
onPersist,
onPersistAndNew,
isPersisting,
hasChanged,
isNewEntry,
} = this.props;
if (!isNewEntry && !hasChanged) {
return <StatusPublished>Published</StatusPublished>;
}
@ -216,12 +220,15 @@ export default class EditorToolbar extends React.Component {
<PublishButton>{isPersisting ? 'Publishing...' : 'Publish'}</PublishButton>
)}
>
<DropdownItem label="Publish now" icon="arrow" iconDirection="right" onClick={onPersist}/>
{
collection.get('create')
? <DropdownItem label="Publish and create new" icon="add" onClick={onPersistAndNew}/>
: null
}
<DropdownItem
label="Publish now"
icon="arrow"
iconDirection="right"
onClick={onPersist}
/>
{collection.get('create') ? (
<DropdownItem label="Publish and create new" icon="add" onClick={onPersistAndNew} />
) : null}
</ToolbarDropdown>
</div>
);
@ -240,21 +247,23 @@ export default class EditorToolbar extends React.Component {
isModification,
} = this.props;
const deleteLabel = (hasUnpublishedChanges && isModification && 'Delete unpublished changes')
|| (hasUnpublishedChanges && (isNewEntry || !isModification) && 'Delete unpublished entry')
|| (!hasUnpublishedChanges && !isModification && 'Delete published entry');
const deleteLabel =
(hasUnpublishedChanges && isModification && 'Delete unpublished changes') ||
(hasUnpublishedChanges && (isNewEntry || !isModification) && 'Delete unpublished entry') ||
(!hasUnpublishedChanges && !isModification && 'Delete published entry');
return [
<SaveButton key="save-button" onClick={() => hasChanged && onPersist()}>
{isPersisting ? 'Saving...' : 'Save'}
</SaveButton>,
isNewEntry || !deleteLabel ? null
: <DeleteButton
key="delete-button"
onClick={hasUnpublishedChanges ? onDeleteUnpublishedChanges : onDelete}
>
{isDeleting ? 'Deleting...' : deleteLabel}
</DeleteButton>,
isNewEntry || !deleteLabel ? null : (
<DeleteButton
key="delete-button"
onClick={hasUnpublishedChanges ? onDeleteUnpublishedChanges : onDelete}
>
{isDeleting ? 'Deleting...' : deleteLabel}
</DeleteButton>
),
];
};
@ -270,63 +279,59 @@ export default class EditorToolbar extends React.Component {
isNewEntry,
} = this.props;
if (currentStatus) {
return (<>
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="120px"
renderButton={() => (
<StatusButton>{isUpdatingStatus ? 'Updating...' : 'Set status'}</StatusButton>
)}
>
<StatusDropdownItem
label="Draft"
onClick={() => onChangeStatus('DRAFT')}
icon={currentStatus === status.get('DRAFT') && 'check'}
/>
<StatusDropdownItem
label="In review"
onClick={() => onChangeStatus('PENDING_REVIEW')}
icon={currentStatus === status.get('PENDING_REVIEW') && 'check'}
/>
<StatusDropdownItem
label="Ready"
onClick={() => onChangeStatus('PENDING_PUBLISH')}
icon={currentStatus === status.get('PENDING_PUBLISH') && 'check'}
/>
</ToolbarDropdown>
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
<PublishButton>{isPublishing ? 'Publishing...' : 'Publish'}</PublishButton>
)}
>
<DropdownItem label="Publish now" icon="arrow" iconDirection="right" onClick={onPublish}/>
{
collection.get('create')
? <DropdownItem label="Publish and create new" icon="add" onClick={onPublishAndNew}/>
: null
}
</ToolbarDropdown>
</>);
return (
<>
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="120px"
renderButton={() => (
<StatusButton>{isUpdatingStatus ? 'Updating...' : 'Set status'}</StatusButton>
)}
>
<StatusDropdownItem
label="Draft"
onClick={() => onChangeStatus('DRAFT')}
icon={currentStatus === status.get('DRAFT') && 'check'}
/>
<StatusDropdownItem
label="In review"
onClick={() => onChangeStatus('PENDING_REVIEW')}
icon={currentStatus === status.get('PENDING_REVIEW') && 'check'}
/>
<StatusDropdownItem
label="Ready"
onClick={() => onChangeStatus('PENDING_PUBLISH')}
icon={currentStatus === status.get('PENDING_PUBLISH') && 'check'}
/>
</ToolbarDropdown>
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
<PublishButton>{isPublishing ? 'Publishing...' : 'Publish'}</PublishButton>
)}
>
<DropdownItem
label="Publish now"
icon="arrow"
iconDirection="right"
onClick={onPublish}
/>
{collection.get('create') ? (
<DropdownItem label="Publish and create new" icon="add" onClick={onPublishAndNew} />
) : null}
</ToolbarDropdown>
</>
);
}
if (!isNewEntry) {
return <StatusPublished>Published</StatusPublished>
return <StatusPublished>Published</StatusPublished>;
}
};
render() {
const {
user,
hasChanged,
displayUrl,
collection,
hasWorkflow,
onLogoutClick,
} = this.props;
const { user, hasChanged, displayUrl, collection, hasWorkflow, onLogoutClick } = this.props;
return (
<ToolbarContainer>
@ -336,19 +341,21 @@ export default class EditorToolbar extends React.Component {
<BackCollection>
Writing in <strong>{collection.get('label')}</strong> collection
</BackCollection>
{
hasChanged
? <BackStatusChanged>Unsaved Changes</BackStatusChanged>
: <BackStatusUnchanged>Changes saved</BackStatusUnchanged>
}
{hasChanged ? (
<BackStatusChanged>Unsaved Changes</BackStatusChanged>
) : (
<BackStatusUnchanged>Changes saved</BackStatusUnchanged>
)}
</div>
</ToolbarSectionBackLink>
<ToolbarSectionMain>
<ToolbarSubSectionFirst>
{ hasWorkflow ? this.renderWorkflowSaveControls() : this.renderSimpleSaveControls() }
{hasWorkflow ? this.renderWorkflowSaveControls() : this.renderSimpleSaveControls()}
</ToolbarSubSectionFirst>
<ToolbarSubSectionLast>
{ hasWorkflow ? this.renderWorkflowPublishControls() : this.renderSimplePublishControls() }
{hasWorkflow
? this.renderWorkflowPublishControls()
: this.renderSimplePublishControls()}
</ToolbarSubSectionLast>
</ToolbarSectionMain>
<ToolbarSectionMeta>

View File

@ -7,7 +7,7 @@ import { loadUnpublishedEntry, persistUnpublishedEntry } from 'Actions/editorial
function mapStateToProps(state, ownProps) {
const { collections } = state;
const isEditorialWorkflow = (state.config.get('publish_mode') === EDITORIAL_WORKFLOW);
const isEditorialWorkflow = state.config.get('publish_mode') === EDITORIAL_WORKFLOW;
const collection = collections.get(ownProps.match.params.name);
const returnObj = {
isEditorialWorkflow,
@ -31,8 +31,7 @@ function mergeProps(stateProps, dispatchProps, ownProps) {
if (isEditorialWorkflow) {
// Overwrite loadEntry to loadUnpublishedEntry
returnObj.loadEntry = (collection, slug) =>
dispatch(loadUnpublishedEntry(collection, slug));
returnObj.loadEntry = (collection, slug) => dispatch(loadUnpublishedEntry(collection, slug));
// Overwrite persistEntry to persistUnpublishedEntry
returnObj.persistEntry = collection =>
@ -47,12 +46,15 @@ function mergeProps(stateProps, dispatchProps, ownProps) {
}
export default function withWorkflow(Editor) {
return connect(mapStateToProps, null, mergeProps)(
return connect(
mapStateToProps,
null,
mergeProps,
)(
class WorkflowEditor extends React.Component {
render() {
return <Editor {...this.props} />;
}
}
},
);
}

View File

@ -2,7 +2,12 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
export default function UnknownPreview({ field }) {
return <div className='nc-widgetPreview'>No preview for widget {field.get('widget')}.</div>;
return (
<div className="nc-widgetPreview">
No preview for widget {field.get('widget')}
.
</div>
);
}
UnknownPreview.propTypes = {

View File

@ -3,14 +3,14 @@ import PropTypes from 'prop-types';
import styled from 'react-emotion';
import { colors } from 'netlify-cms-ui-default';
const EmptyMessageContainer= styled.div`
const EmptyMessageContainer = styled.div`
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
color: ${props => props.isPrivate && colors.textFieldBorder};
`
`;
const EmptyMessage = ({ content, isPrivate }) => (
<EmptyMessageContainer isPrivate={isPrivate}>

View File

@ -16,11 +16,10 @@ import MediaLibraryModal from './MediaLibraryModal';
* Extensions used to determine which files to show when the media library is
* accessed from an image insertion field.
*/
const IMAGE_EXTENSIONS_VIEWABLE = [ 'jpg', 'jpeg', 'webp', 'gif', 'png', 'bmp', 'tiff', 'svg' ];
const IMAGE_EXTENSIONS = [ ...IMAGE_EXTENSIONS_VIEWABLE ];
const IMAGE_EXTENSIONS_VIEWABLE = ['jpg', 'jpeg', 'webp', 'gif', 'png', 'bmp', 'tiff', 'svg'];
const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
class MediaLibrary extends React.Component {
/**
* The currently selected file and query are tracked in component state as
* they do not impact the rest of the application.
@ -49,7 +48,7 @@ class MediaLibrary extends React.Component {
componentDidUpdate(prevProps) {
const isOpening = !prevProps.isVisible && this.props.isVisible;
if (isOpening && (prevProps.privateUpload !== this.props.privateUpload)) {
if (isOpening && prevProps.privateUpload !== this.props.privateUpload) {
this.props.loadMedia({ privateUpload: this.props.privateUpload });
}
}
@ -68,20 +67,22 @@ class MediaLibrary extends React.Component {
* Transform file data for table display.
*/
toTableData = files => {
const tableData = files && files.map(({ key, name, size, queryOrder, url, urlIsPublicPath }) => {
const ext = fileExtension(name).toLowerCase();
return {
key,
name,
type: ext.toUpperCase(),
size,
queryOrder,
url,
urlIsPublicPath,
isImage: IMAGE_EXTENSIONS.includes(ext),
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
};
});
const tableData =
files &&
files.map(({ key, name, size, queryOrder, url, urlIsPublicPath }) => {
const ext = fileExtension(name).toLowerCase();
return {
key,
name,
type: ext.toUpperCase(),
size,
queryOrder,
url,
urlIsPublicPath,
isImage: IMAGE_EXTENSIONS.includes(ext),
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
};
});
/**
* Get the sort order for use with `lodash.orderBy`, and always add the
@ -152,10 +153,9 @@ class MediaLibrary extends React.Component {
return;
}
const file = files.find(file => selectedFile.key === file.key);
deleteMedia(file, { privateUpload })
.then(() => {
this.setState({ selectedFile: {} });
});
deleteMedia(file, { privateUpload }).then(() => {
this.setState({ selectedFile: {} });
});
};
handleLoadMore = () => {
@ -170,17 +170,17 @@ class MediaLibrary extends React.Component {
* the GitHub backend, search is in-memory and occurs as the query is typed,
* so this handler has no impact.
*/
handleSearchKeyDown = async (event) => {
handleSearchKeyDown = async event => {
const { dynamicSearch, loadMedia, privateUpload } = this.props;
if (event.key === 'Enter' && dynamicSearch) {
await loadMedia({ query: this.state.query, privateUpload })
await loadMedia({ query: this.state.query, privateUpload });
this.scrollToTop();
}
};
scrollToTop = () => {
this.scrollContainerRef.scrollTop = 0;
}
};
/**
* Updates query state as the user types in the search field.
@ -248,7 +248,7 @@ class MediaLibrary extends React.Component {
handlePersist={this.handlePersist}
handleDelete={this.handleDelete}
handleInsert={this.handleInsert}
setScrollContainerRef={ref => this.scrollContainerRef = ref}
setScrollContainerRef={ref => (this.scrollContainerRef = ref)}
handleAssetClick={this.handleAssetClick}
handleLoadMore={this.handleLoadMore}
/>
@ -288,4 +288,7 @@ const mapDispatchToProps = {
closeMediaLibrary: closeMediaLibraryAction,
};
export default connect(mapStateToProps, mapDispatchToProps)(MediaLibrary);
export default connect(
mapStateToProps,
mapDispatchToProps,
)(MediaLibrary);

View File

@ -17,11 +17,11 @@ const styles = {
cursor: default;
}
`,
}
};
const ActionsContainer = styled.div`
text-align: right;
`
`;
const StyledUploadButton = styled(FileUploadButton)`
${styles.button};
@ -38,8 +38,8 @@ const StyledUploadButton = styled(FileUploadButton)`
}
input {
height: .1px;
width: .1px;
height: 0.1px;
width: 0.1px;
margin: 0;
padding: 0;
opacity: 0;
@ -48,21 +48,21 @@ const StyledUploadButton = styled(FileUploadButton)`
z-index: 0;
outline: none;
}
`
`;
const DeleteButton = styled.button`
${styles.button};
${buttons.lightRed};
`
`;
const InsertButton = styled.button`
${styles.button};
${buttons.green};
`
`;
const LowerActionsContainer = styled.div`
margin-top: 30px;
`
`;
const MediaLibraryActions = ({
uploadButtonLabel,
@ -88,11 +88,11 @@ const MediaLibraryActions = ({
<DeleteButton onClick={onDelete} disabled={!deleteEnabled}>
{deleteButtonLabel}
</DeleteButton>
{ !insertVisible ? null :
{!insertVisible ? null : (
<InsertButton onClick={onInsert} disabled={!insertEnabled}>
{insertButtonLabel}
</InsertButton>
}
)}
</LowerActionsContainer>
</ActionsContainer>
);

View File

@ -17,14 +17,14 @@ const Card = styled.div`
&:focus {
outline: none;
}
`
`;
const CardImage = styled.img`
width: 100%;
height: 160px;
object-fit: cover;
border-radius: 2px 2px 0 0;
`
`;
const CardImagePlaceholder = CardImage.withComponent(`div`);
@ -34,7 +34,7 @@ const CardText = styled.p`
margin-top: 20px;
overflow-wrap: break-word;
line-height: 1.3 !important;
`
`;
const MediaLibraryCard = ({ isSelected, imageUrl, text, onClick, width, margin, isPrivate }) => (
<Card
@ -45,9 +45,7 @@ const MediaLibraryCard = ({ isSelected, imageUrl, text, onClick, width, margin,
tabIndex="-1"
isPrivate={isPrivate}
>
<div>
{ imageUrl ? <CardImage src={imageUrl}/> : <CardImagePlaceholder/> }
</div>
<div>{imageUrl ? <CardImage src={imageUrl} /> : <CardImagePlaceholder />}</div>
<CardText>{text}</CardText>
</Card>
);

View File

@ -1,25 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'react-emotion'
import styled from 'react-emotion';
import Waypoint from 'react-waypoint';
import MediaLibraryCard from './MediaLibraryCard';
import { colors } from 'netlify-cms-ui-default';
const CardGridContainer = styled.div`
overflow-y: auto;
`
`;
const CardGrid = styled.div`
display: flex;
flex-wrap: wrap;
margin-left: -10px;
margin-left: -10px;
margin-right: -10px;
`
`;
const PaginatingMessage = styled.h1`
color: ${props => props.isPrivate && colors.textFieldBorder};
`
`;
const MediaLibraryCardGrid = ({
setScrollContainerRef,
@ -36,38 +36,36 @@ const MediaLibraryCardGrid = ({
}) => (
<CardGridContainer innerRef={setScrollContainerRef}>
<CardGrid>
{
mediaItems.map(file =>
<MediaLibraryCard
key={file.key}
isSelected={isSelectedFile(file)}
imageUrl={file.isViewableImage && file.url}
text={file.name}
onClick={() => onAssetClick(file)}
width={cardWidth}
margin={cardMargin}
isPrivate={isPrivate}
/>
)
}
{!canLoadMore ? null : <Waypoint onEnter={onLoadMore}/>}
{mediaItems.map(file => (
<MediaLibraryCard
key={file.key}
isSelected={isSelectedFile(file)}
imageUrl={file.isViewableImage && file.url}
text={file.name}
onClick={() => onAssetClick(file)}
width={cardWidth}
margin={cardMargin}
isPrivate={isPrivate}
/>
))}
{!canLoadMore ? null : <Waypoint onEnter={onLoadMore} />}
</CardGrid>
{
!isPaginating ? null :
<PaginatingMessage isPrivate={isPrivate}>{paginatingMessage}</PaginatingMessage>
}
{!isPaginating ? null : (
<PaginatingMessage isPrivate={isPrivate}>{paginatingMessage}</PaginatingMessage>
)}
</CardGridContainer>
);
MediaLibraryCardGrid.propTypes = {
setScrollContainerRef: PropTypes.func.isRequired,
mediaItems: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string.isRequired,
isViewableImage: PropTypes.bool,
url: PropTypes.string,
name: PropTypes.string,
})).isRequired,
mediaItems: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string.isRequired,
isViewableImage: PropTypes.bool,
url: PropTypes.string,
name: PropTypes.string,
}),
).isRequired,
isSelectedFile: PropTypes.func.isRequired,
onAssetClick: PropTypes.func.isRequired,
canLoadMore: PropTypes.bool,

View File

@ -17,7 +17,7 @@ const CloseButton = styled.button`
display: flex;
justify-content: center;
align-items: center;
`
`;
const LibraryTitle = styled.h1`
line-height: 36px;
@ -25,12 +25,12 @@ const LibraryTitle = styled.h1`
text-align: left;
margin-bottom: 25px;
color: ${props => props.isPrivate && colors.textFieldBorder};
`
`;
const MediaLibraryHeader = ({ onClose, title, isPrivate }) => (
<div>
<CloseButton onClick={onClose}>
<Icon type="close"/>
<Icon type="close" />
</CloseButton>
<LibraryTitle isPrivate={isPrivate}>{title}</LibraryTitle>
</div>

View File

@ -27,7 +27,7 @@ const LibraryTop = styled.div`
position: relative;
display: flex;
justify-content: space-between;
`
`;
const StyledModal = styled(Modal)`
display: grid;
@ -63,7 +63,7 @@ const StyledModal = styled(Modal)`
label[disabled] {
background-color: ${props => props.isPrivate && `rgba(217, 217, 217, 0.15)`};
}
`
`;
const MediaLibraryModal = ({
isVisible,
@ -94,18 +94,19 @@ const MediaLibraryModal = ({
handleLoadMore,
}) => {
const filteredFiles = forImage ? handleFilter(files) : files;
const queriedFiles = (!dynamicSearch && query) ? handleQuery(query, filteredFiles) : filteredFiles;
const queriedFiles = !dynamicSearch && query ? handleQuery(query, filteredFiles) : filteredFiles;
const tableData = toTableData(queriedFiles);
const hasFiles = files && !!files.length;
const hasFilteredFiles = filteredFiles && !!filteredFiles.length;
const hasSearchResults = queriedFiles && !!queriedFiles.length;
const hasMedia = hasSearchResults;
const shouldShowEmptyMessage = !hasMedia;
const emptyMessage = (isLoading && !hasMedia && 'Loading...')
|| (dynamicSearchActive && 'No results.')
|| (!hasFiles && 'No assets found.')
|| (!hasFilteredFiles && 'No images found.')
|| (!hasSearchResults && 'No results.');
const emptyMessage =
(isLoading && !hasMedia && 'Loading...') ||
(dynamicSearchActive && 'No results.') ||
(!hasFiles && 'No assets found.') ||
(!hasFilteredFiles && 'No images found.') ||
(!hasSearchResults && 'No results.');
const hasSelection = hasMedia && !isEmpty(selectedFile);
const shouldShowButtonLoader = isPersisting || isDeleting;
@ -140,7 +141,9 @@ const MediaLibraryModal = ({
onInsert={handleInsert}
/>
</LibraryTop>
{ !shouldShowEmptyMessage ? null : <EmptyMessage content={emptyMessage} isPrivate={privateUpload}/> }
{!shouldShowEmptyMessage ? null : (
<EmptyMessage content={emptyMessage} isPrivate={privateUpload} />
)}
<MediaLibraryCardGrid
setScrollContainerRef={setScrollContainerRef}
mediaItems={tableData}
@ -156,7 +159,7 @@ const MediaLibraryModal = ({
/>
</StyledModal>
);
}
};
const fileShape = {
key: PropTypes.string.isRequired,

View File

@ -9,7 +9,7 @@ const SearchContainer = styled.div`
align-items: center;
position: relative;
width: 400px;
`
`;
const SearchInput = styled.input`
background-color: #eff0f4;
@ -25,7 +25,7 @@ const SearchInput = styled.input`
outline: none;
box-shadow: inset 0 0 0 2px ${colors.active};
}
`
`;
const SearchIcon = styled(Icon)`
position: absolute;
@ -33,11 +33,11 @@ const SearchIcon = styled(Icon)`
left: 6px;
z-index: 2;
transform: translate(0, -50%);
`
`;
const MediaLibrarySearch = ({ value, onChange, onKeyDown, placeholder, disabled }) => (
<SearchContainer>
<SearchIcon type="search" size="small"/>
<SearchIcon type="search" size="small" />
<SearchInput
value={value}
onChange={onChange}

View File

@ -2,7 +2,7 @@ import ReactDNDHTML5Backend from 'react-dnd-html5-backend';
import {
DragDropContext as ReactDNDDragDropContext,
DragSource as ReactDNDDragSource,
DropTarget as ReactDNDDropTarget
DropTarget as ReactDNDDropTarget,
} from 'react-dnd';
import React from 'react';
import PropTypes from 'prop-types';
@ -11,7 +11,8 @@ export const DragSource = ({ namespace, ...props }) => {
const DragComponent = ReactDNDDragSource(
namespace,
{
beginDrag({ children, isDragging, connectDragComponent, ...ownProps }) { // eslint-disable-line no-unused-vars
// eslint-disable-next-line no-unused-vars
beginDrag({ children, isDragging, connectDragComponent, ...ownProps }) {
// We return the rest of the props as the ID of the element being dragged.
return ownProps;
},
@ -19,9 +20,7 @@ export const DragSource = ({ namespace, ...props }) => {
connect => ({
connectDragComponent: connect.dragSource(),
}),
)(
({ children, connectDragComponent }) => children(connectDragComponent)
);
)(({ children, connectDragComponent }) => children(connectDragComponent));
return React.createElement(DragComponent, props, props.children);
};
@ -41,9 +40,7 @@ export const DropTarget = ({ onDrop, namespace, ...props }) => {
connectDropTarget: connect.dropTarget(),
isHovered: monitor.isOver(),
}),
)(
({ children, connectDropTarget, isHovered }) => children(connectDropTarget, { isHovered })
);
)(({ children, connectDropTarget, isHovered }) => children(connectDropTarget, { isHovered }));
return React.createElement(DropComponent, props, props.children);
};

View File

@ -2,7 +2,7 @@ import React from 'react';
import { css } from 'react-emotion';
import { colors } from 'netlify-cms-ui-default';
const ISSUE_URL = "https://github.com/netlify/netlify-cms/issues/new";
const ISSUE_URL = 'https://github.com/netlify/netlify-cms/issues/new';
const styles = {
errorBoundary: css`
@ -34,7 +34,15 @@ export class ErrorBoundary extends React.Component {
<h1 className={styles.errorBoundaryText}>Sorry!</h1>
<p>
<span>{"There's been an error - please "}</span>
<a href={ISSUE_URL} target="_blank" rel="noopener noreferrer" className={styles.errorBoundaryText}>report it</a>!
<a
href={ISSUE_URL}
target="_blank"
rel="noopener noreferrer"
className={styles.errorBoundaryText}
>
report it
</a>
!
</p>
<p>{errorMessage}</p>
</div>

View File

@ -46,8 +46,7 @@ const styles = {
background-color: rgba(0, 0, 0, 0);
opacity: 0;
`,
}
};
export class Modal extends React.Component {
static propTypes = {
@ -55,7 +54,7 @@ export class Modal extends React.Component {
isOpen: PropTypes.bool.isRequired,
className: PropTypes.string,
onClose: PropTypes.func.isRequired,
}
};
componentDidMount() {
ReactModal.setAppElement('#nc-root');

View File

@ -16,54 +16,52 @@ const AppHeaderAvatar = styled.button`
cursor: pointer;
color: #1e2532;
background-color: transparent;
`
`;
const AvatarImage = styled.img`
${styles.avatarImage};
`
`;
const AvatarPlaceholderIcon = styled(Icon)`
${styles.avatarImage};
height: 32px;
color: #1e2532;
background-color: ${colors.textFieldBorder};
`
`;
const AppHeaderSiteLink = styled.a`
font-size: 14px;
font-weight: 400;
color: #7b8290;
padding: 10px 16px;
`
`;
const Avatar = ({ imageUrl }) => (
<AppHeaderAvatar>
{imageUrl ? <AvatarImage src={imageUrl}/> : <AvatarPlaceholderIcon type="user" size="large"/>}
{imageUrl ? <AvatarImage src={imageUrl} /> : <AvatarPlaceholderIcon type="user" size="large" />}
</AppHeaderAvatar>
);
const SettingsDropdown = ({ displayUrl, imageUrl, onLogoutClick }) => (
<React.Fragment>
{
displayUrl
? <AppHeaderSiteLink href={displayUrl} target="_blank">
{stripProtocol(displayUrl)}
</AppHeaderSiteLink>
: null
}
{displayUrl ? (
<AppHeaderSiteLink href={displayUrl} target="_blank">
{stripProtocol(displayUrl)}
</AppHeaderSiteLink>
) : null}
<Dropdown
dropdownTopOverlap="50px"
dropdownWidth="100px"
dropdownPosition="right"
renderButton={() => (
<DropdownButton>
<Avatar imageUrl={imageUrl}/>
<Avatar imageUrl={imageUrl} />
</DropdownButton>
)}
>
<DropdownItem label="Log Out" onClick={onLogoutClick}/>
<DropdownItem label="Log Out" onClick={onLogoutClick} />
</Dropdown>
</React.Fragment>
)
);
export default SettingsDropdown;

View File

@ -40,10 +40,9 @@ const styles = {
`,
};
export const Toast = ({ kind, message }) =>
<div className={cx(styles.toast, styles[kind])}>
{message}
</div>;
export const Toast = ({ kind, message }) => (
<div className={cx(styles.toast, styles[kind])}>{message}</div>
);
Toast.propTypes = {
kind: PropTypes.oneOf(['info', 'success', 'warning', 'danger']).isRequired,

View File

@ -18,7 +18,7 @@ import {
loadUnpublishedEntries,
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
deleteUnpublishedEntry
deleteUnpublishedEntry,
} from 'Actions/editorialWorkflow';
import { selectUnpublishedEntriesByStatus } from 'Reducers';
import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes';
@ -27,28 +27,28 @@ import WorkflowList from './WorkflowList';
const WorkflowContainer = styled.div`
padding: ${lengths.pageMargin} 0;
height: 100vh;
`
`;
const WorkflowTop = styled.div`
${components.cardTop};
`
`;
const WorkflowTopRow = styled.div`
display: flex;
justify-content: space-between;
span[role="button"] {
span[role='button'] {
${shadows.dropDeep};
}
`
`;
const WorkflowTopHeading = styled.h1`
${components.cardTopHeading};
`
`;
const WorkflowTopDescription = styled.p`
${components.cardTopDescription};
`
`;
class Workflow extends Component {
static propTypes = {
@ -96,19 +96,21 @@ class Workflow extends Component {
dropdownTopOverlap="40px"
renderButton={() => <StyledDropdownButton>New Post</StyledDropdownButton>}
>
{
collections.filter(collection => collection.get('create')).toList().map(collection =>
{collections
.filter(collection => collection.get('create'))
.toList()
.map(collection => (
<DropdownItem
key={collection.get("name")}
label={collection.get("label")}
key={collection.get('name')}
label={collection.get('label')}
onClick={() => createNewEntry(collection.get('name'))}
/>
)
}
))}
</Dropdown>
</WorkflowTopRow>
<WorkflowTopDescription>
{reviewCount} {reviewCount === 1 ? 'entry' : 'entries'} waiting for review, {readyCount} ready to go live.
{reviewCount} {reviewCount === 1 ? 'entry' : 'entries'} waiting for review, {readyCount}{' '}
ready to go live.
</WorkflowTopDescription>
</WorkflowTop>
<WorkflowList
@ -124,7 +126,7 @@ class Workflow extends Component {
function mapStateToProps(state) {
const { collections } = state;
const isEditorialWorkflow = (state.config.get('publish_mode') === EDITORIAL_WORKFLOW);
const isEditorialWorkflow = state.config.get('publish_mode') === EDITORIAL_WORKFLOW;
const returnObj = { collections, isEditorialWorkflow };
if (isEditorialWorkflow) {
@ -137,15 +139,18 @@ function mapStateToProps(state) {
*/
returnObj.unpublishedEntries = status.reduce((acc, currStatus) => {
const entries = selectUnpublishedEntriesByStatus(state, currStatus);
return acc.set(currStatus, entries)
return acc.set(currStatus, entries);
}, OrderedMap());
}
return returnObj;
}
export default connect(mapStateToProps, {
loadUnpublishedEntries,
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
deleteUnpublishedEntry,
})(Workflow);
export default connect(
mapStateToProps,
{
loadUnpublishedEntries,
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
deleteUnpublishedEntry,
},
)(Workflow);

View File

@ -23,7 +23,7 @@ const WorkflowLink = styled(Link)`
padding: 0 18px 18px;
height: 200px;
overflow: hidden;
`
`;
const CardCollection = styled.div`
font-size: 14px;
@ -33,16 +33,16 @@ const CardCollection = styled.div`
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
`
`;
const CardTitle = styled.h2`
margin: 28px 0 0;
color: ${colors.textLead};
`
`;
const CardDate = styled.div`
${styles.text};
`
`;
const CardBody = styled.p`
${styles.text};
@ -51,7 +51,7 @@ const CardBody = styled.p`
overflow-wrap: break-word;
word-break: break-word;
hyphens: auto;
`
`;
const CardButtonContainer = styled.div`
background-color: ${colors.foreground};
@ -63,14 +63,14 @@ const CardButtonContainer = styled.div`
opacity: 0;
transition: opacity ${transitions.main};
cursor: pointer;
`
`;
const DeleteButton = styled.button`
${styles.button};
background-color: ${colorsRaw.redLight};
color: ${colorsRaw.red};
margin-right: 6px;
`
`;
const PublishButton = styled.button`
${styles.button};
@ -82,7 +82,7 @@ const PublishButton = styled.button`
background-color: ${colorsRaw.grayLight};
color: ${colorsRaw.gray};
}
`
`;
const WorkflowCardContainer = styled.div`
${components.card};
@ -93,7 +93,7 @@ const WorkflowCardContainer = styled.div`
&:hover ${CardButtonContainer} {
opacity: 1;
}
`
`;
const WorkflowCard = ({
collectionName,
@ -111,7 +111,9 @@ const WorkflowCard = ({
<WorkflowLink to={editLink}>
<CardCollection>{collectionName}</CardCollection>
<CardTitle>{title}</CardTitle>
<CardDate>{timestamp} by {authorLastChange}</CardDate>
<CardDate>
{timestamp} by {authorLastChange}
</CardDate>
<CardBody>{body}</CardBody>
</WorkflowLink>
<CardButtonContainer>

View File

@ -5,19 +5,19 @@ import styled, { css, cx } from 'react-emotion';
import moment from 'moment';
import { colors, lengths } from 'netlify-cms-ui-default';
import { status } from 'Constants/publishModes';
import { DragSource, DropTarget, HTML5DragDrop } from 'UI'
import { DragSource, DropTarget, HTML5DragDrop } from 'UI';
import WorkflowCard from './WorkflowCard';
const WorkflowListContainer = styled.div`
min-height: 60%;
display: grid;
grid-template-columns: 33.3% 33.3% 33.3%;
`
`;
const styles = {
column: css`
margin: 0 20px;
transition: background-color .5s ease;
transition: background-color 0.5s ease;
border: 2px dashed transparent;
border-radius: 4px;
position: relative;
@ -39,7 +39,7 @@ const styles = {
width: 2px;
height: 80%;
top: 76px;
background-color: ${colors.textFieldBorder}
background-color: ${colors.textFieldBorder};
}
&:before {
@ -63,21 +63,27 @@ const ColumnHeader = styled.h2`
border-radius: ${lengths.borderRadius};
margin-bottom: 28px;
${props => props.name === 'draft' && css`
background-color: ${colors.statusDraftBackground};
color: ${colors.statusDraftText};
`}
${props =>
props.name === 'draft' &&
css`
background-color: ${colors.statusDraftBackground};
color: ${colors.statusDraftText};
`}
${props => props.name === 'pending_review' && css`
background-color: ${colors.statusReviewBackground};
color: ${colors.statusReviewText};
`}
${props =>
props.name === 'pending_review' &&
css`
background-color: ${colors.statusReviewBackground};
color: ${colors.statusReviewText};
`}
${props => props.name === 'pending_publish' && css`
background-color: ${colors.statusReadyBackground};
color: ${colors.statusReadyText};
`}
`
${props =>
props.name === 'pending_publish' &&
css`
background-color: ${colors.statusReadyBackground};
color: ${colors.statusReadyText};
`}
`;
const ColumnCount = styled.p`
font-size: 13px;
@ -85,18 +91,21 @@ const ColumnCount = styled.p`
color: ${colors.text};
text-transform: uppercase;
margin-bottom: 6px;
`
`;
// This is a namespace so that we can only drop these elements on a DropTarget with the same
const DNDNamespace = 'cms-workflow';
const getColumnHeaderText = columnName => {
switch (columnName) {
case 'draft': return 'Drafts';
case 'pending_review': return 'In Review';
case 'pending_publish': return 'Ready';
case 'draft':
return 'Drafts';
case 'pending_review':
return 'In Review';
case 'pending_publish':
return 'Ready';
}
}
};
class WorkflowList extends React.Component {
static propTypes = {
@ -122,9 +131,9 @@ class WorkflowList extends React.Component {
requestPublish = (collection, slug, ownStatus) => {
if (ownStatus !== status.last()) {
window.alert(
`Only items with a "Ready" status can be published.
`Only items with a "Ready" status can be published.
Please drag the card to the "Ready" column to enable publishing.`
Please drag the card to the "Ready" column to enable publishing.`,
);
return;
} else if (!window.confirm('Are you sure you want to publish this entry?')) {
@ -143,66 +152,69 @@ Please drag the card to the "Ready" column to enable publishing.`
key={currColumn}
onDrop={this.handleChangeStatus.bind(this, currColumn)}
>
{(connect, { isHovered }) => connect(
<div className={cx(styles.column, { [styles.columnHovered]: isHovered })}>
<ColumnHeader name={currColumn}>{getColumnHeaderText(currColumn)}</ColumnHeader>
<ColumnCount>
{currEntries.size} {currEntries.size === 1 ? 'entry' : 'entries'}
</ColumnCount>
{this.renderColumns(currEntries, currColumn)}
</div>
)}
{(connect, { isHovered }) =>
connect(
<div className={cx(styles.column, { [styles.columnHovered]: isHovered })}>
<ColumnHeader name={currColumn}>{getColumnHeaderText(currColumn)}</ColumnHeader>
<ColumnCount>
{currEntries.size} {currEntries.size === 1 ? 'entry' : 'entries'}
</ColumnCount>
{this.renderColumns(currEntries, currColumn)}
</div>,
)
}
</DropTarget>
));
}
return (
<div>
{
entries.map((entry) => {
const timestamp = moment(entry.getIn(['metaData', 'timeStamp'])).format('MMMM D');
const editLink = `collections/${ entry.getIn(['metaData', 'collection']) }/entries/${ entry.get('slug') }`;
const slug = entry.get('slug');
const ownStatus = entry.getIn(['metaData', 'status']);
const collection = entry.getIn(['metaData', 'collection']);
const isModification = entry.get('isModification');
const canPublish = ownStatus === status.last() && !entry.get('isPersisting', false);
return (
<DragSource
namespace={DNDNamespace}
key={slug}
slug={slug}
collection={collection}
ownStatus={ownStatus}
>
{connect => connect(
<div>
<WorkflowCard
collectionName={collection}
title={entry.getIn(['data', 'title'])}
authorLastChange={entry.getIn(['metaData', 'user'])}
body={entry.getIn(['data', 'body'])}
isModification={isModification}
editLink={editLink}
timestamp={timestamp}
onDelete={this.requestDelete.bind(this, collection, slug, ownStatus)}
canPublish={canPublish}
onPublish={this.requestPublish.bind(this, collection, slug, ownStatus)}
/>
</div>
)}
</DragSource>
);
})
}
{entries.map(entry => {
const timestamp = moment(entry.getIn(['metaData', 'timeStamp'])).format('MMMM D');
const editLink = `collections/${entry.getIn([
'metaData',
'collection',
])}/entries/${entry.get('slug')}`;
const slug = entry.get('slug');
const ownStatus = entry.getIn(['metaData', 'status']);
const collection = entry.getIn(['metaData', 'collection']);
const isModification = entry.get('isModification');
const canPublish = ownStatus === status.last() && !entry.get('isPersisting', false);
return (
<DragSource
namespace={DNDNamespace}
key={slug}
slug={slug}
collection={collection}
ownStatus={ownStatus}
>
{connect =>
connect(
<div>
<WorkflowCard
collectionName={collection}
title={entry.getIn(['data', 'title'])}
authorLastChange={entry.getIn(['metaData', 'user'])}
body={entry.getIn(['data', 'body'])}
isModification={isModification}
editLink={editLink}
timestamp={timestamp}
onDelete={this.requestDelete.bind(this, collection, slug, ownStatus)}
canPublish={canPublish}
onPublish={this.requestPublish.bind(this, collection, slug, ownStatus)}
/>
</div>,
)
}
</DragSource>
);
})}
</div>
);
};
render() {
const columns = this.renderColumns(this.props.entries);
return (
<WorkflowListContainer>{columns}</WorkflowListContainer>
);
return <WorkflowListContainer>{columns}</WorkflowListContainer>;
}
}

View File

@ -6,8 +6,8 @@ describe('config', () => {
* log test failures and associated errors as expected.
*/
beforeEach(() => {
spyOn(console, 'error')
})
jest.spyOn(console, 'error');
});
describe('validateConfig', () => {
it('should not throw if no errors', () => {
@ -15,12 +15,14 @@ describe('config', () => {
foo: 'bar',
backend: { name: 'bar' },
media_folder: 'baz',
collections: [{
name: 'posts',
label: 'Posts',
folder: '_posts',
fields: [{ name: 'title', label: 'title', widget: 'string' }],
}],
collections: [
{
name: 'posts',
label: 'Posts',
folder: '_posts',
fields: [{ name: 'title', label: 'title', widget: 'string' }],
},
],
};
expect(() => {
validateConfig(config);
@ -41,7 +43,7 @@ describe('config', () => {
it('should throw if backend name is not a string in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: { } } });
validateConfig({ foo: 'bar', backend: { name: {} } });
}).toThrowError("'backend.name' should be string");
});
@ -65,20 +67,35 @@ describe('config', () => {
it('should throw if collections not an array in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: {} });
validateConfig({
foo: 'bar',
backend: { name: 'bar' },
media_folder: 'baz',
collections: {},
});
}).toThrowError("'collections' should be array");
});
it('should throw if collections is an empty array in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [] });
validateConfig({
foo: 'bar',
backend: { name: 'bar' },
media_folder: 'baz',
collections: [],
});
}).toThrowError("'collections' should NOT have less than 1 items");
});
it('should throw if collections is an array with a single null element in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [null] });
validateConfig({
foo: 'bar',
backend: { name: 'bar' },
media_folder: 'baz',
collections: [null],
});
}).toThrowError("'collections[0]' should be object");
});
});
});
});

View File

@ -1,28 +1,24 @@
import AJV from 'ajv';
import ajvErrors from 'ajv-errors';
import {
formatExtensions,
frontmatterFormats,
extensionFormatters,
} from "Formats/formats";
import { IDENTIFIER_FIELDS } from "Constants/fieldInference";
import { formatExtensions, frontmatterFormats, extensionFormatters } from 'Formats/formats';
import { IDENTIFIER_FIELDS } from 'Constants/fieldInference';
/**
* Config for fields in both file and folder collections.
*/
const fieldsConfig = {
type: "array",
type: 'array',
minItems: 1,
items: {
// ------- Each field: -------
type: "object",
type: 'object',
properties: {
name: { type: "string" },
label: { type: "string" },
widget: { type: "string" },
required: { type: "boolean" },
name: { type: 'string' },
label: { type: 'string' },
widget: { type: 'string' },
required: { type: 'boolean' },
},
required: ["name"],
required: ['name'],
},
};
@ -32,72 +28,72 @@ const fieldsConfig = {
* where the imports get resolved asyncronously.
*/
const getConfigSchema = () => ({
type: "object",
type: 'object',
properties: {
backend: {
type: "object",
properties: { name: { type: "string", examples: ["test-repo"] } },
required: ["name"],
type: 'object',
properties: { name: { type: 'string', examples: ['test-repo'] } },
required: ['name'],
},
display_url: { type: "string", examples: ["https://example.com"] },
media_folder: { type: "string", examples: ["assets/uploads"] },
public_folder: { type: "string", examples: ["/uploads"] },
display_url: { type: 'string', examples: ['https://example.com'] },
media_folder: { type: 'string', examples: ['assets/uploads'] },
public_folder: { type: 'string', examples: ['/uploads'] },
publish_mode: {
type: "string",
enum: ["editorial_workflow"],
examples: ["editorial_workflow"],
type: 'string',
enum: ['editorial_workflow'],
examples: ['editorial_workflow'],
},
slug: {
type: "object",
type: 'object',
properties: {
encoding: { type: "string", enum: ["unicode", "ascii"] },
clean_accents: { type: "boolean" },
encoding: { type: 'string', enum: ['unicode', 'ascii'] },
clean_accents: { type: 'boolean' },
},
},
collections: {
type: "array",
type: 'array',
minItems: 1,
items: {
// ------- Each collection: -------
type: "object",
type: 'object',
properties: {
name: { type: "string" },
label: { type: "string" },
label_singular: { type: "string" },
description: { type: "string" },
folder: { type: "string" },
name: { type: 'string' },
label: { type: 'string' },
label_singular: { type: 'string' },
description: { type: 'string' },
folder: { type: 'string' },
files: {
type: "array",
type: 'array',
items: {
// ------- Each file: -------
type: "object",
type: 'object',
properties: {
name: { type: "string" },
label: { type: "string" },
label_singular: { type: "string" },
description: { type: "string" },
file: { type: "string" },
name: { type: 'string' },
label: { type: 'string' },
label_singular: { type: 'string' },
description: { type: 'string' },
file: { type: 'string' },
fields: fieldsConfig,
},
required: ["name", "label", "file", "fields"],
required: ['name', 'label', 'file', 'fields'],
},
},
slug: { type: "string" },
create: { type: "boolean" },
slug: { type: 'string' },
create: { type: 'boolean' },
editor: {
type: "object",
type: 'object',
properties: {
preview: { type: "boolean" },
preview: { type: 'boolean' },
},
},
format: { type: "string", enum: Object.keys(formatExtensions) },
extension: { type: "string" },
frontmatter_delimiter: { type: "string" },
format: { type: 'string', enum: Object.keys(formatExtensions) },
extension: { type: 'string' },
frontmatter_delimiter: { type: 'string' },
fields: fieldsConfig,
},
required: ["name", "label"],
oneOf: [{ required: ["files"] }, { required: ["folder", "fields"] }],
if: { required: ["extension"] },
required: ['name', 'label'],
oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }],
if: { required: ['extension'] },
then: {
// Cannot infer format from extension.
if: {
@ -105,14 +101,14 @@ const getConfigSchema = () => ({
extension: { enum: Object.keys(extensionFormatters) },
},
},
else: { required: ["format"] },
else: { required: ['format'] },
},
dependencies: {
frontmatter_delimiter: {
properties: {
format: { enum: frontmatterFormats },
},
required: ["format"],
required: ['format'],
},
folder: {
errorMessage: {
@ -132,7 +128,7 @@ const getConfigSchema = () => ({
},
},
},
required: ["backend", "media_folder", "collections"],
required: ['backend', 'media_folder', 'collections'],
});
class ConfigError extends Error {
@ -141,13 +137,13 @@ class ConfigError extends Error {
.map(({ message, dataPath }) => {
const dotPath = dataPath
.slice(1)
.split("/")
.split('/')
.map(seg => (seg.match(/^\d+$/) ? `[${seg}]` : `.${seg}`))
.join("")
.join('')
.slice(1);
return `${dotPath ? `'${dotPath}'` : "config"} ${message}`;
return `${dotPath ? `'${dotPath}'` : 'config'} ${message}`;
})
.join("\n");
.join('\n');
super(message, ...args);
this.errors = errors;
@ -161,7 +157,7 @@ class ConfigError extends Error {
/**
* `validateConfig` is a pure function. It does not mutate
* the config that is passed in.
* the config that is passed in.
*/
export function validateConfig(config) {
const ajv = new AJV({ allErrors: true, jsonPointers: true });

View File

@ -7,7 +7,7 @@ export const INFERABLE_FIELDS = {
type: 'string',
secondaryTypes: [],
synonyms: ['title', 'name', 'label', 'headline', 'header'],
defaultPreview: value => <h1>{ value }</h1>, // eslint-disable-line react/display-name
defaultPreview: value => <h1>{value}</h1>, // eslint-disable-line react/display-name
fallbackToFirstField: true,
showError: true,
},
@ -15,7 +15,7 @@ export const INFERABLE_FIELDS = {
type: 'string',
secondaryTypes: [],
synonyms: ['short_title', 'shortTitle', 'short'],
defaultPreview: value => <h2>{ value }</h2>, // eslint-disable-line react/display-name
defaultPreview: value => <h2>{value}</h2>, // eslint-disable-line react/display-name
fallbackToFirstField: false,
showError: false,
},
@ -23,14 +23,26 @@ export const INFERABLE_FIELDS = {
type: 'string',
secondaryTypes: [],
synonyms: ['author', 'name', 'by', 'byline', 'owner'],
defaultPreview: value => <strong>{ value }</strong>, // eslint-disable-line react/display-name
defaultPreview: value => <strong>{value}</strong>, // eslint-disable-line react/display-name
fallbackToFirstField: false,
showError: false,
},
description: {
type: 'string',
secondaryTypes: ['text', 'markdown'],
synonyms: ['shortDescription', 'short_description', 'shortdescription', 'description', 'intro', 'introduction', 'brief', 'content', 'biography', 'bio', 'summary'],
synonyms: [
'shortDescription',
'short_description',
'shortdescription',
'description',
'intro',
'introduction',
'brief',
'content',
'biography',
'bio',
'summary',
],
defaultPreview: value => value,
fallbackToFirstField: false,
showError: false,

View File

@ -1,166 +1,167 @@
import { FrontmatterInfer, frontmatterJSON, frontmatterTOML, frontmatterYAML } from '../frontmatter';
import {
FrontmatterInfer,
frontmatterJSON,
frontmatterTOML,
frontmatterYAML,
} from '../frontmatter';
jest.mock("../../valueObjects/AssetProxy.js");
jest.mock('../../valueObjects/AssetProxy.js');
describe('Frontmatter', () => {
it('should parse YAML with --- delimiters', () => {
expect(
FrontmatterInfer.fromFile('---\ntitle: YAML\ndescription: Something longer\n---\nContent')
).toEqual(
{
title: 'YAML',
description: 'Something longer',
body: 'Content',
}
);
FrontmatterInfer.fromFile('---\ntitle: YAML\ndescription: Something longer\n---\nContent'),
).toEqual({
title: 'YAML',
description: 'Something longer',
body: 'Content',
});
});
it('should parse YAML with --- delimiters when it is explicitly set as the format without a custom delimiter', () => {
expect(
frontmatterYAML().fromFile('---\ntitle: YAML\ndescription: Something longer\n---\nContent')
).toEqual(
{
title: 'YAML',
description: 'Something longer',
body: 'Content',
}
);
frontmatterYAML().fromFile('---\ntitle: YAML\ndescription: Something longer\n---\nContent'),
).toEqual({
title: 'YAML',
description: 'Something longer',
body: 'Content',
});
});
it('should parse YAML with custom delimiters when it is explicitly set as the format with a custom delimiter', () => {
expect(
frontmatterYAML("~~~").fromFile('~~~\ntitle: YAML\ndescription: Something longer\n~~~\nContent')
).toEqual(
{
title: 'YAML',
description: 'Something longer',
body: 'Content',
}
);
frontmatterYAML('~~~').fromFile(
'~~~\ntitle: YAML\ndescription: Something longer\n~~~\nContent',
),
).toEqual({
title: 'YAML',
description: 'Something longer',
body: 'Content',
});
});
it('should parse YAML with custom delimiters when it is explicitly set as the format with different custom delimiters', () => {
expect(
frontmatterYAML(["~~~", "^^^"]).fromFile('~~~\ntitle: YAML\ndescription: Something longer\n^^^\nContent')
).toEqual(
{
title: 'YAML',
description: 'Something longer',
body: 'Content',
}
);
frontmatterYAML(['~~~', '^^^']).fromFile(
'~~~\ntitle: YAML\ndescription: Something longer\n^^^\nContent',
),
).toEqual({
title: 'YAML',
description: 'Something longer',
body: 'Content',
});
});
it('should parse YAML with ---yaml delimiters', () => {
expect(
FrontmatterInfer.fromFile('---yaml\ntitle: YAML\ndescription: Something longer\n---\nContent')
).toEqual(
{
title: 'YAML',
description: 'Something longer',
body: 'Content',
}
);
FrontmatterInfer.fromFile(
'---yaml\ntitle: YAML\ndescription: Something longer\n---\nContent',
),
).toEqual({
title: 'YAML',
description: 'Something longer',
body: 'Content',
});
});
it('should overwrite any body param in the front matter', () => {
expect(
FrontmatterInfer.fromFile('---\ntitle: The Title\nbody: Something longer\n---\nContent')
).toEqual(
{
title: 'The Title',
body: 'Content',
}
);
FrontmatterInfer.fromFile('---\ntitle: The Title\nbody: Something longer\n---\nContent'),
).toEqual({
title: 'The Title',
body: 'Content',
});
});
it('should parse TOML with +++ delimiters', () => {
expect(
FrontmatterInfer.fromFile('+++\ntitle = "TOML"\ndescription = "Front matter"\n+++\nContent')
).toEqual(
{
title: 'TOML',
description: 'Front matter',
body: 'Content',
}
);
FrontmatterInfer.fromFile('+++\ntitle = "TOML"\ndescription = "Front matter"\n+++\nContent'),
).toEqual({
title: 'TOML',
description: 'Front matter',
body: 'Content',
});
});
it('should parse TOML with +++ delimiters when it is explicitly set as the format without a custom delimiter', () => {
expect(
frontmatterTOML("~~~").fromFile('~~~\ntitle = "TOML"\ndescription = "Front matter"\n~~~\nContent')
).toEqual(
{
title: 'TOML',
description: 'Front matter',
body: 'Content',
}
);
frontmatterTOML('~~~').fromFile(
'~~~\ntitle = "TOML"\ndescription = "Front matter"\n~~~\nContent',
),
).toEqual({
title: 'TOML',
description: 'Front matter',
body: 'Content',
});
});
it('should parse TOML with ---toml delimiters', () => {
expect(
FrontmatterInfer.fromFile('---toml\ntitle = "TOML"\ndescription = "Something longer"\n---\nContent')
).toEqual(
{
title: 'TOML',
description: 'Something longer',
body: 'Content',
}
);
FrontmatterInfer.fromFile(
'---toml\ntitle = "TOML"\ndescription = "Something longer"\n---\nContent',
),
).toEqual({
title: 'TOML',
description: 'Something longer',
body: 'Content',
});
});
it('should parse JSON with { } delimiters', () => {
expect(
FrontmatterInfer.fromFile('{\n"title": "The Title",\n"description": "Something longer"\n}\nContent')
).toEqual(
{
title: 'The Title',
description: 'Something longer',
body: 'Content',
}
);
FrontmatterInfer.fromFile(
'{\n"title": "The Title",\n"description": "Something longer"\n}\nContent',
),
).toEqual({
title: 'The Title',
description: 'Something longer',
body: 'Content',
});
});
it('should parse JSON with { } delimiters when it is explicitly set as the format without a custom delimiter', () => {
expect(
frontmatterJSON().fromFile('{\n"title": "The Title",\n"description": "Something longer"\n}\nContent')
).toEqual(
{
title: 'The Title',
description: 'Something longer',
body: 'Content',
}
);
frontmatterJSON().fromFile(
'{\n"title": "The Title",\n"description": "Something longer"\n}\nContent',
),
).toEqual({
title: 'The Title',
description: 'Something longer',
body: 'Content',
});
});
it('should parse JSON with { } delimiters when it is explicitly set as the format with a custom delimiter', () => {
expect(
frontmatterJSON("~~~").fromFile('~~~\n"title": "The Title",\n"description": "Something longer"\n~~~\nContent')
).toEqual(
{
title: 'The Title',
description: 'Something longer',
body: 'Content',
}
);
frontmatterJSON('~~~').fromFile(
'~~~\n"title": "The Title",\n"description": "Something longer"\n~~~\nContent',
),
).toEqual({
title: 'The Title',
description: 'Something longer',
body: 'Content',
});
});
it('should parse JSON with ---json delimiters', () => {
expect(
FrontmatterInfer.fromFile('---json\n{\n"title": "The Title",\n"description": "Something longer"\n}\n---\nContent')
).toEqual(
{
title: 'The Title',
description: 'Something longer',
body: 'Content',
}
);
FrontmatterInfer.fromFile(
'---json\n{\n"title": "The Title",\n"description": "Something longer"\n}\n---\nContent',
),
).toEqual({
title: 'The Title',
description: 'Something longer',
body: 'Content',
});
});
it('should stringify YAML with --- delimiters', () => {
expect(
FrontmatterInfer.toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'yaml'], title: 'YAML' })
FrontmatterInfer.toFile({
body: 'Some content\nOn another line',
tags: ['front matter', 'yaml'],
title: 'YAML',
}),
).toEqual(
[
'---',
@ -171,31 +172,23 @@ describe('Frontmatter', () => {
'---',
'Some content',
'On another line\n',
].join('\n')
].join('\n'),
);
});
it('should stringify YAML with missing body', () => {
expect(
FrontmatterInfer.toFile({ tags: ['front matter', 'yaml'], title: 'YAML' })
).toEqual(
[
'---',
'tags:',
' - front matter',
' - yaml',
'title: YAML',
'---',
'',
'',
].join('\n')
expect(FrontmatterInfer.toFile({ tags: ['front matter', 'yaml'], title: 'YAML' })).toEqual(
['---', 'tags:', ' - front matter', ' - yaml', 'title: YAML', '---', '', ''].join('\n'),
);
});
it('should stringify YAML with --- delimiters when it is explicitly set as the format without a custom delimiter',
() => {
it('should stringify YAML with --- delimiters when it is explicitly set as the format without a custom delimiter', () => {
expect(
frontmatterYAML().toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'yaml'], title: 'YAML' })
frontmatterYAML().toFile({
body: 'Some content\nOn another line',
tags: ['front matter', 'yaml'],
title: 'YAML',
}),
).toEqual(
[
'---',
@ -206,14 +199,17 @@ describe('Frontmatter', () => {
'---',
'Some content',
'On another line\n',
].join('\n')
);
].join('\n'),
);
});
it('should stringify YAML with --- delimiters when it is explicitly set as the format with a custom delimiter',
() => {
it('should stringify YAML with --- delimiters when it is explicitly set as the format with a custom delimiter', () => {
expect(
frontmatterYAML("~~~").toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'yaml'], title: 'YAML' })
frontmatterYAML('~~~').toFile({
body: 'Some content\nOn another line',
tags: ['front matter', 'yaml'],
title: 'YAML',
}),
).toEqual(
[
'~~~',
@ -224,14 +220,17 @@ describe('Frontmatter', () => {
'~~~',
'Some content',
'On another line\n',
].join('\n')
);
].join('\n'),
);
});
it('should stringify YAML with --- delimiters when it is explicitly set as the format with different custom delimiters',
() => {
it('should stringify YAML with --- delimiters when it is explicitly set as the format with different custom delimiters', () => {
expect(
frontmatterYAML(["~~~", "^^^"]).toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'yaml'], title: 'YAML' })
frontmatterYAML(['~~~', '^^^']).toFile({
body: 'Some content\nOn another line',
tags: ['front matter', 'yaml'],
title: 'YAML',
}),
).toEqual(
[
'~~~',
@ -242,14 +241,17 @@ describe('Frontmatter', () => {
'^^^',
'Some content',
'On another line\n',
].join('\n')
);
].join('\n'),
);
});
it('should stringify TOML with +++ delimiters when it is explicitly set as the format without a custom delimiter',
() => {
it('should stringify TOML with +++ delimiters when it is explicitly set as the format without a custom delimiter', () => {
expect(
frontmatterTOML().toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'toml'], title: 'TOML' })
frontmatterTOML().toFile({
body: 'Some content\nOn another line',
tags: ['front matter', 'toml'],
title: 'TOML',
}),
).toEqual(
[
'+++',
@ -258,14 +260,17 @@ describe('Frontmatter', () => {
'+++',
'Some content',
'On another line\n',
].join('\n')
);
].join('\n'),
);
});
it('should stringify TOML with +++ delimiters when it is explicitly set as the format with a custom delimiter',
() => {
it('should stringify TOML with +++ delimiters when it is explicitly set as the format with a custom delimiter', () => {
expect(
frontmatterTOML("~~~").toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'toml'], title: 'TOML' })
frontmatterTOML('~~~').toFile({
body: 'Some content\nOn another line',
tags: ['front matter', 'toml'],
title: 'TOML',
}),
).toEqual(
[
'~~~',
@ -274,14 +279,17 @@ describe('Frontmatter', () => {
'~~~',
'Some content',
'On another line\n',
].join('\n')
);
].join('\n'),
);
});
it('should stringify JSON with { } delimiters when it is explicitly set as the format without a custom delimiter',
() => {
it('should stringify JSON with { } delimiters when it is explicitly set as the format without a custom delimiter', () => {
expect(
frontmatterJSON().toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'json'], title: 'JSON' })
frontmatterJSON().toFile({
body: 'Some content\nOn another line',
tags: ['front matter', 'json'],
title: 'JSON',
}),
).toEqual(
[
'{',
@ -293,14 +301,17 @@ describe('Frontmatter', () => {
'}',
'Some content',
'On another line\n',
].join('\n')
);
].join('\n'),
);
});
it('should stringify JSON with { } delimiters when it is explicitly set as the format with a custom delimiter',
() => {
it('should stringify JSON with { } delimiters when it is explicitly set as the format with a custom delimiter', () => {
expect(
frontmatterJSON("~~~").toFile({ body: 'Some content\nOn another line', tags: ['front matter', 'json'], title: 'JSON' })
frontmatterJSON('~~~').toFile({
body: 'Some content\nOn another line',
tags: ['front matter', 'json'],
title: 'JSON',
}),
).toEqual(
[
'~~~',
@ -312,7 +323,7 @@ describe('Frontmatter', () => {
'~~~',
'Some content',
'On another line\n',
].join('\n')
);
].join('\n'),
);
});
});

View File

@ -2,14 +2,8 @@ import tomlFormatter from '../toml';
describe('tomlFormatter', () => {
it('should output TOML integer values without decimals', () => {
expect(
tomlFormatter.toFile({ testFloat: 123.456, testInteger: 789, title: 'TOML' })
).toEqual(
[
'testFloat = 123.456',
'testInteger = 789',
'title = "TOML"'
].join('\n')
);
expect(tomlFormatter.toFile({ testFloat: 123.456, testInteger: 789, title: 'TOML' })).toEqual(
['testFloat = 123.456', 'testInteger = 789', 'title = "TOML"'].join('\n'),
);
});
});

View File

@ -5,7 +5,7 @@ import tomlFormatter from './toml';
import jsonFormatter from './json';
import { FrontmatterInfer, frontmatterJSON, frontmatterTOML, frontmatterYAML } from './frontmatter';
export const frontmatterFormats = ['yaml-frontmatter','toml-frontmatter','json-frontmatter']
export const frontmatterFormats = ['yaml-frontmatter', 'toml-frontmatter', 'json-frontmatter'];
export const formatExtensions = {
yml: 'yml',
@ -28,21 +28,24 @@ export const extensionFormatters = {
html: FrontmatterInfer,
};
const formatByName = (name, customDelimiter) => ({
yml: yamlFormatter,
yaml: yamlFormatter,
toml: tomlFormatter,
json: jsonFormatter,
frontmatter: FrontmatterInfer,
'json-frontmatter': frontmatterJSON(customDelimiter),
'toml-frontmatter': frontmatterTOML(customDelimiter),
'yaml-frontmatter': frontmatterYAML(customDelimiter),
}[name]);
const formatByName = (name, customDelimiter) =>
({
yml: yamlFormatter,
yaml: yamlFormatter,
toml: tomlFormatter,
json: jsonFormatter,
frontmatter: FrontmatterInfer,
'json-frontmatter': frontmatterJSON(customDelimiter),
'toml-frontmatter': frontmatterTOML(customDelimiter),
'yaml-frontmatter': frontmatterYAML(customDelimiter),
}[name]);
export function resolveFormat(collectionOrEntity, entry) {
// Check for custom delimiter
const frontmatter_delimiter = collectionOrEntity.get('frontmatter_delimiter');
const customDelimiter = List.isList(frontmatter_delimiter) ? frontmatter_delimiter.toArray() : frontmatter_delimiter;
const customDelimiter = List.isList(frontmatter_delimiter)
? frontmatter_delimiter.toArray()
: frontmatter_delimiter;
// If the format is specified in the collection, use that format.
const formatSpecification = collectionOrEntity.get('format');

View File

@ -33,31 +33,32 @@ const parsers = {
parse: input => yamlFormatter.fromFile(input),
stringify: (metadata, { sortedKeys }) => yamlFormatter.toFile(metadata, sortedKeys),
},
}
};
function inferFrontmatterFormat(str) {
const firstLine = str.substr(0, str.indexOf('\n')).trim();
if ((firstLine.length > 3) && (firstLine.substr(0, 3) === "---")) {
if (firstLine.length > 3 && firstLine.substr(0, 3) === '---') {
// No need to infer, `gray-matter` will handle things like `---toml` for us.
return;
}
switch (firstLine) {
case "---":
case '---':
return getFormatOpts('yaml');
case "+++":
case '+++':
return getFormatOpts('toml');
case "{":
case '{':
return getFormatOpts('json');
default:
throw "Unrecognized front-matter format.";
throw 'Unrecognized front-matter format.';
}
}
export const getFormatOpts = format => ({
yaml: { language: "yaml", delimiters: "---" },
toml: { language: "toml", delimiters: "+++" },
json: { language: "json", delimiters: ["{", "}"] },
}[format]);
export const getFormatOpts = format =>
({
yaml: { language: 'yaml', delimiters: '---' },
toml: { language: 'toml', delimiters: '+++' },
json: { language: 'json', delimiters: ['{', '}'] },
}[format]);
class FrontmatterFormatter {
constructor(format, customDelimiter) {

View File

@ -5,5 +5,5 @@ export default {
toFile(data) {
return JSON.stringify(data, null, 2);
}
}
},
};

View File

@ -9,7 +9,7 @@ const outputReplacer = (key, value) => {
return value.format(value._f);
}
if (value instanceof AssetProxy) {
return `${ value.path }`;
return `${value.path}`;
}
if (Number.isInteger(value)) {
// Return the string representation of integers so tomlify won't render with tenths (".0")
@ -26,5 +26,5 @@ export default {
toFile(data, sortedKeys = []) {
return tomlify.toToml(data, { replace: outputReplacer, sort: sortKeys(sortedKeys) });
}
}
},
};

View File

@ -20,7 +20,7 @@ const ImageType = new yaml.Type('image', {
kind: 'scalar',
instanceOf: AssetProxy,
represent(value) {
return `${ value.path }`;
return `${value.path}`;
},
resolve(value) {
if (value === null) return false;
@ -29,7 +29,6 @@ const ImageType = new yaml.Type('image', {
},
});
const OutputSchema = new yaml.Schema({
include: yaml.DEFAULT_SAFE_SCHEMA.include,
implicit: [MomentType, ImageType].concat(yaml.DEFAULT_SAFE_SCHEMA.implicit),
@ -43,5 +42,5 @@ export default {
toFile(data, sortedKeys = []) {
return yaml.safeDump(data, { schema: OutputSchema, sortKeys: sortKeys(sortedKeys) });
}
}
},
};

View File

@ -10,14 +10,16 @@ export function resolveIntegrations(interationsConfig, getToken) {
integrationInstances = integrationInstances.set('algolia', new Algolia(providerData));
break;
case 'assetStore':
integrationInstances = integrationInstances.set('assetStore', new AssetStore(providerData, getToken));
integrationInstances = integrationInstances.set(
'assetStore',
new AssetStore(providerData, getToken),
);
break;
}
});
return integrationInstances;
}
export const getIntegrationProvider = (function() {
let integrations = null;
@ -29,4 +31,4 @@ export const getIntegrationProvider = (function() {
return integrations.get(provider);
}
};
}());
})();

View File

@ -3,14 +3,16 @@ import { createEntry } from 'ValueObjects/Entry';
import { selectEntrySlug } from 'Reducers/collections';
function getSlug(path) {
return path.split('/').pop().replace(/\.[^.]+$/, '');
return path
.split('/')
.pop()
.replace(/\.[^.]+$/, '');
}
export default class Algolia {
constructor(config) {
this.config = config;
if (config.get('applicationID') == null ||
config.get('apiKey') == null) {
if (config.get('applicationID') == null || config.get('apiKey') == null) {
throw 'The Algolia search integration needs the credentials (applicationID and apiKey) in the integration configuration.';
}
@ -18,9 +20,9 @@ export default class Algolia {
this.apiKey = config.get('apiKey');
const prefix = config.get('indexPrefix');
this.indexPrefix = prefix ? `${ prefix }-` : '';
this.indexPrefix = prefix ? `${prefix}-` : '';
this.searchURL = `https://${ this.applicationID }-dsn.algolia.net/1`;
this.searchURL = `https://${this.applicationID}-dsn.algolia.net/1`;
this.entriesCache = {
collection: null,
@ -39,7 +41,7 @@ export default class Algolia {
}
parseJsonResponse(response) {
return response.json().then((json) => {
return response.json().then(json => {
if (!response.ok) {
return Promise.reject(json);
}
@ -52,11 +54,11 @@ export default class Algolia {
const params = [];
if (options.params) {
for (const key in options.params) {
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
params.push(`${key}=${encodeURIComponent(options.params[key])}`);
}
}
if (params.length) {
path += `?${ params.join('&') }`;
path += `?${params.join('&')}`;
}
return path;
}
@ -64,7 +66,7 @@ export default class Algolia {
request(path, options = {}) {
const headers = this.requestHeaders(options.headers || {});
const url = this.urlFor(path, options);
return fetch(url, { ...options, headers }).then((response) => {
return fetch(url, { ...options, headers }).then(response => {
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
@ -75,25 +77,28 @@ export default class Algolia {
}
search(collections, searchTerm, page) {
const searchCollections = collections.map(collection => (
{ indexName: `${ this.indexPrefix }${ collection }`, params: `query=${ searchTerm }&page=${ page }` }
));
const searchCollections = collections.map(collection => ({
indexName: `${this.indexPrefix}${collection}`,
params: `query=${searchTerm}&page=${page}`,
}));
return this.request(`${ this.searchURL }/indexes/*/queries`, {
return this.request(`${this.searchURL}/indexes/*/queries`, {
method: 'POST',
body: JSON.stringify({ requests: searchCollections }),
}).then((response) => {
const entries = response.results.map((result, index) => result.hits.map((hit) => {
const slug = getSlug(hit.path);
return createEntry(collections[index], slug, hit.path, { data: hit.data, partial: true });
}));
}).then(response => {
const entries = response.results.map((result, index) =>
result.hits.map(hit => {
const slug = getSlug(hit.path);
return createEntry(collections[index], slug, hit.path, { data: hit.data, partial: true });
}),
);
return { entries: _.flatten(entries), pagination: page };
});
}
searchBy(field, collection, query) {
return this.request(`${ this.searchURL }/indexes/${ this.indexPrefix }${ collection }`, {
return this.request(`${this.searchURL}/indexes/${this.indexPrefix}${collection}`, {
params: {
restrictSearchableAttributes: field,
query,
@ -105,12 +110,18 @@ export default class Algolia {
if (this.entriesCache.collection === collection && this.entriesCache.page === page) {
return Promise.resolve({ page: this.entriesCache.page, entries: this.entriesCache.entries });
} else {
return this.request(`${ this.searchURL }/indexes/${ this.indexPrefix }${ collection.get('name') }`, {
params: { page },
}).then((response) => {
const entries = response.hits.map((hit) => {
return this.request(
`${this.searchURL}/indexes/${this.indexPrefix}${collection.get('name')}`,
{
params: { page },
},
).then(response => {
const entries = response.hits.map(hit => {
const slug = selectEntrySlug(collection, hit.path);
return createEntry(collection.get('name'), slug, hit.path, { data: hit.data, partial: true });
return createEntry(collection.get('name'), slug, hit.path, {
data: hit.data,
partial: true,
});
});
this.entriesCache = { collection, pagination: response.page, entries };
return { entries, pagination: response.page };
@ -119,9 +130,12 @@ export default class Algolia {
}
getEntry(collection, slug) {
return this.searchBy('slug', collection.get('name'), slug).then((response) => {
return this.searchBy('slug', collection.get('name'), slug).then(response => {
const entry = response.hits.filter(hit => hit.slug === slug)[0];
return createEntry(collection.get('name'), slug, entry.path, { data: entry.data, partial: true });
return createEntry(collection.get('name'), slug, entry.path, {
data: entry.data,
partial: true,
});
});
}
}

View File

@ -14,7 +14,7 @@ export default class AssetStore {
}
parseJsonResponse(response) {
return response.json().then((json) => {
return response.json().then(json => {
if (!response.ok) {
return Promise.reject(json);
}
@ -27,16 +27,15 @@ export default class AssetStore {
const params = [];
if (options.params) {
for (const key in options.params) {
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
params.push(`${key}=${encodeURIComponent(options.params[key])}`);
}
}
if (params.length) {
path += `?${ params.join('&') }`;
path += `?${params.join('&')}`;
}
return path;
}
requestHeaders(headers = {}) {
return {
...headers,
@ -44,15 +43,16 @@ export default class AssetStore {
}
confirmRequest(assetID) {
this.getToken()
.then(token => this.request(`${ this.getSignedFormURL }/${ assetID }`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ token }`,
},
body: JSON.stringify({ state: 'uploaded' }),
}));
this.getToken().then(token =>
this.request(`${this.getSignedFormURL}/${assetID}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ state: 'uploaded' }),
}),
);
}
async request(path, options = {}) {
@ -66,12 +66,15 @@ export default class AssetStore {
}
async retrieve(query, page, privateUpload) {
const params = pickBy({ search: query, page, filter: privateUpload ? 'private' : 'public' }, val => !!val);
const params = pickBy(
{ search: query, page, filter: privateUpload ? 'private' : 'public' },
val => !!val,
);
const url = addParams(this.getSignedFormURL, params);
const token = await this.getToken();
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ token }`,
Authorization: `Bearer ${token}`,
};
const response = await this.request(url, { headers });
const files = response.map(({ id, name, size, url }) => {
@ -81,21 +84,22 @@ export default class AssetStore {
}
delete(assetID) {
const url = `${ this.getSignedFormURL }/${ assetID }`
return this.getToken()
.then(token => this.request(url, {
const url = `${this.getSignedFormURL}/${assetID}`;
return this.getToken().then(token =>
this.request(url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ token }`,
Authorization: `Bearer ${token}`,
},
}));
}),
);
}
async upload(file, privateUpload = false) {
const fileData = {
name: file.name,
size: file.size
size: file.size,
};
if (file.type) {
fileData.content_type = file.type;
@ -111,7 +115,7 @@ export default class AssetStore {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ token }`,
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(fileData),
});
@ -131,8 +135,7 @@ export default class AssetStore {
const asset = { id, name, size, url, urlIsPublicPath: true };
return { success: true, url, asset };
}
catch(error) {
} catch (error) {
console.error(error);
}
}

View File

@ -4,111 +4,101 @@ import { sanitizeURI, sanitizeSlug } from '../urlHelper';
describe('sanitizeURI', () => {
// `sanitizeURI` tests from RFC 3987
it('should keep valid URI chars (letters digits _ - . ~)', () => {
expect(
sanitizeURI("This, that-one_or.the~other 123!")
).toEqual('Thisthat-one_or.the~other123');
expect(sanitizeURI('This, that-one_or.the~other 123!')).toEqual('Thisthat-one_or.the~other123');
});
it('should not remove accents', () => {
expect(
sanitizeURI("ěščřžý")
).toEqual('ěščřžý');
expect(sanitizeURI('ěščřžý')).toEqual('ěščřžý');
});
it('should keep valid non-latin chars (ucschars in RFC 3987)', () => {
expect(
sanitizeURI("日本語のタイトル")
).toEqual('日本語のタイトル');
expect(sanitizeURI('日本語のタイトル')).toEqual('日本語のタイトル');
});
it('should not keep valid non-latin chars (ucschars in RFC 3987) if set to ASCII mode', () => {
expect(
sanitizeURI("ěščřžý日本語のタイトル", { encoding: 'ascii' })
).toEqual('');
expect(sanitizeURI('ěščřžý日本語のタイトル', { encoding: 'ascii' })).toEqual('');
});
it('should not normalize Unicode strings', () => {
expect(
sanitizeURI('\u017F\u0323\u0307')
).toEqual('\u017F\u0323\u0307');
expect(
sanitizeURI('\u017F\u0323\u0307')
).not.toEqual('\u1E9B\u0323');
expect(sanitizeURI('\u017F\u0323\u0307')).toEqual('\u017F\u0323\u0307');
expect(sanitizeURI('\u017F\u0323\u0307')).not.toEqual('\u1E9B\u0323');
});
it('should allow a custom replacement character', () => {
expect(
sanitizeURI("duck\\goose.elephant", { replacement: '-' })
).toEqual('duck-goose.elephant');
expect(sanitizeURI('duck\\goose.elephant', { replacement: '-' })).toEqual(
'duck-goose.elephant',
);
});
it('should not allow an improper replacement character', () => {
expect(() => {
sanitizeURI("I! like! dollars!", { replacement: '$' });
}).toThrow();
sanitizeURI('I! like! dollars!', { replacement: '$' });
}).toThrow();
});
it('should not actually URI-encode the characters', () => {
expect(
sanitizeURI("🎉")
).toEqual('🎉');
expect(
sanitizeURI("🎉")
).not.toEqual("%F0%9F%8E%89");
expect(sanitizeURI('🎉')).toEqual('🎉');
expect(sanitizeURI('🎉')).not.toEqual('%F0%9F%8E%89');
});
});
describe('sanitizeSlug', ()=> {
describe('sanitizeSlug', () => {
it('throws an error for non-strings', () => {
expect(() => sanitizeSlug({})).toThrowError("The input slug must be a string.");
expect(() => sanitizeSlug([])).toThrowError("The input slug must be a string.");
expect(() => sanitizeSlug(false)).toThrowError("The input slug must be a string.");
expect(() => sanitizeSlug(null)).toThrowError("The input slug must be a string.");
expect(() => sanitizeSlug(11234)).toThrowError("The input slug must be a string.");
expect(() => sanitizeSlug(undefined)).toThrowError("The input slug must be a string.");
expect(() => sanitizeSlug(()=>{})).toThrowError("The input slug must be a string.");
expect(() => sanitizeSlug({})).toThrowError('The input slug must be a string.');
expect(() => sanitizeSlug([])).toThrowError('The input slug must be a string.');
expect(() => sanitizeSlug(false)).toThrowError('The input slug must be a string.');
expect(() => sanitizeSlug(null)).toThrowError('The input slug must be a string.');
expect(() => sanitizeSlug(11234)).toThrowError('The input slug must be a string.');
expect(() => sanitizeSlug(undefined)).toThrowError('The input slug must be a string.');
expect(() => sanitizeSlug(() => {})).toThrowError('The input slug must be a string.');
});
it('throws an error for non-string replacements', () => {
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: {} }))).toThrowError("`options.replacement` must be a string.");
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: [] }))).toThrowError("`options.replacement` must be a string.");
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: false }))).toThrowError("`options.replacement` must be a string.");
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: null } ))).toThrowError("`options.replacement` must be a string.");
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: 11232 }))).toThrowError("`options.replacement` must be a string.");
// do not test undefined for this variant since a default is set in the cosntructor.
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: {} }))).toThrowError(
'`options.replacement` must be a string.',
);
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: [] }))).toThrowError(
'`options.replacement` must be a string.',
);
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: false }))).toThrowError(
'`options.replacement` must be a string.',
);
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: null }))).toThrowError(
'`options.replacement` must be a string.',
);
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: 11232 }))).toThrowError(
'`options.replacement` must be a string.',
);
// do not test undefined for this variant since a default is set in the cosntructor.
//expect(() => sanitizeSlug('test', { sanitize_replacement: undefined })).toThrowError("`options.replacement` must be a string.");
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: ()=>{} }))).toThrowError("`options.replacement` must be a string.");
expect(() => sanitizeSlug('test', Map({ sanitize_replacement: () => {} }))).toThrowError(
'`options.replacement` must be a string.',
);
});
it('should keep valid URI chars (letters digits _ - . ~)', () => {
expect(
sanitizeSlug("This, that-one_or.the~other 123!")
).toEqual('This-that-one_or.the~other-123');
expect(sanitizeSlug('This, that-one_or.the~other 123!')).toEqual(
'This-that-one_or.the~other-123',
);
});
it('should remove accents with `clean_accents` set', () => {
expect(
sanitizeSlug("ěščřžý", Map({ clean_accents: true }))
).toEqual('escrzy');
expect(sanitizeSlug('ěščřžý', Map({ clean_accents: true }))).toEqual('escrzy');
});
it('should remove non-latin chars in "ascii" mode', () => {
expect(
sanitizeSlug("ěščřžý日本語のタイトル", Map({ encoding: 'ascii' }))
).toEqual('');
expect(sanitizeSlug('ěščřžý日本語のタイトル', Map({ encoding: 'ascii' }))).toEqual('');
});
it('should clean accents and strip non-latin chars in "ascii" mode with `clean_accents` set', () => {
expect(
sanitizeSlug("ěščřžý日本語のタイトル", Map({ encoding: 'ascii', clean_accents: true }))
sanitizeSlug('ěščřžý日本語のタイトル', Map({ encoding: 'ascii', clean_accents: true })),
).toEqual('escrzy');
});
it('removes double replacements', () => {
expect(sanitizeSlug('test--test')).toEqual('test-test');
expect(sanitizeSlug('test test')).toEqual('test-test');
expect(sanitizeSlug('test--test')).toEqual('test-test');
expect(sanitizeSlug('test test')).toEqual('test-test');
});
it('removes trailing replacemenets', () => {
@ -118,5 +108,4 @@ describe('sanitizeSlug', ()=> {
it('uses alternate replacements', () => {
expect(sanitizeSlug('test test ', Map({ sanitize_replacement: '_' }))).toEqual('test_test');
});
});

View File

@ -1,7 +1,7 @@
export default function consoleError(title, description) {
console.error(
`%c ⛔ ${ title }\n` + `%c${ description }\n\n`,
`%c ⛔ ${title}\n` + `%c${description}\n\n`,
'color: black; font-weight: bold; font-size: 16px; line-height: 50px;',
'color: black;'
'color: black;',
);
}

View File

@ -1,11 +1,11 @@
import { Map } from 'immutable';
import EditorComponent from 'ValueObjects/EditorComponent'
import EditorComponent from 'ValueObjects/EditorComponent';
/**
* Global Registry Object
*/
const registry = {
backends: { },
backends: {},
templates: {},
previewStyles: [],
widgets: {},
@ -29,7 +29,6 @@ export default {
getBackend,
};
/**
* Preview Styles
*
@ -43,7 +42,6 @@ export function getPreviewStyles() {
return registry.previewStyles;
}
/**
* Preview Templates
*/
@ -54,7 +52,6 @@ export function getPreviewTemplate(name) {
return registry.templates[name];
}
/**
* Editor Widgets
*/
@ -71,7 +68,6 @@ export function resolveWidget(name) {
return getWidget(name || 'string') || getWidget('unknown');
}
/**
* Markdown Editor Custom Components
*/
@ -83,7 +79,6 @@ export function getEditorComponents() {
return registry.editorComponents;
}
/**
* Widget Serializers
*/
@ -99,9 +94,11 @@ export function getWidgetValueSerializer(widgetName) {
*/
export function registerBackend(name, BackendClass) {
if (!name || !BackendClass) {
console.error("Backend parameters invalid. example: CMS.registerBackend('myBackend', BackendClass)");
console.error(
"Backend parameters invalid. example: CMS.registerBackend('myBackend', BackendClass)",
);
} else if (registry.backends[name]) {
console.error(`Backend [${ name }] already registered. Please choose a different name.`);
console.error(`Backend [${name}] already registered. Please choose a different name.`);
} else {
registry.backends[name] = {
init: (...args) => new BackendClass(...args),
@ -112,4 +109,3 @@ export function registerBackend(name, BackendClass) {
export function getBackend(name) {
return registry.backends[name];
}

View File

@ -21,7 +21,6 @@ import { getWidgetValueSerializer } from './registry';
* handlers run on persist.
*/
const runSerializer = (values, fields, method) => {
/**
* Reduce the list of fields to a map where keys are field names and values
* are field values, serializing the values of fields whose widgets have

View File

@ -1,13 +1,11 @@
export function stringToRGB(str) {
if (!str) return "000000";
if (!str) return '000000';
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const c = (hash & 0x00FFFFFF)
.toString(16)
.toUpperCase();
const c = (hash & 0x00ffffff).toString(16).toUpperCase();
return "00000".substring(0, 6 - c.length) + c;
return '00000'.substring(0, 6 - c.length) + c;
}

View File

@ -5,15 +5,15 @@ import { isString, escapeRegExp, flow, partialRight } from 'lodash';
import { Map } from 'immutable';
function getUrl(urlString, direct) {
return `${ direct ? '/#' : '' }${ urlString }`;
return `${direct ? '/#' : ''}${urlString}`;
}
export function getCollectionUrl(collectionName, direct) {
return getUrl(`/collections/${ collectionName }`, direct);
return getUrl(`/collections/${collectionName}`, direct);
}
export function getNewEntryUrl(collectionName, direct) {
return getUrl(`/collections/${ collectionName }/new`, direct);
return getUrl(`/collections/${collectionName}/new`, direct);
}
export function addParams(urlString, params) {
@ -39,18 +39,18 @@ const ucsChars = /[\xA0-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFEF}\u{10000}-\u{1
const validURIChar = char => uriChars.test(char);
const validIRIChar = char => uriChars.test(char) || ucsChars.test(char);
// `sanitizeURI` does not actually URI-encode the chars (that is the browser's and server's job), just removes the ones that are not allowed.
export function sanitizeURI(str, { replacement = "", encoding = "unicode" } = {}) {
export function sanitizeURI(str, { replacement = '', encoding = 'unicode' } = {}) {
if (!isString(str)) {
throw new Error("The input slug must be a string.");
throw new Error('The input slug must be a string.');
}
if (!isString(replacement)) {
throw new Error("`options.replacement` must be a string.");
throw new Error('`options.replacement` must be a string.');
}
let validChar;
if (encoding === "unicode") {
if (encoding === 'unicode') {
validChar = validIRIChar;
} else if (encoding === "ascii") {
} else if (encoding === 'ascii') {
validChar = validURIChar;
} else {
throw new Error('`options.encoding` must be "unicode" or "ascii".');
@ -58,12 +58,14 @@ export function sanitizeURI(str, { replacement = "", encoding = "unicode" } = {}
// Check and make sure the replacement character is actually a safe char itself.
if (!Array.from(replacement).every(validChar)) {
throw new Error("The replacement character(s) (options.replacement) is itself unsafe.");
throw new Error('The replacement character(s) (options.replacement) is itself unsafe.');
}
// `Array.from` must be used instead of `String.split` because
// `split` converts things like emojis into UTF-16 surrogate pairs.
return Array.from(str).map(char => (validChar(char) ? char : replacement)).join('');
return Array.from(str)
.map(char => (validChar(char) ? char : replacement))
.join('');
}
export function sanitizeSlug(str, options = Map()) {
@ -71,8 +73,10 @@ export function sanitizeSlug(str, options = Map()) {
const stripDiacritics = options.get('clean_accents', false);
const replacement = options.get('sanitize_replacement', '-');
if (!isString(str)) { throw new Error("The input slug must be a string."); }
if (!isString(str)) {
throw new Error('The input slug must be a string.');
}
const sanitizedSlug = flow([
...(stripDiacritics ? [diacritics.remove] : []),
partialRight(sanitizeURI, { replacement, encoding }),
@ -80,8 +84,8 @@ export function sanitizeSlug(str, options = Map()) {
])(str);
// Remove any doubled or trailing replacement characters (that were added in the sanitizers).
const doubleReplacement = new RegExp(`(?:${ escapeRegExp(replacement) })+`, 'g');
const trailingReplacment = new RegExp(`${ escapeRegExp(replacement) }$`);
const doubleReplacement = new RegExp(`(?:${escapeRegExp(replacement)})+`, 'g');
const trailingReplacment = new RegExp(`${escapeRegExp(replacement)}$`);
const normalizedSlug = sanitizedSlug
.replace(doubleReplacement, replacement)
.replace(trailingReplacment, '');

View File

@ -4,36 +4,24 @@ import auth from '../auth';
describe('auth', () => {
it('should handle an empty state', () => {
expect(
auth(undefined, {})
).toEqual(
null
);
expect(auth(undefined, {})).toEqual(null);
});
it('should handle an authentication request', () => {
expect(
auth(undefined, authenticating())
).toEqual(
Immutable.Map({ isFetching: true })
);
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' } })
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(
expect(auth(undefined, authError(new Error('Bad credentials')))).toEqual(
Immutable.Map({
error: 'Error: Bad credentials',
})
}),
);
});

View File

@ -4,24 +4,25 @@ import collections from '../collections';
describe('collections', () => {
it('should handle an empty state', () => {
expect(
collections(undefined, {})
).toEqual(
null
);
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' }],
},
],
})))
collections(
undefined,
configLoaded(
fromJS({
collections: [
{
name: 'posts',
folder: '_posts',
fields: [{ name: 'title', widget: 'string' }],
},
],
}),
),
),
).toEqual(
OrderedMap({
posts: fromJS({
@ -30,7 +31,7 @@ describe('collections', () => {
fields: [{ name: 'title', widget: 'string' }],
type: 'folder_based_collection',
}),
})
}),
);
});
});

View File

@ -4,34 +4,22 @@ import config from 'Reducers/config';
describe('config', () => {
it('should handle an empty state', () => {
expect(
config(undefined, {})
).toEqual(
Map({ isFetching: true })
);
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' })
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 })
);
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' })
expect(config(Map(), configFailed(new Error('Config could not be loaded')))).toEqual(
Map({ error: 'Error: Config could not be loaded' }),
);
});
});

View File

@ -8,25 +8,25 @@ const initialState = OrderedMap({
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 },
},
}))
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))
reducer(initialState, actions.entriesLoaded(Map({ name: 'posts' }), entries, 0)),
).toEqual(
OrderedMap(fromJS(
{
OrderedMap(
fromJS({
posts: { name: 'posts' },
entities: {
'posts.a': { slug: 'a', path: '', isFetching: false },
@ -38,8 +38,8 @@ describe('entries', () => {
ids: ['a', 'b'],
},
},
}
))
}),
),
);
});
});

View File

@ -23,12 +23,7 @@ const entry = {
describe('entryDraft reducer', () => {
describe('DRAFT_CREATE_FROM_ENTRY', () => {
it('should create draft from the entry', () => {
expect(
reducer(
initialState,
actions.createDraftFromEntry(fromJS(entry))
)
).toEqual(
expect(reducer(initialState, actions.createDraftFromEntry(fromJS(entry)))).toEqual(
fromJS({
entry: {
...entry,
@ -38,19 +33,14 @@ describe('entryDraft reducer', () => {
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
})
}),
);
});
});
describe('DRAFT_CREATE_EMPTY', () => {
it('should create a new draft ', () => {
expect(
reducer(
initialState,
actions.emptyDraftCreated(fromJS(entry))
)
).toEqual(
expect(reducer(initialState, actions.emptyDraftCreated(fromJS(entry)))).toEqual(
fromJS({
entry: {
...entry,
@ -60,15 +50,14 @@ describe('entryDraft reducer', () => {
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);
expect(reducer(initialState, actions.discardDraft())).toEqual(initialState);
});
});
@ -78,15 +67,16 @@ describe('entryDraft reducer', () => {
...entry,
raw: 'updated',
};
expect(reducer(initialState, actions.changeDraft(newEntry)))
.toEqual(fromJS({
expect(reducer(initialState, actions.changeDraft(newEntry))).toEqual(
fromJS({
entry: {
...entry,
raw: 'updated',
},
mediaFiles: [],
hasChanged: true,
}));
}),
);
});
});
@ -111,27 +101,31 @@ describe('entryDraft reducer', () => {
it('should handle persisting request', () => {
const newState = reducer(
initialState,
actions.entryPersisting(Map({ name: 'posts' }), Map({ slug: 'slug' }))
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' }))
let newState = reducer(
initialState,
actions.entryPersisting(Map({ name: 'posts' }), Map({ slug: 'slug' })),
);
newState = reducer(newState,
actions.entryPersisted(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' }))
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')
newState = reducer(
newState,
actions.entryPersistFail(Map({ name: 'posts' }), Map({ slug: 'slug' }), 'Error message'),
);
expect(newState.getIn(['entry', 'isPersisting'])).toBeUndefined();
});

View File

@ -30,16 +30,24 @@ const collections = (state = null, action) => {
const selectors = {
[FOLDER]: {
entryExtension(collection) {
return (collection.get('extension') || get(formatExtensions, (collection.get('format') || 'frontmatter'))).replace(/^\./, '');
return (
collection.get('extension') ||
get(formatExtensions, collection.get('format') || 'frontmatter')
).replace(/^\./, '');
},
fields(collection) {
return collection.get('fields');
},
entryPath(collection, slug) {
return `${ collection.get('folder').replace(/\/$/, '') }/${ slug }.${ this.entryExtension(collection) }`;
return `${collection.get('folder').replace(/\/$/, '')}/${slug}.${this.entryExtension(
collection,
)}`;
},
entrySlug(collection, path) {
return path.split('/').pop().replace(new RegExp(`\\.${ escapeRegExp(this.entryExtension(collection)) }$`), '');
return path
.split('/')
.pop()
.replace(new RegExp(`\\.${escapeRegExp(this.entryExtension(collection))}$`), '');
},
listMethod() {
return 'entriesByFolder';
@ -68,7 +76,10 @@ const selectors = {
return file && file.get('file');
},
entrySlug(collection, path) {
const file = collection.get('files').filter(f => f.get('file') === path).get(0);
const file = collection
.get('files')
.filter(f => f.get('file') === path)
.get(0);
return file && file.get('name');
},
listMethod() {
@ -86,14 +97,21 @@ const selectors = {
},
};
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 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 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));
@ -107,12 +125,16 @@ export const selectInferedField = (collection, fieldName) => {
// 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'));
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'));
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();
@ -122,8 +144,10 @@ export const selectInferedField = (collection, fieldName) => {
// 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.`
`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.`,
);
}

View File

@ -4,8 +4,10 @@ import { reducer as notifReducer } from 'redux-notifications';
import optimist from 'redux-optimist';
import reducers from '.';
export default optimist(combineReducers({
...reducers,
notifs: notifReducer,
routing: routerReducer,
}));
export default optimist(
combineReducers({
...reducers,
notifs: notifReducer,
routing: routerReducer,
}),
);

View File

@ -1,21 +1,19 @@
import { fromJS } from 'immutable';
import { Cursor } from 'netlify-cms-lib-util';
import {
ENTRIES_SUCCESS,
} from 'Actions/entries';
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]));
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
['cursorsByType', 'collectionEntries', action.payload.collection],
Cursor.create(action.payload.cursor).store,
);
}

View File

@ -29,77 +29,115 @@ const unpublishedEntries = (state = Map(), action) => {
return state;
}
case UNPUBLISHED_ENTRY_REQUEST:
return state.setIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`, 'isFetching'], true);
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 }`]);
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)
['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)),
}));
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);
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']);
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);
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);
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);
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 }`]);
map.deleteIn(['entities', `${action.payload.collection}.${action.payload.slug}`]);
});
case UNPUBLISHED_ENTRY_DELETE_SUCCESS:
return state.deleteIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`]);
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 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();
return state
.get('entities')
.filter(entry => entry.getIn(['metaData', 'status']) === status)
.valueSeq();
};
export default unpublishedEntries;

View File

@ -19,12 +19,15 @@ 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);
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)
['entities', `${action.payload.collection}.${action.payload.entry.slug}`],
fromJS(action.payload.entry),
);
case ENTRIES_REQUEST:
@ -35,42 +38,56 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
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))
));
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,
}));
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);
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))
));
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));
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:
@ -78,9 +95,8 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
}
};
export const selectEntry = (state, collection, slug) => (
state.getIn(['entities', `${ collection }.${ slug }`])
);
export const selectEntry = (state, collection, slug) =>
state.getIn(['entities', `${collection}.${slug}`]);
export const selectEntries = (state, collection) => {
const slugs = state.getIn(['pages', collection, 'ids']);

View File

@ -15,10 +15,7 @@ import {
UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
UNPUBLISHED_ENTRY_PERSIST_FAILURE,
} from 'Actions/editorialWorkflow';
import {
ADD_ASSET,
REMOVE_ASSET,
} from 'Actions/media';
import { ADD_ASSET, REMOVE_ASSET } from 'Actions/media';
const initialState = Map({
entry: Map(),
@ -32,7 +29,7 @@ const entryDraftReducer = (state = Map(), action) => {
switch (action.type) {
case DRAFT_CREATE_FROM_ENTRY:
// Existing Entry
return state.withMutations((state) => {
return state.withMutations(state => {
state.set('entry', action.payload.entry);
state.setIn(['entry', 'newRecord'], false);
state.set('mediaFiles', List());
@ -45,7 +42,7 @@ const entryDraftReducer = (state = Map(), action) => {
});
case DRAFT_CREATE_EMPTY:
// New Entry
return state.withMutations((state) => {
return state.withMutations(state => {
state.set('entry', fromJS(action.payload));
state.setIn(['entry', 'newRecord'], true);
state.set('mediaFiles', List());
@ -56,7 +53,7 @@ const entryDraftReducer = (state = Map(), action) => {
case DRAFT_DISCARD:
return initialState;
case DRAFT_CHANGE_FIELD:
return state.withMutations((state) => {
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);
@ -81,7 +78,7 @@ const entryDraftReducer = (state = Map(), action) => {
case ENTRY_PERSIST_SUCCESS:
case UNPUBLISHED_ENTRY_PERSIST_SUCCESS:
return state.withMutations((state) => {
return state.withMutations(state => {
state.deleteIn(['entry', 'isPersisting']);
state.set('hasChanged', false);
if (!state.getIn(['entry', 'slug'])) {
@ -90,7 +87,7 @@ const entryDraftReducer = (state = Map(), action) => {
});
case ENTRY_DELETE_SUCCESS:
return state.withMutations((state) => {
return state.withMutations(state => {
state.deleteIn(['entry', 'isPersisting']);
state.set('hasChanged', false);
});

View File

@ -4,12 +4,9 @@ import { Map } from 'immutable';
* */
const globalUI = (state = Map({ isFetching: false }), action) => {
// Generic, global loading indicator
if ((action.type.indexOf('REQUEST') > -1)) {
if (action.type.indexOf('REQUEST') > -1) {
return state.set('isFetching', true);
} else if (
(action.type.indexOf('SUCCESS') > -1) ||
(action.type.indexOf('FAILURE') > -1)
) {
} else if (action.type.indexOf('SUCCESS') > -1 || action.type.indexOf('FAILURE') > -1) {
return state.set('isFetching', false);
}
return state;

View File

@ -37,9 +37,14 @@ export const selectEntry = (state, collection, slug) =>
export const selectEntries = (state, collection) =>
fromEntries.selectEntries(state.entries, collection);
export const selectSearchedEntries = (state) => {
export const selectSearchedEntries = state => {
const searchItems = state.search.get('entryIds');
return searchItems && searchItems.map(({ collection, slug }) => fromEntries.selectEntry(state.entries, collection, slug));
return (
searchItems &&
searchItems.map(({ collection, slug }) =>
fromEntries.selectEntry(state.entries, collection, slug),
)
);
};
export const selectUnpublishedEntry = (state, collection, slug) =>

View File

@ -5,23 +5,31 @@ 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;
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;
}
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: {} });
},
{ providers: {}, hooks: {} },
);
return fromJS(newState);
}
default:
@ -29,9 +37,9 @@ const integrations = (state = null, action) => {
}
};
export const selectIntegration = (state, collection, hook) => (
collection? state.getIn(['hooks', collection, hook], false) : state.getIn(['hooks', hook], false)
);
export const selectIntegration = (state, collection, hook) =>
collection
? state.getIn(['hooks', collection, hook], false)
: state.getIn(['hooks', hook], false);
export default integrations;

View File

@ -18,7 +18,8 @@ import {
} from 'Actions/mediaLibrary';
const mediaLibrary = (state = Map({ isVisible: false, controlMedia: Map() }), action) => {
const privateUploadChanged = state.get('privateUpload') !== get(action, ['payload', 'privateUpload']);
const privateUploadChanged =
state.get('privateUpload') !== get(action, ['payload', 'privateUpload']);
switch (action.type) {
case MEDIA_LIBRARY_OPEN: {
const { controlID, forImage, privateUpload } = action.payload || {};

View File

@ -13,7 +13,13 @@ let response;
let page;
let searchTerm;
const defaultState = Map({ isFetching: false, term: null, page: 0, entryIds: List([]), queryHits: Map({}) });
const defaultState = Map({
isFetching: false,
term: null,
page: 0,
entryIds: List([]),
queryHits: Map({}),
});
const entries = (state = defaultState, action) => {
switch (action.type) {
@ -22,7 +28,7 @@ const entries = (state = defaultState, action) => {
case SEARCH_ENTRIES_REQUEST:
if (action.payload.searchTerm !== state.get('term')) {
return state.withMutations((map) => {
return state.withMutations(map => {
map.set('isFetching', true);
map.set('term', action.payload.searchTerm);
});
@ -33,20 +39,27 @@ const entries = (state = defaultState, action) => {
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 })));
return state.withMutations(map => {
const entryIds = List(
loadedEntries.map(entry => ({ collection: entry.collection, slug: entry.slug })),
);
map.set('isFetching', false);
map.set('fetchID', null);
map.set('page', page);
map.set('term', searchTerm);
map.set('entryIds', (!page || isNaN(page) || page === 0) ? entryIds : map.get('entryIds', List()).concat(entryIds));
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) => {
return state.withMutations(map => {
map.set('isFetching', action.payload.namespace ? true : false);
map.set('fetchID', action.payload.namespace)
map.set('fetchID', action.payload.namespace);
map.set('term', action.payload.searchTerm);
});
}
@ -55,7 +68,7 @@ const entries = (state = defaultState, action) => {
case QUERY_SUCCESS:
searchTerm = action.payload.searchTerm;
response = action.payload.response;
return state.withMutations((map) => {
return state.withMutations(map => {
map.set('isFetching', false);
map.set('fetchID', null);
map.set('term', searchTerm);

View File

@ -4,10 +4,14 @@ import waitUntilAction from './middleware/waitUntilAction';
import reducer from 'Reducers/combinedReducer';
export default function configureStore(initialState) {
const store = createStore(reducer, initialState, compose(
applyMiddleware(thunkMiddleware, waitUntilAction),
window.devToolsExtension ? window.devToolsExtension() : f => f
));
const store = createStore(
reducer,
initialState,
compose(
applyMiddleware(thunkMiddleware, waitUntilAction),
window.devToolsExtension ? window.devToolsExtension() : f => f,
),
);
return store;
}

View File

@ -1,8 +1,8 @@
// Based on wait-service by Mozilla:
// Based on wait-service by Mozilla:
// https://github.com/mozilla/gecko-dev/blob/master/devtools/client/shared/redux/middleware/wait-service.js
/**
* A middleware that provides the ability for actions to install a
* A middleware that provides the ability for actions to install a
* function to be run once when a specific condition is met by an
* action coming through the system. Think of it as a thunk that
* blocks until the condition is met.
@ -35,7 +35,7 @@ export default function waitUntilAction({ dispatch, getState }) {
}
}
return next => (action) => {
return next => action => {
if (action.type === WAIT_UNTIL_ACTION) {
pending.push(action);
return null;

View File

@ -4,7 +4,7 @@ import { getIntegrationProvider } from 'Integrations';
import { selectIntegration } from 'Reducers';
let store;
export const setStore = (storeObj) => {
export const setStore = storeObj => {
store = storeObj;
};
@ -14,12 +14,15 @@ export default function AssetProxy(value, fileObj, uploaded = false, asset) {
this.fileObj = fileObj;
this.uploaded = uploaded;
this.sha = null;
this.path = config.get('media_folder') && !uploaded ? resolvePath(value, config.get('media_folder')) : value;
this.path =
config.get('media_folder') && !uploaded
? resolvePath(value, config.get('media_folder'))
: value;
this.public_path = !uploaded ? resolvePath(value, config.get('public_folder')) : value;
this.asset = asset;
}
AssetProxy.prototype.toString = function () {
AssetProxy.prototype.toString = function() {
// Use the deployed image path if we do not have a locally cached copy.
if (this.uploaded && !this.fileObj) return this.public_path;
try {
@ -29,10 +32,10 @@ AssetProxy.prototype.toString = function () {
}
};
AssetProxy.prototype.toBase64 = function () {
AssetProxy.prototype.toBase64 = function() {
return new Promise(resolve => {
const fr = new FileReader();
fr.onload = (readerEvt) => {
fr.onload = readerEvt => {
const binaryString = readerEvt.target.result;
resolve(binaryString.split('base64,')[1]);
@ -45,16 +48,23 @@ export function createAssetProxy(value, fileObj, uploaded = false, privateUpload
const state = store.getState();
const integration = selectIntegration(state, null, 'assetStore');
if (integration && !uploaded) {
const provider = integration && getIntegrationProvider(state.integrations, currentBackend(state.config).getToken, integration);
return provider.upload(fileObj, privateUpload).then(
response => (
new AssetProxy(response.asset.url.replace(/^(https?):/, ''), null, true, response.asset)
),
() => new AssetProxy(value, fileObj, false)
);
const provider =
integration &&
getIntegrationProvider(
state.integrations,
currentBackend(state.config).getToken,
integration,
);
return provider
.upload(fileObj, privateUpload)
.then(
response =>
new AssetProxy(response.asset.url.replace(/^(https?):/, ''), null, true, response.asset),
() => new AssetProxy(value, fileObj, false),
);
} else if (privateUpload) {
throw new Error('The Private Upload option is only avaible for Asset Store Integration');
}
return Promise.resolve(new AssetProxy(value, fileObj, uploaded));
}

View File

@ -9,24 +9,31 @@ const EditorComponent = Record({
icon: 'exclamation-triangle',
fields: [],
pattern: catchesNothing,
fromBlock(match) { return {}; },
toBlock(attributes) { return 'Plugin'; },
toPreview(attributes) { return 'Plugin'; },
fromBlock(match) {
return {};
},
toBlock(attributes) {
return 'Plugin';
},
toPreview(attributes) {
return 'Plugin';
},
});
/* eslint-enable */
export default function createEditorComponent(config) {
const configObj = new EditorComponent({
id: config.id || config.label.replace(/[^A-Z0-9]+/ig, '_'),
id: config.id || config.label.replace(/[^A-Z0-9]+/gi, '_'),
label: config.label,
icon: config.icon,
fields: fromJS(config.fields),
pattern: config.pattern,
fromBlock: isFunction(config.fromBlock) ? config.fromBlock.bind(null) : null,
toBlock: isFunction(config.toBlock) ? config.toBlock.bind(null) : null,
toPreview: isFunction(config.toPreview) ? config.toPreview.bind(null) : config.toBlock.bind(null),
toPreview: isFunction(config.toPreview)
? config.toPreview.bind(null)
: config.toBlock.bind(null),
});
return configObj;
}

View File

@ -1,4 +1,4 @@
import { isBoolean } from "lodash";
import { isBoolean } from 'lodash';
export function createEntry(collection, slug = '', path = '', options = {}) {
const returnObj = {};
@ -10,8 +10,6 @@ export function createEntry(collection, slug = '', path = '', options = {}) {
returnObj.data = options.data || {};
returnObj.label = options.label || null;
returnObj.metaData = options.metaData || null;
returnObj.isModification = isBoolean(options.isModification)
? options.isModification
: null;
returnObj.isModification = isBoolean(options.isModification) ? options.isModification : null;
return returnObj;
}

View File

@ -14,8 +14,8 @@ module.exports = {
module: {
rules: [
...Object.entries(rules)
.filter(([ key ]) => key !== 'js')
.map(([ , rule ]) => rule()),
.filter(([key]) => key !== 'js')
.map(([, rule]) => rule()),
{
test: /\.js$/,
exclude: /node_modules/,