diff --git a/.tx/config b/.tx/config index b4ac4e5..75ad0ea 100644 --- a/.tx/config +++ b/.tx/config @@ -6,3 +6,9 @@ file_filter = lang/.yml source_file = lang/en.yml source_lang = en type = YML + +[silverstripe-asset-admin.master-js] +file_filter = client/lang/src/.json +source_file = client/lang/src/en.json +source_lang = en +type = KEYVALUEJSON diff --git a/_config/config.yml b/_config/config.yml index 120f2eb..df7e28c 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -1,21 +1,30 @@ --- Name: contentreviewextensions --- -SilverStripe\CMS\Model\SiteTree: +SilverStripe\Admin\LeftAndMain: extensions: - - SilverStripe\ContentReview\Extensions\SiteTreeContentReview -SilverStripe\Security\Group: - extensions: - - SilverStripe\ContentReview\Extensions\ContentReviewOwner -SilverStripe\Security\Member: - extensions: - - SilverStripe\ContentReview\Extensions\ContentReviewOwner + - SilverStripe\ContentReview\Extensions\ContentReviewLeftAndMainExtension + SilverStripe\CMS\Controllers\CMSPageEditController: extensions: - SilverStripe\ContentReview\Extensions\ContentReviewCMSExtension + SilverStripe\CMS\Controllers\CMSPageSettingsController: extensions: - SilverStripe\ContentReview\Extensions\ContentReviewCMSExtension + +SilverStripe\CMS\Model\SiteTree: + extensions: + - SilverStripe\ContentReview\Extensions\SiteTreeContentReview + +SilverStripe\Security\Group: + extensions: + - SilverStripe\ContentReview\Extensions\ContentReviewOwner + +SilverStripe\Security\Member: + extensions: + - SilverStripe\ContentReview\Extensions\ContentReviewOwner + SilverStripe\SiteConfig\SiteConfig: extensions: - SilverStripe\ContentReview\Extensions\ContentReviewDefaultSettings diff --git a/client/dist/js/contentreview.js b/client/dist/js/contentreview.js index 2a5e95f..6f24145 100644 --- a/client/dist/js/contentreview.js +++ b/client/dist/js/contentreview.js @@ -1 +1 @@ -!function(n){function t(i){if(e[i])return e[i].exports;var o=e[i]={i:i,l:!1,exports:{}};return n[i].call(o.exports,o,o.exports,t),o.l=!0,o.exports}var e={};t.m=n,t.c=e,t.i=function(n){return n},t.d=function(n,e,i){t.o(n,e)||Object.defineProperty(n,e,{configurable:!1,enumerable:!0,get:i})},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,"a",e),e},t.o=function(n,t){return Object.prototype.hasOwnProperty.call(n,t)},t.p="",t(t.s=3)}([function(n,t){window.jQuery.entwine("ss",function(n){n('.review-notes input[name="action_savereview"]').entwine({onclick:function(){this._super(),n(".contentreview-tab .nav-link").trigger("click")}})})},function(n,t){window.jQuery.entwine("ss",function(n){n(".cms-edit-form #Form_EditForm_ContentReviewType_Holder").entwine({onmatch:function(){var n=this;this.find(".optionset :input").bind("change",function(t){n.show_option(t.target.value)});var t=this.find("input[name=ContentReviewType]:checked").val();this.show_option(t),this._super()},onunmatch:function(){return this._super()},show_option:function(n){"Custom"===n?this._custom():"Inherit"===n?this._inherited():this._disabled()},_custom:function(){n(".review-settings").show(),n(".field.custom-setting").show()},_inherited:function(){n(".review-settings").show(),n(".field.custom-setting").hide()},_disabled:function(){n(".review-settings").hide()}})})},function(n,t){window.jQuery.entwine("ss",function(n){function t(t){var e="ContentReviewOwnerID"+t,i=n("div.subsiteSpecificOwnerID"),o=0;for(o=0;o'),e("body").append(t)),t.open(),!1}}),e(".content-review-modal .content-review-modal__nav-link").entwine({onclick:function(n){n.preventDefault();var t=e(n.target);window.location=t.attr("href")}}),e("#content-review__dialog-wrapper").entwine({onunmatch:function(){this._clearModal()},open:function(){this._renderModal(!0)},close:function(){this._renderModal(!1)},_renderModal:function(n){var t=this,o=function(){return t.close()},r=function(){return t._handleSubmitModal.apply(t,arguments)},s=e("form.cms-edit-form :input[name=ID]").val(),c=window.ss.store,u=c.getState().config.sections.find(function(e){return"SilverStripe\\CMS\\Controllers\\CMSPageEditController"===e.name}),f=u.form.ReviewContentForm.schemaUrl+"/"+s,_=i.a._t("ContentReview.CONTENT_DUE_FOR_REVIEW","Content due for review");d.a.render(a.a.createElement(l.Provider,{store:c},a.a.createElement(h,{title:_,show:n,handleSubmit:r,handleHide:o,schemaUrl:f,bodyClassName:"modal__dialog",className:"content-review-modal",responseClassBad:"modal__response modal__response--error",responseClassGood:"modal__response modal__response--good",identifier:"ContentReview.CONTENT_DUE_FOR_REVIEW"})),this[0])},_clearModal:function(){d.a.unmountComponentAtNode(this[0])},_handleSubmitModal:function(e,n,t){return t()}})})},function(e,n,t){"use strict";var o=t(0);t.n(o).a.entwine("ss",function(e){e(".cms-edit-form #Form_EditForm_ContentReviewType_Holder").entwine({onmatch:function(){var e=this;this.find(".optionset :input").bind("change",function(n){e.show_option(n.target.value)});var n=this.find("input[name=ContentReviewType]:checked").val();this.show_option(n),this._super()},onunmatch:function(){return this._super()},show_option:function(e){"Custom"===e?this._custom():"Inherit"===e?this._inherited():this._disabled()},_custom:function(){e(".review-settings").show(),e(".field.custom-setting").show()},_inherited:function(){e(".review-settings").show(),e(".field.custom-setting").hide()},_disabled:function(){e(".review-settings").hide()}})})},function(e,n,t){"use strict";var o=t(0);t.n(o).a.entwine("ss",function(e){function n(n){var t="ContentReviewOwnerID"+n,o=e("div.subsiteSpecificOwnerID"),i=0;for(i=0;i {\n $('.review-notes input[name=\"action_savereview\"]').entwine({\n /**\n * Close the review popup when the form is submitted\n */\n onclick() {\n this._super();\n $('.contentreview-tab .nav-link').trigger('click');\n }\n });\n});\n\n\n\n// WEBPACK FOOTER //\n// ./client/src/bundles/ContentReviewPopup.js","window.jQuery.entwine('ss', ($) => {\n /**\n * Class: .cms-edit-form #Form_EditForm_ContentReviewType_Holder\n *\n * Toggle display of group dropdown in \"access\" tab,\n * based on selection of radiobuttons.\n */\n $('.cms-edit-form #Form_EditForm_ContentReviewType_Holder').entwine({\n // Constructor: onmatch\n onmatch() {\n const self = this;\n this.find('.optionset :input').bind('change', function (e) {\n self.show_option(e.target.value);\n });\n\n // initial state\n const currentVal = this.find(`input[name=${this.attr('id')}]:checked`).val();\n this.show_option(currentVal);\n this._super();\n },\n\n onunmatch() {\n return this._super();\n },\n\n show_option(value) {\n if (value === 'Custom') {\n this._custom();\n } else if (value === 'Inherit') {\n this._inherited();\n } else {\n this._disabled();\n }\n },\n\n _custom() {\n $('.review-settings').show();\n $('.field.custom-setting').show();\n },\n\n _inherited() {\n $('.review-settings').show();\n $('.field.custom-setting').hide();\n },\n\n _disabled() {\n $('.review-settings').hide();\n },\n });\n});\n\n\n\n// WEBPACK FOOTER //\n// ./client/src/bundles/ContentReviewSettings.js","/**\n * @todo Re-validate this with Subsites\n */\nwindow.jQuery.entwine('ss', ($) => {\n // Hide all owner dropdowns except the one for the current subsite\n function showCorrectSubsiteIDDropdown(value) {\n const domid = `ContentReviewOwnerID${value}`;\n\n const ownerIDDropdowns = $('div.subsiteSpecificOwnerID');\n let i = 0;\n for (i = 0; i < ownerIDDropdowns.length; i++) {\n if (ownerIDDropdowns[i].id === domid) {\n $(ownerIDDropdowns[i]).show();\n } else {\n $(ownerIDDropdowns[i]).hide();\n }\n }\n }\n\n $('#Form_EditForm_SubsiteIDWithOwner').entwine({\n // Call method to show on report load\n onmatch() {\n showCorrectSubsiteIDDropdown(this.value);\n },\n\n // Call method to show on dropdown change\n change() {\n showCorrectSubsiteIDDropdown(this.value);\n },\n });\n});\n\n\n\n// WEBPACK FOOTER //\n// ./client/src/bundles/PagesDueForReview.js","import 'bundles/PagesDueForReview.js';\nimport 'bundles/ContentReviewPopup.js';\nimport 'bundles/ContentReviewSettings.js';\n\n\n\n// WEBPACK FOOTER //\n// ./client/src/bundles/bundle.js"],"sourceRoot":""} \ No newline at end of file +{"version":3,"sources":["webpack:///webpack/bootstrap 2e84be8a35a52c0a303b","webpack:///external \"jQuery\"","webpack:///./client/src/bundles/ContentReviewForm.js","webpack:///./client/src/bundles/ContentReviewSettings.js","webpack:///./client/src/bundles/PagesDueForReview.js","webpack:///./client/src/bundles/bundle.js","webpack:///external \"FormBuilderModal\"","webpack:///external \"Injector\"","webpack:///external \"React\"","webpack:///external \"ReactDom\"","webpack:///external \"ReactRedux\"","webpack:///external \"i18n\""],"names":["InjectableFormBuilderModal","provideInjector","jQuery","entwine","$","onclick","e","preventDefault","dialog","length","append","open","$link","target","window","location","attr","onunmatch","_clearModal","_renderModal","close","show","handleHide","handleSubmit","_handleSubmitModal","id","val","store","ss","sectionConfigKey","sectionConfig","getState","config","sections","find","section","name","modalSchemaUrl","form","ReviewContentForm","schemaUrl","title","i18n","_t","ReactDOM","render","unmountComponentAtNode","data","action","submitFn","onmatch","self","bind","show_option","value","currentVal","_super","_custom","_inherited","_disabled","hide","showCorrectSubsiteIDDropdown","domid","ownerIDDropdowns","i","change"],"mappings":"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","file":"js/contentreview.js","sourcesContent":[" \t// The module cache\n \tvar installedModules = {};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// identity function for calling harmony imports with the correct context\n \t__webpack_require__.i = function(value) { return value; };\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, {\n \t\t\t\tconfigurable: false,\n \t\t\t\tenumerable: true,\n \t\t\t\tget: getter\n \t\t\t});\n \t\t}\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"\";\n\n \t// Load entry module and return exports\n \treturn __webpack_require__(__webpack_require__.s = 4);\n\n\n\n// WEBPACK FOOTER //\n// webpack/bootstrap 2e84be8a35a52c0a303b","module.exports = jQuery;\n\n\n//////////////////\n// WEBPACK FOOTER\n// external \"jQuery\"\n// module id = 0\n// module chunks = 0","import i18n from 'i18n';\nimport jQuery from 'jquery';\nimport React from 'react';\nimport ReactDOM from 'react-dom';\nimport { Provider } from 'react-redux';\nimport { provideInjector } from 'lib/Injector';\nimport FormBuilderModal from 'components/FormBuilderModal/FormBuilderModal';\n\nconst InjectableFormBuilderModal = provideInjector(FormBuilderModal);\n\n/**\n * \"Content due for review\" modal popup. See AddToCampaignForm.js in\n * silverstripe/admin for reference.\n */\njQuery.entwine('ss', ($) => {\n\t/**\n * Kick off a \"content due for review\" dialog from the CMS actions.\n */\n $('.cms-content-actions .content-review__button').entwine({\n onclick(e) {\n e.preventDefault();\n\n let dialog = $('#content-review__dialog-wrapper');\n\n if (!dialog.length) {\n dialog = $('
');\n $('body').append(dialog);\n }\n\n dialog.open();\n\n return false;\n },\n });\n\n // This is required because the React version of e.preventDefault() doesn't work\n // this is to prevent PJAX request to occur when clicking a link the modal\n $('.content-review-modal .content-review-modal__nav-link').entwine({\n onclick: (e) => {\n e.preventDefault();\n const $link = $(e.target);\n window.location = $link.attr('href');\n },\n });\n\n\t/**\n * Uses React-Bootstrap in order to replicate the bootstrap styling and JavaScript behaviour.\n */\n $('#content-review__dialog-wrapper').entwine({\n onunmatch() {\n // solves errors given by ReactDOM \"no matched root found\" error.\n this._clearModal();\n },\n\n open() {\n this._renderModal(true);\n },\n\n close() {\n this._renderModal(false);\n },\n\n _renderModal(show) {\n const handleHide = () => this.close();\n const handleSubmit = (...args) => this._handleSubmitModal(...args);\n const id = $('form.cms-edit-form :input[name=ID]').val();\n const store = window.ss.store;\n const sectionConfigKey = 'SilverStripe\\\\CMS\\\\Controllers\\\\CMSPageEditController';\n const sectionConfig = store.getState().config.sections\n .find((section) => section.name === sectionConfigKey);\n const modalSchemaUrl = `${sectionConfig.form.ReviewContentForm.schemaUrl}/${id}`;\n const title = i18n._t(\n 'SilverStripe\\\\ContentReview\\\\Models\\\\ContentReviewLog.CONTENT_DUE_FOR_REVIEW',\n 'Content due for review'\n );\n\n ReactDOM.render(\n \n \n ,\n this[0]\n );\n },\n\n _clearModal() {\n ReactDOM.unmountComponentAtNode(this[0]);\n // this.empty();\n },\n\n _handleSubmitModal(data, action, submitFn) {\n return submitFn();\n },\n });\n});\n\n\n\n// WEBPACK FOOTER //\n// ./client/src/bundles/ContentReviewForm.js","import jQuery from 'jquery';\n\njQuery.entwine('ss', ($) => {\n /**\n * Class: .cms-edit-form #Form_EditForm_ContentReviewType_Holder\n *\n * Toggle display of group dropdown in \"access\" tab,\n * based on selection of radiobuttons.\n */\n $('.cms-edit-form #Form_EditForm_ContentReviewType_Holder').entwine({\n // Constructor: onmatch\n onmatch() {\n const self = this;\n this.find('.optionset :input').bind('change', (e) => {\n self.show_option(e.target.value);\n });\n\n // initial state\n const currentVal = this.find('input[name=ContentReviewType]:checked').val();\n this.show_option(currentVal);\n this._super();\n },\n\n onunmatch() {\n return this._super();\n },\n\n show_option(value) {\n if (value === 'Custom') {\n this._custom();\n } else if (value === 'Inherit') {\n this._inherited();\n } else {\n this._disabled();\n }\n },\n\n _custom() {\n $('.review-settings').show();\n $('.field.custom-setting').show();\n },\n\n _inherited() {\n $('.review-settings').show();\n $('.field.custom-setting').hide();\n },\n\n _disabled() {\n $('.review-settings').hide();\n },\n });\n});\n\n\n\n// WEBPACK FOOTER //\n// ./client/src/bundles/ContentReviewSettings.js","import jQuery from 'jquery';\n\n/**\n * @todo Re-validate this with Subsites\n */\njQuery.entwine('ss', ($) => {\n // Hide all owner dropdowns except the one for the current subsite\n function showCorrectSubsiteIDDropdown(value) {\n const domid = `ContentReviewOwnerID${value}`;\n\n const ownerIDDropdowns = $('div.subsiteSpecificOwnerID');\n let i = 0;\n for (i = 0; i < ownerIDDropdowns.length; i++) {\n if (ownerIDDropdowns[i].id === domid) {\n $(ownerIDDropdowns[i]).show();\n } else {\n $(ownerIDDropdowns[i]).hide();\n }\n }\n }\n\n $('#Form_EditForm_SubsiteIDWithOwner').entwine({\n // Call method to show on report load\n onmatch() {\n showCorrectSubsiteIDDropdown(this.value);\n },\n\n // Call method to show on dropdown change\n change() {\n showCorrectSubsiteIDDropdown(this.value);\n },\n });\n});\n\n\n\n// WEBPACK FOOTER //\n// ./client/src/bundles/PagesDueForReview.js","import 'bundles/ContentReviewForm.js';\nimport 'bundles/ContentReviewSettings.js';\nimport 'bundles/PagesDueForReview.js';\n\n\n\n// WEBPACK FOOTER //\n// ./client/src/bundles/bundle.js","module.exports = FormBuilderModal;\n\n\n//////////////////\n// WEBPACK FOOTER\n// external \"FormBuilderModal\"\n// module id = 5\n// module chunks = 0","module.exports = Injector;\n\n\n//////////////////\n// WEBPACK FOOTER\n// external \"Injector\"\n// module id = 6\n// module chunks = 0","module.exports = React;\n\n\n//////////////////\n// WEBPACK FOOTER\n// external \"React\"\n// module id = 7\n// module chunks = 0","module.exports = ReactDom;\n\n\n//////////////////\n// WEBPACK FOOTER\n// external \"ReactDom\"\n// module id = 8\n// module chunks = 0","module.exports = ReactRedux;\n\n\n//////////////////\n// WEBPACK FOOTER\n// external \"ReactRedux\"\n// module id = 9\n// module chunks = 0","module.exports = i18n;\n\n\n//////////////////\n// WEBPACK FOOTER\n// external \"i18n\"\n// module id = 10\n// module chunks = 0"],"sourceRoot":""} \ No newline at end of file diff --git a/client/dist/styles/contentreview.css b/client/dist/styles/contentreview.css index 60cb873..3ff8498 100644 --- a/client/dist/styles/contentreview.css +++ b/client/dist/styles/contentreview.css @@ -1 +1 @@ -.cms .review-notes textarea{-webkit-box-sizing:border-box;box-sizing:border-box;height:60px;margin:4px 0;resize:none}.cms .ss-ui-action-tabset.action-menus.ss-tabset .ui-tabs-panel .review-notes label.left{margin:0;padding:0}.cms .ss-ui-action-tabset.action-menus.ss-tabset .ui-tabs-panel .review-notes .btn{width:auto}.cms .ss-ui-action-tabset.action-menus.ss-tabset .ui-tabs-nav .contentreview-tab a{width:20px;height:20px;background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAoCAYAAAD+MdrbAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAPlJREFUeNpi/P//PwM1AeOAGPg1h3EBiOae8j+BkFoWIi22pIoLgS5rAFIFQMwPFfoIxBNAGOjaDyQZCDTsApDSx2HXRSB2wGYoEx6X6ePxmT7U5QxEGYhLMTFqMLwMdJ0AkHpPZBwYAr19gZALDUhIdgLEeplsQBcDBUjQr4A3UoARQlbGBkYMI828TN/SBkeaFMSVjwl6GaqxEUmoEZ9hA1fAjkADL8dC6hTdxYOtTgG6DGedAnQtaXUK0DCCdQo2Q5nwuGyQ1ilA15FUpwC9PVqn0LJOAUYIWRkbGDHDpU7BkSYFceVjgl6GakSpU/AZNjRKbIAAAwDhCJTckjfR5wAAAABJRU5ErkJggg==) 50% no-repeat;background-position:0 0;text-indent:-9999px;padding:0;margin:2px 4px 0 12px}.cms .ss-ui-action-tabset.action-menus.ss-tabset .ui-tabs-nav .contentreview-tab a:focus,.cms .ss-ui-action-tabset.action-menus.ss-tabset .ui-tabs-nav .contentreview-tab a:hover{background-position:0 -20px}.cms .ui-tabs-panel.contentreview-tab .form-group .form__fieldgroup{margin:0;min-width:250px}.cms .ui-tabs-panel.contentreview-tab .form-group:after{border-bottom:0} \ No newline at end of file +.content-review__button{background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAoCAYAAAD+MdrbAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAPlJREFUeNpi/P//PwM1AeOAGPg1h3EBiOae8j+BkFoWIi22pIoLgS5rAFIFQMwPFfoIxBNAGOjaDyQZCDTsApDSx2HXRSB2wGYoEx6X6ePxmT7U5QxEGYhLMTFqMLwMdJ0AkHpPZBwYAr19gZALDUhIdgLEeplsQBcDBUjQr4A3UoARQlbGBkYMI828TN/SBkeaFMSVjwl6GaqxEUmoEZ9hA1fAjkADL8dC6hTdxYOtTgG6DGedAnQtaXUK0DCCdQo2Q5nwuGyQ1ilA15FUpwC9PVqn0LJOAUYIWRkbGDHDpU7BkSYFceVjgl6GakSpU/AZNjRKbIAAAwDhCJTckjfR5wAAAABJRU5ErkJggg==) 50% no-repeat;background-position:0 0;display:inline-block;height:20px;margin:6px 4px 0 12px;padding:0;text-indent:-9999px;width:20px}.content-review__button:focus,.content-review__button:hover{background-position:0 -20px} \ No newline at end of file diff --git a/client/dist/styles/contentreview.css.map b/client/dist/styles/contentreview.css.map index 1c5e84e..6a08807 100644 --- a/client/dist/styles/contentreview.css.map +++ b/client/dist/styles/contentreview.css.map @@ -1 +1 @@ -{"version":3,"sources":["webpack:///./client/src/styles/contentreview.scss?f47b","webpack:///./bundle.scss?6663"],"names":[],"mappings":"AAAA,4BAKI,oDACA,YACA,aACA,YCMH,yFDAO,SACA,UCUP,mFDNO,WCUP,mFDFK,WACA,YACA,uDACA,wBACA,oBACA,UACA,sBCML,kLDFO,4BCOP,oEDGK,SACA,gBCCL,wDDGK,gBCCL","file":"styles/contentreview.css","sourcesContent":[".cms {\n /**\n * The textarea for entering review comments\n */\n .review-notes textarea {\n box-sizing: border-box;\n height: 60px;\n margin: 4px 0;\n resize: none;\n }\n\n .ss-ui-action-tabset.action-menus.ss-tabset {\n .ui-tabs-panel .review-notes {\n label.left {\n margin: 0;\n padding: 0;\n }\n\n .btn {\n width: auto;\n }\n }\n\n /**\n * The icon showing that reviews are required (shows next to actions dropdown)\n */\n .ui-tabs-nav .contentreview-tab a {\n width: 20px;\n height: 20px;\n background: url('images/icon-bell.png') center center no-repeat;\n background-position: 0 0;\n text-indent: -9999px;\n padding: 0;\n margin: 2px 4px 0 12px;\n\n &:hover,\n &:focus {\n background-position: 0 -20px;\n }\n }\n }\n\n /**\n * The box container for the popup\n */\n .ui-tabs-panel.contentreview-tab .form-group {\n .form__fieldgroup {\n margin: 0;\n min-width: 250px;\n }\n\n &:after {\n border-bottom: 0;\n }\n }\n}\n\n\n\n// WEBPACK FOOTER //\n// ./client/src/styles/contentreview.scss",".cms {\n /**\n * The textarea for entering review comments\n */\n /**\n * The box container for the popup\n */\n}\n\n.cms .review-notes textarea {\n box-sizing: border-box;\n height: 60px;\n margin: 4px 0;\n resize: none;\n}\n\n.cms .ss-ui-action-tabset.action-menus.ss-tabset {\n /**\n * The icon showing that reviews are required (shows next to actions dropdown)\n */\n}\n\n.cms .ss-ui-action-tabset.action-menus.ss-tabset .ui-tabs-panel .review-notes label.left {\n margin: 0;\n padding: 0;\n}\n\n.cms .ss-ui-action-tabset.action-menus.ss-tabset .ui-tabs-panel .review-notes .btn {\n width: auto;\n}\n\n.cms .ss-ui-action-tabset.action-menus.ss-tabset .ui-tabs-nav .contentreview-tab a {\n width: 20px;\n height: 20px;\n background: url(\"../images/icon-bell.png\") center center no-repeat;\n background-position: 0 0;\n text-indent: -9999px;\n padding: 0;\n margin: 2px 4px 0 12px;\n}\n\n.cms .ss-ui-action-tabset.action-menus.ss-tabset .ui-tabs-nav .contentreview-tab a:hover,\n.cms .ss-ui-action-tabset.action-menus.ss-tabset .ui-tabs-nav .contentreview-tab a:focus {\n background-position: 0 -20px;\n}\n\n.cms .ui-tabs-panel.contentreview-tab .form-group .form__fieldgroup {\n margin: 0;\n min-width: 250px;\n}\n\n.cms .ui-tabs-panel.contentreview-tab .form-group:after {\n border-bottom: 0;\n}\n\n\n\n\n// WEBPACK FOOTER //\n// ./bundle.scss"],"sourceRoot":""} \ No newline at end of file +{"version":3,"sources":["webpack:///./client/src/styles/ContentReviewForm.scss?a3bd","webpack:///./bundle.scss?6663"],"names":[],"mappings":"AAEA,wBACE,uDACA,wBACA,qBACA,YACA,sBACA,UACA,oBACA,WCDD,4DDKG,4BCAH","file":"styles/contentreview.css","sourcesContent":["// The bell button, shows up next to the major actions (save, publish, etc) when\n// viewing a page in the CMS\n.content-review__button {\n background: url(\"images/icon-bell.png\") center center no-repeat;\n background-position: 0 0;\n display: inline-block;\n height: 20px;\n margin: 6px 4px 0 12px;\n padding: 0;\n text-indent: -9999px;\n width: 20px;\n\n &:hover,\n &:focus {\n background-position: 0 -20px;\n }\n}\n\n\n\n// WEBPACK FOOTER //\n// ./client/src/styles/ContentReviewForm.scss",".content-review__button {\n background: url(\"../images/icon-bell.png\") center center no-repeat;\n background-position: 0 0;\n display: inline-block;\n height: 20px;\n margin: 6px 4px 0 12px;\n padding: 0;\n text-indent: -9999px;\n width: 20px;\n}\n\n.content-review__button:hover,\n.content-review__button:focus {\n background-position: 0 -20px;\n}\n\n\n\n\n// WEBPACK FOOTER //\n// ./bundle.scss"],"sourceRoot":""} \ No newline at end of file diff --git a/client/lang/src/en.json b/client/lang/src/en.json new file mode 100644 index 0000000..c4a20b4 --- /dev/null +++ b/client/lang/src/en.json @@ -0,0 +1,3 @@ +{ + "ContentReview.CONTENT_DUE_FOR_REVIEW": "Content due for review" +} diff --git a/client/src/bundles/ContentReviewForm.js b/client/src/bundles/ContentReviewForm.js new file mode 100644 index 0000000..9ad4895 --- /dev/null +++ b/client/src/bundles/ContentReviewForm.js @@ -0,0 +1,101 @@ +import i18n from 'i18n'; +import jQuery from 'jquery'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { provideInjector } from 'lib/Injector'; +import FormBuilderModal from 'components/FormBuilderModal/FormBuilderModal'; + +const InjectableFormBuilderModal = provideInjector(FormBuilderModal); + +/** + * "Content due for review" modal popup. See AddToCampaignForm.js in + * silverstripe/admin for reference. + */ +jQuery.entwine('ss', ($) => { + /** + * Kick off a "content due for review" dialog from the CMS actions. + */ + $('.cms-content-actions .content-review__button').entwine({ + onclick(e) { + e.preventDefault(); + + let dialog = $('#content-review__dialog-wrapper'); + + if (!dialog.length) { + dialog = $('
'); + $('body').append(dialog); + } + + dialog.open(); + + return false; + }, + }); + + // This is required because the React version of e.preventDefault() doesn't work + // this is to prevent PJAX request to occur when clicking a link the modal + $('.content-review-modal .content-review-modal__nav-link').entwine({ + onclick: (e) => { + e.preventDefault(); + const $link = $(e.target); + window.location = $link.attr('href'); + }, + }); + + /** + * Uses React-Bootstrap in order to replicate the bootstrap styling and JavaScript behaviour. + */ + $('#content-review__dialog-wrapper').entwine({ + onunmatch() { + // solves errors given by ReactDOM "no matched root found" error. + this._clearModal(); + }, + + open() { + this._renderModal(true); + }, + + close() { + this._renderModal(false); + }, + + _renderModal(show) { + const handleHide = () => this.close(); + const handleSubmit = (...args) => this._handleSubmitModal(...args); + const id = $('form.cms-edit-form :input[name=ID]').val(); + const store = window.ss.store; + const sectionConfigKey = 'SilverStripe\\CMS\\Controllers\\CMSPageEditController'; + const sectionConfig = store.getState().config.sections + .find((section) => section.name === sectionConfigKey); + const modalSchemaUrl = `${sectionConfig.form.ReviewContentForm.schemaUrl}/${id}`; + const title = i18n._t('ContentReview.CONTENT_DUE_FOR_REVIEW', 'Content due for review'); + + ReactDOM.render( + + + , + this[0] + ); + }, + + _clearModal() { + ReactDOM.unmountComponentAtNode(this[0]); + }, + + _handleSubmitModal(data, action, submitFn) { + return submitFn(); + }, + }); +}); diff --git a/client/src/bundles/ContentReviewPopup.js b/client/src/bundles/ContentReviewPopup.js deleted file mode 100644 index 4e595f8..0000000 --- a/client/src/bundles/ContentReviewPopup.js +++ /dev/null @@ -1,11 +0,0 @@ -window.jQuery.entwine('ss', ($) => { - $('.review-notes input[name="action_savereview"]').entwine({ - /** - * Close the review popup when the form is submitted - */ - onclick() { - this._super(); - $('.contentreview-tab .nav-link').trigger('click'); - }, - }); -}); diff --git a/client/src/bundles/ContentReviewSettings.js b/client/src/bundles/ContentReviewSettings.js index dbab394..11300d6 100644 --- a/client/src/bundles/ContentReviewSettings.js +++ b/client/src/bundles/ContentReviewSettings.js @@ -1,4 +1,6 @@ -window.jQuery.entwine('ss', ($) => { +import jQuery from 'jquery'; + +jQuery.entwine('ss', ($) => { /** * Class: .cms-edit-form #Form_EditForm_ContentReviewType_Holder * diff --git a/client/src/bundles/PagesDueForReview.js b/client/src/bundles/PagesDueForReview.js index 4fa98fb..f8cec4e 100644 --- a/client/src/bundles/PagesDueForReview.js +++ b/client/src/bundles/PagesDueForReview.js @@ -1,7 +1,9 @@ +import jQuery from 'jquery'; + /** * @todo Re-validate this with Subsites */ -window.jQuery.entwine('ss', ($) => { +jQuery.entwine('ss', ($) => { // Hide all owner dropdowns except the one for the current subsite function showCorrectSubsiteIDDropdown(value) { const domid = `ContentReviewOwnerID${value}`; diff --git a/client/src/bundles/bundle.js b/client/src/bundles/bundle.js index 5c51988..cf5a1d3 100644 --- a/client/src/bundles/bundle.js +++ b/client/src/bundles/bundle.js @@ -1,3 +1,3 @@ -import 'bundles/PagesDueForReview.js'; -import 'bundles/ContentReviewPopup.js'; +import 'bundles/ContentReviewForm.js'; import 'bundles/ContentReviewSettings.js'; +import 'bundles/PagesDueForReview.js'; diff --git a/client/src/styles/ContentReviewForm.scss b/client/src/styles/ContentReviewForm.scss new file mode 100644 index 0000000..4eaec1b --- /dev/null +++ b/client/src/styles/ContentReviewForm.scss @@ -0,0 +1,17 @@ +// The bell button, shows up next to the major actions (save, publish, etc) when +// viewing a page in the CMS +.content-review__button { + background: url("images/icon-bell.png") center center no-repeat; + background-position: 0 0; + display: inline-block; + height: 20px; + margin: 6px 4px 0 12px; + padding: 0; + text-indent: -9999px; + width: 20px; + + &:hover, + &:focus { + background-position: 0 -20px; + } +} diff --git a/client/src/styles/bundle.scss b/client/src/styles/bundle.scss index 556ba47..ae52d95 100644 --- a/client/src/styles/bundle.scss +++ b/client/src/styles/bundle.scss @@ -1,4 +1,4 @@ // See includePaths in webpack.config.js @import "variables"; -@import "contentreview"; +@import "ContentReviewForm"; diff --git a/client/src/styles/contentreview.scss b/client/src/styles/contentreview.scss deleted file mode 100644 index abdfb18..0000000 --- a/client/src/styles/contentreview.scss +++ /dev/null @@ -1,50 +0,0 @@ -.cms { - // The textarea for entering review comments - .review-notes textarea { - box-sizing: border-box; - height: 60px; - margin: 4px 0; - resize: none; - } - - .ss-ui-action-tabset.action-menus.ss-tabset { - .ui-tabs-panel .review-notes { - label.left { - margin: 0; - padding: 0; - } - - .btn { - width: auto; - } - } - - // The icon showing that reviews are required (shows next to actions dropdown) - .ui-tabs-nav .contentreview-tab a { - width: 20px; - height: 20px; - background: url("images/icon-bell.png") center center no-repeat; - background-position: 0 0; - text-indent: -9999px; - padding: 0; - margin: 2px 4px 0 12px; - - &:hover, - &:focus { - background-position: 0 -20px; - } - } - } - - // The box container for the popup - .ui-tabs-panel.contentreview-tab .form-group { - .form__fieldgroup { - margin: 0; - min-width: 250px; - } - - &:after { - border-bottom: 0; - } - } -} diff --git a/lang/en.yml b/lang/en.yml index 8bf285c..447ba37 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -46,3 +46,11 @@ en: TITLE: 'Pages without a scheduled review.' Review: EMAILFROM_RIGHTTITLE: 'e.g: do-not-reply@site.com' + + SilverStripe\ContentReview\Forms\ReviewContentHandler: + ContentDueForReview: Content due for review + ErrorReviewPermissionDenied: It seems you don't have the necessary permissions to submit a content review + MarkAsReviewedAction: Mark as reviewed + NoComments: (no comments) + ObjectDoesntExist: That object doesn't exist + Success: Review successfully added diff --git a/package.json b/package.json index 0c88574..9bde7b7 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,13 @@ "url": "https://github.com/silverstripe/silverstripe-contentreview/issues" }, "homepage": "https://github.com/silverstripe/silverstripe-contentreview#readme", + "dependencies": { + "jquery": "^3.2.1", + "react": "15.3.1", + "react-dom": "15.3.1", + "react-redux": "^4.4.1", + "redux": "https://registry.npmjs.org/redux/-/redux-3.0.5.tgz" + }, "devDependencies": { "@silverstripe/webpack-config": "^0.2.5", "babel-core": "^6.24.1", @@ -31,9 +38,6 @@ "eslint": "^2.7.0", "eslint-config-airbnb": "^6.2.0" }, - "dependencies": { - "jquery": "^3.2.1" - }, "engines": { "node": "^6.x" } diff --git a/src/Extensions/ContentReviewCMSExtension.php b/src/Extensions/ContentReviewCMSExtension.php index 9b2095c..8210bb3 100644 --- a/src/Extensions/ContentReviewCMSExtension.php +++ b/src/Extensions/ContentReviewCMSExtension.php @@ -2,11 +2,15 @@ namespace SilverStripe\ContentReview\Extensions; +use SilverStripe\Admin\LeftAndMain; use SilverStripe\Admin\LeftAndMainExtension; use SilverStripe\CMS\Model\SiteTree; -use SilverStripe\Control\HTTPResponse_Exception; +use SilverStripe\ContentReview\Forms\ReviewContentHandler; +use SilverStripe\Control\HTTPRequest; +use SilverStripe\Control\HTTPResponse; +use SilverStripe\Core\Convert; use SilverStripe\Forms\Form; -use SilverStripe\Security\Member; +use SilverStripe\ORM\ValidationResult; use SilverStripe\Security\Security; /** @@ -15,64 +19,127 @@ use SilverStripe\Security\Security; */ class ContentReviewCMSExtension extends LeftAndMainExtension { - /** - * @var array - */ - private static $allowed_actions = array( - "savereview" - ); + private static $allowed_actions = [ + 'ReviewContentForm', + 'submitReview', + ]; /** - * Save the review notes and redirect back to the page edit form. + * URL handler for the "content due for review" form + * + * @param HTTPRequest $request + * @return Form|null + */ + public function ReviewContentForm(HTTPRequest $request) + { + // Get ID either from posted back value, or url parameter + $id = $request->param('ID') ?: $request->postVar('ID'); + return $this->getReviewContentForm($id); + } + + /** + * Return a handler for "content due for review" forms, according to the given object ID + * + * @param int $id + * @return Form|null + */ + public function getReviewContentForm($id) + { + $record = SiteTree::get()->byID($id); + + if (!$record) { + $this->owner->httpError(404, _t(__CLASS__ . '.ErrorNotFound', 'That object couldn\'t be found')); + return null; + } + + $user = Security::getCurrentUser(); + if (!$record->canEdit() || ($record->hasMethod('canBeReviewedBy') && !$record->canBeReviewedBy($user))) { + $this->owner->httpError(403, _t( + __CLASS__.'.ErrorItemPermissionDenied', + 'It seems you don\'t have the necessary permissions to review this content' + )); + return null; + } + + $handler = ReviewContentHandler::create($this->owner, $record); + $form = $handler->Form($record); + + $form->setValidationResponseCallback(function (ValidationResult $errors) use ($form, $id) { + $schemaId = $this->owner->join_links($this->owner->Link('schema/ReviewContentForm'), $id); + return $this->owner->getSchemaResponse($schemaId, $form, $errors); + }); + + return $form; + } + + /** + * Action handler for processing the submitted content review * * @param array $data - * @param Form $form - * - * @return string - * - * @throws HTTPResponse_Exception + * @param Form $form + * @return DBHTMLText|HTTPResponse */ - public function savereview($data, Form $form) + public function submitReview($data = '', $form = '') { - $page = $this->findRecord($data); - if (!$page->canEdit()) { - return Security::permissionFailure($this->owner); + $id = $data['ID']; + $record = SiteTree::get()->byID($id); + + $handler = ReviewContentHandler::create($this->owner, $record); + $form = $handler->Form($record); + $results = $handler->submitReview($record, $data); + if (is_null($results)) { + return null; } - $notes = (!empty($data["ReviewNotes"]) - ? $data["ReviewNotes"] - : _t("ContentReview.NOCOMMENTS", "(no comments)")); - $page->addReviewNote(Member::currentUser(), $notes); - $page->advanceReviewDate(); + if ($this->getSchemaRequested()) { + // Send extra "message" data with schema response + $extraData = ['message' => $results]; + $schemaId = $this->owner->join_links($this->owner->Link('schema/ReviewContentForm'), $id); + return $this->getSchemaResponse($schemaId, $form, null, $extraData); + } - $this->owner->getResponse() - ->addHeader("X-Status", _t("ContentReview.REVIEWSUCCESSFUL", "Content reviewed successfully")); - return $this->owner->redirectBack(); + return $results; } /** - * Find the page this form is updating + * Check if the current request has a X-Formschema-Request header set. + * Used by conditional logic that responds to validation results * - * @param array $data Form data - * @return SiteTree Record - * @throws SS_HTTPResponse_Exception + * @todo Remove duplication. See https://github.com/silverstripe/silverstripe-admin/issues/240 + * + * @return bool */ - protected function findRecord($data) + protected function getSchemaRequested() { - if (empty($data["ID"])) { - throw new HTTPResponse_Exception("No record ID", 404); + $parts = $this->owner->getRequest()->getHeader(LeftAndMain::SCHEMA_HEADER); + return !empty($parts); + } + + /** + * Generate schema for the given form based on the X-Formschema-Request header value + * + * @todo Remove duplication. See https://github.com/silverstripe/silverstripe-admin/issues/240 + * + * @param string $schemaID ID for this schema. Required. + * @param Form $form Required for 'state' or 'schema' response + * @param ValidationResult $errors Required for 'error' response + * @param array $extraData Any extra data to be merged with the schema response + * @return HTTPResponse + */ + protected function getSchemaResponse($schemaID, $form = null, ValidationResult $errors = null, $extraData = []) + { + $parts = $this->owner->getRequest()->getHeader(LeftAndMain::SCHEMA_HEADER); + $data = $this->owner + ->getFormSchema() + ->getMultipartSchema($parts, $schemaID, $form, $errors); + + if ($extraData) { + $data = array_merge($data, $extraData); } - $page = null; - $id = $data["ID"]; - if (is_numeric($id)) { - $page = SiteTree::get()->byID($id); - } + $response = HTTPResponse::create(Convert::raw2json($data)); + $response->addHeader('Content-Type', 'application/json'); - if (!$page || !$page->ID) { - throw new HTTPResponse_Exception("Bad record ID #{$id}", 404); - } - - return $page; + return $response; } } diff --git a/src/Extensions/ContentReviewLeftAndMainExtension.php b/src/Extensions/ContentReviewLeftAndMainExtension.php new file mode 100644 index 0000000..03ce9d1 --- /dev/null +++ b/src/Extensions/ContentReviewLeftAndMainExtension.php @@ -0,0 +1,20 @@ + $this->owner->Link('schema/ReviewContentForm') + ]; + } +} diff --git a/src/Extensions/SiteTreeContentReview.php b/src/Extensions/SiteTreeContentReview.php index 337a1c9..0ebd77d 100644 --- a/src/Extensions/SiteTreeContentReview.php +++ b/src/Extensions/SiteTreeContentReview.php @@ -161,28 +161,15 @@ class SiteTreeContentReview extends DataExtension implements PermissionProvider */ public function updateCMSActions(FieldList $actions) { - if ($this->canBeReviewedBy(Security::getCurrentUser())) { - Requirements::css('silverstripe/contentreview:client/dist/styles/contentreview.css'); - Requirements::javascript('silverstripe/contentreview:client/dist/js/contentreview.js'); - - $reviewTitle = LiteralField::create( - "ReviewContentNotesLabel", - "" - ); - - $ReviewNotes = LiteralField::create("ReviewNotes", ""); - - $quickReviewAction = FormAction::create("savereview", _t("ContentReview.MARKREVIEWED", "Mark as reviewed")) - ->addExtraClass('btn btn-primary'); - - $allFields = CompositeField::create($reviewTitle, $ReviewNotes, $quickReviewAction) - ->addExtraClass('review-notes field'); - - $reviewTab = Tab::create('ReviewContent', $allFields); - $reviewTab->addExtraClass('contentreview-tab'); - - $actions->fieldByName('ActionMenus')->insertBefore($reviewTab, 'MoreOptions'); + if (!$this->canBeReviewedBy(Security::getCurrentUser())) { + return; } + + Requirements::css('silverstripe/contentreview:client/dist/styles/contentreview.css'); + Requirements::javascript('silverstripe/contentreview:client/dist/js/contentreview.js'); + + $reviewTab = LiteralField::create('ContentReviewButton', $this->owner->renderWith(__CLASS__ . '_button')); + $actions->insertAfter('MajorActions', $reviewTab); } /** diff --git a/src/Forms/ReviewContentHandler.php b/src/Forms/ReviewContentHandler.php new file mode 100644 index 0000000..bb09164 --- /dev/null +++ b/src/Forms/ReviewContentHandler.php @@ -0,0 +1,139 @@ +controller = $controller; + if ($data instanceof DataObject) { + $data = $data->toMap(); + } + $this->data = $data; + $this->name = $name; + } + + /** + * Bootstrap the form fields for the content review modal + * + * @param DataObject $object + * @return Form + */ + public function Form($object) + { + $placeholder = 'Add comments (optional)'; + + $fields = FieldList::create([ + HiddenField::create('ID', null, $object->ID), + HiddenField::create('ClassName', null, $object->baseClass()), + TextareaField::create('Review', '') + ->setAttribute('placeholder', $placeholder) + ->setSchemaData(['attributes' => ['placeholder' => $placeholder]]) + ]); + + $actions = FieldList::create([ + ReviewContentHandlerFormAction::create() + ->setTitle(_t(__CLASS__ . '.MarkAsReviewedAction', 'Mark as reviewed')) + ->addExtraClass('review-content__action') + ]); + + $form = Form::create($this->controller, $this->name, $fields, $actions); + + $form->setHTMLID('Form_EditForm_ReviewContent'); + $form->addExtraClass('form--no-dividers review-content__form'); + + return $form; + } + + /** + * Validate, and save the submitted form's review + * + * @param DataObject $record + * @param array $data + * @return HTTPResponse|string + */ + public function submitReview($record, $data) + { + if (!$record || !$record->exists()) { + throw new ValidationException(_t(__CLASS__ . '.ObjectDoesntExist', 'That object doesn\'t exist')); + } + + if (!$record->canEdit() + || !$record->hasMethod('canBeReviewedBy') + || !$record->canBeReviewedBy(Security::getCurrentUser()) + ) { + throw new ValidationException(_t( + __CLASS__ . '.ErrorReviewPermissionDenied', + 'It seems you don\'t have the necessary permissions to submit a content review' + )); + } + + $this->saveRecord($record, $data); + + $request = $this->controller->getRequest(); + $message = _t(__CLASS__ . '.Success', 'Review successfully added'); + + if ($request->getHeader('X-Formschema-Request')) { + return $message; + } elseif (Director::is_ajax()) { + $response = HTTPResponse::create($message, 200); + $response->addHeader('Content-Type', 'text/html; charset=utf-8'); + return $response; + } else { + return $this->controller->redirectBack(); + } + } + + /** + * Save the review provided in $data to the $record + * + * @param DataObject $record + * @param array $data + */ + protected function saveRecord($record, $data) + { + $notes = (!empty($data['Review']) ? $data['Review'] : _t(__CLASS__ . '.NoComments', '(no comments)')); + $record->addReviewNote(Security::getCurrentUser(), $notes); + $record->advanceReviewDate(); + } +} diff --git a/src/Forms/ReviewContentHandlerFormAction.php b/src/Forms/ReviewContentHandlerFormAction.php new file mode 100644 index 0000000..321abfd --- /dev/null +++ b/src/Forms/ReviewContentHandlerFormAction.php @@ -0,0 +1,19 @@ +setUseButtonTag(false) + ->addExtraClass('review-content-action btn btn-primary'); + } +} diff --git a/templates/SilverStripe/ContentReview/Extensions/SiteTreeContentReview_button.ss b/templates/SilverStripe/ContentReview/Extensions/SiteTreeContentReview_button.ss new file mode 100644 index 0000000..ba5d93b --- /dev/null +++ b/templates/SilverStripe/ContentReview/Extensions/SiteTreeContentReview_button.ss @@ -0,0 +1,3 @@ + diff --git a/webpack.config.js b/webpack.config.js index f7b3e4e..fa7090e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,4 @@ const Path = require('path'); -const webpack = require('webpack'); // Import the core config const webpackConfig = require('@silverstripe/webpack-config'); const { @@ -14,11 +13,11 @@ const { const ENV = process.env.NODE_ENV; const PATHS = { MODULES: 'node_modules', + THIRDPARTY: 'thirdparty', FILES_PATH: '../', ROOT: Path.resolve(), SRC: Path.resolve('client/src'), DIST: Path.resolve('client/dist'), - THIRDPARTY: Path.resolve('thirdparty'), }; const config = [ @@ -33,6 +32,7 @@ const config = [ }, devtool: (ENV !== 'production') ? 'source-map' : '', resolve: resolveJS(ENV, PATHS), + externals: externalJS(ENV, PATHS), module: moduleJS(ENV, PATHS), plugins: pluginJS(ENV, PATHS), }, diff --git a/yarn.lock b/yarn.lock index 9d246ed..b7d1001 100644 --- a/yarn.lock +++ b/yarn.lock @@ -197,6 +197,10 @@ arrify@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + asn1.js@^4.0.0: version "4.9.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.9.1.tgz#48ba240b45a9280e94748990ba597d216617fd40" @@ -1171,6 +1175,10 @@ convert-source-map@^1.1.1, convert-source-map@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.0.tgz#9acd70851c6d5dfdd93d9282e5edf94a03ff46b5" +core-js@^1.0.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" + core-js@^2.4.0, core-js@^2.5.0: version "2.5.1" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.1.tgz#ae6874dc66937789b80754ff5428df66819ca50b" @@ -1218,6 +1226,14 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: safe-buffer "^5.0.1" sha.js "^2.4.8" +create-react-class@^15.5.1: + version "15.6.0" + resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.0.tgz#ab448497c26566e1e29413e883207d57cfe7bed4" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.3.1" + object-assign "^4.1.1" + cross-spawn@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" @@ -1463,6 +1479,12 @@ emojis-list@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" +encoding@^0.1.11: + version "0.1.12" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" + dependencies: + iconv-lite "~0.4.13" + enhanced-resolve@^3.3.0: version "3.4.1" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e" @@ -1708,6 +1730,18 @@ fastparse@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.1.tgz#d1e2643b38a94d7583b479060e6c4affc94071f8" +fbjs@^0.8.4, fbjs@^0.8.9: + version "0.8.15" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.15.tgz#4f0695fdfcc16c37c0b07facec8cb4c4091685b9" + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.9" + figures@^1.3.5: version "1.7.0" resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" @@ -2046,6 +2080,10 @@ hoek@2.x.x: version "2.16.3" resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" +hoist-non-react-statics@^1.0.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" + home-or-tmp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" @@ -2073,6 +2111,10 @@ https-browserify@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82" +iconv-lite@~0.4.13: + version "0.4.19" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" + icss-replace-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" @@ -2161,7 +2203,7 @@ interpret@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.3.tgz#cbc35c62eeee73f19ab7b10a801511401afc0f90" -invariant@^2.2.2: +invariant@^2.0.0, invariant@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360" dependencies: @@ -2304,6 +2346,10 @@ is-resolvable@^1.0.0: dependencies: tryit "^1.0.1" +is-stream@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + is-svg@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-2.1.0.tgz#cf61090da0d9efbcab8722deba6f032208dbb0e9" @@ -2336,6 +2382,13 @@ isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" +isomorphic-fetch@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" + dependencies: + node-fetch "^1.0.1" + whatwg-fetch ">=0.10.0" + isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -2621,7 +2674,7 @@ lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" -lodash@4.17.4, lodash@^4.0.0, lodash@^4.14.0, lodash@^4.17.4, lodash@^4.3.0, lodash@~4.17.4: +lodash@4.17.4, lodash@^4.0.0, lodash@^4.14.0, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.3.0, lodash@~4.17.4: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" @@ -2629,7 +2682,7 @@ longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" -loose-envify@^1.0.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" dependencies: @@ -2807,6 +2860,13 @@ nan@^2.3.0, nan@^2.3.2: version "2.7.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.7.0.tgz#d95bf721ec877e08db276ed3fc6eb78f9083ad46" +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + node-gyp@^3.3.1: version "3.6.2" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.6.2.tgz#9bfbe54562286284838e750eac05295853fa1c60" @@ -2952,7 +3012,7 @@ oauth-sign@~0.8.1: version "0.8.2" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" -object-assign@^4.0.1, object-assign@^4.1.0: +object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -3440,6 +3500,19 @@ progress@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + dependencies: + asap "~2.0.3" + +prop-types@^15.5.4: + version "15.5.10" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.3.1" + prr@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a" @@ -3515,6 +3588,29 @@ rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-dom@15.3.1: + version "15.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.3.1.tgz#6d42cd2b64c8c5e0b693f3ffaec301e6e627e24e" + +react-redux@^4.4.1: + version "4.4.8" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-4.4.8.tgz#e7bc1dd100e8b64e96ac8212db113239b9e2e08f" + dependencies: + create-react-class "^15.5.1" + hoist-non-react-statics "^1.0.3" + invariant "^2.0.0" + lodash "^4.2.0" + loose-envify "^1.1.0" + prop-types "^15.5.4" + +react@15.3.1: + version "15.3.1" + resolved "https://registry.yarnpkg.com/react/-/react-15.3.1.tgz#f78501ed8c2ec6e6e31c3223652e97f1369d2bd6" + dependencies: + fbjs "^0.8.4" + loose-envify "^1.1.0" + object-assign "^4.1.0" + read-pkg-up@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" @@ -3580,6 +3676,10 @@ reduce-function-call@^1.0.1: dependencies: balanced-match "^0.4.2" +"redux@https://registry.npmjs.org/redux/-/redux-3.0.5.tgz": + version "3.0.5" + resolved "https://registry.npmjs.org/redux/-/redux-3.0.5.tgz#f3f23f780b98c8dd7f84b9187ab5f86fe90199b8" + regenerate@^1.2.1: version "1.3.2" resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.2.tgz#d1941c67bad437e1be76433add5b385f95b19260" @@ -3857,7 +3957,7 @@ set-immediate-shim@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" -setimmediate@^1.0.4: +setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" @@ -4191,6 +4291,10 @@ typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" +ua-parser-js@^0.7.9: + version "0.7.14" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.14.tgz#110d53fa4c3f326c121292bbeac904d2e03387ca" + uglify-js@^2.8.27: version "2.8.29" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd" @@ -4338,6 +4442,10 @@ webpack@^2.6.1: webpack-sources "^1.0.1" yargs "^6.0.0" +whatwg-fetch@>=0.10.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84" + whet.extend@~0.9.9: version "0.9.9" resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"