diff --git a/_config.php b/_config.php index 9e519d4a..71c7914c 100644 --- a/_config.php +++ b/_config.php @@ -5,5 +5,4 @@ // Avoid creating global variables call_user_func(function () { - }); diff --git a/_config/config.yml b/_config/config.yml index 553ede50..1f778d13 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -1,23 +1,6 @@ --- Name: linkfield --- - SilverStripe\Admin\LeftAndMain: extensions: - - SilverStripe\LinkField\Extensions\LeftAndMain - -SilverStripe\Admin\ModalController: - extensions: - - SilverStripe\LinkField\Extensions\ModalController - -SilverStripe\Forms\TreeDropdownField: - extensions: - - SilverStripe\LinkField\Extensions\AjaxField - -SilverStripe\CMS\Forms\AnchorSelectorField: - extensions: - - SilverStripe\LinkField\Extensions\AjaxField - -SilverStripe\LinkField\Form\FormFactory: - extensions: - - SilverStripe\LinkField\Extensions\FormFactoryExtension + - SilverStripe\LinkField\Extensions\LeftAndMainExtension diff --git a/_config/types.yml b/_config/types.yml index 29b5dcbb..84960245 100644 --- a/_config/types.yml +++ b/_config/types.yml @@ -1,7 +1,6 @@ --- Name: linkfield-types --- - SilverStripe\LinkField\Type\Registry: types: cms: diff --git a/_graphql/queries.yml b/_graphql/queries.yml index 9775dadb..8010e060 100644 --- a/_graphql/queries.yml +++ b/_graphql/queries.yml @@ -1,6 +1,3 @@ -'readLinkDescription(dataStr: String!)': - type: LinkDescription - resolver: ['SilverStripe\LinkField\GraphQL\LinkDescriptionResolver', 'resolve'] 'readLinkTypes(keys: [ID])': type: '[LinkType]' resolver: ['SilverStripe\LinkField\GraphQL\LinkTypeResolver', 'resolve'] diff --git a/_graphql/types.yml b/_graphql/types.yml index 70525b16..8af9de12 100644 --- a/_graphql/types.yml +++ b/_graphql/types.yml @@ -1,11 +1,5 @@ -LinkDescription: - description: Given some Link data, computes the matching description - fields: - description: String - LinkType: description: Describe a Type of Link that can be managed by a LinkField fields: key: ID - handlerName: String! title: String! diff --git a/client/dist/js/bundle.js b/client/dist/js/bundle.js index 8726ffc2..79727623 100644 --- a/client/dist/js/bundle.js +++ b/client/dist/js/bundle.js @@ -1 +1,787 @@ -!function(){"use strict";var e={274:function(e,t,n){i(n(510));var r=i(n(180)),a=i(n(521)),o=i(n(154));function i(e){return e&&e.__esModule?e:{default:e}}document.addEventListener("DOMContentLoaded",(()=>{(0,a.default)(),(0,o.default)(),(0,r.default)()}))},521:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=u(n(648)),a=u(n(809)),o=u(n(852)),i=u(n(117)),l=u(n(606));function u(e){return e&&e.__esModule?e:{default:e}}var d=()=>{r.default.component.registerMany({LinkPicker:a.default,LinkField:o.default,"LinkModal.FormBuilderModal":i.default,"LinkModal.InsertMediaModal":l.default})};t.default=d},154:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=i(n(648)),a=i(n(689)),o=i(n(287));function i(e){return e&&e.__esModule?e:{default:e}}var l=()=>{r.default.query.register("readLinkTypes",a.default),r.default.query.register("readLinkDescription",o.default)};t.default=l},180:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r;(r=n(648))&&r.__esModule,n(827);var a=()=>{};t.default=a},852:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r,a=function(e,t){if(!t&&e&&e.__esModule)return e;if(null===e||"object"!=typeof e&&"function"!=typeof e)return{default:e};var n=u(t);if(n&&n.has(e))return n.get(e);var r={},a=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var o in e)if("default"!==o&&Object.prototype.hasOwnProperty.call(e,o)){var i=a?Object.getOwnPropertyDescriptor(e,o):null;i&&(i.get||i.set)?Object.defineProperty(r,o,i):r[o]=e[o]}r.default=e,n&&n.set(e,r);return r}(n(363)),o=n(827),i=n(648),l=(r=n(42))&&r.__esModule?r:{default:r};function u(e){if("function"!=typeof WeakMap)return null;var t=new WeakMap,n=new WeakMap;return(u=function(e){return e?n:t})(e)}function d(){return d=Object.assign?Object.assign.bind():function(e){for(var t=1;tt=>{let{data:n,value:r,...o}=t,i=r||n;return"string"==typeof i&&(i=JSON.parse(i)),a.default.createElement(e,d({dataStr:JSON.stringify(i)},o,{data:i}))}),(0,i.injectGraphql)("readLinkDescription"),l.default)((e=>{let{id:t,loading:n,Loading:r,data:o,LinkPicker:l,onChange:u,types:d,linkDescription:s,...f}=e;if(n)return a.default.createElement(r,null);const[c,p]=(0,a.useState)(!1),[y,v]=(0,a.useState)(""),{typeKey:g}=o,m=d[g],k=y?d[y]:m;let _=o?o.Title:"";_||(_=o?o.TitleRelField:"");const O={title:_,link:m?{type:m,title:_,description:s}:void 0,onEdit:()=>{p(!0)},onClear:e=>{"function"==typeof u&&u(e,{id:t,value:{}})},onSelect:e=>{v(e),p(!0)},types:Object.values(d)},h={type:k,editing:c,onSubmit:(e,n,r)=>{const{SecurityID:a,action_insert:o,...i}=e;return"function"==typeof u&&u(event,{id:t,value:i}),p(!1),v(""),Promise.resolve()},onClosed:()=>{p(!1)},data:o},b=k?k.handlerName:"FormBuilderModal",j=(0,i.loadComponent)(`LinkModal.${b}`);return a.default.createElement(a.Fragment,null,a.default.createElement(l,O),a.default.createElement(j,h))}));t.default=s},606:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;i(n(754));var r=function(e,t){if(!t&&e&&e.__esModule)return e;if(null===e||"object"!=typeof e&&"function"!=typeof e)return{default:e};var n=o(t);if(n&&n.has(e))return n.get(e);var r={},a=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var i in e)if("default"!==i&&Object.prototype.hasOwnProperty.call(e,i)){var l=a?Object.getOwnPropertyDescriptor(e,i):null;l&&(l.get||l.set)?Object.defineProperty(r,i,l):r[i]=e[i]}r.default=e,n&&n.set(e,r);return r}(n(363)),a=i(n(475));function o(e){if("function"!=typeof WeakMap)return null;var t=new WeakMap,n=new WeakMap;return(o=function(e){return e?n:t})(e)}function i(e){return e&&e.__esModule?e:{default:e}}function l(){return l=Object.assign?Object.assign.bind():function(e){for(var t=1;te({type:"INIT_FORM_SCHEMA_STACK",payload:{formSchema:{type:"insert-link",nextType:"admin"}}}),reset:()=>e({type:"RESET"})}}}))((e=>{let{type:t,editing:n,data:o,actions:i,onSubmit:u,...d}=e;if(!t)return!1;(0,r.useEffect)((()=>{n?i.initModal():i.reset()}),[n]);const s=o?{ID:o.FileID,Description:o.Title,TargetBlank:!!o.OpenInNew}:{};return r.default.createElement(a.default,l({isOpen:n,type:"insert-link",title:!1,bodyClassName:"modal__dialog",className:"insert-link__dialog-wrapper--internal",fileAttributes:s,onInsert:e=>{let{ID:n,Description:r,TargetBlank:a}=e;return u({FileID:n,Title:r,OpenInNew:a,typeKey:t.key},"",(()=>{}))}},d))}));t.default=u},117:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;u(n(754));var r=u(n(363)),a=(u(n(86)),u(n(912))),o=u(n(872)),i=u(n(902)),l=u(n(510));function u(e){return e&&e.__esModule?e:{default:e}}function d(){return d=Object.assign?Object.assign.bind():function(e){for(var t=1;t{const{schemaUrl:n}=l.default.getSection("SilverStripe\\Admin\\LeftAndMain").form.DynamicLink,r=o.default.parse(n),a=i.default.parse(r.query);return a.key=e,t&&(a.data=JSON.stringify(t)),o.default.format({...r,search:i.default.stringify(a)})};var f=e=>{let{type:t,editing:n,data:o,...i}=e;return!!t&&r.default.createElement(a.default,d({title:t.title,isOpen:n,schemaUrl:s(t.key,o),identifier:"Link.EditingLinkInfo"},i))};t.default=f},809:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=t.Component=void 0;u(n(754));var r=u(n(363)),a=(n(648),u(n(86))),o=(n(127),u(n(820))),i=u(n(97)),l=u(n(734));u(n(686));function u(e){return e&&e.__esModule?e:{default:e}}function d(){return d=Object.assign?Object.assign.bind():function(e){for(var t=1;t{let{types:t,onSelect:n,link:a,onEdit:u,onClear:s}=e;return r.default.createElement("div",{className:(0,o.default)("link-picker","form-control",{"link-picker--selected":a})},void 0===a&&r.default.createElement(i.default,{types:t,onSelect:n}),a&&r.default.createElement(l.default,d({},a,{onClear:s,onClick:()=>a&&u&&u(a)})))};t.Component=s,s.propTypes={...i.default.propTypes,link:a.default.shape(l.default.propTypes),onEdit:a.default.func,onClear:a.default.func};var f=s;t.default=f},97:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=d(n(754)),a=function(e,t){if(!t&&e&&e.__esModule)return e;if(null===e||"object"!=typeof e&&"function"!=typeof e)return{default:e};var n=u(t);if(n&&n.has(e))return n.get(e);var r={},a=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var o in e)if("default"!==o&&Object.prototype.hasOwnProperty.call(e,o)){var i=a?Object.getOwnPropertyDescriptor(e,o):null;i&&(i.get||i.set)?Object.defineProperty(r,o,i):r[o]=e[o]}r.default=e,n&&n.set(e,r);return r}(n(363)),o=(n(648),d(n(86))),i=n(127),l=(d(n(820)),d(n(686)));function u(e){if("function"!=typeof WeakMap)return null;var t=new WeakMap,n=new WeakMap;return(u=function(e){return e?n:t})(e)}function d(e){return e&&e.__esModule?e:{default:e}}const s=e=>{let{types:t,onSelect:n}=e;const[o,l]=(0,a.useState)(!1);return a.default.createElement(i.Dropdown,{isOpen:o,toggle:()=>l((e=>!e)),className:"link-picker__menu"},a.default.createElement(i.DropdownToggle,{className:"link-picker__menu-toggle font-icon-link",caret:!0},r.default._t("Link.ADD_LINK","Add Link")),a.default.createElement(i.DropdownMenu,null,t.map((e=>{let{key:t,title:r}=e;return a.default.createElement(i.DropdownItem,{key:t,onClick:()=>n(t)},r)}))))};s.propTypes={types:o.default.arrayOf(l.default).isRequired,onSelect:o.default.func.isRequired};var f=s;t.default=f},734:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=u(n(754)),a=u(n(363)),o=u(n(86)),i=u(n(686)),l=n(127);function u(e){return e&&e.__esModule?e:{default:e}}const d=e=>t=>{t.nativeEvent.stopImmediatePropagation(),t.preventDefault(),t.nativeEvent.preventDefault(),t.stopPropagation(),e&&e()},s=e=>{let{title:t,type:n,description:o,onClear:i,onClick:u}=e;return a.default.createElement("div",{className:"link-picker__link"},a.default.createElement(l.Button,{className:"link-picker__button font-icon-link",color:"secondary",onClick:d(u)},a.default.createElement("div",{className:"link-picker__link-detail"},a.default.createElement("div",{className:"link-picker__title"},t),a.default.createElement("small",{className:"link-picker__type"},n.title,": ",a.default.createElement("span",{className:"link-picker__url"},o)))),a.default.createElement(l.Button,{className:"link-picker__clear",color:"link",onClick:d(i)},r.default._t("Link.CLEAR","Clear")))};s.propTypes={title:o.default.string.isRequired,type:i.default,description:o.default.string,onClear:o.default.func,onClick:o.default.func};var f=s;t.default=f},115:function(e,t,n){var r=l(n(311)),a=l(n(363)),o=l(n(691)),i=n(648);function l(e){return e&&e.__esModule?e:{default:e}}function u(){return u=Object.assign?Object.assign.bind():function(e){for(var t=1;t{e(".js-injector-boot .entwine-jsonfield").entwine({Component:null,Root:null,onmatch(){const e=this.closest(".cms-content").attr("id"),t=e?{context:e}:{},n=this.data("schema-component"),r=(0,i.loadComponent)(n,t);this.setComponent(r),this.setRoot(o.default.createRoot(this[0])),this._super(),this.refresh()},refresh(){const e=this.getProps(),t=this.getComponent();this.getRoot().render(a.default.createElement(t,u({},e,{noHolder:!0})))},handleChange(t,n){let{id:r,value:a}=n;const o=e(this).data("field-id");e("#"+o).val(JSON.stringify(a)).trigger("change"),this.refresh()},getProps(){const t=e(this).data("field-id"),n=e("#"+t).val();return{id:t,value:n?JSON.parse(n):void 0,onChange:this.handleChange.bind(this)}},onunmatch(){this.getRoot().unmount()}})}))},287:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=n(648);const a={props(e){const{data:{error:t,readLinkDescription:n,loading:r}}=e,a=t&&t.graphQLErrors&&t.graphQLErrors.map((e=>e.message));return{loading:r,linkDescription:n?n.description:"",graphQLErrors:a}}},{READ:o}=r.graphqlTemplates;var i={apolloConfig:a,templateName:o,pluralName:"LinkDescription",pagination:!1,params:{dataStr:"String!"},args:{root:{dataStr:"dataStr"}},fields:["description"]};t.default=i},689:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=n(648);const a={props(e){const{data:{error:t,readLinkTypes:n,loading:r}}=e,a=t&&t.graphQLErrors&&t.graphQLErrors.map((e=>e.message));return{loading:r,types:n?n.reduce(((e,t)=>({...e,[t.key]:t})),{}):{},graphQLErrors:a}}},{READ:o}=r.graphqlTemplates;var i={apolloConfig:a,templateName:o,pluralName:"LinkTypes",pagination:!1,params:{keys:"[ID]"},args:{root:{keys:"keys"}},fields:["key","title","handlerName"]};t.default=i},686:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r,a=(r=n(86))&&r.__esModule?r:{default:r};var o=a.default.shape({key:a.default.string.isRequired,title:a.default.string.isRequired});t.default=o},510:function(e){e.exports=Config},42:function(e){e.exports=FieldHolder},912:function(e){e.exports=FormBuilderModal},648:function(e){e.exports=Injector},475:function(e){e.exports=InsertMediaModal},872:function(e){e.exports=NodeUrl},86:function(e){e.exports=PropTypes},363:function(e){e.exports=React},691:function(e){e.exports=ReactDomClient},624:function(e){e.exports=ReactRedux},127:function(e){e.exports=Reactstrap},827:function(e){e.exports=Redux},820:function(e){e.exports=classnames},754:function(e){e.exports=i18n},311:function(e){e.exports=jQuery},902:function(e){e.exports=qs}},t={};function n(r){var a=t[r];if(void 0!==a)return a.exports;var o=t[r]={exports:{}};return e[r](o,o.exports,n),o.exports}n(274),n(115)}(); \ No newline at end of file +/******/ (function() { // webpackBootstrap +/******/ "use strict"; +/******/ var __webpack_modules__ = ({ + +/***/ "./client/src/boot/index.js": +/*!**********************************!*\ + !*** ./client/src/boot/index.js ***! + \**********************************/ +/***/ (function(__unused_webpack_module, __unused_webpack_exports, __webpack_require__) { + + + +var _registerComponents = _interopRequireDefault(__webpack_require__(/*! ./registerComponents */ "./client/src/boot/registerComponents.js")); +var _registerQueries = _interopRequireDefault(__webpack_require__(/*! ./registerQueries */ "./client/src/boot/registerQueries.js")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +document.addEventListener('DOMContentLoaded', () => { + (0, _registerComponents.default)(); + (0, _registerQueries.default)(); +}); + +/***/ }), + +/***/ "./client/src/boot/registerComponents.js": +/*!***********************************************!*\ + !*** ./client/src/boot/registerComponents.js ***! + \***********************************************/ +/***/ (function(__unused_webpack_module, exports, __webpack_require__) { + + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; +var _Injector = _interopRequireDefault(__webpack_require__(/*! lib/Injector */ "lib/Injector")); +var _LinkField = _interopRequireDefault(__webpack_require__(/*! components/LinkField/LinkField */ "./client/src/components/LinkField/LinkField.js")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const registerComponents = () => { + _Injector.default.component.registerMany({ + LinkField: _LinkField.default + }); +}; +var _default = registerComponents; +exports["default"] = _default; + +/***/ }), + +/***/ "./client/src/boot/registerQueries.js": +/*!********************************************!*\ + !*** ./client/src/boot/registerQueries.js ***! + \********************************************/ +/***/ (function(__unused_webpack_module, exports, __webpack_require__) { + + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; +var _Injector = _interopRequireDefault(__webpack_require__(/*! lib/Injector */ "lib/Injector")); +var _readLinkTypes = _interopRequireDefault(__webpack_require__(/*! state/linkTypes/readLinkTypes */ "./client/src/state/linkTypes/readLinkTypes.js")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const registerQueries = () => { + _Injector.default.query.register('readLinkTypes', _readLinkTypes.default); +}; +var _default = registerQueries; +exports["default"] = _default; + +/***/ }), + +/***/ "./client/src/components/LinkField/LinkField.js": +/*!******************************************************!*\ + !*** ./client/src/components/LinkField/LinkField.js ***! + \******************************************************/ +/***/ (function(__unused_webpack_module, exports, __webpack_require__) { + + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; +var _react = _interopRequireWildcard(__webpack_require__(/*! react */ "react")); +var _redux = __webpack_require__(/*! redux */ "redux"); +var _reactRedux = __webpack_require__(/*! react-redux */ "react-redux"); +var _Injector = __webpack_require__(/*! lib/Injector */ "lib/Injector"); +var _FieldHolder = _interopRequireDefault(__webpack_require__(/*! components/FieldHolder/FieldHolder */ "components/FieldHolder/FieldHolder")); +var _LinkModal = _interopRequireDefault(__webpack_require__(/*! components/LinkModal/LinkModal */ "./client/src/components/LinkModal/LinkModal.js")); +var _LinkPicker = _interopRequireDefault(__webpack_require__(/*! components/LinkPicker/LinkPicker */ "./client/src/components/LinkPicker/LinkPicker.js")); +var toastsActions = _interopRequireWildcard(__webpack_require__(/*! state/toasts/ToastsActions */ "state/toasts/ToastsActions")); +var _Backend = _interopRequireDefault(__webpack_require__(/*! lib/Backend */ "lib/Backend")); +var _Config = _interopRequireDefault(__webpack_require__(/*! lib/Config */ "lib/Config")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +const LinkField = _ref => { + let { + value, + onChange, + types, + actions + } = _ref; + const [typeKey, setTypeKey] = (0, _react.useState)(value.typeKey || ''); + const [linkID, setLinkID] = (0, _react.useState)(value.ID || 0); + const [data, setData] = (0, _react.useState)(value); + const [editing, setEditing] = (0, _react.useState)(false); + const onClear = () => { + const endpoint = `${section.form.linkForm.deleteUrl}/${linkID}`; + _Backend.default.delete(endpoint, {}, { + 'X-SecurityID': _Config.default.get('SecurityID') + }).then(() => { + actions.toasts.success('Deleted link'); + }).catch(() => { + actions.toasts.error('Failed to delete link'); + }); + setTypeKey(''); + setLinkID(0); + setData({}); + if (typeof onChange === 'function') { + onChange({}); + } + }; + const onModalSubmit = async (modalData, action, submitFn) => { + const { + SecurityID, + action_submit: actionSubmit, + ...data + } = modalData; + let id = 0; + const formSchema = await submitFn(); + let match = formSchema.id.match(/\/linkForm\/([0-9]+)/); + if (match) { + id = parseInt(match[1]); + } else { + match = formSchema.id.match(/\/schema\/linkfield\/([0-9]+)/); + if (match) { + id = parseInt(match[1]); + } + } + if (typeof onChange === 'function') { + data.ID = id; + onChange(data); + } + setLinkID(id); + setEditing(false); + actions.toasts.success('Saved link'); + return Promise.resolve(); + }; + const title = data.Title || data.TitleRelField || ''; + const type = types.hasOwnProperty(typeKey) ? types[typeKey] : {}; + const section = _Config.default.getSection('SilverStripe\\LinkField\\Controllers\\LinkFieldController'); + const pickerProps = { + title, + description: data.description, + typeTitle: type.title || '', + onEdit: () => { + setEditing(true); + }, + onClear, + onSelect: key => { + setTypeKey(key); + setEditing(true); + }, + types: Object.values(types) + }; + const modalProps = { + typeTitle: type.title || '', + typeKey, + editing, + onSubmit: onModalSubmit, + onClosed: () => { + setEditing(false); + }, + linkID, + data + }; + (0, _react.useEffect)(() => { + if (!editing && linkID) { + const endpoint = `${section.form.linkForm.dataUrl}/${linkID}`; + _Backend.default.get(endpoint).then(response => response.json()).then(responseJson => { + setData(responseJson); + }); + } + }, [editing, linkID]); + return _react.default.createElement(_react.default.Fragment, null, _react.default.createElement(_LinkPicker.default, pickerProps), _react.default.createElement(_LinkModal.default, modalProps)); +}; +const mapDispatchToProps = dispatch => { + return { + actions: { + toasts: (0, _redux.bindActionCreators)(toastsActions, dispatch) + } + }; +}; +var _default = (0, _redux.compose)((0, _Injector.injectGraphql)('readLinkTypes'), _FieldHolder.default, (0, _reactRedux.connect)(null, mapDispatchToProps))(LinkField); +exports["default"] = _default; + +/***/ }), + +/***/ "./client/src/components/LinkModal/LinkModal.js": +/*!******************************************************!*\ + !*** ./client/src/components/LinkModal/LinkModal.js ***! + \******************************************************/ +/***/ (function(__unused_webpack_module, exports, __webpack_require__) { + + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; +var _react = _interopRequireDefault(__webpack_require__(/*! react */ "react")); +var _FormBuilderModal = _interopRequireDefault(__webpack_require__(/*! components/FormBuilderModal/FormBuilderModal */ "components/FormBuilderModal/FormBuilderModal")); +var _url = _interopRequireDefault(__webpack_require__(/*! url */ "url")); +var _qs = _interopRequireDefault(__webpack_require__(/*! qs */ "qs")); +var _Config = _interopRequireDefault(__webpack_require__(/*! lib/Config */ "lib/Config")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } +const buildSchemaUrl = (typeKey, linkID) => { + const { + schemaUrl + } = _Config.default.getSection('SilverStripe\\LinkField\\Controllers\\LinkFieldController').form.linkForm; + const parsedURL = _url.default.parse(schemaUrl); + const parsedQs = _qs.default.parse(parsedURL.query); + parsedQs.typeKey = typeKey; + for (const prop of ['href', 'path', 'pathname']) { + parsedURL[prop] = `${parsedURL[prop]}/${linkID}`; + } + return _url.default.format({ + ...parsedURL, + search: _qs.default.stringify(parsedQs) + }); +}; +const LinkModal = _ref => { + let { + typeTitle, + typeKey, + linkID, + data, + editing, + ...props + } = _ref; + if (!typeKey) { + return false; + } + return _react.default.createElement(_FormBuilderModal.default, _extends({ + title: typeTitle, + isOpen: editing, + schemaUrl: buildSchemaUrl(typeKey, linkID), + identifier: "Link.EditingLinkInfo" + }, props)); +}; +var _default = LinkModal; +exports["default"] = _default; + +/***/ }), + +/***/ "./client/src/components/LinkPicker/LinkPicker.js": +/*!********************************************************!*\ + !*** ./client/src/components/LinkPicker/LinkPicker.js ***! + \********************************************************/ +/***/ (function(__unused_webpack_module, exports, __webpack_require__) { + + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = exports.Component = void 0; +var _react = _interopRequireDefault(__webpack_require__(/*! react */ "react")); +var _propTypes = _interopRequireDefault(__webpack_require__(/*! prop-types */ "prop-types")); +var _classnames = _interopRequireDefault(__webpack_require__(/*! classnames */ "classnames")); +var _LinkPickerMenu = _interopRequireDefault(__webpack_require__(/*! ./LinkPickerMenu */ "./client/src/components/LinkPicker/LinkPickerMenu.js")); +var _LinkPickerTitle = _interopRequireDefault(__webpack_require__(/*! ./LinkPickerTitle */ "./client/src/components/LinkPicker/LinkPickerTitle.js")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const LinkPicker = _ref => { + let { + title, + description, + typeTitle, + types, + onSelect, + onEdit, + onClear, + ...props + } = _ref; + return _react.default.createElement("div", { + className: (0, _classnames.default)('link-picker', 'form-control', { + 'link-picker--selected': typeTitle ? true : false + }) + }, !typeTitle && _react.default.createElement(_LinkPickerMenu.default, { + types: types, + onSelect: onSelect + }), typeTitle && _react.default.createElement(_LinkPickerTitle.default, { + title: title, + description: description, + typeTitle: typeTitle, + onClear: onClear, + onClick: () => onEdit() + })); +}; +exports.Component = LinkPicker; +LinkPicker.propTypes = { + ..._LinkPickerMenu.default.propTypes, + title: _propTypes.default.string, + description: _propTypes.default.string, + typeTitle: _propTypes.default.string, + onEdit: _propTypes.default.func, + onClear: _propTypes.default.func, + onSelect: _propTypes.default.func +}; +var _default = LinkPicker; +exports["default"] = _default; + +/***/ }), + +/***/ "./client/src/components/LinkPicker/LinkPickerMenu.js": +/*!************************************************************!*\ + !*** ./client/src/components/LinkPicker/LinkPickerMenu.js ***! + \************************************************************/ +/***/ (function(__unused_webpack_module, exports, __webpack_require__) { + + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; +var _i18n = _interopRequireDefault(__webpack_require__(/*! i18n */ "i18n")); +var _react = _interopRequireWildcard(__webpack_require__(/*! react */ "react")); +var _propTypes = _interopRequireDefault(__webpack_require__(/*! prop-types */ "prop-types")); +var _reactstrap = __webpack_require__(/*! reactstrap */ "reactstrap"); +var _LinkType = _interopRequireDefault(__webpack_require__(/*! types/LinkType */ "./client/src/types/LinkType.js")); +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const LinkPickerMenu = _ref => { + let { + types, + onSelect + } = _ref; + const [isOpen, setIsOpen] = (0, _react.useState)(false); + const toggle = () => setIsOpen(prevState => !prevState); + return _react.default.createElement(_reactstrap.Dropdown, { + isOpen: isOpen, + toggle: toggle, + className: "link-picker__menu" + }, _react.default.createElement(_reactstrap.DropdownToggle, { + className: "link-picker__menu-toggle font-icon-link", + caret: true + }, _i18n.default._t('Link.ADD_LINK', 'Add Link')), _react.default.createElement(_reactstrap.DropdownMenu, null, types.map(_ref2 => { + let { + key, + title + } = _ref2; + return _react.default.createElement(_reactstrap.DropdownItem, { + key: key, + onClick: () => onSelect(key) + }, title); + }))); +}; +LinkPickerMenu.propTypes = { + types: _propTypes.default.arrayOf(_LinkType.default).isRequired, + onSelect: _propTypes.default.func.isRequired +}; +var _default = LinkPickerMenu; +exports["default"] = _default; + +/***/ }), + +/***/ "./client/src/components/LinkPicker/LinkPickerTitle.js": +/*!*************************************************************!*\ + !*** ./client/src/components/LinkPicker/LinkPickerTitle.js ***! + \*************************************************************/ +/***/ (function(__unused_webpack_module, exports, __webpack_require__) { + + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; +var _i18n = _interopRequireDefault(__webpack_require__(/*! i18n */ "i18n")); +var _react = _interopRequireDefault(__webpack_require__(/*! react */ "react")); +var _propTypes = _interopRequireDefault(__webpack_require__(/*! prop-types */ "prop-types")); +var _reactstrap = __webpack_require__(/*! reactstrap */ "reactstrap"); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const stopPropagation = fn => e => { + e.nativeEvent.stopImmediatePropagation(); + e.preventDefault(); + e.nativeEvent.preventDefault(); + e.stopPropagation(); + fn && fn(); +}; +const LinkPickerTitle = _ref => { + let { + title, + description, + typeTitle, + onClear, + onClick, + ...props + } = _ref; + return _react.default.createElement("div", { + className: "link-picker__link" + }, _react.default.createElement(_reactstrap.Button, { + className: "link-picker__button font-icon-link", + color: "secondary", + onClick: stopPropagation(onClick) + }, _react.default.createElement("div", { + className: "link-picker__link-detail" + }, _react.default.createElement("div", { + className: "link-picker__title" + }, title), _react.default.createElement("small", { + className: "link-picker__type" + }, typeTitle, ":\xA0", _react.default.createElement("span", { + className: "link-picker__url" + }, description)))), _react.default.createElement(_reactstrap.Button, { + className: "link-picker__clear", + color: "link", + onClick: stopPropagation(onClear) + }, _i18n.default._t('Link.CLEAR', 'Clear'))); +}; +LinkPickerTitle.propTypes = { + title: _propTypes.default.string.isRequired, + linkTypeTitle: _propTypes.default.string, + description: _propTypes.default.string, + onClear: _propTypes.default.func, + onClick: _propTypes.default.func +}; +var _default = LinkPickerTitle; +exports["default"] = _default; + +/***/ }), + +/***/ "./client/src/entwine/JsonField.js": +/*!*****************************************!*\ + !*** ./client/src/entwine/JsonField.js ***! + \*****************************************/ +/***/ (function(__unused_webpack_module, __unused_webpack_exports, __webpack_require__) { + + + +var _jquery = _interopRequireDefault(__webpack_require__(/*! jquery */ "jquery")); +var _react = _interopRequireDefault(__webpack_require__(/*! react */ "react")); +var _client = _interopRequireDefault(__webpack_require__(/*! react-dom/client */ "react-dom/client")); +var _Injector = __webpack_require__(/*! lib/Injector */ "lib/Injector"); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } +_jquery.default.entwine('ss', $ => { + $('.js-injector-boot .entwine-jsonfield').entwine({ + Component: null, + Root: null, + onmatch() { + const cmsContent = this.closest('.cms-content').attr('id'); + const context = cmsContent ? { + context: cmsContent + } : {}; + const schemaComponent = this.data('schema-component'); + const ReactField = (0, _Injector.loadComponent)(schemaComponent, context); + this.setComponent(ReactField); + this.setRoot(_client.default.createRoot(this[0])); + this._super(); + this.refresh(); + }, + refresh() { + const props = this.getProps(); + const ReactField = this.getComponent(); + const Root = this.getRoot(); + Root.render(_react.default.createElement(ReactField, _extends({}, props, { + noHolder: true + }))); + }, + handleChange(data) { + const fieldID = $(this).data('field-id'); + $('#' + fieldID).val(JSON.stringify({ + ID: data.ID + })); + }, + getProps() { + const fieldID = $(this).data('field-id'); + const dataStr = $('#' + fieldID).val(); + const value = JSON.parse(dataStr); + return { + value, + onChange: this.handleChange.bind(this) + }; + }, + onunmatch() { + const Root = this.getRoot(); + Root.unmount(); + } + }); +}); + +/***/ }), + +/***/ "./client/src/state/linkTypes/readLinkTypes.js": +/*!*****************************************************!*\ + !*** ./client/src/state/linkTypes/readLinkTypes.js ***! + \*****************************************************/ +/***/ (function(__unused_webpack_module, exports, __webpack_require__) { + + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; +var _Injector = __webpack_require__(/*! lib/Injector */ "lib/Injector"); +const apolloConfig = { + props(props) { + const { + data: { + error, + readLinkTypes, + loading: networkLoading + } + } = props; + const errors = error && error.graphQLErrors && error.graphQLErrors.map(graphQLError => graphQLError.message); + const types = readLinkTypes ? readLinkTypes.reduce((accumulator, type) => ({ + ...accumulator, + [type.key]: type + }), {}) : {}; + return { + loading: networkLoading, + types, + graphQLErrors: errors + }; + } +}; +const { + READ +} = _Injector.graphqlTemplates; +const query = { + apolloConfig, + templateName: READ, + pluralName: 'LinkTypes', + pagination: false, + params: { + keys: '[ID]' + }, + args: { + root: { + keys: 'keys' + } + }, + fields: ['key', 'title'] +}; +var _default = query; +exports["default"] = _default; + +/***/ }), + +/***/ "./client/src/types/LinkType.js": +/*!**************************************!*\ + !*** ./client/src/types/LinkType.js ***! + \**************************************/ +/***/ (function(__unused_webpack_module, exports, __webpack_require__) { + + + +Object.defineProperty(exports, "__esModule", ({ + value: true +})); +exports["default"] = void 0; +var _propTypes = _interopRequireDefault(__webpack_require__(/*! prop-types */ "prop-types")); +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +const LinkType = _propTypes.default.shape({ + key: _propTypes.default.string.isRequired, + title: _propTypes.default.string.isRequired +}); +var _default = LinkType; +exports["default"] = _default; + +/***/ }), + +/***/ "lib/Backend": +/*!**************************!*\ + !*** external "Backend" ***! + \**************************/ +/***/ (function(module) { + +module.exports = Backend; + +/***/ }), + +/***/ "lib/Config": +/*!*************************!*\ + !*** external "Config" ***! + \*************************/ +/***/ (function(module) { + +module.exports = Config; + +/***/ }), + +/***/ "components/FieldHolder/FieldHolder": +/*!******************************!*\ + !*** external "FieldHolder" ***! + \******************************/ +/***/ (function(module) { + +module.exports = FieldHolder; + +/***/ }), + +/***/ "components/FormBuilderModal/FormBuilderModal": +/*!***********************************!*\ + !*** external "FormBuilderModal" ***! + \***********************************/ +/***/ (function(module) { + +module.exports = FormBuilderModal; + +/***/ }), + +/***/ "lib/Injector": +/*!***************************!*\ + !*** external "Injector" ***! + \***************************/ +/***/ (function(module) { + +module.exports = Injector; + +/***/ }), + +/***/ "url": +/*!**************************!*\ + !*** external "NodeUrl" ***! + \**************************/ +/***/ (function(module) { + +module.exports = NodeUrl; + +/***/ }), + +/***/ "prop-types": +/*!****************************!*\ + !*** external "PropTypes" ***! + \****************************/ +/***/ (function(module) { + +module.exports = PropTypes; + +/***/ }), + +/***/ "react": +/*!************************!*\ + !*** external "React" ***! + \************************/ +/***/ (function(module) { + +module.exports = React; + +/***/ }), + +/***/ "react-dom/client": +/*!*********************************!*\ + !*** external "ReactDomClient" ***! + \*********************************/ +/***/ (function(module) { + +module.exports = ReactDomClient; + +/***/ }), + +/***/ "react-redux": +/*!*****************************!*\ + !*** external "ReactRedux" ***! + \*****************************/ +/***/ (function(module) { + +module.exports = ReactRedux; + +/***/ }), + +/***/ "reactstrap": +/*!*****************************!*\ + !*** external "Reactstrap" ***! + \*****************************/ +/***/ (function(module) { + +module.exports = Reactstrap; + +/***/ }), + +/***/ "redux": +/*!************************!*\ + !*** external "Redux" ***! + \************************/ +/***/ (function(module) { + +module.exports = Redux; + +/***/ }), + +/***/ "state/toasts/ToastsActions": +/*!********************************!*\ + !*** external "ToastsActions" ***! + \********************************/ +/***/ (function(module) { + +module.exports = ToastsActions; + +/***/ }), + +/***/ "classnames": +/*!*****************************!*\ + !*** external "classnames" ***! + \*****************************/ +/***/ (function(module) { + +module.exports = classnames; + +/***/ }), + +/***/ "i18n": +/*!***********************!*\ + !*** external "i18n" ***! + \***********************/ +/***/ (function(module) { + +module.exports = i18n; + +/***/ }), + +/***/ "jquery": +/*!*************************!*\ + !*** external "jQuery" ***! + \*************************/ +/***/ (function(module) { + +module.exports = jQuery; + +/***/ }), + +/***/ "qs": +/*!*********************!*\ + !*** external "qs" ***! + \*********************/ +/***/ (function(module) { + +module.exports = qs; + +/***/ }) + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +var __webpack_exports__ = {}; +// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. +!function() { +/*!**************************************!*\ + !*** ./client/src/bundles/bundle.js ***! + \**************************************/ + + +__webpack_require__(/*! boot */ "./client/src/boot/index.js"); +__webpack_require__(/*! entwine/JsonField */ "./client/src/entwine/JsonField.js"); +}(); +/******/ })() +; +//# sourceMappingURL=bundle.js.map \ No newline at end of file diff --git a/client/dist/styles/bundle.css b/client/dist/styles/bundle.css index 5c415c8c..9b3eaaf0 100644 --- a/client/dist/styles/bundle.css +++ b/client/dist/styles/bundle.css @@ -1 +1,68 @@ -.link-picker{display:flex;height:auto;min-height:54px;background:#fff;width:100%;align-items:stretch;cursor:pointer;padding:0;box-shadow:none}.link-picker.font-icon-link::before{margin:.76925rem}.link-picker__menu{flex-grow:1}.link-picker__menu-toggle{width:100%;height:100%;text-align:left}.link-picker__menu-toggle::before{padding:.76925rem}.link-picker__link{display:flex;align-items:center;width:100%;text-align:left;border:none;margin-right:0;justify-content:space-between}.link-picker__link:hover,.link-picker__link:focus{background:#eef0f4;text-decoration:none;color:inherit}.link-picker__button{display:flex;align-items:center;flex-grow:1;height:100%;text-align:left;border:none;margin-right:0}.link-picker__button::before{font-size:1.231rem;padding:.76925rem;margin-right:6px;flex-grow:0}.link-picker__link-detail{flex-grow:1}.link-picker__clear{flex-grow:0}.link-picker__url{color:#0071c4} +/*!*****************************************************************************************************************************************************************************************************************************************************************************************************************************!*\ + !*** css ./node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[0].use[1]!./node_modules/postcss-loader/dist/cjs.js??ruleSet[1].rules[0].use[2]!./node_modules/resolve-url-loader/index.js??ruleSet[1].rules[0].use[3]!./node_modules/sass-loader/dist/cjs.js??ruleSet[1].rules[0].use[4]!./client/src/styles/bundle.scss ***! + \*****************************************************************************************************************************************************************************************************************************************************************************************************************************/ +.link-picker { + display: flex; + height: auto; + min-height: 54px; + background: white; + width: 100%; + align-items: stretch; + cursor: pointer; + padding: 0; + box-shadow: none; +} +.link-picker.font-icon-link::before { + margin: 0.76925rem; +} +.link-picker__menu { + flex-grow: 1; +} +.link-picker__menu-toggle { + width: 100%; + height: 100%; + text-align: left; +} +.link-picker__menu-toggle::before { + padding: 0.76925rem; +} +.link-picker__link { + display: flex; + align-items: center; + width: 100%; + text-align: left; + border: none; + margin-right: 0; + justify-content: space-between; +} +.link-picker__link:hover, .link-picker__link:focus { + background: #eef0f4; + text-decoration: none; + color: inherit; +} +.link-picker__button { + display: flex; + align-items: center; + flex-grow: 1; + height: 100%; + text-align: left; + border: none; + margin-right: 0; +} +.link-picker__button::before { + font-size: 1.231rem; + padding: 0.76925rem; + margin-right: 6px; + flex-grow: 0; +} +.link-picker__link-detail { + flex-grow: 1; +} +.link-picker__clear { + flex-grow: 0; +} +.link-picker__url { + color: #0071c4; +} + +/*# sourceMappingURL=bundle.css.map*/ \ No newline at end of file diff --git a/client/src/boot/index.js b/client/src/boot/index.js index 4d7bb2a5..7fac11d0 100644 --- a/client/src/boot/index.js +++ b/client/src/boot/index.js @@ -1,14 +1,9 @@ /* global document */ /* eslint-disable */ -import Config from 'lib/Config'; -import registerReducers from './registerReducers'; import registerComponents from './registerComponents'; import registerQueries from './registerQueries'; document.addEventListener('DOMContentLoaded', () => { registerComponents(); - registerQueries(); - - registerReducers(); }); diff --git a/client/src/boot/registerComponents.js b/client/src/boot/registerComponents.js index 1fd6283b..e933341d 100644 --- a/client/src/boot/registerComponents.js +++ b/client/src/boot/registerComponents.js @@ -1,17 +1,10 @@ /* eslint-disable */ import Injector from 'lib/Injector'; -import LinkPicker from 'components/LinkPicker/LinkPicker'; import LinkField from 'components/LinkField/LinkField'; -import LinkModal from 'components/LinkModal/LinkModal'; -import FileLinkModal from 'components/LinkModal/FileLinkModal'; - const registerComponents = () => { Injector.component.registerMany({ - LinkPicker, LinkField, - 'LinkModal.FormBuilderModal': LinkModal, - 'LinkModal.InsertMediaModal': FileLinkModal }); }; diff --git a/client/src/boot/registerQueries.js b/client/src/boot/registerQueries.js index 1d68cfa3..229ae7d9 100644 --- a/client/src/boot/registerQueries.js +++ b/client/src/boot/registerQueries.js @@ -1,10 +1,8 @@ /* eslint-disable */ import Injector from 'lib/Injector'; import readLinkTypes from 'state/linkTypes/readLinkTypes'; -import readLinkDescription from 'state/linkDescription/readLinkDescription'; const registerQueries = () => { Injector.query.register('readLinkTypes', readLinkTypes); - Injector.query.register('readLinkDescription', readLinkDescription); }; export default registerQueries; diff --git a/client/src/boot/registerReducers.js b/client/src/boot/registerReducers.js deleted file mode 100644 index 1abd586a..00000000 --- a/client/src/boot/registerReducers.js +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable */ -import Injector from 'lib/Injector'; -import { combineReducers } from 'redux'; -// import gallery from 'state/gallery/GalleryReducer'; -// import queuedFiles from 'state/queuedFiles/QueuedFilesReducer'; -// import uploadField from 'state/uploadField/UploadFieldReducer'; -// import previewField from 'state/previewField/PreviewFieldReducer'; -// import imageLoad from 'state/imageLoad/ImageLoadReducer'; -// import displaySearch from 'state/displaySearch/DisplaySearchReducer'; -// import confirmDeletion from 'state/confirmDeletion/ConfirmDeletionReducer'; -// import modal from 'state/modal/ModalReducer'; - -const registerReducers = () => { - // Injector.reducer.register('assetAdmin', combineReducers({ - // gallery, - // queuedFiles, - // uploadField, - // previewField, - // imageLoad, - // displaySearch, - // confirmDeletion, - // modal - // })); -}; - -export default registerReducers; diff --git a/client/src/components/LinkField/LinkField.js b/client/src/components/LinkField/LinkField.js index 5571450f..924a3b2b 100644 --- a/client/src/components/LinkField/LinkField.js +++ b/client/src/components/LinkField/LinkField.js @@ -1,90 +1,144 @@ -import React, { Fragment, useState } from 'react'; -import { compose } from 'redux'; -import { inject, injectGraphql, loadComponent } from 'lib/Injector'; +import React, { useState, useEffect } from 'react'; +import { bindActionCreators, compose } from 'redux'; +import { connect } from 'react-redux'; +import { injectGraphql } from 'lib/Injector'; import fieldHolder from 'components/FieldHolder/FieldHolder'; - -const LinkField = ({ id, loading, Loading, data, LinkPicker, onChange, types, linkDescription, ...props }) => { - if (loading) { - return ; - } - +import LinkModal from 'components/LinkModal/LinkModal'; +import LinkPicker from 'components/LinkPicker/LinkPicker'; +import * as toastsActions from 'state/toasts/ToastsActions'; +import backend from 'lib/Backend'; +import Config from 'lib/Config'; + +/** + * value - initial value of the field passed from JsonField + * onChange - function passed from JsonField + * types - injected by the GraphQL query + * actions - object of redux actions + */ +const LinkField = ({value, onChange, types, actions}) => { + const [typeKey, setTypeKey] = useState(value.typeKey || ''); + const [linkID, setLinkID] = useState(value.ID || 0); + const [data, setData] = useState(value); const [editing, setEditing] = useState(false); - const [newTypeKey, setNewTypeKey] = useState(''); - const onClear = (event) => { - if (typeof onChange !== 'function') { - return; + const onClear = () => { + const endpoint = `${section.form.linkForm.deleteUrl}/${linkID}`; + backend.delete(endpoint, {}, {'X-SecurityID': Config.get('SecurityID')}) + .then(() => { + actions.toasts.success('Deleted link'); + }) + .catch(() => { + actions.toasts.error('Failed to delete link'); + }); + + setTypeKey(''); + setLinkID(0); + setData({}); + if (typeof onChange === 'function') { + onChange({}); } - - onChange(event, { id, value: {} }); }; + + const onModalSubmit = async (modalData, action, submitFn) => { + // extract data from modalData + const { + SecurityID, + action_submit: actionSubmit, + ...data + } = modalData; + + // get link id from formSchema + let id = 0; + const formSchema = await submitFn(); + let match = formSchema.id.match(/\/linkForm\/([0-9]+)/); + if (match) { + // handle onsuccess formSchema + id = parseInt(match[1]); + } else { + // handle onfailure formSchema e.g. validation error + match = formSchema.id.match(/\/schema\/linkfield\/([0-9]+)/); + if (match) { + id = parseInt(match[1]); + } + } - const { typeKey } = data; - const type = types[typeKey]; - const modalType = newTypeKey ? types[newTypeKey] : type; - - let title = data ? data.Title : ''; + // update parent JsonField data id - this is required to update the underlying + // so that the Page (or other parent DataObject) gets the Link relation ID set + if (typeof onChange === 'function') { + data.ID = id; + onChange(data); + } + + // update component state + setLinkID(id); + setEditing(false); + + // trigger success toast + actions.toasts.success('Saved link'); + + return Promise.resolve(); + }; - if (!title) { - title = data ? data.TitleRelField : ''; - } + const title = data.Title || data.TitleRelField || ''; + const type = types.hasOwnProperty(typeKey) ? types[typeKey] : {}; + const section = Config.getSection('SilverStripe\\LinkField\\Controllers\\LinkFieldController'); - const linkProps = { + const pickerProps = { title, - link: type ? { type, title, description: linkDescription } : undefined, - onEdit: () => { setEditing(true); }, + description: data.description, + typeTitle: type.title || '', + onEdit: () => { + setEditing(true); + }, onClear, onSelect: (key) => { - setNewTypeKey(key); + setTypeKey(key); setEditing(true); }, types: Object.values(types) }; - const onModalSubmit = (modalData, action, submitFn) => { - const { SecurityID, action_insert: actionInsert, ...value } = modalData; - - if (typeof onChange === 'function') { - onChange(event, { id, value }); - } - - setEditing(false); - setNewTypeKey(''); - - return Promise.resolve(); - }; - const modalProps = { - type: modalType, + typeTitle: type.title || '', + typeKey, editing, onSubmit: onModalSubmit, onClosed: () => { setEditing(false); }, + linkID, data }; - const handlerName = modalType ? modalType.handlerName : 'FormBuilderModal'; - const LinkModal = loadComponent(`LinkModal.${handlerName}`); + // read data from endpoint and update component state + useEffect(() => { + if (!editing && linkID) { + const endpoint = `${section.form.linkForm.dataUrl}/${linkID}`; + backend.get(endpoint) + .then(response => response.json()) + .then(responseJson => { + setData(responseJson); + }); + } + }, [editing, linkID]); - return - - - ; + return <> + + + ; }; -const stringifyData = (Component) => (({ data, value, ...props }) => { - let dataValue = value || data; - if (typeof dataValue === 'string') { - dataValue = JSON.parse(dataValue); - } - return ; -}); +// redux actions loaded into props - used to get toast notifications +const mapDispatchToProps = (dispatch) => { + return { + actions: { + toasts: bindActionCreators(toastsActions, dispatch), + }, + }; +} export default compose( - inject(['LinkPicker', 'Loading']), injectGraphql('readLinkTypes'), - stringifyData, - injectGraphql('readLinkDescription'), - fieldHolder + fieldHolder, + connect(null, mapDispatchToProps) )(LinkField); diff --git a/client/src/components/LinkModal/LinkModal.js b/client/src/components/LinkModal/LinkModal.js index 7e5ce86d..382b870d 100644 --- a/client/src/components/LinkModal/LinkModal.js +++ b/client/src/components/LinkModal/LinkModal.js @@ -1,36 +1,29 @@ /* eslint-disable */ -import i18n from 'i18n'; import React from 'react'; -import PropTypes from 'prop-types'; import FormBuilderModal from 'components/FormBuilderModal/FormBuilderModal'; import url from 'url'; import qs from 'qs'; import Config from 'lib/Config'; -const leftAndMain = 'SilverStripe\\Admin\\LeftAndMain'; - -const buildSchemaUrl = (key, data) => { - - const {schemaUrl} = Config.getSection(leftAndMain).form.DynamicLink; - +const buildSchemaUrl = (typeKey, linkID) => { + const {schemaUrl} = Config.getSection('SilverStripe\\LinkField\\Controllers\\LinkFieldController').form.linkForm; const parsedURL = url.parse(schemaUrl); const parsedQs = qs.parse(parsedURL.query); - parsedQs.key = key; - if (data) { - parsedQs.data = JSON.stringify(data); + parsedQs.typeKey = typeKey; + for (const prop of ['href', 'path', 'pathname']) { + parsedURL[prop] = `${parsedURL[prop]}/${linkID}`; } return url.format({ ...parsedURL, search: qs.stringify(parsedQs)}); } -const LinkModal = ({type, editing, data, ...props}) => { - if (!type) { +const LinkModal = ({ typeTitle, typeKey, linkID, data, editing, ...props }) => { + if (!typeKey) { return false; } - return ; diff --git a/client/src/components/LinkPicker/LinkPicker.js b/client/src/components/LinkPicker/LinkPicker.js index 98aab6ec..62cd7217 100644 --- a/client/src/components/LinkPicker/LinkPicker.js +++ b/client/src/components/LinkPicker/LinkPicker.js @@ -1,30 +1,33 @@ /* eslint-disable */ -import i18n from 'i18n'; import React from 'react'; -import { inject } from 'lib/Injector'; import PropTypes from 'prop-types'; -import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem, Button } from 'reactstrap'; import classnames from 'classnames'; import LinkPickerMenu from './LinkPickerMenu'; import LinkPickerTitle from './LinkPickerTitle'; -import LinkType from 'types/LinkType'; -const LinkPicker = ({ types, onSelect, link, onEdit, onClear }) => ( -
- {link === undefined && } - {link && link && onEdit && onEdit(link)}/>} +const LinkPicker = ({ title, description, typeTitle, types, onSelect, onEdit, onClear, ...props }) => { + return
+ {!typeTitle && } + {typeTitle && onEdit()} + />}
-); +} LinkPicker.propTypes = { ...LinkPickerMenu.propTypes, - link: PropTypes.shape(LinkPickerTitle.propTypes), + title: PropTypes.string, + description: PropTypes.string, + typeTitle: PropTypes.string, onEdit: PropTypes.func, onClear: PropTypes.func, + onSelect: PropTypes.func }; - export {LinkPicker as Component}; export default LinkPicker; diff --git a/client/src/components/LinkPicker/LinkPickerMenu.js b/client/src/components/LinkPicker/LinkPickerMenu.js index 036f8392..a3eb3c70 100644 --- a/client/src/components/LinkPicker/LinkPickerMenu.js +++ b/client/src/components/LinkPicker/LinkPickerMenu.js @@ -1,17 +1,14 @@ /* eslint-disable */ import i18n from 'i18n'; -import React, {useState, setState} from 'react'; -import { inject } from 'lib/Injector'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap'; -import classnames from 'classnames'; import LinkType from 'types/LinkType'; const LinkPickerMenu = ({ types, onSelect }) => { const [isOpen, setIsOpen] = useState(false); const toggle = () => setIsOpen(prevState => !prevState); - return ( (e) => { @@ -13,24 +12,24 @@ const stopPropagation = (fn) => (e) => { fn && fn(); } -const LinkPickerTitle = ({ title, type, description, onClear, onClick }) => ( -
+const LinkPickerTitle = ({ title, description, typeTitle, onClear, onClick, ...props }) => { + return
-); +}; LinkPickerTitle.propTypes = { title: PropTypes.string.isRequired, - type: LinkType, + linkTypeTitle: PropTypes.string, description: PropTypes.string, onClear: PropTypes.func, onClick: PropTypes.func diff --git a/client/src/entwine/JsonField.js b/client/src/entwine/JsonField.js index ee8f22c4..3f7b4c2c 100644 --- a/client/src/entwine/JsonField.js +++ b/client/src/entwine/JsonField.js @@ -30,13 +30,12 @@ jQuery.entwine('ss', ($) => { const props = this.getProps(); const ReactField = this.getComponent(); const Root = this.getRoot(); - Root.render(); + Root.render(); }, - handleChange(event, {id, value}) { + handleChange(data) { const fieldID = $(this).data('field-id'); - $('#' + fieldID).val(JSON.stringify(value)).trigger('change'); - this.refresh(); + $('#' + fieldID).val(JSON.stringify({ID: data.ID})); }, /** @@ -47,10 +46,9 @@ jQuery.entwine('ss', ($) => { getProps() { const fieldID = $(this).data('field-id'); const dataStr = $('#' + fieldID).val(); - const value = dataStr ? JSON.parse(dataStr) : undefined; + const value = JSON.parse(dataStr); return { - id: fieldID, value, onChange: this.handleChange.bind(this) }; diff --git a/client/src/state/linkDescription/readLinkDescription.js b/client/src/state/linkDescription/readLinkDescription.js deleted file mode 100644 index 2301bd26..00000000 --- a/client/src/state/linkDescription/readLinkDescription.js +++ /dev/null @@ -1,43 +0,0 @@ -/* eslint-disable */ -import { graphqlTemplates } from 'lib/Injector'; - -const apolloConfig = { - props( - props - ) { - const { - data: { - error, - readLinkDescription, - loading: networkLoading, - }, - } = props; - const errors = error && error.graphQLErrors && - error.graphQLErrors.map((graphQLError) => graphQLError.message); - const linkDescription = readLinkDescription ? readLinkDescription.description : ''; - - return { - loading: networkLoading, - linkDescription, - graphQLErrors: errors, - }; - }, -}; - -const { READ } = graphqlTemplates; -const query = { - apolloConfig, - templateName: READ, - pluralName: 'LinkDescription', - pagination: false, - params: { - dataStr: 'String!' - }, - args: { - root: { - dataStr: 'dataStr' - } - }, - fields: ['description'], -}; -export default query; diff --git a/client/src/state/linkTypes/readLinkTypes.js b/client/src/state/linkTypes/readLinkTypes.js index 1bb0bb7e..932032d0 100644 --- a/client/src/state/linkTypes/readLinkTypes.js +++ b/client/src/state/linkTypes/readLinkTypes.js @@ -43,6 +43,6 @@ const query = { keys: 'keys' } }, - fields: ['key', 'title', 'handlerName'], + fields: ['key', 'title'], }; export default query; diff --git a/docs/en/linkable-migration.md b/docs/en/linkable-migration.md deleted file mode 100644 index d58fcc42..00000000 --- a/docs/en/linkable-migration.md +++ /dev/null @@ -1,247 +0,0 @@ -# Instructions - -## Preamble - -This migration process covers shifting data from the `Linkable` tables to the appropriate `LinkField` tables. - -This does not cover usages of `EmbeddedObject` (at least, not at this time). - -**Versioned:** If you have `Versioned` `Linkable`, then the expectation is that you will also `Version` `LinkField`. If -you have not `Versioned` `Linkable`, then the expectation is that you will **not** `Version` `LinkField`. - -**No support for internal links with query params (GET params):** Please be aware that Linkfield does not support -internal links with query params (`?`) out of the box, and therefor the migration task will **remove** any query -params that are present in the Linkable's `Anchor` field. - -## Install Silvesrtripe Linkfield - -Install the Silverstripe Linkfield module: - -```bash -$ composer require silverstripe/linkfield 1.x-dev -``` - -Or if you would like the (experimental) GraphQL 4 version: - -```bash -$ composer require silverstripe/linkfield 2.x-dev -``` - -Optionally, you can also remove the Linkable module (though, you might find it useful to keep around as a reference -while you are upgrading your code). - -Do this step at whatever point makes sense to you. - -```bash -$ composer remove sheadawson/silverstripe-linkable -``` - -## Replace app usages - -You should review how you are using the original `Link` model and `LinkField`, but if you don't have any customisations, -then replacing the old with the new **might** be quite simple. - -If you have used imports (`use` statements), then your first step might just be to search for `use [old];` and replace -with `use [new];` (since the class name references have not changed at all). - -Old: `Sheadawson\Linkable\Models\Link` -New: `SilverStripe\LinkField\Models\Link` - -Old: `Sheadawson\Linkable\Forms\LinkField` -New: `SilverStripe\LinkField\Form\LinkField` - -If you have extensions, new fields, etc, then your replacements might need to be a bit more considered. - -The other key (less easy to automate) thing that you'll need to update is that the old `LinkField` required you to -specify the related field with `ID` appended, whereas the new `LinkField` requires you to specify the field without -`ID` appended. EG. - -Old: `LinkField::create('MyLinkID')` -New: `LinkField::create('MyLink')` - -Search for instances of `LinkField::create` and `new LinkField`, and hopefully that should give you all of the places -where you need to update field name references. - -### Configuration - -Be sure to check how the old module classes are referenced in config `yml` files (eg: `app/_config`). Update -appropriately. - -### Populate module - -If you use the populate module, you will not be able to simply "replace" the namespace. Fixture definitions for the -new Linkfield module are quite different. There are entirely different models for different link types, whereas before -it was just a DB field to specify the type. - -See below for example before/after usage: - -#### Before - -```yml -Sheadawson\Linkable\Models\Link: - internal: - Title: Internal link - Type: SiteTree - SiteTreeID: 1 - external: - Title: External link - Type: URL - URL: https://example.org - file: - Title: File link - Type: File - File: =>SilverStripe\Assets\File.example - phone: - Title: Phone link - Type: Phone - Phone: +64 1 234 567 - email: - Title: Email link - Type: Email - Email: foo@example.org -``` - -#### After - -```yml -SilverStripe\LinkField\Models\SiteTreeLink: - internal: - Title: Internal link - Page: =>Page.home -SilverStripe\LinkField\Models\ExternalLink: - external: - Title: External link - ExternalUrl: https://example.org -SilverStripe\LinkField\Models\FileLink: - file: - Title: File link - File: =>SilverStripe\Assets\File.example -SilverStripe\LinkField\Models\PhoneLink: - phone: - Title: Phone link - Phone: +64 1 234 567 -SilverStripe\LinkField\Models\EmailLink: - email: - Title: Email link - Email: foo@example.org -``` - -## Replace template usages - -Before: You might have had references to `$LinkURL` or `$Link.LinkURL`. -After: These would need to be updated to `$URL` or `$Link.URL` respectively. - -Before: `$OpenInNewWindow` or `$Link.OpenInNewWindow`. -After: `$OpenInNew` or `$Link.OpenInNew` respectively. - -Before: `$Link.TargetAttr` or `$TargetAttr` would output the appropriate `target="xx"`. -After: There is no direct replacement. - -This is an area where you should spend some decent effort to make sure each implementation is outputting as you expect -it to. There may be more "handy" methods that Linkable provided that no longer exist (that we haven't covered above). - -## Table structures - -It's important to understand that we are going from a single table in Linkable to multiple tables in LinkField. - -**Before:** We had 1 table with all data, and one of the field in there specified the type of the Link. -**Now:** We have 1 table for each type of Link, with a base `Link` table for all record. - -## Specify any custom configuration - -Have a look at `LinkableMigrationTask`. There are some configuration properties defined in there: - -- `$link_mapping` -- `$email_mapping` -- `$external_mapping` -- `$file_mapping` -- `$phone_mapping` -- `$sitetree_mapping` - -Each of these specifies how an original field from the `LinkableLink` table will map to one of the new LinkField tables. - -If you previously had some custom fields that needed to be available across **all** Link types, then you're (probably) -going to add this as an extension on the (base) `Link` class. This is going to mean that the new fields will be added -to the `LinkField_Link` table. This means that you need to update the configuration for `$link_mapping` so that we -correctly migrate those field values into the `LinkField_Link` table. - -If you had/have a field that you only want displayed on (say) SiteTree links, then you would want to add that extension -to `SiteTreeLink`. This would create new fields in the `LinkField_SiteTreeLink` table, which will mean you need to -also update the config for `$sitetree_mapping`. - -It's important that you get the correct mappings to the correct tables. - -### Linkable `has_one` to one of your other DataObjects - -An example for the above [Specify any custom configuration](#specify-any-custom-configuration) would be that if one -of your DataObjects `has_many` `Link`. This would require there to be a `has_one` on `Link` back to your DataObject. - -Let's say that our `Page` `has_many` `Link`: -```php -class Page extends SiteTree -{ - private static array $has_many = [ - 'Links' => Link::class, - ]; -} -``` - -This would require a corresponding `has_one` on `Link`: -```yaml -Sheadawson\Linkable\Models\Link: - has_one: - ParentPage: Page -``` - -If we inspect the `LinkableLink` table, we'll see that there is a field called `ParentPageID`. We need to tell the -migration task about this field, and where it needs to migrate to. - -Assuming you keep the same relationship name, you'll want to add the following `$link_mapping` configuration: -```yaml -SilverStripe\LinkField\Tasks\LinkableMigrationTask: - link_mapping: - ParentPageID: ParentPageID -``` - -## Adding support for internal links with query params - -No official support is provided, but you can achieve this through adding your own extensions. - -Add a new field to `SiteTreeLink` to store your query params, EG: - -```php -class SiteTreeLinkExtension extends DataExtension -{ - private static array $db = [ - 'QueryParams' => 'Varchar', - ]; -} -``` - -An extension point called `updateGetURLBeforeAnchor` is available: - -```php -class SiteTreeLinkExtension extends DataExtension -{ - ... - - public function updateGetURLBeforeAnchor(&$url): void - { - // Assumes that you save your QueryParams within prepending the ?, so we append it here - $url .= sprintf('?%s', $this->owner->QueryParams); - } -} -``` - -If you also plan to use the `LinkableMigrationTask`, then there is a configuration that you can enable to tell us where -you would like the query params from the old `AnchorLink` to be migrated. - -Please note: The migration task assumes that you will store your query params without prepending the `?` (following -the same paradigm as our `Anchor` field). - -EG: - -```yaml -SilverStripe\LinkField\Tasks\LinkableMigrationTask: - sitetree_query_params_to: QueryParams -``` diff --git a/src/Controllers/LinkFieldController.php b/src/Controllers/LinkFieldController.php new file mode 100644 index 00000000..cee0eb21 --- /dev/null +++ b/src/Controllers/LinkFieldController.php @@ -0,0 +1,288 @@ + 'linkForm', + 'GET data/$ItemID' => 'linkData', + 'DELETE delete/$ItemID' => 'linkDelete', + ]; + + private static $allowed_actions = [ + 'linkForm', + 'linkData', + 'linkDelete', + ]; + + public function getClientConfig() + { + $clientConfig = parent::getClientConfig(); + $clientConfig['form']['linkForm'] = [ + // schema() is defined on LeftAndMain + // schemaUrl will get the $ItemID and ?typeKey dynamically suffixed in LinkModal.js + 'schemaUrl' => $this->Link('schema/linkForm'), + 'deleteUrl' => $this->Link('delete'), + 'dataUrl' => $this->Link('data'), + 'saveMethod' => 'post', + 'formNameTemplate' => sprintf(self::FORM_NAME_TEMPLATE, '{id}'), + ]; + return $clientConfig; + } + + /** + * Used for both: + * - GET requests to get the FormSchema via `getLinkForm()` called from LeftAndMain::schema() + * - POST Requests to save the Form. Will be handled by to FormRequestHandler::httpSubmission() + * /admin/linkfield/linkForm/ + */ + public function linkForm(): Form + { + $id = (int) $this->itemIDFromRequest(); + if ($id) { + $link = Link::get()->byID($id); + if (!$link) { + $this->jsonError(400, 'Invalid Link id'); + } + } else { + $typeKey = $this->typeKeyFromRequest(); + $link = Registry::create()->byKey($typeKey); + if (!$link) { + $this->jsonError(400, 'Invalid key'); + } + } + return $this->createLinkForm($link); + } + + /** + * Get data for a Link + * /admin/linkfield/data/ + */ + public function linkData(): HTTPResponse + { + $link = $this->linkFromRequest(); + if (!$link->canView()) { + $this->jsonError(403, 'Unable to view link'); + } + $response = $this->getResponse(); + $response->addHeader('Content-type', 'application/json'); + $data = $link->jsonSerialize(); + $data['description'] = $link->getDescription(); + $response->setBody(json_encode($data)); + return $response; + } + + /** + * Delete a Link + * /admin/linkfield/delete/ + */ + public function linkDelete(): HTTPResponse + { + $link = $this->linkFromRequest(); + if (!$link->canDelete()) { + $this->jsonError(403, 'Unable to delete Link'); + } + // Check security token on destructive operation + if (!SecurityToken::inst()->checkRequest($this->getRequest())) { + $this->jsonError(400, 'Invalid CSRF token'); + } + // delete() will also delete any published version immediately + $link->delete(); + $response = $this->getResponse(); + $response->addHeader('Content-type', 'application/json'); + $response->setBody(json_encode(['success' => true])); + return $response; + } + + /** + * This method is called from LeftAndMain::schema() + * /admin/linkfield/schema/linkForm/ + */ + public function getLinkForm(): Form + { + return $this->linkForm(); + } + + /** + * Arrive here from FormRequestHandler::httpSubmission() during a POST request to + * /admin/linkfield/linkForm/ + * The 'save' method is called because it is the FormAction set on the Form + */ + public function save(array $data, Form $form): HTTPResponse + { + if (empty($data)) { + $this->jsonError(400, 'Empty data'); + } + // Ensure that ItemID url param is used as the source of truth for ID. ID shouldn't be in the + // POST request, though just remove it in case someone tries to add it in + if (isset($data['ID'])) { + unset($data['ID']); + } + + /** @var Link $link */ + $id = (int) $this->itemIDFromRequest(); + if ($id) { + // Editing an existing Link + $link = Link::get()->byID($id); + } else { + // Creating a new Link + $typeKey = $this->typeKeyFromRequest(); + $className = Registry::create()->list()[$typeKey] ?? ''; + if (!$className) { + $this->jsonError(400, 'Invalid key'); + } + $link = $className::create(); + } + + // Ensure the DataObject can be edited by the current user + if (!$link || !$link->canEdit()) { + $this->jsonError(403, 'Unauthorized'); + } + + // Update DataObject from form data + $form->saveInto($link); + + // Special logic for FileLink + if (is_a($link, FileLink::class)) { + // FileField value will come in as $postVars['File']['Files'][0]; + // $form->saveInto($link); doesn't seem to handle this + $link->FileID = $data['File']['Files'][0] ?? 0; + } + + // DataObject validation + // thrown ValidationException will be caught in FormRequestHandler::httpSubmission() + // Note: Form (as opposed to DataObject) validate() is run in FormRequestHandler::httpSubmission() + $validationResult = $link->validate(); + if (!$validationResult->isValid()) { + throw new ValidationException($validationResult); + } + + // Write to the database if the DataObject has changed + if ($link->isChanged()) { + $link->write(); + } + + // Create a new Form so that it has the correct ID for the DataObject when creating + // a new DataObject, as well as anything else on the DataObject that may have been + // updated in an extension hook. We do this so that the FormSchema state is correct + // before returning it in the response + $form = $this->createLinkForm($link); + + // Create and send FormSchema JSON response + $schemaID = $form->FormAction(); + $response = $this->getSchemaResponse($schemaID, $form, $validationResult); + return $response; + } + + + /** + * Create the Form used to content manage a Link in a modal + */ + private function createLinkForm(Link $link): Form + { + $id = $link->ID; + + // Create the form + $formFactory = Injector::inst()->get(DefaultFormFactory::class); + $name = sprintf(self::FORM_NAME_TEMPLATE, $id); + /** @var Form $form */ + $form = $formFactory->getForm($this, $name, ['Record' => $link]); + + // Set where the form is submitted to + // Ideally would use getClientConfig() 'saveUrl' instaed, not sure why that doesn't work + $urlSegment = $this->config()->get('url_segment'); + $typeKey = Registry::create()->keyByClassName($link->ClassName); + $form->setFormAction("admin/$urlSegment/linkForm/$id?typeKey=$typeKey"); + + // Add save action button + $title = $id ? 'Update link' : 'Create link'; // todo: _t() + $actions = FieldList::create([ + FormAction::create('save', $title) + ->setSchemaData(['data' => ['buttonStyle' => 'primary']]), + ]); + $form->setActions($actions); + + // Set the form request handler to return a FormSchema response during a POST request + // This will override the default FormRequestHandler::getAjaxErrorResponse() which isn't useful + $form->setValidationResponseCallback(function (ValidationResult $errors) use ($form, $id) { + $schemaId = Controller::join_links( + $this->Link('schema'), + $this->config()->get('url_segment'), + $id + ); + return $this->getSchemaResponse($schemaId, $form, $errors); + }); + + // Make readonly if cannot edit + if (!$link->canEdit()) { + $form->makeReadonly(); + } + + // Styling + $form->addExtraClass('form--no-dividers'); + + return $form; + } + + /** + * Get a Link object based on the $ItemID request param + */ + private function linkFromRequest(): Link + { + $itemID = (int) $this->itemIDFromRequest(); + if (!$itemID) { + $this->jsonError(400, 'Missing ID'); + } + $link = Link::get()->byID($itemID); + if (!$link) { + $this->jsonError(400, 'Invalid ID'); + } + return $link; + } + + /** + * Get the $ItemID request param + */ + private function itemIDFromRequest(): string + { + $request = $this->getRequest(); + $itemID = (string) $request->param('ItemID'); + if (!ctype_digit($itemID)) { + $this->jsonError(400, "Invalid itemID param $itemID"); + } + return $itemID; + } + + /** + * Get the ?typeKey request querystring param + */ + private function typeKeyFromRequest(): string + { + $request = $this->getRequest(); + $typeKey = (string) $request->getVar('typeKey'); + if (strlen($typeKey) === 0 || !preg_match('#^[a-z\-]+$#', $typeKey)) { + $this->jsonError(400, "Invalid typeKey $typeKey"); + } + return $typeKey; + } +} diff --git a/src/Extensions/AjaxField.php b/src/Extensions/AjaxField.php deleted file mode 100644 index 7764c65c..00000000 --- a/src/Extensions/AjaxField.php +++ /dev/null @@ -1,33 +0,0 @@ -getOwner(); - $formName = $owner->getForm()->getName(); - - if ($formName !== 'Modals/DynamicLink') { - return; - } - - $request = $owner->getForm()->getController()->getRequest(); - $key = $request->getVar('key'); - - $link .= strpos($link, '?') === false ? '?' : '&'; - $link .= "key={$key}"; - } -} diff --git a/src/Extensions/FormFactoryExtension.php b/src/Extensions/FormFactoryExtension.php deleted file mode 100644 index 1491f924..00000000 --- a/src/Extensions/FormFactoryExtension.php +++ /dev/null @@ -1,55 +0,0 @@ -exists()) { - // This is a new link, so we don't need to to any further customisation - return; - } - - /** @var FormAction $insertAction */ - $insertAction = $actions->fieldByName('action_insert'); - - if (!$insertAction) { - // We couldn't find the insert action - return; - } - - // Update the title of the action to reflect the link model data state - $insertActionTitle = _t('Admin.EDIT_LINK', 'Edit link'); - $insertAction->setTitle($insertActionTitle); - } -} diff --git a/src/Extensions/LeftAndMain.php b/src/Extensions/LeftAndMain.php deleted file mode 100644 index d1173a3d..00000000 --- a/src/Extensions/LeftAndMain.php +++ /dev/null @@ -1,25 +0,0 @@ -init(); - } - - public function updateClientConfig(&$clientConfig) - { - $clientConfig['form']['DynamicLink'] = [ - 'schemaUrl' => $this->getOwner()->Link('methodSchema/Modals/DynamicLink'), - ]; - } -} diff --git a/src/Extensions/LeftAndMainExtension.php b/src/Extensions/LeftAndMainExtension.php new file mode 100644 index 00000000..a227d73b --- /dev/null +++ b/src/Extensions/LeftAndMainExtension.php @@ -0,0 +1,16 @@ + 'editorAnchorLink', // Matches LeftAndMain::methodSchema args - ]; - - private static array $allowed_actions = [ - 'DynamicLink', - ]; - - /** - * Builds and returns the external link form - * - * @return Form - */ - public function DynamicLink() - { - // Show link text field if requested - $linkDataJsonStr = $this->getOwner()->controller->getRequest()->getVar('data'); - - /** @var OwnerController $owner */ - $owner = $this->getOwner(); - - $factory = FormFactory::singleton(); - - $data = $this->getData(); - - return $factory->getForm( - $owner->getController(), - "{$owner->getName()}/DynamicLink", - $this->getContext() - )->loadDataFrom($data); - } - - /** - * Build the context to pass to the Form Link Factory - * @return array - * @throws HTTPResponse_Exception - */ - private function getContext(): array - { - $linkTypeKey = $this->getOwner()->controller->getRequest()->getVar('key'); - - if (!$linkTypeKey) { - throw new HTTPResponse_Exception(sprintf('key for class "%s" is required', static::class), 400); - } - - $type = Registry::singleton()->byKey($linkTypeKey); - - if (!$type) { - throw new HTTPResponse_Exception(sprintf('%s is not a valid link type', $type), 400); - } - - $data = $this->getData(); - - // Hydrate current model in case data is available, so more options are available for CMS fields customsation - // This allows model-level form customisation - if ($data && array_key_exists('ID', $data) && $data['ID']) { - /** @var Link $type */ - $type = Injector::inst()->create($type->ClassName, $data, DataObject::CREATE_HYDRATED); - } - - return [ - 'LinkData' => $data, - 'LinkType' => $type, - 'LinkTypeKey' => $linkTypeKey, - // TODO this is likely a legacy field, use form validator instead - 'RequireLinkText' => false - ]; - } - - /** - * Extract the Link Data out of the Request. - * @return array - */ - private function getData(): array - { - $data = []; - $dataString = $this->getOwner()->controller->getRequest()->getVar('data'); - - if ($dataString) { - $parsedData = json_decode($dataString, true); - - if (json_last_error() === JSON_ERROR_NONE) { - $data = $parsedData; - } else { - throw new InvalidArgumentException(json_last_error_msg()); - } - } - - return $data; - } -} diff --git a/src/Form/FormFactory.php b/src/Form/FormFactory.php deleted file mode 100644 index e9f57f5c..00000000 --- a/src/Form/FormFactory.php +++ /dev/null @@ -1,47 +0,0 @@ -scaffoldLinkFields($linkData); - $fields->push(HiddenField::create('typeKey')->setValue($context['LinkTypeKey'])); - $this->extend('updateFormFields', $fields, $controller, $name, $context); - - return $fields; - } - - protected function getValidator($controller, $name, $context) - { - if (!array_key_exists('LinkType', $context)) { - return null; - } - - /** @var DataObject|Type $type */ - $type = $context['LinkType']; - - return $type->getCMSCompositeValidator(); - } -} diff --git a/src/Form/JsonField.php b/src/Form/JsonField.php index 4f7ad0b6..e2f78824 100644 --- a/src/Form/JsonField.php +++ b/src/Form/JsonField.php @@ -28,7 +28,7 @@ public function setValue($value, $data = null) } /** - * @param DataObject|DataObjectInterface $record + * @param DataObject|DataObjectInterface $record - A DataObject such as a Page * @return $this */ public function saveInto(DataObjectInterface $record) @@ -40,37 +40,14 @@ public function saveInto(DataObjectInterface $record) return $this; } - $dataValue = $this->dataValue(); - $value = is_string($dataValue) ? $this->parseString($this->dataValue()) : $dataValue; - - if ($class = DataObject::getSchema()->hasOneComponent(get_class($record), $fieldname)) { - /** @var JsonData|DataObject $jsonDataObject */ - - $jsonDataObjectID = $record->{"{$fieldname}ID"}; - - if ($jsonDataObjectID && $jsonDataObject = $record->$fieldname) { - if ($value) { - $jsonDataObject = $jsonDataObject->setData($value); - $this->extend('onBeforeLinkEdit', $jsonDataObject, $record); - $jsonDataObject->write(); - $this->extend('onAfterLinkEdit', $jsonDataObject, $record); - } else { - $this->extend('onBeforeLinkDelete', $jsonDataObject, $record); - $jsonDataObject->delete(); - $record->{"{$fieldname}ID"} = 0; - $this->extend('onAfterLinkDelete', $jsonDataObject, $record); - } - } elseif ($value) { - $jsonDataObject = new $class(); - $jsonDataObject = $jsonDataObject->setData($value); - $this->extend('onBeforeLinkCreate', $jsonDataObject, $record); - $jsonDataObject->write(); - $record->{"{$fieldname}ID"} = $jsonDataObject->ID; - $this->extend('onAfterLinkCreate', $jsonDataObject, $record); - } - } elseif ((DataObject::getSchema()->databaseField(get_class($record), $fieldname))) { - $record->{$fieldname} = $value; - } + // This code will all change in a later PR once we refactor out the JSON data format + // This method used to update the Link DataObject which was a relation + // Update the Pages has_one relationship ID using the existing JSON data format + $data = $this->parseString($this->dataValue()); + // Not 'ID' key is perfectly valid e.g. removing an existing link + $linkID = $data['ID'] ?? 0; + $dbColumn = $fieldname . 'ID'; + $record->$dbColumn = $linkID; return $this; } diff --git a/src/Form/LinkField.php b/src/Form/LinkField.php index bdf7e9de..7f5c1a1e 100644 --- a/src/Form/LinkField.php +++ b/src/Form/LinkField.php @@ -8,9 +8,4 @@ class LinkField extends JsonField { protected $schemaComponent = 'LinkField'; - - public function setValue($value, $data = null) - { - return parent::setValue($value, $data); - } } diff --git a/src/GraphQL/LinkDescriptionResolver.php b/src/GraphQL/LinkDescriptionResolver.php deleted file mode 100644 index 2259f25d..00000000 --- a/src/GraphQL/LinkDescriptionResolver.php +++ /dev/null @@ -1,34 +0,0 @@ - '']; - } - - $type = Registry::singleton()->byKey($typeKey); - - if (!$type) { - return ['description' => '']; - } - - return ['description' => $type->generateLinkDescription($data)]; - } -} diff --git a/src/GraphQL/LinkTypeResolver.php b/src/GraphQL/LinkTypeResolver.php index a61f28f1..7f642607 100644 --- a/src/GraphQL/LinkTypeResolver.php +++ b/src/GraphQL/LinkTypeResolver.php @@ -6,7 +6,7 @@ use InvalidArgumentException; use SilverStripe\GraphQL\Schema\DataObject\Resolver; use SilverStripe\LinkField\Type\Registry; -use SilverStripe\LinkField\Type\Type; +use SilverStripe\LinkField\Models\Link; class LinkTypeResolver extends Resolver { @@ -17,10 +17,9 @@ public static function resolve($obj, $args = [], $context = [], ?ResolveInfo $in } $types = Registry::singleton()->list(); - $flattenType = array_map(function (Type $type, string $key) { + $flattenType = array_map(function (Link $type, string $key) { return [ 'key' => $key, - 'handlerName' => $type->LinkTypeHandlerName(), 'title' => $type->LinkTypeTile() ]; }, $types, array_keys($types)); diff --git a/src/Models/EmailLink.php b/src/Models/EmailLink.php index 410455db..c41143ee 100644 --- a/src/Models/EmailLink.php +++ b/src/Models/EmailLink.php @@ -18,9 +18,9 @@ class EmailLink extends Link 'Email' => 'Varchar(255)', ]; - public function generateLinkDescription(array $data): string + public function getDescription(): string { - return isset($data['Email']) ? $data['Email'] : ''; + return $this->Email ?: ''; } public function getCMSFields(): FieldList @@ -28,7 +28,6 @@ public function getCMSFields(): FieldList $this->beforeUpdateCMSFields(static function (FieldList $fields) { $fields->replaceField('Email', EmailField::create('Email')); }); - return parent::getCMSFields(); } diff --git a/src/Models/ExternalLink.php b/src/Models/ExternalLink.php index c51fb50c..732d620e 100644 --- a/src/Models/ExternalLink.php +++ b/src/Models/ExternalLink.php @@ -15,13 +15,13 @@ class ExternalLink extends Link 'ExternalUrl' => 'Varchar', ]; - public function generateLinkDescription(array $data): string + public function getDescription(): string { - return isset($data['ExternalUrl']) ? $data['ExternalUrl'] : ''; + return $this->ExternalUrl ?: ''; } public function getURL(): string { - return $this->ExternalUrl ?? ''; + return $this->ExternalUrl ?: ''; } } diff --git a/src/Models/FileLink.php b/src/Models/FileLink.php index 0d40b933..21fbd006 100644 --- a/src/Models/FileLink.php +++ b/src/Models/FileLink.php @@ -4,12 +4,6 @@ use SilverStripe\Assets\File; -/** - * A link to a File track in asset-admin - * - * @property int $FileID - * @method File File() - */ class FileLink extends Link { private static string $table_name = 'LinkField_FileLink'; @@ -18,28 +12,14 @@ class FileLink extends Link 'File' => File::class, ]; - public function generateLinkDescription(array $data): string + public function getDescription(): string { - $fileId = $data['FileID'] ?? null; - - if (!$fileId) { - return ''; - } - - $file = File::get()->byID($fileId); - - return $file?->getFilename() ?? ''; - } - - public function LinkTypeHandlerName(): string - { - return 'InsertMediaModal'; + return $this->File()?->getFilename() ?? ''; } public function getURL(): string { $file = $this->File(); - return $file->exists() ? (string) $file->getURL() : ''; } } diff --git a/src/Models/Link.php b/src/Models/Link.php index 87d2de0f..7c973e58 100644 --- a/src/Models/Link.php +++ b/src/Models/Link.php @@ -24,7 +24,7 @@ * @property string $Title * @property bool $OpenInNew */ -class Link extends DataObject implements JsonData, Type +class Link extends DataObject implements JsonData { private static $table_name = 'LinkField_Link'; @@ -40,19 +40,7 @@ class Link extends DataObject implements JsonData, Type */ private ?string $linkType = null; - public function defineLinkTypeRequirements() - { - Requirements::add_i18n_javascript('silverstripe/linkfield:client/lang', false, true); - Requirements::javascript('silverstripe/linkfield:client/dist/js/bundle.js'); - Requirements::css('silverstripe/linkfield:client/dist/styles/bundle.css'); - } - - public function LinkTypeHandlerName(): string - { - return 'FormBuilderModal'; - } - - public function generateLinkDescription(array $data): string + public function getDescription(): string { return ''; } diff --git a/src/Models/PhoneLink.php b/src/Models/PhoneLink.php index 855e3b1c..c4d70fd6 100644 --- a/src/Models/PhoneLink.php +++ b/src/Models/PhoneLink.php @@ -2,6 +2,10 @@ namespace SilverStripe\LinkField\Models; +use SilverStripe\Forms\CompositeValidator; +use SilverStripe\Forms\RequiredFields; +use SilverStripe\Forms\Validator; + /** * A link to a phone number * @@ -15,13 +19,41 @@ class PhoneLink extends Link 'Phone' => 'Varchar(255)', ]; - public function generateLinkDescription(array $data): string + public function getDescription(): string { - return isset($data['Phone']) ? $data['Phone'] : ''; + return $this->Phone ?: ''; } public function getURL(): string { return $this->Phone ? sprintf('tel:%s', $this->Phone) : ''; } + + public function validate() + { + $result = parent::validate(); + $phone = $this->Phone; + if ($phone == 'x') { + $result->addFieldError('Phone', 'Cannot be x - DataObject::validate()'); + } + return $result; + } + + public function getCMSCompositeValidator(): CompositeValidator + { + $validator = parent::getCMSCompositeValidator(); + $validator->addValidator(new class extends Validator { + public function php($data): bool + { + $valid = true; + $phone = $data['Phone']; + if ($phone == 'y') { + $valid = false; + $this->validationError('Phone', 'Cannot be y -- DataObject::getCMSCompositeValidator()'); + } + return $valid; + } + }); + return $validator; + } } diff --git a/src/Models/SiteTreeLink.php b/src/Models/SiteTreeLink.php index f3f611d8..5f8b70d2 100644 --- a/src/Models/SiteTreeLink.php +++ b/src/Models/SiteTreeLink.php @@ -30,22 +30,9 @@ class SiteTreeLink extends Link 'Page' => SiteTree::class, ]; - public function generateLinkDescription(array $data): string + public function getDescription(): string { - $pageId = $data['PageID'] ?? null; - - if (!$pageId) { - return ''; - } - - /** @var SiteTree $page */ - $page = SiteTree::get()->byID($pageId); - - if (!$page?->exists()) { - return ''; - } - - return $page->URLSegment ?: ''; + return $this->Page()?->URLSegment ?? ''; } public function getCMSFields(): FieldList diff --git a/src/ORM/DBJson.php b/src/ORM/DBJson.php deleted file mode 100644 index 9e6391b1..00000000 --- a/src/ORM/DBJson.php +++ /dev/null @@ -1,76 +0,0 @@ -defaultVal = is_array($defaultVal) ? $defaultVal : []; - - parent::__construct($name); - } - - public function requireField() - { - $charset = Config::inst()->get('SilverStripe\ORM\Connect\MySQLDatabase', 'charset'); - $collation = Config::inst()->get('SilverStripe\ORM\Connect\MySQLDatabase', 'collation'); - - $parts = [ - 'datatype' => 'mediumtext', - 'character set' => $charset, - 'collate' => $collation, - 'arrayValue' => $this->arrayValue - ]; - - $values = [ - 'type' => 'text', - 'parts' => $parts - ]; - - DB::require_field($this->tableName, $this->name, $values); - } - - public function nullValue() - { - return null; - } - - public function setValue($value, $record = null, $markChanged = true) - { - if (!$value) { - $value = null; - } - - if (is_string($value)) { - $value = json_decode($value, true); - } elseif ($value instanceof JsonData) { - $value = $value->jsonSerialize(); - } - - return parent::setValue($value, $record, $markChanged); - } - - public function prepValueForDB($value) - { - if (is_array($value) || $value instanceof JsonData) { - $value = json_encode($value); - } - - return $value; - } - - public function scalarValueOnly() - { - return false; - } -} diff --git a/src/ORM/DBLink.php b/src/ORM/DBLink.php deleted file mode 100644 index a5889434..00000000 --- a/src/ORM/DBLink.php +++ /dev/null @@ -1,36 +0,0 @@ -getValue(); - - if ($value) { - $type = Registry::singleton()->byKey($value['typeKey']); - - if ($type) { - return $type->loadLinkData($value)->forTemplate(); - } - } - } - - public function scaffoldFormField($title = null, $params = null) - { - return LinkField::create($this->getName(), $this->getValue()); - } -} diff --git a/src/Tasks/LinkableMigrationTask.php b/src/Tasks/LinkableMigrationTask.php deleted file mode 100644 index a3914992..00000000 --- a/src/Tasks/LinkableMigrationTask.php +++ /dev/null @@ -1,578 +0,0 @@ - '%s', - self::TABLE_LIVE => '%s_Live', - self::TABLE_VERSIONS => '%s_Versions', - ]; - - protected const TABLE_MAP_EMAIL_LINK = [ - self::TABLE_BASE => '%s', - self::TABLE_LIVE => '%s_Live', - self::TABLE_VERSIONS => '%s_Versions', - ]; - - protected const TABLE_MAP_EXTERNAL_LINK = [ - self::TABLE_BASE => '%s', - self::TABLE_LIVE => '%s_Live', - self::TABLE_VERSIONS => '%s_Versions', - ]; - - protected const TABLE_MAP_FILE_LINK = [ - self::TABLE_BASE => '%s', - self::TABLE_LIVE => '%s_Live', - self::TABLE_VERSIONS => '%s_Versions', - ]; - - protected const TABLE_MAP_PHONE_LINK = [ - self::TABLE_BASE => '%s', - self::TABLE_LIVE => '%s_Live', - self::TABLE_VERSIONS => '%s_Versions', - ]; - - protected const TABLE_MAP_SITE_TREE_LINK = [ - self::TABLE_BASE => '%s', - self::TABLE_LIVE => '%s_Live', - self::TABLE_VERSIONS => '%s_Versions', - ]; - - private static array $versions_mapping_global = [ - 'RecordID' => 'RecordID', - 'Version' => 'Version', - ]; - - private static array $versions_mapping_base_only = [ - 'WasPublished' => 'WasPublished', - 'WasDeleted' => 'WasDeleted', - 'WasDraft' => 'WasDraft', - 'AuthorID' => 'AuthorID', - 'PublisherID' => 'PublisherID', - ]; - - /** - * LinkableLink field => LinkField_Link field - * Note: If Link is versioned on your project add Version field to the mapping - * - * @config - * @var string[] - */ - private static array $link_mapping = [ - 'ID' => 'ID', - 'LastEdited' => 'LastEdited', - 'Created' => 'Created', - 'Title' => 'Title', - 'OpenInNewWindow' => 'OpenInNew', - ]; - - /** - * LinkableLink field => LinkField_EmailLink field - */ - private static array $email_mapping = [ - 'ID' => 'ID', - 'Email' => 'Email', - ]; - - /** - * LinkableLink field => LinkField_ExternalLink field - */ - private static array $external_mapping = [ - 'ID' => 'ID', - 'URL' => 'ExternalUrl', - ]; - - /** - * LinkableLink field => LinkField_FileLink field - */ - private static array $file_mapping = [ - 'ID' => 'ID', - 'FileID' => 'FileID', - ]; - - /** - * LinkableLink field => LinkField_PhoneLink field - */ - private static array $phone_mapping = [ - 'ID' => 'ID', - 'Phone' => 'Phone', - ]; - - /** - * LinkableLink field => LinkField_SiteTreeLink field - */ - private static array $sitetree_mapping = [ - 'ID' => 'ID', - 'SiteTreeID' => 'PageID', - // @see insertSiteTree() for the migration of the Anchor field - ]; - - /** - * @see insertSiteTree() for the migration of the Anchor field - */ - private static string $sitetree_anchor_from = 'Anchor'; - - /** - * @see insertSiteTree() for the migration of the Anchor field - */ - private static string $sitetree_anchor_to = 'Anchor'; - - /** - * @see insertSiteTree() for the migration of the Anchor field - */ - private static ?string $sitetree_query_params_from = 'Anchor'; - - /** - * @see insertSiteTree() for the migration of the Anchor field - */ - private static ?string $sitetree_query_params_to = null; - - private static $segment = 'linkable-migration-task'; - - protected $title = 'Linkable Migration Task'; - - protected $description = 'Truncate LinkField records and migrate from Linkable records'; - - /** - * @param HTTPRequest $request - * @return void - * @throws Exception - */ - public function run($request): void - { - // Check that we have matching Versioned states between Linkable and LinkField - if (!$this->versionedStatusMatches()) { - throw new Exception( - 'Linkable and LinkField do not have matching Versioned applications. Make sure that both are' - . ' either un-Versioned or Versioned' - ); - } - - // If we're un-Versioned then it's just going to be the base table - $tables = [ - self::TABLE_BASE, - ]; - - // Since we passed the versionedStatusMatches() step, then we can just check if Link is Versioned, and we can - // safely assume that the Linkable Versioned tables also exist - if (Link::singleton()->hasExtension(Versioned::class)) { - // Add the _Live and _Versions tables to the list of things we need to copy - $tables[] = self::TABLE_LIVE; - $tables[] = self::TABLE_VERSIONS; - } - - // We expect your LinkField tables to be completely clear before migration is kicked off - $this->truncateLinkFieldTables(); - - foreach ($tables as $table) { - // Grab any/all records from the desired table (base, live, versions) - $query = $this->getDataSelect($table); - $linkableResults = $query->execute(); - - // Nothing to see here - if ($linkableResults->numRecords() === 0) { - echo sprintf("Nothing to process for `%s`\r\n", $table); - - continue; - } - - echo sprintf("Processing `%s`\r\n", $table); - - // Loop through each DB record - foreach ($linkableResults as $linkableData) { - // We now need to determine what type of Link the original Linkable record was, because we're going to - // have to process each of those slightly differently - switch ($linkableData['Type']) { - case 'Email': - $this->insertEmail($linkableData, $table); - - break; - case 'URL': - $this->insertExternal($linkableData, $table); - - break; - case 'File': - $this->insertFile($linkableData, $table); - - break; - case 'Phone': - $this->insertPhone($linkableData, $table); - - break; - case 'SiteTree': - $this->insertSiteTree($linkableData, $table); - - break; - } - } - - echo sprintf("%d records inserted, finished processing `%s`\r\n", $linkableResults->numRecords(), $table); - } - } - - /** - * Check to see if there is the existence of a _Live table for Linkable (indicating that it was Versioned) - * @return bool - */ - protected function versionedStatusMatches(): bool - { - $wasVersioned = DB::query('SHOW TABLES LIKE \'LinkableLink_Live\';')->numRecords() > 0; - $isVersioned = Link::singleton()->hasExtension(Versioned::class); - - return $wasVersioned === $isVersioned; - } - - /** - * We expect your LinkField tables to be completely clear before migration is kicked off - * This method will delete all data in the new tables providing a clear start and the ability - * to repeat this dev task - * - * @return void - */ - protected function truncateLinkFieldTables(): void - { - $tables = [ - 'LinkField_Link', - 'LinkField_EmailLink', - 'LinkField_ExternalLink', - 'LinkField_FileLink', - 'LinkField_PhoneLink', - 'LinkField_SiteTreeLink', - ]; - $versioned = [ - '_Live', - '_Versions', - ]; - $isVersioned = Link::singleton()->hasExtension(Versioned::class); - - foreach ($tables as $table) { - $this->clearTable($table); - - if (!$isVersioned) { - continue; - } - - foreach ($versioned as $tableSuffix) { - $this->clearTable($table . $tableSuffix); - } - } - } - - /** - * Fetch link data that needs to undergo migration - * - * @param string $tableName - * @return SQLSelect - */ - protected function getDataSelect(string $tableName): SQLSelect - { - return SQLSelect::create('*', sprintf('"%s"', $tableName)); - } - - /** - * Called before migration to delete existing data from the target tables, so we have a clean state - * to migrate into - * - * @param string $tableName - * @return void - */ - protected function clearTable(string $tableName): void - { - DB::get_conn()->clearTable($tableName); - } - - /** - * Create assignments from the old field values to the new fields based on provided configuration - * - * @param array $config - * @param array $linkableData - * @param string $originTable - * @return array - */ - protected function getAssignmentsForMapping(array $config, array $linkableData, string $originTable): array - { - // If we're processing the _Versions table, then we need to add all the Version table field assignments - if ($originTable === self::TABLE_VERSIONS) { - $config += $this->config()->get('versions_mapping_global'); - } - - // We're now going to start assigning values to the new fields (as you've specified in your config) - $assignments = []; - - // Loop through each config - foreach ($config as $oldField => $newField) { - // Assign the new field to equal whatever value was in the original record (based on the old field name) - $assignments[$newField] = $linkableData[$oldField]; - } - - return $assignments; - } - - /** - * Create new generic link record based on provided data - * - * @param string $className - * @param array $linkableData - * @param string $originTable - * @return void - */ - protected function insertLink(string $className, array $linkableData, string $originTable): void - { - $config = $this->config()->get('link_mapping'); - - // If we're processing the _Versions table, then we need to add all the Version table field assignments that are - // specifically for the base record (such as all the "WasPublished", "WasDraft", etc fields) - if ($originTable === self::TABLE_VERSIONS) { - $config += $this->config()->get('versions_mapping_base_only'); - } - - // These assignments are based on our config - $assignments = $this->getAssignmentsForMapping( - $config, - $linkableData, - $originTable - ); - // We also need to add ClassName for the base table, and this is not configurable - $assignments['ClassName'] = $className; - - // Find out what the corresponding table is for the origin table - $newTable = sprintf(self::TABLE_MAP_LINK[$originTable], DataObject::getSchema()->tableName(Link::class)); - - // Insert our new record - SQLInsert::create($newTable, $assignments)->execute(); - } - - /** - * Insert new record for email type link - * - * @param array $linkableData - * @param string $originTable - * @return void - */ - protected function insertEmail(array $linkableData, string $originTable): void - { - // Insert the base record for this EmailLink - $this->insertLink(EmailLink::class, $linkableData, $originTable); - - $newTable = sprintf( - self::TABLE_MAP_EMAIL_LINK[$originTable], - DataObject::getSchema()->tableName(EmailLink::class) - ); - - $assignments = $this->getAssignmentsForMapping( - $this->config()->get('email_mapping'), - $linkableData, - $originTable - ); - - SQLInsert::create($newTable, $assignments)->execute(); - } - - /** - * Insert new record for external type link - * - * @param array $linkableData - * @param string $originTable - * @return void - */ - protected function insertExternal(array $linkableData, string $originTable): void - { - // Insert the base record for this ExternalLink - $this->insertLink(ExternalLink::class, $linkableData, $originTable); - - $newTable = sprintf( - self::TABLE_MAP_EXTERNAL_LINK[$originTable], - DataObject::getSchema()->tableName(ExternalLink::class) - ); - - $assignments = $this->getAssignmentsForMapping( - $this->config()->get('external_mapping'), - $linkableData, - $originTable - ); - - SQLInsert::create($newTable, $assignments)->execute(); - } - - /** - * Insert new record for file type link - * - * @param array $linkableData - * @param string $originTable - * @return void - */ - protected function insertFile(array $linkableData, string $originTable): void - { - // Insert the base record for this FileLink - $this->insertLink(FileLink::class, $linkableData, $originTable); - - $newTable = sprintf( - self::TABLE_MAP_FILE_LINK[$originTable], - DataObject::getSchema()->tableName(FileLink::class) - ); - - $assignments = $this->getAssignmentsForMapping( - $this->config()->get('file_mapping'), - $linkableData, - $originTable - ); - - SQLInsert::create($newTable, $assignments)->execute(); - } - - /** - * Insert new record for phone type link - * - * @param array $linkableData - * @param string $originTable - * @return void - */ - protected function insertPhone(array $linkableData, string $originTable): void - { - // Insert the base record for this PhoneLink - $this->insertLink(PhoneLink::class, $linkableData, $originTable); - - $newTable = sprintf( - self::TABLE_MAP_PHONE_LINK[$originTable], - DataObject::getSchema()->tableName(PhoneLink::class) - ); - - $assignments = $this->getAssignmentsForMapping( - $this->config()->get('phone_mapping'), - $linkableData, - $originTable - ); - - SQLInsert::create($newTable, $assignments)->execute(); - } - - /** - * Insert new record for site tree (internal) type link - * - * @param array $linkableData - * @param string $originTable - * @return void - */ - protected function insertSiteTree(array $linkableData, string $originTable): void - { - // Insert the base record for this SiteTreeLink - $this->insertLink(SiteTreeLink::class, $linkableData, $originTable); - - $newTable = sprintf( - self::TABLE_MAP_SITE_TREE_LINK[$originTable], - DataObject::getSchema()->tableName(SiteTreeLink::class) - ); - - $assignments = $this->getAssignmentsForMapping( - $this->config()->get('sitetree_mapping'), - $linkableData, - $originTable - ); - - // Special case for the Anchor field. Linkable supports query params and/or anchors, but the Linkfield module - // only supports anchors. Linkable also requires that you prepend the #, and Linkfield requires you to *not* - $anchorFrom = $this->config()->get('sitetree_anchor_from'); - $anchorTo = $this->config()->get('sitetree_anchor_to'); - - $assignments[$anchorTo] = $this->getAnchorString($linkableData[$anchorFrom]); - - // Linkable supports adding query params and anchors together in the Anchor field. This module does not. If you - // would like to add support for query params, then you will need to have created (probably through an - // extension) a new and separate field (EG: QueryParams) on SiteTreeLink. You can then update the config for - // $sitetree_query_params_to to the name of the field you created (EG: QueryParams) - $queryParamsFrom = $this->config()->get('sitetree_query_params_from'); - $queryParamsTo = $this->config()->get('sitetree_query_params_to'); - - if ($queryParamsFrom && $queryParamsTo) { - $assignments[$queryParamsTo] = $this->getQueryString($linkableData[$queryParamsFrom]); - } - - SQLInsert::create($newTable, $assignments)->execute(); - } - - protected function getAnchorString(?string $originalAnchor): ?string - { - if (!$originalAnchor) { - return null; - } - - // We know that Linkable requires users to include a hash (#) for any anchors that they want. If we don't find - // a hash then there is no anchor here - if (!str_contains($originalAnchor, '#')) { - return null; - } - - $firstChar = $originalAnchor[0] ?? null; - - // Linkable supported query params (?) and anchors (#) in the same Anchor field. We know that query params must - // be provided before anchor, so if the first char is an anchor then we can just trim that and return; - if ($firstChar === '#') { - return ltrim($originalAnchor, '#'); - } - - // The only remaining possibility is that there is a string before the hash - // Explode the string at the first #, and we would expect there to always be exactly 2 parts - $parts = explode('#', $originalAnchor, 2); - - // return the second part - return $parts[1]; - } - - /** - * This method is not used (out of the box) as part of the migration process. This has been provided so that if - * you have a need for it, you can extend this class and access it - */ - protected function getQueryString(?string $originalAnchor): ?string - { - if (!$originalAnchor) { - return null; - } - - // We know that Linkable requires users to include a ? for any query params that they want. If we don't find - // a ? then there are no query params here - if (!str_contains($originalAnchor, '?')) { - return null; - } - - // Linkable supported query params (?) and anchors (#) in the same Anchor field. We know that query params must - // be provided before anchor, so if there are no anchors in the string, then we can just trim the ? and return - if (!str_contains($originalAnchor, '#')) { - return ltrim($originalAnchor, '?'); - } - - // The only remaining possibility is that there are query params followed by an anchor - // Explode the string at the first #, and we would expect there to always be exactly 2 parts - $parts = explode('#', $originalAnchor, 2); - - // return the first part (the query params) after trimming the prepended ? - return ltrim($parts[1], '?'); - } -} diff --git a/src/Type/Registry.php b/src/Type/Registry.php index 152138ea..50d96f94 100644 --- a/src/Type/Registry.php +++ b/src/Type/Registry.php @@ -8,6 +8,7 @@ use SilverStripe\Core\Extensible; use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Injector\Injector; +use SilverStripe\LinkField\Models\Link; /** * Manage the list of known link types @@ -23,10 +24,10 @@ class Registry /** * Find the matching LinkType by its key or null it can't be found. * @param string $key - * @return Type|null + * @return Link|null * @throws InvalidArgumentException */ - public function byKey(string $key): ?Type + public function byKey(string $key): ?Link { /** @var array $types */ $typeDefinitions = self::config()->get('types'); @@ -40,12 +41,12 @@ public function byKey(string $key): ?Type } /** - * @return Type[] + * @return Link[] * @throws InvalidArgumentException */ public function list(): array { - /** @var Type[] $types */ + /** @var Link[] $types */ $types = []; /** @var array $types */ @@ -90,7 +91,7 @@ public function init() * @param array $def * @throws LogicException */ - private function definitionToType(array $def): Type + private function definitionToType(array $def): Link { $className = $def['classname'] ?? null; @@ -98,10 +99,10 @@ private function definitionToType(array $def): Type throw new LogicException(sprintf('%s: All types should reference a valid classname', static::class)); } - /** @var Type $type */ + /** @var Link $type */ $type = Injector::inst()->get($className); - if (!$type instanceof Type) { + if (!$type instanceof Link) { throw new LogicException(sprintf('%s: %s is not a valid link type', static::class, $className)); } diff --git a/src/Type/Type.php b/src/Type/Type.php deleted file mode 100644 index 1d0cb428..00000000 --- a/src/Type/Type.php +++ /dev/null @@ -1,46 +0,0 @@ -objFromFixture(Image::class, 'image-1'); - $image->setFromLocalFile(Director::baseFolder() . '/tests/resources/600x400.png'); + $image->setFromLocalFile(dirname(dirname(dirname(__FILE__))) . '/resources/600x400.png'); $image->write(); $image->publishSingle(); @@ -53,13 +52,6 @@ protected function tearDown(): void parent::tearDown(); } - public function testLinkModel(): void - { - $model = $this->objFromFixture(Link::class, 'link-1'); - - $this->assertEquals('FormBuilderModal', $model->LinkTypeHandlerName()); - } - /** * @throws ValidationException */ @@ -130,8 +122,8 @@ public function testLinkTypeEnabled(array $types, array $expected): void $config->set(Registry::class, 'types', $types); $enabledTypes = Registry::singleton()->list(); - $enabledTypes = array_map(static function (Type $type): string { - return $type->LinkTypeTile(); + $enabledTypes = array_map(static function (Link $link): string { + return $link->LinkTypeTile(); }, $enabledTypes); $enabledTypes = array_values($enabledTypes); sort($enabledTypes, SORT_STRING);