diff --git a/.gitignore b/.gitignore
index 3ce22d3a4..57479a462 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ npm-debug.log
css/GridField_print.css
admin/thirdparty/chosen/node_modules
node_modules/
+coverage/
diff --git a/admin/code/CampaignAdmin.php b/admin/code/CampaignAdmin.php
new file mode 100644
index 000000000..dae35a644
--- /dev/null
+++ b/admin/code/CampaignAdmin.php
@@ -0,0 +1,102 @@
+ 'createCampaign',
+ 'GET item/$ID' => 'readCampaign',
+ 'PUT item/$ID' => 'updateCampaign',
+ 'DELETE item/$ID' => 'deleteCampaign',
+ ];
+
+ private static $url_segment = 'campaigns';
+
+ public function init() {
+ parent::init();
+
+ Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/javascript/dist/bundle-react.js');
+ Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/javascript/dist/campaign-admin.js');
+ }
+
+ public function getEditForm($id = null, $fields = null) {
+ return '';
+ }
+
+ /**
+ * REST endpoint to create a campaign.
+ *
+ * @param SS_HTTPRequest $request
+ *
+ * @return SS_HTTPResponse
+ */
+ public function createCampaign(SS_HTTPRequest $request) {
+ $response = new SS_HTTPResponse();
+ $response->addHeader('Content-Type', 'application/json');
+ $response->setBody(Convert::raw2json(['campaign' => 'create']));
+
+ return $response;
+ }
+
+ /**
+ * REST endpoint to get a campaign.
+ *
+ * @param SS_HTTPRequest $request
+ *
+ * @return SS_HTTPResponse
+ */
+ public function readCampaign(SS_HTTPRequest $request) {
+ $response = new SS_HTTPResponse();
+ $response->addHeader('Content-Type', 'application/json');
+ $response->setBody(Convert::raw2json(['campaign' => 'read']));
+
+ return $response;
+ }
+
+ /**
+ * REST endpoint to update a campaign.
+ *
+ * @param SS_HTTPRequest $request
+ *
+ * @return SS_HTTPResponse
+ */
+ public function updateCampaign(SS_HTTPRequest $request) {
+ $response = new SS_HTTPResponse();
+ $response->addHeader('Content-Type', 'application/json');
+ $response->setBody(Convert::raw2json(['campaign' => 'update']));
+
+ return $response;
+ }
+
+ /**
+ * REST endpoint to delete a campaign.
+ *
+ * @param SS_HTTPRequest $request
+ *
+ * @return SS_HTTPResponse
+ */
+ public function deleteCampaign(SS_HTTPRequest $request) {
+ $response = new SS_HTTPResponse();
+ $response->addHeader('Content-Type', 'application/json');
+ $response->setBody(Convert::raw2json(['campaign' => 'delete']));
+
+ return $response;
+ }
+
+}
diff --git a/admin/code/LeftAndMain.php b/admin/code/LeftAndMain.php
index 2dfd69c80..15818f604 100644
--- a/admin/code/LeftAndMain.php
+++ b/admin/code/LeftAndMain.php
@@ -481,6 +481,8 @@ class LeftAndMain extends Controller implements PermissionProvider {
Requirements::javascript(THIRDPARTY_DIR . '/jquery-entwine/src/jquery.entwine.inspector.js');
Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/javascript/dist/leaktools.js');
}
+
+ Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/javascript/dist/boot.js');
Requirements::css(FRAMEWORK_ADMIN_DIR . '/css/bootstrap/bootstrap.css');
Requirements::css(FRAMEWORK_ADMIN_DIR . '/thirdparty/jquery-notice/jquery.notice.css');
diff --git a/admin/images/sprites/src/menu-icons/16x16-2x/collection.png b/admin/images/sprites/src/menu-icons/16x16-2x/collection.png
new file mode 100644
index 000000000..2728d8ddf
Binary files /dev/null and b/admin/images/sprites/src/menu-icons/16x16-2x/collection.png differ
diff --git a/admin/images/sprites/src/menu-icons/16x16/collection.png b/admin/images/sprites/src/menu-icons/16x16/collection.png
new file mode 100644
index 000000000..df0c2db4d
Binary files /dev/null and b/admin/images/sprites/src/menu-icons/16x16/collection.png differ
diff --git a/admin/images/sprites/src/menu-icons/24x24/collection.png b/admin/images/sprites/src/menu-icons/24x24/collection.png
new file mode 100644
index 000000000..d3ef3844f
Binary files /dev/null and b/admin/images/sprites/src/menu-icons/24x24/collection.png differ
diff --git a/admin/javascript/src/boot/campaign-admin.js b/admin/javascript/src/boot/campaign-admin.js
new file mode 100644
index 000000000..e336ed65f
--- /dev/null
+++ b/admin/javascript/src/boot/campaign-admin.js
@@ -0,0 +1,23 @@
+import reducerRegister from 'reducer-register';
+import $ from 'jQuery';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import CampaignAdmin from '../sections/campaign-admin/controller';
+import campaignsReducer from '../state/campaigns/reducer';
+
+// TODO: Move this to the controller.
+reducerRegister.add('campaigns', campaignsReducer);
+
+$.entwine('ss', function ($) {
+
+ $('.cms-content.CampaignAdmin').entwine({
+ onadd: function () {
+ ReactDOM.render(, this[0]);
+ },
+
+ onremove: function () {
+ ReactDOM.unmountComponentAtNode(this[0]);
+ }
+ });
+
+});
diff --git a/admin/javascript/src/boot/index.js b/admin/javascript/src/boot/index.js
new file mode 100644
index 000000000..6abb0ed5b
--- /dev/null
+++ b/admin/javascript/src/boot/index.js
@@ -0,0 +1,15 @@
+import { combineReducers, createStore, applyMiddleware } from 'redux';
+import thunkMiddleware from 'redux-thunk';
+import createLogger from 'redux-logger';
+import reducerRegister from 'reducer-register';
+
+function appBoot() {
+ const initialState = {};
+ const rootReducer = combineReducers(reducerRegister.getAll());
+ const createStoreWithMiddleware = applyMiddleware(thunkMiddleware, createLogger())(createStore);
+ const store = createStoreWithMiddleware(rootReducer, initialState);
+
+ console.log(store.getState());
+}
+
+window.onload = appBoot;
diff --git a/admin/javascript/src/components/grid-field-cell/README.md b/admin/javascript/src/components/grid-field-cell/README.md
new file mode 100644
index 000000000..98912dae6
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-cell/README.md
@@ -0,0 +1,5 @@
+# GridFieldCell
+
+This component represents a data cell in a GridFieldRow.
+
+## Props
diff --git a/admin/javascript/src/components/grid-field-cell/index.js b/admin/javascript/src/components/grid-field-cell/index.js
new file mode 100644
index 000000000..e6a6c137b
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-cell/index.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import SilverStripeComponent from 'silverstripe-component';
+
+class GridFieldCellComponent extends SilverStripeComponent {
+
+ render() {
+ return (
+
{this.props.children} |
+ );
+ }
+
+}
+
+export default GridFieldCellComponent;
diff --git a/admin/javascript/src/components/grid-field-cell/styles.scss b/admin/javascript/src/components/grid-field-cell/styles.scss
new file mode 100644
index 000000000..521563a1e
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-cell/styles.scss
@@ -0,0 +1,3 @@
+.grid-field-cell-component {
+
+}
diff --git a/admin/javascript/src/components/grid-field-cell/tests/grid-field-cell-test.js b/admin/javascript/src/components/grid-field-cell/tests/grid-field-cell-test.js
new file mode 100644
index 000000000..7ae444b05
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-cell/tests/grid-field-cell-test.js
@@ -0,0 +1,5 @@
+jest.dontMock('../index');
+
+describe('GridFieldCellComponent', () => {
+
+});
diff --git a/admin/javascript/src/components/grid-field-header-cell/README.md b/admin/javascript/src/components/grid-field-header-cell/README.md
new file mode 100644
index 000000000..c917cd964
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-header-cell/README.md
@@ -0,0 +1,6 @@
+# GridFieldHeaderCell
+
+This component is a cell in a GridFirldHeader component.
+
+## Props
+
diff --git a/admin/javascript/src/components/grid-field-header-cell/index.js b/admin/javascript/src/components/grid-field-header-cell/index.js
new file mode 100644
index 000000000..70aab6f55
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-header-cell/index.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import SilverStripeComponent from 'silverstripe-component';
+
+class GridFieldHeaderCellComponent extends SilverStripeComponent {
+
+ render() {
+ return (
+ {this.props.children} |
+ );
+ }
+
+}
+
+export default GridFieldHeaderCellComponent;
diff --git a/admin/javascript/src/components/grid-field-header-cell/styles.scss b/admin/javascript/src/components/grid-field-header-cell/styles.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/admin/javascript/src/components/grid-field-header-cell/tests/grid-field-header-cell-test.js b/admin/javascript/src/components/grid-field-header-cell/tests/grid-field-header-cell-test.js
new file mode 100644
index 000000000..3ac2048b3
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-header-cell/tests/grid-field-header-cell-test.js
@@ -0,0 +1,5 @@
+jest.dontMock('../index');
+
+describe('GridFieldHeaderCellComponent', () => {
+
+});
diff --git a/admin/javascript/src/components/grid-field-header/README.md b/admin/javascript/src/components/grid-field-header/README.md
new file mode 100644
index 000000000..3b6a0c345
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-header/README.md
@@ -0,0 +1,5 @@
+# GridFieldHeader
+
+This component is used to display a tabel header row on a GridFieldComponent.
+
+## Props
diff --git a/admin/javascript/src/components/grid-field-header/index.js b/admin/javascript/src/components/grid-field-header/index.js
new file mode 100644
index 000000000..c5fda9753
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-header/index.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import SilverStripeComponent from 'silverstripe-component';
+import GridFieldRowComponent from '../grid-field-row';
+
+class GridFieldHeaderComponent extends SilverStripeComponent {
+
+ render() {
+ return (
+
+ {this.props.children}
+
+ );
+ }
+
+}
+
+export default GridFieldHeaderComponent;
diff --git a/admin/javascript/src/components/grid-field-header/styles.scss b/admin/javascript/src/components/grid-field-header/styles.scss
new file mode 100644
index 000000000..39a7db6ec
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-header/styles.scss
@@ -0,0 +1,3 @@
+.grid-field-header-component {
+
+}
diff --git a/admin/javascript/src/components/grid-field-header/tests/grid-field-header-test.js b/admin/javascript/src/components/grid-field-header/tests/grid-field-header-test.js
new file mode 100644
index 000000000..cb5eb4bf1
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-header/tests/grid-field-header-test.js
@@ -0,0 +1,5 @@
+jest.dontMock('../index');
+
+describe('GridFieldHeaderComponent', () => {
+
+});
diff --git a/admin/javascript/src/components/grid-field-row/README.md b/admin/javascript/src/components/grid-field-row/README.md
new file mode 100644
index 000000000..9cdee86ca
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-row/README.md
@@ -0,0 +1,9 @@
+# GridFieldRow
+
+Represents a row in a GridField.
+
+## Props
+
+### cells (array)
+
+The table data to display in the row.
diff --git a/admin/javascript/src/components/grid-field-row/index.js b/admin/javascript/src/components/grid-field-row/index.js
new file mode 100644
index 000000000..3818c00db
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-row/index.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import SilverStripeComponent from 'silverstripe-component';
+
+class GridFieldRowComponent extends SilverStripeComponent {
+
+ render() {
+ return (
+ {this.props.children}
+ );
+ }
+
+}
+
+export default GridFieldRowComponent;
diff --git a/admin/javascript/src/components/grid-field-row/styles.scss b/admin/javascript/src/components/grid-field-row/styles.scss
new file mode 100644
index 000000000..3f8dbc1a9
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-row/styles.scss
@@ -0,0 +1,3 @@
+.grid-field-row-component {
+
+}
diff --git a/admin/javascript/src/components/grid-field-row/tests/grid-field-row-test.js b/admin/javascript/src/components/grid-field-row/tests/grid-field-row-test.js
new file mode 100644
index 000000000..d660e32f0
--- /dev/null
+++ b/admin/javascript/src/components/grid-field-row/tests/grid-field-row-test.js
@@ -0,0 +1,5 @@
+jest.dontMock('../index');
+
+describe('GridFieldRowComponent', () => {
+
+});
diff --git a/admin/javascript/src/components/grid-field/README.md b/admin/javascript/src/components/grid-field/README.md
new file mode 100644
index 000000000..42e40c433
--- /dev/null
+++ b/admin/javascript/src/components/grid-field/README.md
@@ -0,0 +1,13 @@
+# GridField
+
+This component is used to display structured data in an extendible table layout.
+
+## Props
+
+### Headings (array)
+
+The column headings.
+
+### Rows (array)
+
+The table rows.
diff --git a/admin/javascript/src/components/grid-field/index.js b/admin/javascript/src/components/grid-field/index.js
new file mode 100644
index 000000000..c072b00dc
--- /dev/null
+++ b/admin/javascript/src/components/grid-field/index.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import SilverStripeComponent from 'silverstripe-component';
+
+class GridFieldComponent extends SilverStripeComponent {
+
+ render() {
+ return (
+
+ {this.generateHeader()}
+
+ {this.generateRows()}
+
+
+ );
+ }
+
+ /**
+ * Generates the header component.
+ *
+ * Uses the header component passed via the `header` prop if it exists.
+ * Otherwise generates a header from the `data` prop.
+ *
+ * @return object|null
+ */
+ generateHeader() {
+ if (typeof this.props.header !== 'undefined') {
+ return this.props.header;
+ }
+
+ if (typeof this.props.data !== 'undefined') {
+ // TODO: Generate the header.
+ }
+
+ return null;
+ }
+
+ /**
+ * Generates the table rows.
+ *
+ * Uses the components passed via the `rows` props if it exists.
+ * Otherwise generates rows from the `data` prop.
+ *
+ * @return object|null
+ */
+ generateRows() {
+ if (typeof this.props.rows !== 'undefined') {
+ return this.props.rows;
+ }
+
+ if (typeof this.props.data !== 'undefined') {
+ // TODO: Generate the rows.
+ }
+
+ return null;
+ }
+
+}
+
+GridFieldComponent.propTypes = {
+ data: React.PropTypes.object,
+ header: React.PropTypes.object,
+ rows: React.PropTypes.array
+};
+
+export default GridFieldComponent;
diff --git a/admin/javascript/src/components/grid-field/styles.scss b/admin/javascript/src/components/grid-field/styles.scss
new file mode 100644
index 000000000..1254bf0d1
--- /dev/null
+++ b/admin/javascript/src/components/grid-field/styles.scss
@@ -0,0 +1,3 @@
+.grid-field-component {
+
+}
diff --git a/admin/javascript/src/components/grid-field/tests/grid-field-test.js b/admin/javascript/src/components/grid-field/tests/grid-field-test.js
new file mode 100644
index 000000000..426c20aec
--- /dev/null
+++ b/admin/javascript/src/components/grid-field/tests/grid-field-test.js
@@ -0,0 +1,5 @@
+jest.dontMock('../index');
+
+describe('GridFieldComponent', () => {
+
+});
diff --git a/admin/javascript/src/mocks/jQuery.js b/admin/javascript/src/mocks/jQuery.js
new file mode 100644
index 000000000..b19a7680d
--- /dev/null
+++ b/admin/javascript/src/mocks/jQuery.js
@@ -0,0 +1,17 @@
+function jQuery() {
+ return {
+ // Add jQuery methods such as 'find', 'change', 'trigger' as needed.
+ };
+}
+
+var mockAjaxFn = jest.genMockFunction();
+
+mockAjaxFn.mockReturnValue({
+ done: jest.genMockFunction(),
+ fail: jest.genMockFunction(),
+ always: jest.genMockFunction()
+});
+
+jQuery.ajax = mockAjaxFn;
+
+export default jQuery;
diff --git a/admin/javascript/src/reducer-register.js b/admin/javascript/src/reducer-register.js
new file mode 100644
index 000000000..5b0ac5e5d
--- /dev/null
+++ b/admin/javascript/src/reducer-register.js
@@ -0,0 +1,64 @@
+/**
+ * The register of Redux reducers.
+ * @private
+ */
+var register = {};
+
+/**
+ * The central register of Redux reducers for the CMS. All registered reducers are combined when the application boots.
+ */
+class ReducerRegister {
+
+ /**
+ * Adds a reducer to the register.
+ *
+ * @param string key - The key to register the reducer against.
+ * @param object reducer - Redux reducer.
+ */
+ add(key, reducer) {
+ if (typeof register[key] !== 'undefined') {
+ throw new Error(`Reducer already exists at '${key}'`);
+ }
+
+ register[key] = reducer;
+ }
+
+ /**
+ * Gets all reducers from the register.
+ *
+ * @return object
+ */
+ getAll() {
+ return register;
+ }
+
+ /**
+ * Gets a reducer from the register.
+ *
+ * @param string [key] - The key the reducer is registered against.
+ *
+ * @return object|undefined
+ */
+ getByKey(key) {
+ return register[key];
+ }
+
+
+
+ /**
+ * Removes a reducer from the register.
+ *
+ * @param string key - The key the reducer is registered against.
+ */
+ remove(key) {
+ delete register[key];
+ }
+
+}
+
+// Create an instance to export. The same instance is exported to
+// each script which imports the reducerRegister. This means the
+// same register is available throughout the application.
+let reducerRegister = new ReducerRegister();
+
+export default reducerRegister;
diff --git a/admin/javascript/src/sections/campaign-admin/README.md b/admin/javascript/src/sections/campaign-admin/README.md
new file mode 100644
index 000000000..c4655da39
--- /dev/null
+++ b/admin/javascript/src/sections/campaign-admin/README.md
@@ -0,0 +1,3 @@
+# CampaignAdmin
+
+This section is used for managing Campaigns in the CMS.
diff --git a/admin/javascript/src/sections/campaign-admin/controller.js b/admin/javascript/src/sections/campaign-admin/controller.js
new file mode 100644
index 000000000..f8d3cca14
--- /dev/null
+++ b/admin/javascript/src/sections/campaign-admin/controller.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import SilverStripeComponent from 'silverstripe-component';
+import NorthHeader from '../../components/north-header';
+import GridField from '../../components/grid-field';
+import GridFieldHeader from '../../components/grid-field-header';
+import GridFieldHeaderCell from '../../components/grid-field-header-cell';
+import GridFieldRow from '../../components/grid-field-row';
+import GridFieldCell from '../../components/grid-field-cell';
+
+class CampaignAdminContainer extends SilverStripeComponent {
+
+ constructor(props) {
+ super(props);
+
+ // TODO: This will be an AJAX call and it's response stored in state.
+ this.mockData = {
+ campaigns: [
+ {
+ title: 'SilverStripe 4.0 release',
+ description: 'All the stuff related to the 4.0 announcement',
+ changes: 20
+ },
+ {
+ title: 'March release',
+ description: 'march release stuff',
+ changes: 2
+ },
+ {
+ title: 'About us',
+ description: 'The team',
+ changes: 1345
+ }
+ ]
+ };
+ }
+
+ render() {
+ const columnNames = ['title', 'changes', 'description'];
+
+ const headerCells = columnNames.map((columnName, i) => {columnName});
+ const header = {headerCells};
+
+ const rows = this.mockData.campaigns.map((campaign, i) => {
+ const cells = columnNames.map((columnName, i) => {
+ return {campaign[columnName]}
+ });
+ return {cells};
+ });
+
+ return (
+
+
+
+
+ );
+ }
+
+}
+
+export default CampaignAdminContainer;
diff --git a/admin/javascript/src/sections/campaign-admin/styles.scss b/admin/javascript/src/sections/campaign-admin/styles.scss
new file mode 100644
index 000000000..173bfabdd
--- /dev/null
+++ b/admin/javascript/src/sections/campaign-admin/styles.scss
@@ -0,0 +1,3 @@
+.CampaignAdmin {
+
+}
diff --git a/admin/javascript/src/sections/campaign-admin/tests/campaign-admin-test.js b/admin/javascript/src/sections/campaign-admin/tests/campaign-admin-test.js
new file mode 100644
index 000000000..fc29b6172
--- /dev/null
+++ b/admin/javascript/src/sections/campaign-admin/tests/campaign-admin-test.js
@@ -0,0 +1,5 @@
+jest.dontMock('../controller');
+
+describe('CampaignAdminContainer', () => {
+
+});
diff --git a/admin/javascript/src/silverstripe-backend.js b/admin/javascript/src/silverstripe-backend.js
new file mode 100644
index 000000000..e896765f6
--- /dev/null
+++ b/admin/javascript/src/silverstripe-backend.js
@@ -0,0 +1,58 @@
+import $ from 'jQuery';
+
+class SilverStripeBackend {
+
+ /**
+ * Makes a network request using the GET HTTP verb.
+ *
+ * @param string url - Endpoint URL.
+ *
+ * @return object - jqXHR. See http://api.jquery.com/Types/#jqXHR
+ */
+ get(url) {
+ return $.ajax({ type: 'GET', url });
+ }
+
+ /**
+ * Makes a network request using the POST HTTP verb.
+ *
+ * @param string url - Endpoint URL.
+ * @param object data - Data to send with the request.
+ *
+ * @return object - jqXHR. See http://api.jquery.com/Types/#jqXHR
+ */
+ post(url, data) {
+ return $.ajax({ type: 'POST', url, data });
+ }
+
+ /**
+ * Makes a newtwork request using the PUT HTTP verb.
+ *
+ * @param string url - Endpoint URL.
+ * @param object data - Data to send with the request.
+ *
+ * @return object - jqXHR. See http://api.jquery.com/Types/#jqXHR
+ */
+ put(url, data) {
+ return $.ajax({ type: 'PUT', url, data });
+ }
+
+ /**
+ * Makes a newtwork request using the DELETE HTTP verb.
+ *
+ * @param string url - Endpoint URL.
+ * @param object data - Data to send with the request.
+ *
+ * @return object - jqXHR. See http://api.jquery.com/Types/#jqXHR
+ */
+ delete(url, data) {
+ return $.ajax({ type: 'DELETE', url, data });
+ }
+
+}
+
+// Exported as a singleton so we can implement things like
+// global caching and request batching at some stage.
+let backend = new SilverStripeBackend();
+
+export default backend;
diff --git a/admin/javascript/src/state/campaigns/action-types.js b/admin/javascript/src/state/campaigns/action-types.js
new file mode 100644
index 000000000..d52b75776
--- /dev/null
+++ b/admin/javascript/src/state/campaigns/action-types.js
@@ -0,0 +1,8 @@
+export default {
+ CREATE_CAMPAIGN: 'CREATE_CAMPAIGN',
+ UPDATE_CAMPAIGN: 'UPDATE_CAMPAIGN',
+ DELETE_CAMPAIGN: 'DELETE_CAMPAIGN',
+ FETCH_CAMPAIGN_REQUEST: 'FETCH_CAMPAIGN_REQUEST',
+ FETCH_CAMPAIGN_FAILURE: 'FETCH_CAMPAIGN_FAILURE',
+ FETCH_CAMPAIGN_SUCCESS: 'FETCH_CAMPAIGN_SUCCESS'
+};
diff --git a/admin/javascript/src/state/campaigns/actions.js b/admin/javascript/src/state/campaigns/actions.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/admin/javascript/src/state/campaigns/reducer.js b/admin/javascript/src/state/campaigns/reducer.js
new file mode 100644
index 000000000..f9e8d271d
--- /dev/null
+++ b/admin/javascript/src/state/campaigns/reducer.js
@@ -0,0 +1,49 @@
+import deepFreeze from 'deep-freeze';
+import ACTION_TYPES from './action-types';
+
+const initialState = {
+ isFetching: false,
+ items: []
+};
+
+function campaignsReducer(state = initialState, action) {
+
+ switch (action.type) {
+
+ case ACTION_TYPES.CREATE_CAMPAIGN:
+ return deepFreeze(Object.assign({}, state, {
+
+ }));
+
+ case ACTION_TYPES.UPDATE_CAMPAIGN:
+ return deepFreeze(Object.assign({}, state, {
+
+ }));
+
+ case ACTION_TYPES.DELETE_CAMPAIGN:
+ return deepFreeze(Object.assign({}, state, {
+
+ }));
+
+ case ACTION_TYPES.FETCH_CAMPAIGN_REQUEST:
+ return deepFreeze(Object.assign({}, state, {
+ isFetching: true
+ }));
+
+ case ACTION_TYPES.FETCH_CAMPAIGN_FAILURE:
+ return deepFreeze(Object.assign({}, state, {
+ isFetching: false
+ }));
+
+ case ACTION_TYPES.FETCH_CAMPAIGN_SUCCESS:
+ return deepFreeze(Object.assign({}, state, {
+ isFetching: false
+ }));
+
+ default:
+ return state;
+ }
+
+}
+
+export default campaignsReducer;
diff --git a/admin/javascript/src/state/campaigns/tests/campaigns-reducer-test.js b/admin/javascript/src/state/campaigns/tests/campaigns-reducer-test.js
new file mode 100644
index 000000000..b9d28fa28
--- /dev/null
+++ b/admin/javascript/src/state/campaigns/tests/campaigns-reducer-test.js
@@ -0,0 +1,70 @@
+jest.dontMock('deep-freeze');
+jest.dontMock('../reducer');
+jest.dontMock('../action-types');
+
+var campaignsReducer = require('../reducer').default,
+ ACTION_TYPES = require('../action-types').default;
+
+describe('campaignsReducer', () => {
+
+ describe('CREATE_CAMPAIGN', () => {
+
+ });
+
+ describe('UPDATE_CAMPAIGN', () => {
+
+ });
+
+ describe('DELETE_CAMPAIGN', () => {
+
+ });
+
+ describe('FETCH_CAMPAIGN_REQUEST', () => {
+
+ it('should set the "isFetching" flag', () => {
+ const initialState = {
+ isFetching: false
+ };
+
+ const action = { type: ACTION_TYPES.FETCH_CAMPAIGN_REQUEST };
+
+ const nextState = campaignsReducer(initialState, action);
+
+ expect(nextState.isFetching).toBe(true);
+ });
+
+ });
+
+ describe('FETCH_CAMPAIGN_FAILURE', () => {
+
+ it('should unset the "isFetching" flag', () => {
+ const initialState = {
+ isFetching: true
+ };
+
+ const action = { type: ACTION_TYPES.FETCH_CAMPAIGN_FAILURE };
+
+ const nextState = campaignsReducer(initialState, action);
+
+ expect(nextState.isFetching).toBe(false);
+ });
+
+ });
+
+ describe('FETCH_CAMPAIGN_SUCCESS', () => {
+
+ it('should unset the "isFetching" flag', () => {
+ const initialState = {
+ isFetching: true
+ };
+
+ const action = { type: ACTION_TYPES.FETCH_CAMPAIGN_FAILURE };
+
+ const nextState = campaignsReducer(initialState, action);
+
+ expect(nextState.isFetching).toBe(false);
+ });
+
+ });
+
+});
diff --git a/admin/javascript/src/styles/main.scss b/admin/javascript/src/styles/main.scss
new file mode 100644
index 000000000..e7e3af67b
--- /dev/null
+++ b/admin/javascript/src/styles/main.scss
@@ -0,0 +1,14 @@
+/** -----------------------------
+ * Sections
+ * ------------------------------ */
+@import "../sections/campaign-admin/styles";
+
+/** -----------------------------
+ * components
+ * ------------------------------ */
+@import "../components/grid-field/styles";
+@import "../components/grid-field-cell/styles";
+@import "../components/grid-field-header/styles";
+@import "../components/grid-field-header-cell/styles";
+@import "../components/grid-field-row/styles";
+@import "../components/north-header/styles";
diff --git a/admin/javascript/src/tests/reducer-register-test.js b/admin/javascript/src/tests/reducer-register-test.js
new file mode 100644
index 000000000..1935c2b37
--- /dev/null
+++ b/admin/javascript/src/tests/reducer-register-test.js
@@ -0,0 +1,44 @@
+jest.dontMock('../reducer-register');
+
+var reducerRegister = require('../reducer-register').default;
+
+describe('ReducerRegister', () => {
+
+ var reducer = () => null;
+
+ it('should add a reducer to the register', () => {
+ expect(reducerRegister.getAll().test).toBe(undefined);
+
+ reducerRegister.add('test', reducer);
+ expect(reducerRegister.getAll().test).toBe(reducer);
+
+ reducerRegister.remove('test');
+ });
+
+ it('should remove a reducer from the register', () => {
+ reducerRegister.add('test', reducer);
+ expect(reducerRegister.getAll().test).toBe(reducer);
+
+ reducerRegister.remove('test');
+ expect(reducerRegister.getAll().test).toBe(undefined);
+ });
+
+ it('should get all reducers from the register', () => {
+ reducerRegister.add('test1', reducer);
+ reducerRegister.add('test2', reducer);
+
+ expect(reducerRegister.getAll().test1).toBe(reducer);
+ expect(reducerRegister.getAll().test2).toBe(reducer);
+
+ reducerRegister.remove('test1');
+ reducerRegister.remove('test2');
+ });
+
+ it('should get a single reducer from the register', () => {
+ reducerRegister.add('test', reducer);
+ expect(reducerRegister.getByKey('test')).toBe(reducer);
+
+ reducerRegister.remove('test');
+ });
+
+});
diff --git a/admin/javascript/src/tests/silverstripe-backend-test.js b/admin/javascript/src/tests/silverstripe-backend-test.js
new file mode 100644
index 000000000..cded489e9
--- /dev/null
+++ b/admin/javascript/src/tests/silverstripe-backend-test.js
@@ -0,0 +1,106 @@
+jest.mock('jQuery');
+jest.unmock('../silverstripe-backend');
+
+import $ from 'jQuery';
+import backend from '../silverstripe-backend';
+
+describe('SilverStripeBackend', () => {
+
+ describe('get()', () => {
+
+ it('should return a jqXHR', () => {
+ var jqxhr = backend.get('http://example.com');
+
+ expect(typeof jqxhr).toBe('object');
+ expect(typeof jqxhr.done).toBe('function');
+ expect(typeof jqxhr.fail).toBe('function');
+ expect(typeof jqxhr.always).toBe('function');
+ });
+
+ it('should send a GET request to an endpoint', () => {
+ backend.get('http://example.com');
+
+ expect($.ajax).toBeCalledWith({
+ type: 'GET',
+ url: 'http://example.com'
+ });
+ });
+
+ });
+
+ describe('post()', () => {
+
+ it('should return a jqXHR', () => {
+ var jqxhr = backend.get('http://example.com/item');
+
+ expect(typeof jqxhr).toBe('object');
+ expect(typeof jqxhr.done).toBe('function');
+ expect(typeof jqxhr.fail).toBe('function');
+ expect(typeof jqxhr.always).toBe('function');
+ });
+
+ it('should send a POST request to an endpoint', () => {
+ const postData = { id: 1 };
+
+ backend.post('http://example.com', postData);
+
+ expect($.ajax).toBeCalledWith({
+ type: 'POST',
+ url: 'http://example.com',
+ data: postData
+ });
+ });
+
+ });
+
+ describe('put()', () => {
+
+ it('should return a jqXHR', () => {
+ var jqxhr = backend.get('http://example.com/item');
+
+ expect(typeof jqxhr).toBe('object');
+ expect(typeof jqxhr.done).toBe('function');
+ expect(typeof jqxhr.fail).toBe('function');
+ expect(typeof jqxhr.always).toBe('function');
+ });
+
+ it('should send a PUT request to an endpoint', () => {
+ const putData = { id: 1 };
+
+ backend.put('http://example.com', putData);
+
+ expect($.ajax).toBeCalledWith({
+ type: 'PUT',
+ url: 'http://example.com',
+ data: putData
+ });
+ });
+
+ });
+
+ describe('delete()', () => {
+
+ it('should return a jqXHR', () => {
+ var jqxhr = backend.get('http://example.com/item');
+
+ expect(typeof jqxhr).toBe('object');
+ expect(typeof jqxhr.done).toBe('function');
+ expect(typeof jqxhr.fail).toBe('function');
+ expect(typeof jqxhr.always).toBe('function');
+ });
+
+ it('should send a DELETE request to an endpoint', () => {
+ const deleteData = { id: 1 };
+
+ backend.delete('http://example.com', deleteData);
+
+ expect($.ajax).toBeCalledWith({
+ type: 'DELETE',
+ url: 'http://example.com',
+ data: deleteData
+ });
+ });
+
+ });
+
+});
diff --git a/admin/scss/_retina.scss b/admin/scss/_retina.scss
index 0b8487806..01e1c3bcc 100644
--- a/admin/scss/_retina.scss
+++ b/admin/scss/_retina.scss
@@ -16,6 +16,9 @@
background-image: sprite-url($sprite);
background-size: ceil(image-width(sprite-path($sprite)) / 2) auto;
+ &.icon-campaignadmin {
+ background-position: 0 round(nth(sprite-position($sprite, "collection"), 2) / 2);
+ }
&.icon-assetadmin {
background-position: 0 round(nth(sprite-position($sprite, "picture"), 2) / 2);
}
@@ -289,7 +292,11 @@
height: 16px;
@extend .retina-menu-icons-16x16-2x;
- &.icon-assetadmin {
+ &.icon-campaignadmin {
+ @include retina-sprite($menu-icons-16x16-2x-collection);
+ display: inline-block;
+ }
+ &.icon-assetadmin {
@include retina-sprite($menu-icons-16x16-2x-picture);
display: inline-block;
}
diff --git a/admin/scss/_sprites.scss b/admin/scss/_sprites.scss
index e60170729..ed96fa1ef 100644
--- a/admin/scss/_sprites.scss
+++ b/admin/scss/_sprites.scss
@@ -68,6 +68,9 @@
height: 24px;
@extend .icon-menu-icons-24x24;
+ &.icon-campaignadmin {
+ @include sprite($menu-icons-24x24-collection, inline-block);
+ }
&.icon-assetadmin {
@include sprite($menu-icons-24x24-picture, inline-block);
}
@@ -99,6 +102,9 @@
height: 16px;
@extend .icon-menu-icons-16x16;
+ &.icon-campaignadmin {
+ @include sprite($menu-icons-16x16-collection, inline-block);
+ }
&.icon-assetadmin {
@include sprite($menu-icons-16x16-picture, inline-block);
}
diff --git a/admin/scss/_spritey.scss b/admin/scss/_spritey.scss
index b0a7be053..96c5f18d4 100644
--- a/admin/scss/_spritey.scss
+++ b/admin/scss/_spritey.scss
@@ -120,39 +120,42 @@ $sprites-64x64-2x-tab-list-hover: -0px -160px 80px 80px;
$sprites-64x64-2x-tab-list: -0px -240px 80px 80px;
$sprites-64x64-2x-tab-tree-hover: -0px -320px 80px 80px;
$sprites-64x64-2x-tab-tree: -0px -400px 80px 80px;
-$menu-icons-24x24-home: -0px -0px 24px 24px;
-$menu-icons-24x24-blog: -0px -24px 24px 24px;
-$menu-icons-24x24-db: -0px -48px 24px 24px;
-$menu-icons-24x24-document: -0px -72px 24px 24px;
-$menu-icons-24x24-gears: -0px -96px 24px 24px;
-$menu-icons-24x24-community: -0px -120px 24px 24px;
-$menu-icons-24x24-information: -0px -144px 24px 24px;
-$menu-icons-24x24-network: -0px -168px 24px 24px;
-$menu-icons-24x24-pencil: -0px -192px 24px 24px;
-$menu-icons-24x24-picture: -0px -216px 24px 24px;
-$menu-icons-24x24-pie-chart: -0px -240px 24px 24px;
-$menu-icons-16x16-2x-home: -0px -0px 32px 32px;
-$menu-icons-16x16-2x-blog: -0px -32px 32px 32px;
-$menu-icons-16x16-2x-db: -0px -64px 32px 32px;
-$menu-icons-16x16-2x-document: -0px -96px 32px 32px;
-$menu-icons-16x16-2x-gears: -0px -128px 32px 32px;
-$menu-icons-16x16-2x-community: -0px -160px 32px 32px;
-$menu-icons-16x16-2x-information: -0px -192px 32px 32px;
-$menu-icons-16x16-2x-network: -0px -224px 32px 32px;
-$menu-icons-16x16-2x-pencil: -0px -256px 32px 32px;
-$menu-icons-16x16-2x-picture: -0px -288px 32px 32px;
-$menu-icons-16x16-2x-pie-chart: -0px -320px 32px 32px;
$menu-icons-16x16-home: -0px -0px 16px 16px;
$menu-icons-16x16-blog: -0px -16px 16px 16px;
-$menu-icons-16x16-db: -0px -32px 16px 16px;
-$menu-icons-16x16-document: -0px -48px 16px 16px;
-$menu-icons-16x16-gears: -0px -64px 16px 16px;
-$menu-icons-16x16-community: -0px -80px 16px 16px;
-$menu-icons-16x16-information: -0px -96px 16px 16px;
-$menu-icons-16x16-network: -0px -112px 16px 16px;
-$menu-icons-16x16-pencil: -0px -128px 16px 16px;
-$menu-icons-16x16-picture: -0px -144px 16px 16px;
-$menu-icons-16x16-pie-chart: -0px -160px 16px 16px;
+$menu-icons-16x16-community: -0px -32px 16px 16px;
+$menu-icons-16x16-db: -0px -48px 16px 16px;
+$menu-icons-16x16-document: -0px -64px 16px 16px;
+$menu-icons-16x16-gears: -0px -80px 16px 16px;
+$menu-icons-16x16-collection: -0px -96px 16px 16px;
+$menu-icons-16x16-information: -0px -112px 16px 16px;
+$menu-icons-16x16-network: -0px -128px 16px 16px;
+$menu-icons-16x16-pencil: -0px -144px 16px 16px;
+$menu-icons-16x16-picture: -0px -160px 16px 16px;
+$menu-icons-16x16-pie-chart: -0px -176px 16px 16px;
+$menu-icons-16x16-2x-home: -0px -0px 32px 32px;
+$menu-icons-16x16-2x-blog: -0px -32px 32px 32px;
+$menu-icons-16x16-2x-community: -0px -64px 32px 32px;
+$menu-icons-16x16-2x-db: -0px -96px 32px 32px;
+$menu-icons-16x16-2x-document: -0px -128px 32px 32px;
+$menu-icons-16x16-2x-gears: -0px -160px 32px 32px;
+$menu-icons-16x16-2x-collection: -0px -192px 32px 32px;
+$menu-icons-16x16-2x-information: -0px -224px 32px 32px;
+$menu-icons-16x16-2x-network: -0px -256px 32px 32px;
+$menu-icons-16x16-2x-pencil: -0px -288px 32px 32px;
+$menu-icons-16x16-2x-picture: -0px -320px 32px 32px;
+$menu-icons-16x16-2x-pie-chart: -0px -352px 32px 32px;
+$menu-icons-24x24-home: -0px -0px 24px 24px;
+$menu-icons-24x24-blog: -0px -24px 24px 24px;
+$menu-icons-24x24-community: -0px -48px 24px 24px;
+$menu-icons-24x24-db: -0px -72px 24px 24px;
+$menu-icons-24x24-document: -0px -96px 24px 24px;
+$menu-icons-24x24-gears: -0px -120px 24px 24px;
+$menu-icons-24x24-collection: -0px -144px 24px 24px;
+$menu-icons-24x24-information: -0px -168px 24px 24px;
+$menu-icons-24x24-network: -0px -192px 24px 24px;
+$menu-icons-24x24-pencil: -0px -216px 24px 24px;
+$menu-icons-24x24-picture: -0px -240px 24px 24px;
+$menu-icons-24x24-pie-chart: -0px -264px 24px 24px;
$menu-icons-24x24-2x-home: -0px -0px 48px 48px;
$menu-icons-24x24-2x-blog: -0px -48px 48px 48px;
$menu-icons-24x24-2x-db: -0px -96px 48px 48px;
@@ -204,15 +207,15 @@ $menu-icons-24x24-2x-pie-chart: -0px -480px 48px 48px;
.icon-sprites-64x64-2x {
background-image: url('../images/sprites/dist/sprite-sprites-64x64-2x.png');
}
-.icon-menu-icons-24x24 {
- background-image: url('../images/sprites/dist/sprite-menu-icons-24x24.png');
+.icon-menu-icons-16x16 {
+ background-image: url('../images/sprites/dist/sprite-menu-icons-16x16.png');
}
.icon-menu-icons-16x16-2x {
background-image: url('../images/sprites/dist/sprite-menu-icons-16x16-2x.png');
}
-.icon-menu-icons-16x16 {
- background-image: url('../images/sprites/dist/sprite-menu-icons-16x16.png');
+.icon-menu-icons-24x24 {
+ background-image: url('../images/sprites/dist/sprite-menu-icons-24x24.png');
}
.icon-menu-icons-24x24-2x {
background-image: url('../images/sprites/dist/sprite-menu-icons-24x24-2x.png');
-}
+}
\ No newline at end of file
diff --git a/admin/scss/bootstrap/_variables.scss b/admin/scss/bootstrap/_variables.scss
index af7af8a7c..0bc6a242d 100644
--- a/admin/scss/bootstrap/_variables.scss
+++ b/admin/scss/bootstrap/_variables.scss
@@ -29,7 +29,7 @@
// $gray-dark: #373a3c;
// $gray: #55595c;
// $gray-light: #818a91;
-// $gray-lighter: #eceeef;
+$gray-lighter: #e8e9ea;
// $gray-lightest: #f7f7f9;
//
// $brand-primary: #0275d8;
diff --git a/admin/scss/screen.scss b/admin/scss/screen.scss
index 06a4dffca..58b95e698 100644
--- a/admin/scss/screen.scss
+++ b/admin/scss/screen.scss
@@ -51,6 +51,11 @@
@import "SecurityAdmin.scss";
@import "CMSSecurity.scss";
+/** -----------------------------
+ * Include React components' css
+ * ------------------------------ */
+@import "../javascript/src/styles/main.scss";
+
/** -----------------------------
* Retina graphics
* ----------------------------- */
diff --git a/admin/scss/themes/_default.scss b/admin/scss/themes/_default.scss
index 7253124a1..0a350be10 100644
--- a/admin/scss/themes/_default.scss
+++ b/admin/scss/themes/_default.scss
@@ -4,6 +4,8 @@
* and leave the actual styling to _style.scss and auxilliary files.
*/
+@import "../bootstrap/variables.scss";
+
/** -----------------------------------------------
* Colours
* ------------------------------------------------ */
diff --git a/docs/en/05_Contributing/01_Code.md b/docs/en/05_Contributing/01_Code.md
index 322ed9c3a..f4e8b6776 100644
--- a/docs/en/05_Contributing/01_Code.md
+++ b/docs/en/05_Contributing/01_Code.md
@@ -295,6 +295,18 @@ $ npm run sanity
`sanity` makes sure files in `thirdparty` match files copied from `node_modules`. You should never commit custom changes to a library file. This script will catch them if you do :smile:
+```
+$ npm run test
+```
+
+This script runs the JavaScript unit tests.
+
+```
+$ npm run coverage
+```
+
+This script generates a coverage report for the JavaScript unit tests. The report is generated in the `coverage` directory.
+
```
$ npm run css
```
diff --git a/gulpfile.js b/gulpfile.js
index 2dfb05605..4404d50bf 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -10,7 +10,6 @@ var gulp = require('gulp'),
autoprefixer = require('autoprefixer'),
browserify = require('browserify'),
babelify = require('babelify'),
- watchify = require('watchify'),
source = require('vinyl-source-stream'),
buffer = require('vinyl-buffer'),
path = require('path'),
@@ -42,11 +41,7 @@ var PATHS = {
// Folders which contain both scss and css folders to be compiled
var rootCompileFolders = [PATHS.FRAMEWORK, PATHS.ADMIN, PATHS.FRAMEWORK_DEV_INSTALL]
-var browserifyOptions = {
- cache: {},
- packageCache: {},
- poll: true
-};
+var browserifyOptions = {};
// Used for autoprefixing css properties (same as Bootstrap Aplha.2 defaults)
var supportedBrowsers = [
@@ -173,17 +168,23 @@ if (!semver.satisfies(process.versions.node, packageJson.engines.node)) {
if (isDev) {
browserifyOptions.debug = true;
- browserifyOptions.plugin = [watchify];
}
-gulp.task('build', ['umd', 'bundle']);
+gulp.task('build', ['umd', 'bundle'], function () {
+ if (isDev) {
+ gulp.watch([
+ PATHS.ADMIN_JAVASCRIPT_SRC + '/**/*.js',
+ PATHS.FRAMEWORK_JAVASCRIPT_SRC + '/**/*.js',
+ ], ['build']);
+ }
+});
-gulp.task('bundle', ['bundle-lib', 'bundle-leftandmain', 'bundle-react']);
+gulp.task('bundle', ['bundle-lib', 'bundle-leftandmain', 'bundle-boot', 'bundle-react', 'bundle-campaign-admin']);
gulp.task('bundle-leftandmain', function bundleLeftAndMain() {
- return browserify(Object.assign({}, browserifyOptions, {
- entries: PATHS.ADMIN_JAVASCRIPT_SRC + '/bundles/leftandmain.js'
- }))
+ var bundleFileName = 'bundle-leftandmain.js';
+
+ return browserify(Object.assign({}, browserifyOptions, { entries: PATHS.ADMIN_JAVASCRIPT_SRC + '/bundles/leftandmain.js' }))
.transform(babelify.configure({
presets: ['es2015'],
ignore: /(thirdparty)/,
@@ -194,18 +195,18 @@ gulp.task('bundle-leftandmain', function bundleLeftAndMain() {
.external('i18n')
.external('router')
.bundle()
- .on('update', bundleLeftAndMain)
- .on('error', notify.onError({ message: 'Error: <%= error.message %>' }))
- .pipe(source('bundle-leftandmain.js'))
+ .on('update', bundleLeftAndMain)
+ .on('error', notify.onError({ message: bundleFileName + ': <%= error.message %>' }))
+ .pipe(source(bundleFileName))
.pipe(buffer())
.pipe(gulpif(!isDev, uglify()))
.pipe(gulp.dest(PATHS.ADMIN_JAVASCRIPT_DIST));
});
gulp.task('bundle-lib', function bundleLib() {
- return browserify(Object.assign({}, browserifyOptions, {
- entries: PATHS.ADMIN_JAVASCRIPT_SRC + '/bundles/lib.js'
- }))
+ var bundleFileName = 'bundle-lib.js';
+
+ return browserify(Object.assign({}, browserifyOptions, { entries: PATHS.ADMIN_JAVASCRIPT_SRC + '/bundles/lib.js' }))
.transform(babelify.configure({
presets: ['es2015'],
ignore: /(thirdparty)/,
@@ -215,10 +216,11 @@ gulp.task('bundle-lib', function bundleLib() {
.require(PATHS.FRAMEWORK_JAVASCRIPT_SRC + '/jQuery.js', { expose: 'jQuery' })
.require(PATHS.FRAMEWORK_JAVASCRIPT_SRC + '/i18n.js', { expose: 'i18n' })
.require(PATHS.FRAMEWORK_JAVASCRIPT_SRC + '/router.js', { expose: 'router' })
+ .require(PATHS.ADMIN_JAVASCRIPT_SRC + '/reducer-register.js', { expose: 'reducer-register' })
.bundle()
- .on('update', bundleLib)
- .on('error', notify.onError({ message: 'Error: <%= error.message %>' }))
- .pipe(source('bundle-lib.js'))
+ .on('update', bundleLib)
+ .on('error', notify.onError({ message: bundleFileName + ': <%= error.message %>' }))
+ .pipe(source(bundleFileName))
.pipe(buffer())
.pipe(gulpif(!isDev, uglify()))
.pipe(gulp.dest(PATHS.ADMIN_JAVASCRIPT_DIST));
@@ -235,9 +237,54 @@ gulp.task('bundle-react', function bundleReact() {
.require('isomorphic-fetch', { expose: 'isomorphic-fetch' })
.require(PATHS.ADMIN_JAVASCRIPT_DIST + '/SilverStripeComponent', { expose: 'silverstripe-component' })
.bundle()
- .on('update', bundleReact)
- .on('error', notify.onError({ message: 'Error: <%= error.message %>' }))
- .pipe(source('bundle-react.js'))
+ .on('update', bundleReact)
+ .on('error', notify.onError({ message: bundleFileName + ': <%= error.message %>' }))
+ .pipe(source(bundleFileName))
+ .pipe(buffer())
+ .pipe(gulpif(!isDev, uglify()))
+ .pipe(gulp.dest(PATHS.ADMIN_JAVASCRIPT_DIST));
+});
+
+gulp.task('bundle-boot', function bundleBoot() {
+ var bundleFileName = 'boot.js';
+
+ return browserify(Object.assign({}, browserifyOptions, { entries: PATHS.ADMIN_JAVASCRIPT_SRC + '/boot/index.js' }))
+ .transform(babelify.configure({
+ presets: ['es2015'],
+ ignore: /(node_modules)/
+ }))
+ .external('reducer-register')
+ .bundle()
+ .on('error', notify.onError({ message: bundleFileName + ': <%= error.message %>' }))
+ .pipe(source(bundleFileName))
+ .pipe(buffer())
+ .pipe(gulpif(!isDev, uglify()))
+ .pipe(gulp.dest(PATHS.ADMIN_JAVASCRIPT_DIST));
+});
+
+gulp.task('bundle-campaign-admin', function bundleCampaignAdmin() {
+ var bundleFileName = 'campaign-admin.js';
+
+ return browserify(Object.assign({}, browserifyOptions, { entries: PATHS.ADMIN_JAVASCRIPT_SRC + '/boot/campaign-admin.js' }))
+ .transform(babelify.configure({
+ presets: ['es2015', 'react'],
+ ignore: /(node_modules)/
+ }))
+ .external('deep-freeze')
+ .external('i18n')
+ .external('jQuery')
+ .external('page.js')
+ .external('react')
+ .external('react-addons-test-utils')
+ .external('react-dom')
+ .external('react-redux')
+ .external('redux')
+ .external('redux-thunk')
+ .external('silverstripe-component')
+ .external('reducer-register')
+ .bundle()
+ .on('error', notify.onError({ message: bundleFileName + ': <%= error.message %>' }))
+ .pipe(source(bundleFileName))
.pipe(buffer())
.pipe(gulpif(!isDev, uglify()))
.pipe(gulp.dest(PATHS.ADMIN_JAVASCRIPT_DIST));
diff --git a/package.json b/package.json
index 716c60441..28d66a896 100644
--- a/package.json
+++ b/package.json
@@ -12,11 +12,13 @@
"scripts": {
"build": "gulp build",
"bundle": "gulp bundle",
- "sanity": "gulp sanity",
- "thirdparty": "gulp thirdparty",
+ "coverage": "jest --coverage",
"css": "gulp css",
+ "lock": "npm-shrinkwrap --dev",
+ "sanity": "gulp sanity",
"sprites": "gulp sprites",
- "lock": "npm-shrinkwrap --dev"
+ "test": "jest",
+ "thirdparty": "gulp thirdparty"
},
"repository": {
"type": "git",
@@ -35,8 +37,10 @@
"devDependencies": {
"autoprefixer": "^6.3.1",
"babel-core": "^6.4.0",
+ "babel-jest": "^9.0.3",
"babel-plugin-transform-es2015-modules-umd": "^6.4.0",
- "babel-preset-es2015": "^6.3.13",
+ "babel-preset-es2015": "^6.6.0",
+ "babel-preset-react": "^6.5.0",
"babelify": "^7.2.0",
"browserify": "^13.0.0",
"event-stream": "^3.3.2",
@@ -51,29 +55,33 @@
"gulp-sourcemaps": "^1.6.0",
"gulp-uglify": "^1.5.1",
"gulp-util": "^3.0.7",
+ "jest-cli": "^0.9.2",
+ "npm-shrinkwrap": "^5.4.1",
+ "react-addons-test-utils": "^0.14.6",
+ "redux-logger": "^2.6.1",
"semver": "^5.1.0",
"sprity": "^1.0.8",
"sprity-sass": "^1.0.4",
"vinyl-buffer": "^1.0.0",
"vinyl-source-stream": "^1.1.0",
- "watchify": "^3.7.0"
+ "watchify": "^3.7.0"
},
- "dependencies": {
- "blueimp-file-upload": "^6.0.3",
- "blueimp-load-image": "^1.1.3",
- "blueimp-tmpl": "^1.0.2",
- "bootstrap": "^4.0.0-alpha.2",
- "isomorphic-fetch": "^2.2.1",
- "jquery-sizes": "^0.33.0",
- "json-js": "^1.1.2",
- "npm-shrinkwrap": "^5.4.1",
- "page.js": "^4.13.3",
- "react": "^0.14.6",
- "react-addons-test-utils": "^0.14.6",
- "react-dom": "^0.14.6",
- "react-redux": "^4.0.6",
- "redux": "^3.0.5",
- "redux-thunk": "^1.0.3",
- "tinymce": "^4.3.3"
+ "jest": {
+ "scriptPreprocessor": "/node_modules/babel-jest",
+ "testPathDirs": [
+ "admin/javascript/src"
+ ],
+ "testDirectoryName": "tests",
+ "mocksPattern": "mocks",
+ "unmockedModulePathPatterns": [
+ "/node_modules/react"
+ ],
+ "bail": true,
+ "testRunner": "/node_modules/jest-cli/src/testRunners/jasmine/jasmine2.js"
+ },
+ "babel": {
+ "presets": [
+ "es2015"
+ ]
}
}