chore: add code formatting and linting (#952)
This commit is contained in:
@ -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/'),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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));
|
||||
});
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -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)));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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)),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
};
|
||||
}());
|
||||
})();
|
||||
|
4
packages/netlify-cms-core/src/bootstrap.js
vendored
4
packages/netlify-cms-core/src/bootstrap.js
vendored
@ -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>
|
||||
|
@ -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),
|
||||
);
|
||||
|
@ -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}
|
||||
|
@ -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};
|
||||
`;
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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)}
|
||||
|
@ -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));
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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 };
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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}>
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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 });
|
||||
|
@ -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,
|
||||
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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');
|
||||
|
@ -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) {
|
||||
|
@ -5,5 +5,5 @@ export default {
|
||||
|
||||
toFile(data) {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -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) });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -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) });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
}());
|
||||
})();
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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;',
|
||||
);
|
||||
}
|
||||
|
@ -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];
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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, '');
|
||||
|
@ -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',
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -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',
|
||||
}),
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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'],
|
||||
},
|
||||
},
|
||||
}
|
||||
))
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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']);
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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) =>
|
||||
|
@ -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;
|
||||
|
@ -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 || {};
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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/,
|
||||
|
Reference in New Issue
Block a user