Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

allow manual initialization and config as code #1149

Merged
merged 2 commits into from
Mar 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
"setupFiles": [
"./setupTests.js"
],
"mapCoverage": true,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer required in Jest 22.

"coverageReporters": [
"lcov"
],
Expand Down Expand Up @@ -75,7 +74,7 @@
"babel": "^6.5.2",
"babel-cli": "^6.18.0",
"babel-core": "^6.23.1",
"babel-jest": "^21.2.0",
"babel-jest": "^22.0.0",
"babel-loader": "^7.0.0",
"babel-plugin-lodash": "^3.2.0",
"babel-plugin-module-resolver": "^3.0.0",
Expand All @@ -102,8 +101,8 @@
"file-loader": "^1.1.4",
"identity-obj-proxy": "^3.0.0",
"imports-loader": "^0.7.1",
"jest": "^21.2.1",
"jest-cli": "^21.2.1",
"jest": "^22.0.0",
"jest-cli": "^22.0.0",
"lint-staged": "^3.3.1",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jest updated for fix of toEqual requiring matching order when comparing maps.

"npm-check": "^5.2.3",
"postcss-cssnext": "^3.0.2",
Expand Down
55 changes: 29 additions & 26 deletions src/actions/__tests__/config.spec.js
Original file line number Diff line number Diff line change
@@ -1,117 +1,120 @@
import { fromJS } from 'immutable';
Copy link
Contributor Author

@erquhart erquhart Mar 1, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Causes for change in this file:

  • Config objects are now converted to immutable maps before processing, so functions like applyDefaults now expect a map.
  • The isFetching config state value is now true by default.

import { applyDefaults, validateConfig } from '../config';

describe('config', () => {
describe('applyDefaults', () => {
it('should set publish_mode if not set', () => {
expect(applyDefaults({
const config = fromJS({
foo: 'bar',
media_folder: 'path/to/media',
})).toEqual({
foo: 'bar',
publish_mode: 'simple',
media_folder: 'path/to/media',
public_folder: '/path/to/media',
});
expect(
applyDefaults(config)
).toEqual(
config.set('publish_mode', 'simple')
);
});

it('should set publish_mode from config', () => {
expect(applyDefaults({
foo: 'bar',
publish_mode: 'complex',
media_folder: 'path/to/media',
})).toEqual({
const config = fromJS({
foo: 'bar',
publish_mode: 'complex',
media_folder: 'path/to/media',
public_folder: '/path/to/media',
});
expect(
applyDefaults(config)
).toEqual(
config
);
});

it('should set public_folder based on media_folder if not set', () => {
expect(applyDefaults({
expect(applyDefaults(fromJS({
foo: 'bar',
media_folder: 'path/to/media',
})).toEqual({
}))).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({
expect(applyDefaults(fromJS({
foo: 'bar',
media_folder: 'path/to/media',
public_folder: '/publib/path',
})).toEqual({
}))).toEqual(fromJS({
foo: 'bar',
publish_mode: 'simple',
media_folder: 'path/to/media',
public_folder: '/publib/path',
});
}));
});
});

describe('validateConfig', () => {
it('should return the config if no errors', () => {
const config = { foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [{}] };
const config = fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [{}] });
expect(
validateConfig(config)
).toEqual(config);
});

it('should throw if backend is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar' });
validateConfig(fromJS({ foo: 'bar' }));
}).toThrowError('Error in configuration file: A `backend` wasn\'t found. Check your config.yml file.');
});

it('should throw if backend name is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: {} });
validateConfig(fromJS({ foo: 'bar', backend: {} }));
}).toThrowError('Error in configuration file: A `backend.name` wasn\'t found. Check your config.yml file.');
});

it('should throw if backend name is not a string in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: { } } });
validateConfig(fromJS({ foo: 'bar', backend: { name: { } } }));
}).toThrowError('Error in configuration file: Your `backend.name` must be a string. Check your config.yml file.');
});

it('should throw if media_folder is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' } });
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' } }));
}).toThrowError('Error in configuration file: A `media_folder` wasn\'t found. Check your config.yml file.');
});

it('should throw if media_folder is not a string in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: {} });
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: {} }));
}).toThrowError('Error in configuration file: Your `media_folder` must be a string. Check your config.yml file.');
});

it('should throw if collections is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz' });
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz' }));
}).toThrowError('Error in configuration file: A `collections` wasn\'t found. Check your config.yml file.');
});

it('should throw if collections not an array in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: {} });
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: {} }));
}).toThrowError('Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.');
});

it('should throw if collections is an empty array in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [] });
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [] }));
}).toThrowError('Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.');
});

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(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [null] }));
}).toThrowError('Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.');
});
});
Expand Down
80 changes: 52 additions & 28 deletions src/actions/config.js
Original file line number Diff line number Diff line change
@@ -1,50 +1,63 @@
import yaml from "js-yaml";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Causes for change in this file:

  • Config objects are now converted to immutable maps before processing.
  • We must now differentiate between merging a config value into state and loading config state into the application, since state values could be merged in an arbitrary number of times before the final configuration state is ready.

import { set, defaultsDeep, get } from "lodash";
import { Map, List, fromJS } from "immutable";
import { trimStart, flow } from "lodash";
import { authenticateUser } from "Actions/auth";
import * as publishModes from "Constants/publishModes";

export const CONFIG_REQUEST = "CONFIG_REQUEST";
export const CONFIG_SUCCESS = "CONFIG_SUCCESS";
export const CONFIG_FAILURE = "CONFIG_FAILURE";
export const CONFIG_MERGE = "CONFIG_MERGE";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allows configuration to be merged into the state without triggering CONFIG_SUCCESS, which should only occur after an intentional configuration retrieval cycle.


const defaults = {
publish_mode: publishModes.SIMPLE,
};

export function applyDefaults(config) {
// Make sure there is a public folder
set(defaults,
"public_folder",
config.media_folder.charAt(0) === "/" ? config.media_folder : `/${ config.media_folder }`);

return defaultsDeep(config, defaults);
return Map(defaults)
.mergeDeep(config)
.withMutations(map => {
/**
* Use media_folder as default public_folder.
*/
const defaultPublicFolder = `/${trimStart(map.get('media_folder'), '/')}`;
if (!map.get('public_folder')) {
map.set('public_folder', defaultPublicFolder);
}
});
}

export function validateConfig(config) {
if (!get(config, 'backend')) {
if (!config.get('backend')) {
throw new Error("Error in configuration file: A `backend` wasn't found. Check your config.yml file.");
}
if (!get(config, ['backend', 'name'])) {
if (!config.getIn(['backend', 'name'])) {
throw new Error("Error in configuration file: A `backend.name` wasn't found. Check your config.yml file.");
}
if (typeof config.backend.name !== 'string') {
if (typeof config.getIn(['backend', 'name']) !== 'string') {
throw new Error("Error in configuration file: Your `backend.name` must be a string. Check your config.yml file.");
}
if (!get(config, 'media_folder')) {
if (!config.get('media_folder')) {
throw new Error("Error in configuration file: A `media_folder` wasn\'t found. Check your config.yml file.");
}
if (typeof config.media_folder !== 'string') {
if (typeof config.get('media_folder') !== 'string') {
throw new Error("Error in configuration file: Your `media_folder` must be a string. Check your config.yml file.");
}
if (!get(config, 'collections')) {
if (!config.get('collections')) {
throw new Error("Error in configuration file: A `collections` wasn\'t found. Check your config.yml file.");
}
if (!Array.isArray(config.collections) || config.collections.length === 0 || !config.collections[0]) {
const collections = config.get('collections');
if (!List.isList(collections) || collections.isEmpty() || !collections.first()) {
throw new Error("Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.");
}
return config;
}

function mergePreloadedConfig(preloadedConfig, loadedConfig) {
const map = fromJS(loadedConfig) || Map();
return preloadedConfig ? preloadedConfig.mergeDeep(map) : map;
}

function parseConfig(data) {
const config = yaml.safeLoad(data);
if (typeof CMS_ENV === "string" && config[CMS_ENV]) {
Expand Down Expand Up @@ -82,29 +95,40 @@ export function configDidLoad(config) {
};
}

export function mergeConfig(config) {
return { type: CONFIG_MERGE, payload: config };
}

export function loadConfig() {
if (window.CMS_CONFIG) {
return configDidLoad(window.CMS_CONFIG);
return configDidLoad(fromJS(window.CMS_CONFIG));
}
return (dispatch) => {
return async (dispatch, getState) => {
dispatch(configLoading());

fetch("config.yml", { credentials: 'same-origin' })
.then((response) => {
if (response.status !== 200) {
try {
const preloadedConfig = getState().config;
const response = await fetch('config.yml', { credentials: 'same-origin' })
const requestSuccess = response.status === 200;

if (!preloadedConfig && !requestSuccess) {
throw new Error(`Failed to load config.yml (${ response.status })`);
}
return response.text();
})
.then(parseConfig)
.then(validateConfig)
.then(applyDefaults)
.then((config) => {

const loadedConfig = parseConfig(requestSuccess ? await response.text() : '');

/**
* Merge any existing configuration so the result can be validated.
*/
const mergedConfig = mergePreloadedConfig(preloadedConfig, loadedConfig)
const config = flow(validateConfig, applyDefaults)(mergedConfig);

dispatch(configDidLoad(config));
dispatch(authenticateUser());
})
.catch((err) => {
}
catch(err) {
dispatch(configFailed(err));
});
throw(err)
}
};
}
67 changes: 67 additions & 0 deletions src/bootstrap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is simply a paste of the core application initialization code that used to be in index.js. It was created to be shared between index.js for auto init, and init.js for manual init.

import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { Route } from 'react-router-dom';
import { ConnectedRouter } from 'react-router-redux';
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 App from 'App/App';
import 'EditorWidgets';
import 'MarkdownPlugins';
import './index.css';

function bootstrap({ config }) {
/**
* Log the version number.
*/
console.log(`Netlify CMS version ${NETLIFY_CMS_VERSION}`);

/**
* Create mount element dynamically.
*/
const el = document.createElement('div');
el.id = 'nc-root';
document.body.appendChild(el);

/**
* Configure Redux store.
*/
const store = configureStore();

/**
* Dispatch config to store if received. This config will be merged into
* config.yml if it exists, and any portion that produces a conflict will be
* overwritten.
*/
if (config) {
store.dispatch(mergeConfig(config));
}

/**
* Pass initial state into AssetProxy factory.
*/
setStore(store);

/**
* Create connected root component.
*/
const Root = () => (
<ErrorBoundary>
<Provider store={store}>
<ConnectedRouter history={history}>
<Route component={App}/>
</ConnectedRouter>
</Provider>
</ErrorBoundary>
);

/**
* Render application root.
*/
render(<Root />, el);
}

export default bootstrap;
2 changes: 1 addition & 1 deletion src/components/App/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class App extends React.Component {
<div>
<p>The <code>config.yml</code> file could not be loaded or failed to parse properly.</p>
<p><strong>Error message:</strong> {config.get('error')}</p>
<p>Check your console for details.</p>
</div>
</div>);
}
Expand Down Expand Up @@ -105,7 +106,6 @@ class App extends React.Component {
openMediaLibrary,
} = this.props;


if (config === null) {
return null;
}
Expand Down
Loading