From 279cfb585dcbe4ad6dc8031877808420e5cf0416 Mon Sep 17 00:00:00 2001 From: Steve Boyd Date: Fri, 22 Dec 2023 14:31:41 +1300 Subject: [PATCH] ENH Save relations on link creation --- client/dist/js/bundle.js | 2 +- client/src/components/LinkField/LinkField.js | 47 ++++++-- client/src/components/LinkModal/LinkModal.js | 9 +- client/src/entwine/LinkField.js | 7 +- composer.json | 1 + src/Controllers/LinkFieldController.php | 101 +++++++++++++++++- src/Form/LinkField.php | 41 ++----- src/Form/MultiLinkField.php | 36 ++----- src/Form/Traits/LinkFieldGetOwnerTrait.php | 18 ++++ .../Controllers/LinkFieldControllerTest.php | 82 ++++++++++++-- .../Controllers/LinkFieldControllerTest.yml | 3 + tests/php/Form/LinkFieldTest.php | 50 ++++----- tests/php/Form/LinkFieldTest.yml | 7 ++ tests/php/Form/LinkFieldTest/TestBlock.php | 20 ++++ 14 files changed, 313 insertions(+), 111 deletions(-) create mode 100644 tests/php/Form/LinkFieldTest.yml create mode 100644 tests/php/Form/LinkFieldTest/TestBlock.php diff --git a/client/dist/js/bundle.js b/client/dist/js/bundle.js index af7a89c3..5204a639 100644 --- a/client/dist/js/bundle.js +++ b/client/dist/js/bundle.js @@ -1 +1 @@ -!function(){"use strict";var e={274:function(e,t,n){var r,l=(r=n(521))&&r.__esModule?r:{default:r};document.addEventListener("DOMContentLoaded",(()=>{(0,l.default)()}))},521:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=u(n(648)),l=u(n(809)),a=u(n(852)),i=u(n(117)),o=u(n(606));function u(e){return e&&e.__esModule?e:{default:e}}var s=()=>{r.default.component.registerMany({LinkPicker:l.default,LinkField:a.default,"LinkModal.FormBuilderModal":i.default,"LinkModal.InsertMediaModal":o.default})};t.default=s},852:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=k(n(363)),l=n(827),a=n(624),i=(n(648),v(n(42))),o=v(n(809)),u=v(n(734)),s=(v(n(686)),v(n(697))),d=k(n(123)),c=v(n(159)),f=v(n(510)),p=v(n(86)),y=v(n(754));function v(e){return e&&e.__esModule?e:{default:e}}function m(e){if("function"!=typeof WeakMap)return null;var t=new WeakMap,n=new WeakMap;return(m=function(e){return e?n:t})(e)}function k(e,t){if(!t&&e&&e.__esModule)return e;if(null===e||"object"!=typeof e&&"function"!=typeof e)return{default:e};var n=m(t);if(n&&n.has(e))return n.get(e);var r={},l=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var a in e)if("default"!==a&&Object.prototype.hasOwnProperty.call(e,a)){var i=l?Object.getOwnPropertyDescriptor(e,a):null;i&&(i.get||i.set)?Object.defineProperty(r,a,i):r[a]=e[a]}return r.default=e,n&&n.set(e,r),r}const _="SilverStripe\\LinkField\\Controllers\\LinkFieldController",h=e=>{var t;let{value:n=null,onChange:l,types:a=[],actions:i,isMulti:d=!1,canCreate:p}=e;const[v,m]=(0,r.useState)({}),[k,h]=(0,r.useState)(0);let g=n;Array.isArray(g)||("number"==typeof g&&0!=g&&(g=[g]),g||(g=[])),(0,r.useEffect)((()=>{if(!k&&g.length>0){const e=[];for(const t of g)e.push(`itemIDs[]=${t}`);const t=`${f.default.getSection(_).form.linkForm.dataUrl}?${e.join("&")}`;c.default.get(t).then((e=>e.json())).then((e=>{m(e)}))}}),[k,n&&n.length]);const O=()=>{h(0)},b=e=>{h(0);const t=[...g];t.includes(e)||t.push(e),l(d?t:t[0]),i.toasts.success(y.default._t("LinkField.SAVE_SUCCESS","Saved link"))},M=e=>{const t=`${f.default.getSection(_).form.linkForm.deleteUrl}/${e}`;c.default.delete(t,{},{"X-SecurityID":f.default.get("SecurityID")}).then((()=>{i.toasts.success(y.default._t("LinkField.DELETE_SUCCESS","Deleted link"))})).catch((()=>{i.toasts.error(y.default._t("LinkField.DELETE_ERROR","Failed to delete link"))}));const n={...v};delete n[e],m(n),l(d?Object.keys(n):0)},C=d||0===Object.keys(v).length,E=Boolean(k);return r.default.createElement(r.default.Fragment,null,C&&r.default.createElement(o.default,{canCreate:p,onModalSuccess:b,onModalClosed:O,types:a}),r.default.createElement("div",null," ",(()=>{const e=[];for(const d of g){var t,n,l,i,o,s;if(!v[d])continue;const c=a.hasOwnProperty(null===(t=v[d])||void 0===t?void 0:t.typeKey)?a[null===(n=v[d])||void 0===n?void 0:n.typeKey]:{};e.push(r.default.createElement(u.default,{key:d,id:d,title:null===(l=v[d])||void 0===l?void 0:l.Title,description:null===(i=v[d])||void 0===i?void 0:i.description,versionState:null===(o=v[d])||void 0===o?void 0:o.versionState,typeTitle:c.title||"",onClear:M,onClick:()=>{h(d)},canDelete:!(null===(s=v[d])||void 0===s||!s.canDelete)}))}return e})()," "),E&&r.default.createElement(s.default,{types:a,typeKey:null===(t=v[k])||void 0===t?void 0:t.typeKey,isOpen:Boolean(k),onSuccess:b,onClosed:O,linkID:k}))};h.propTypes={value:p.default.oneOfType([p.default.arrayOf(p.default.number),p.default.number]),onChange:p.default.func.isRequired,types:p.default.array.isRequired,actions:p.default.object.isRequired,isMulti:p.default.bool,canCreate:p.default.bool.isRequired};var g=(0,l.compose)(i.default,(0,a.connect)(null,(e=>({actions:{toasts:(0,l.bindActionCreators)(d,e)}}))))(h);t.default=g},606:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;s(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=u(t);if(n&&n.has(e))return n.get(e);var r={},l=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var a in e)if("default"!==a&&Object.prototype.hasOwnProperty.call(e,a)){var i=l?Object.getOwnPropertyDescriptor(e,a):null;i&&(i.get||i.set)?Object.defineProperty(r,a,i):r[a]=e[a]}r.default=e,n&&n.set(e,r);return r}(n(363)),l=s(n(475)),a=n(624),i=s(n(686)),o=s(n(86));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 s(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{type:t,editing:n,data:a,actions:i,onSubmit:o,...u}=e;if(!t)return!1;(0,r.useEffect)((()=>{n?i.initModal():i.reset()}),[n]);const s=a?{ID:a.FileID,Description:a.Title,TargetBlank:!!a.OpenInNew}:{};return r.default.createElement(l.default,d({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:l}=e;return o({FileID:n,Title:r,OpenInNew:l,typeKey:t.key},"",(()=>{}))}},u))};c.propTypes={type:i.default.isRequired,editing:o.default.bool.isRequired,data:o.default.object.isRequired,actions:o.default.object.isRequired,onClick:o.default.func.isRequired};var f=(0,a.connect)((function(){return{}}),(function(e){return{actions:{initModal:()=>e({type:"INIT_FORM_SCHEMA_STACK",payload:{formSchema:{type:"insert-link",nextType:"admin"}}}),reset:()=>e({type:"RESET"})}}}))(c);t.default=f},117:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=s(n(363)),l=s(n(912)),a=s(n(872)),i=s(n(902)),o=s(n(510)),u=s(n(86));function s(e){return e&&e.__esModule?e:{default:e}}const d=(e,t)=>{const{schemaUrl:n}=o.default.getSection("SilverStripe\\LinkField\\Controllers\\LinkFieldController").form.linkForm,r=a.default.parse(n),l=i.default.parse(r.query);l.typeKey=e;for(const e of["href","path","pathname"])r[e]=`${r[e]}/${t}`;return a.default.format({...r,search:i.default.stringify(l)})},c=e=>{let{typeTitle:t,typeKey:n,linkID:a=0,isOpen:i,onSuccess:o,onClosed:u}=e;if(!n)return!1;return r.default.createElement(l.default,{title:t,isOpen:i,schemaUrl:d(n,a),identifier:"Link.EditingLinkInfo",onSubmit:async(e,t,n)=>{const r=await n();if(!r.id.match(/\/schema\/linkfield\/([0-9]+)/)){const e=r.id.match(/\/linkForm\/([0-9]+)/),t=parseInt(e[1],10);o(t)}return Promise.resolve()},onClosed:u})};c.propTypes={typeTitle:u.default.string.isRequired,typeKey:u.default.string.isRequired,linkID:u.default.number,isOpen:u.default.bool.isRequired,onSuccess:u.default.func.isRequired,onClosed:u.default.func.isRequired};var f=c;t.default=f},809:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=t.Component=void 0;var r=d(n(754)),l=function(e,t){if(!t&&e&&e.__esModule)return e;if(null===e||"object"!=typeof e&&"function"!=typeof e)return{default:e};var n=s(t);if(n&&n.has(e))return n.get(e);var r={},l=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var a in e)if("default"!==a&&Object.prototype.hasOwnProperty.call(e,a)){var i=l?Object.getOwnPropertyDescriptor(e,a):null;i&&(i.get||i.set)?Object.defineProperty(r,a,i):r[a]=e[a]}r.default=e,n&&n.set(e,r);return r}(n(363)),a=d(n(86)),i=d(n(820)),o=d(n(97)),u=(d(n(686)),d(n(697)));function s(e){if("function"!=typeof WeakMap)return null;var t=new WeakMap,n=new WeakMap;return(s=function(e){return e?n:t})(e)}function d(e){return e&&e.__esModule?e:{default:e}}const c=e=>{let{types:t,onModalSuccess:n,onModalClosed:a,canCreate:s}=e;const[d,c]=(0,l.useState)(""),f=""!==d,p=(0,i.default)("link-picker","form-control"),y=Object.values(t);return s?l.default.createElement("div",{className:p},l.default.createElement(o.default,{types:y,onSelect:e=>{c(e)}}),f&&l.default.createElement(u.default,{types:t,typeKey:d,isOpen:f,onSuccess:e=>{c(""),n(e)},onClosed:()=>{"function"==typeof a&&a(),c("")}})):l.default.createElement("div",{className:p},l.default.createElement("div",{className:"link-picker__cannot-create"},r.default._t("LinkField.CANNOT_CREATE_LINK","Cannot create link")))};t.Component=c,c.propTypes={types:a.default.array.isRequired,onModalSuccess:a.default.func.isRequired,onModalClosed:a.default.func,canCreate:a.default.bool.isRequired};var f=c;t.default=f},97:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=s(n(754)),l=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={},l=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var a in e)if("default"!==a&&Object.prototype.hasOwnProperty.call(e,a)){var i=l?Object.getOwnPropertyDescriptor(e,a):null;i&&(i.get||i.set)?Object.defineProperty(r,a,i):r[a]=e[a]}r.default=e,n&&n.set(e,r);return r}(n(363)),a=s(n(86)),i=n(127),o=s(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 s(e){return e&&e.__esModule?e:{default:e}}const d=e=>{let{types:t,onSelect:n}=e;const[a,o]=(0,l.useState)(!1);return l.default.createElement(i.Dropdown,{isOpen:a,toggle:()=>o((e=>!e)),className:"link-picker__menu"},l.default.createElement(i.DropdownToggle,{className:"link-picker__menu-toggle font-icon-plus-1",caret:!0},r.default._t("LinkField.ADD_LINK","Add Link")),l.default.createElement(i.DropdownMenu,null,t.map((e=>{let{key:t,title:r}=e;return l.default.createElement(i.DropdownItem,{key:t,onClick:()=>n(t)},r)}))))};d.propTypes={types:a.default.arrayOf(o.default).isRequired,onSelect:a.default.func.isRequired};var c=d;t.default=c},734:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=u(n(820)),l=u(n(754)),a=u(n(363)),i=u(n(86)),o=n(127);function u(e){return e&&e.__esModule?e:{default:e}}const s=e=>t=>{t.nativeEvent.stopImmediatePropagation(),t.preventDefault(),t.nativeEvent.preventDefault(),t.stopPropagation(),e&&e()},d=e=>{let{id:t,title:n,description:i,versionState:u,typeTitle:d,onClear:c,onClick:f,canDelete:p}=e;const y={"link-picker__link":!0,"form-control":!0};u&&(y[` link-picker__link--${u}`]=!0),n&&n.length>25&&(n=n.substring(0,25)+"...");const v=(0,r.default)(y);return a.default.createElement("div",{className:v},a.default.createElement(o.Button,{className:"link-picker__button font-icon-link",color:"secondary",onClick:s(f)},a.default.createElement("div",{className:"link-picker__link-detail"},a.default.createElement("div",{className:"link-picker__title"},a.default.createElement("span",{className:"link-picker__title-text"},n),(e=>{let t="",n="";if("draft"===e)t=l.default._t("LinkField.LINK_DRAFT_TITLE","Link has draft changes"),n=l.default._t("LinkField.LINK_DRAFT_LABEL","Draft");else{if("modified"!==e)return null;t=l.default._t("LinkField.LINK_MODIFIED_TITLE","Link has unpublished changes"),n=l.default._t("LinkField.LINK_MODIFIED_LABEL","Modified")}const i=(0,r.default)("badge",`status-${e}`);return a.default.createElement("span",{className:i,title:t},n)})(u)),a.default.createElement("small",{className:"link-picker__type"},d,": ",a.default.createElement("span",{className:"link-picker__url"},i)))),p&&a.default.createElement(o.Button,{className:"link-picker__clear",color:"link",onClick:s((()=>c(t)))},l.default._t("LinkField.CLEAR","Clear")))};d.propTypes={id:i.default.number.isRequired,title:i.default.string,description:i.default.string,versionState:i.default.string,typeTitle:i.default.string.isRequired,onClear:i.default.func.isRequired,onClick:i.default.func.isRequired,canDelete:i.default.bool.isRequired};var c=d;t.default=c},697:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=i(n(363)),l=n(648),a=i(n(86));function i(e){return e&&e.__esModule?e:{default:e}}const o=e=>{let{types:t,typeKey:n,linkID:a=0,isOpen:i,onSuccess:o,onClosed:u}=e;if(!n)return!1;const s=t.hasOwnProperty(n)?t[n]:{},d=s&&s.hasOwnProperty("handlerName")?s.handlerName:"FormBuilderModal",c=(0,l.loadComponent)(`LinkModal.${d}`);return r.default.createElement(c,{typeTitle:s.title||"",typeKey:n,linkID:a,isOpen:i,onSuccess:o,onClosed:u})};o.propTypes={types:a.default.array.isRequired,typeKey:a.default.string.isRequired,linkID:a.default.number,isOpen:a.default.bool.isRequired,onSuccess:a.default.func.isRequired,onClosed:a.default.func.isRequired};var u=o;t.default=u},41:function(e,t,n){var r=o(n(311)),l=o(n(363)),a=o(n(691)),i=n(648);function o(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-linkfield").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(a.default.createRoot(this[0])),this._super(),this.refresh()},refresh(){const e=this.getProps();this.getInputField().val(e.value);const t=this.getComponent();this.getRoot().render(l.default.createElement(t,u({},e,{noHolder:!0})))},handleChange(e){this.getInputField().data("value",e),this.refresh()},getProps(){return{value:this.getInputField().data("value"),onChange:this.handleChange.bind(this),isMulti:this.data("is-multi")??!1,types:this.data("types")??[],canCreate:this.getInputField().data("can-create")??!1}},getInputField(){const t=this.data("field-id");return e(`#${t}`)},onunmatch(){const e=this.getRoot();e&&e.unmount()}})}))},686:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r,l=(r=n(86))&&r.__esModule?r:{default:r};var a=l.default.shape({key:l.default.string.isRequired,title:l.default.string.isRequired});t.default=a},159:function(e){e.exports=Backend},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},123:function(e){e.exports=ToastsActions},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 l=t[r];if(void 0!==l)return l.exports;var a=t[r]={exports:{}};return e[r](a,a.exports,n),a.exports}n(274),n(41)}(); \ No newline at end of file +!function(){"use strict";var e={274:function(e,t,n){var r,o=(r=n(521))&&r.__esModule?r:{default:r};document.addEventListener("DOMContentLoaded",(()=>{(0,o.default)()}))},521:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=u(n(648)),o=u(n(809)),a=u(n(852)),l=u(n(117)),i=u(n(606));function u(e){return e&&e.__esModule?e:{default:e}}var s=()=>{r.default.component.registerMany({LinkPicker:o.default,LinkField:a.default,"LinkModal.FormBuilderModal":l.default,"LinkModal.InsertMediaModal":i.default})};t.default=s},852:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=t.LinkFieldContext=void 0;var r=g(n(363)),o=n(827),a=n(624),l=(n(648),m(n(42))),i=m(n(809)),u=m(n(734)),s=(m(n(686)),m(n(697))),d=g(n(123)),f=m(n(159)),c=m(n(510)),p=m(n(86)),y=m(n(754)),v=m(n(872)),k=m(n(902));function m(e){return e&&e.__esModule?e:{default:e}}function _(e){if("function"!=typeof WeakMap)return null;var t=new WeakMap,n=new WeakMap;return(_=function(e){return e?n:t})(e)}function g(e,t){if(!t&&e&&e.__esModule)return e;if(null===e||"object"!=typeof e&&"function"!=typeof e)return{default:e};var n=_(t);if(n&&n.has(e))return n.get(e);var r={},o=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var a in e)if("default"!==a&&Object.prototype.hasOwnProperty.call(e,a)){var l=o?Object.getOwnPropertyDescriptor(e,a):null;l&&(l.get||l.set)?Object.defineProperty(r,a,l):r[a]=e[a]}return r.default=e,n&&n.set(e,r),r}const O=(0,r.createContext)(null);t.LinkFieldContext=O;const h="SilverStripe\\LinkField\\Controllers\\LinkFieldController",b=e=>{var t;let{value:n=null,onChange:o,types:a=[],actions:l,isMulti:d=!1,canCreate:p,ownerID:m,ownerClass:_,ownerRelation:g}=e;const[b,C]=(0,r.useState)({}),[M,w]=(0,r.useState)(0);let R=n;Array.isArray(R)||("number"==typeof R&&0!=R&&(R=[R]),R||(R=[])),(0,r.useEffect)((()=>{if(!M&&R.length>0){const e=[];for(const t of R)e.push(`itemIDs[]=${t}`);const t=`${c.default.getSection(h).form.linkForm.dataUrl}?${e.join("&")}`;f.default.get(t).then((e=>e.json())).then((e=>{C(e)}))}}),[M,n&&n.length]);const j=()=>{w(0)},D=e=>{w(0);const t=[...R];t.includes(e)||t.push(e),o(d?t:t[0]),l.toasts.success(y.default._t("LinkField.SAVE_SUCCESS","Saved link"))},E=e=>{let t=`${c.default.getSection(h).form.linkForm.deleteUrl}/${e}`;const n=v.default.parse(t),r=k.default.parse(n.query);r.ownerID=m,r.ownerClass=_,r.ownerRelation=g,t=v.default.format({...n,search:k.default.stringify(r)}),f.default.delete(t,{},{"X-SecurityID":c.default.get("SecurityID")}).then((()=>{l.toasts.success(y.default._t("LinkField.DELETE_SUCCESS","Deleted link"))})).catch((()=>{l.toasts.error(y.default._t("LinkField.DELETE_ERROR","Failed to delete link"))}));const a={...b};delete a[e],C(a),o(d?Object.keys(a):0)},I=d||0===Object.keys(b).length,P=Boolean(M);return r.default.createElement(O.Provider,{value:{ownerID:m,ownerClass:_,ownerRelation:g}},I&&r.default.createElement(i.default,{onModalSuccess:D,onModalClosed:j,types:a,canCreate:p}),r.default.createElement("div",null," ",(()=>{const e=[];for(const d of R){var t,n,o,l,i,s;if(!b[d])continue;const f=a.hasOwnProperty(null===(t=b[d])||void 0===t?void 0:t.typeKey)?a[null===(n=b[d])||void 0===n?void 0:n.typeKey]:{};e.push(r.default.createElement(u.default,{key:d,id:d,title:null===(o=b[d])||void 0===o?void 0:o.Title,description:null===(l=b[d])||void 0===l?void 0:l.description,versionState:null===(i=b[d])||void 0===i?void 0:i.versionState,typeTitle:f.title||"",onClear:E,onClick:()=>{w(d)},canDelete:!(null===(s=b[d])||void 0===s||!s.canDelete)}))}return e})()," "),P&&r.default.createElement(s.default,{types:a,typeKey:null===(t=b[M])||void 0===t?void 0:t.typeKey,isOpen:Boolean(M),onSuccess:D,onClosed:j,linkID:M}))};b.propTypes={value:p.default.oneOfType([p.default.arrayOf(p.default.number),p.default.number]),onChange:p.default.func.isRequired,types:p.default.array.isRequired,actions:p.default.object.isRequired,isMulti:p.default.bool,canCreate:p.default.bool.isRequired,ownerID:p.default.number.isRequired,ownerClass:p.default.string.isRequired,ownerRelation:p.default.string.isRequired};var C=(0,o.compose)(l.default,(0,a.connect)(null,(e=>({actions:{toasts:(0,o.bindActionCreators)(d,e)}}))))(b);t.default=C},606:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;s(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=u(t);if(n&&n.has(e))return n.get(e);var r={},o=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var a in e)if("default"!==a&&Object.prototype.hasOwnProperty.call(e,a)){var l=o?Object.getOwnPropertyDescriptor(e,a):null;l&&(l.get||l.set)?Object.defineProperty(r,a,l):r[a]=e[a]}r.default=e,n&&n.set(e,r);return r}(n(363)),o=s(n(475)),a=n(624),l=s(n(686)),i=s(n(86));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 s(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{type:t,editing:n,data:a,actions:l,onSubmit:i,...u}=e;if(!t)return!1;(0,r.useEffect)((()=>{n?l.initModal():l.reset()}),[n]);const s=a?{ID:a.FileID,Description:a.Title,TargetBlank:!!a.OpenInNew}:{};return r.default.createElement(o.default,d({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:o}=e;return i({FileID:n,Title:r,OpenInNew:o,typeKey:t.key},"",(()=>{}))}},u))};f.propTypes={type:l.default.isRequired,editing:i.default.bool.isRequired,data:i.default.object.isRequired,actions:i.default.object.isRequired,onClick:i.default.func.isRequired};var c=(0,a.connect)((function(){return{}}),(function(e){return{actions:{initModal:()=>e({type:"INIT_FORM_SCHEMA_STACK",payload:{formSchema:{type:"insert-link",nextType:"admin"}}}),reset:()=>e({type:"RESET"})}}}))(f);t.default=c},117:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;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=f(t);if(n&&n.has(e))return n.get(e);var r={},o=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var a in e)if("default"!==a&&Object.prototype.hasOwnProperty.call(e,a)){var l=o?Object.getOwnPropertyDescriptor(e,a):null;l&&(l.get||l.set)?Object.defineProperty(r,a,l):r[a]=e[a]}r.default=e,n&&n.set(e,r);return r}(n(363)),o=d(n(912)),a=n(852),l=d(n(872)),i=d(n(902)),u=d(n(510)),s=d(n(86));function d(e){return e&&e.__esModule?e:{default:e}}function f(e){if("function"!=typeof WeakMap)return null;var t=new WeakMap,n=new WeakMap;return(f=function(e){return e?n:t})(e)}const c=(e,t)=>{const{schemaUrl:n}=u.default.getSection("SilverStripe\\LinkField\\Controllers\\LinkFieldController").form.linkForm,o=l.default.parse(n),s=i.default.parse(o.query);s.typeKey=e;const{ownerID:d,ownerClass:f,ownerRelation:c}=(0,r.useContext)(a.LinkFieldContext);s.ownerID=d,s.ownerClass=f,s.ownerRelation=c;for(const e of["href","path","pathname"])o[e]=`${o[e]}/${t}`;return l.default.format({...o,search:i.default.stringify(s)})},p=e=>{let{typeTitle:t,typeKey:n,linkID:a=0,isOpen:l,onSuccess:i,onClosed:u}=e;if(!n)return!1;return r.default.createElement(o.default,{title:t,isOpen:l,schemaUrl:c(n,a),identifier:"Link.EditingLinkInfo",onSubmit:async(e,t,n)=>{const r=await n();if(!r.id.match(/\/schema\/linkfield\/([0-9]+)/)){const e=r.id.match(/\/linkForm\/([0-9]+)/),t=parseInt(e[1],10);i(t)}return Promise.resolve()},onClosed:u})};p.propTypes={typeTitle:s.default.string.isRequired,typeKey:s.default.string.isRequired,linkID:s.default.number,isOpen:s.default.bool.isRequired,onSuccess:s.default.func.isRequired,onClosed:s.default.func.isRequired};var y=p;t.default=y},809:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=t.Component=void 0;var r=d(n(754)),o=function(e,t){if(!t&&e&&e.__esModule)return e;if(null===e||"object"!=typeof e&&"function"!=typeof e)return{default:e};var n=s(t);if(n&&n.has(e))return n.get(e);var r={},o=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var a in e)if("default"!==a&&Object.prototype.hasOwnProperty.call(e,a)){var l=o?Object.getOwnPropertyDescriptor(e,a):null;l&&(l.get||l.set)?Object.defineProperty(r,a,l):r[a]=e[a]}r.default=e,n&&n.set(e,r);return r}(n(363)),a=d(n(86)),l=d(n(820)),i=d(n(97)),u=(d(n(686)),d(n(697)));function s(e){if("function"!=typeof WeakMap)return null;var t=new WeakMap,n=new WeakMap;return(s=function(e){return e?n:t})(e)}function d(e){return e&&e.__esModule?e:{default:e}}const f=e=>{let{types:t,onModalSuccess:n,onModalClosed:a,canCreate:s}=e;const[d,f]=(0,o.useState)(""),c=""!==d,p=(0,l.default)("link-picker","form-control"),y=Object.values(t);return s?o.default.createElement("div",{className:p},o.default.createElement(i.default,{types:y,onSelect:e=>{f(e)}}),c&&o.default.createElement(u.default,{types:t,typeKey:d,isOpen:c,onSuccess:e=>{f(""),n(e)},onClosed:()=>{"function"==typeof a&&a(),f("")}})):o.default.createElement("div",{className:p},o.default.createElement("div",{className:"link-picker__cannot-create"},r.default._t("LinkField.CANNOT_CREATE_LINK","Cannot create link")))};t.Component=f,f.propTypes={types:a.default.array.isRequired,onModalSuccess:a.default.func.isRequired,onModalClosed:a.default.func,canCreate:a.default.bool.isRequired};var c=f;t.default=c},97:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=s(n(754)),o=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={},o=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var a in e)if("default"!==a&&Object.prototype.hasOwnProperty.call(e,a)){var l=o?Object.getOwnPropertyDescriptor(e,a):null;l&&(l.get||l.set)?Object.defineProperty(r,a,l):r[a]=e[a]}r.default=e,n&&n.set(e,r);return r}(n(363)),a=s(n(86)),l=n(127),i=s(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 s(e){return e&&e.__esModule?e:{default:e}}const d=e=>{let{types:t,onSelect:n}=e;const[a,i]=(0,o.useState)(!1);return o.default.createElement(l.Dropdown,{isOpen:a,toggle:()=>i((e=>!e)),className:"link-picker__menu"},o.default.createElement(l.DropdownToggle,{className:"link-picker__menu-toggle font-icon-plus-1",caret:!0},r.default._t("LinkField.ADD_LINK","Add Link")),o.default.createElement(l.DropdownMenu,null,t.map((e=>{let{key:t,title:r}=e;return o.default.createElement(l.DropdownItem,{key:t,onClick:()=>n(t)},r)}))))};d.propTypes={types:a.default.arrayOf(i.default).isRequired,onSelect:a.default.func.isRequired};var f=d;t.default=f},734:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=u(n(820)),o=u(n(754)),a=u(n(363)),l=u(n(86)),i=n(127);function u(e){return e&&e.__esModule?e:{default:e}}const s=e=>t=>{t.nativeEvent.stopImmediatePropagation(),t.preventDefault(),t.nativeEvent.preventDefault(),t.stopPropagation(),e&&e()},d=e=>{let{id:t,title:n,description:l,versionState:u,typeTitle:d,onClear:f,onClick:c,canDelete:p}=e;const y={"link-picker__link":!0,"form-control":!0};u&&(y[` link-picker__link--${u}`]=!0),n&&n.length>25&&(n=n.substring(0,25)+"...");const v=(0,r.default)(y);return a.default.createElement("div",{className:v},a.default.createElement(i.Button,{className:"link-picker__button font-icon-link",color:"secondary",onClick:s(c)},a.default.createElement("div",{className:"link-picker__link-detail"},a.default.createElement("div",{className:"link-picker__title"},a.default.createElement("span",{className:"link-picker__title-text"},n),(e=>{let t="",n="";if("draft"===e)t=o.default._t("LinkField.LINK_DRAFT_TITLE","Link has draft changes"),n=o.default._t("LinkField.LINK_DRAFT_LABEL","Draft");else{if("modified"!==e)return null;t=o.default._t("LinkField.LINK_MODIFIED_TITLE","Link has unpublished changes"),n=o.default._t("LinkField.LINK_MODIFIED_LABEL","Modified")}const l=(0,r.default)("badge",`status-${e}`);return a.default.createElement("span",{className:l,title:t},n)})(u)),a.default.createElement("small",{className:"link-picker__type"},d,": ",a.default.createElement("span",{className:"link-picker__url"},l)))),p&&a.default.createElement(i.Button,{className:"link-picker__clear",color:"link",onClick:s((()=>f(t)))},o.default._t("LinkField.CLEAR","Clear")))};d.propTypes={id:l.default.number.isRequired,title:l.default.string,description:l.default.string,versionState:l.default.string,typeTitle:l.default.string.isRequired,onClear:l.default.func.isRequired,onClick:l.default.func.isRequired,canDelete:l.default.bool.isRequired};var f=d;t.default=f},697:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=l(n(363)),o=n(648),a=l(n(86));function l(e){return e&&e.__esModule?e:{default:e}}const i=e=>{let{types:t,typeKey:n,linkID:a=0,isOpen:l,onSuccess:i,onClosed:u}=e;if(!n)return!1;const s=t.hasOwnProperty(n)?t[n]:{},d=s&&s.hasOwnProperty("handlerName")?s.handlerName:"FormBuilderModal",f=(0,o.loadComponent)(`LinkModal.${d}`);return r.default.createElement(f,{typeTitle:s.title||"",typeKey:n,linkID:a,isOpen:l,onSuccess:i,onClosed:u})};i.propTypes={types:a.default.array.isRequired,typeKey:a.default.string.isRequired,linkID:a.default.number,isOpen:a.default.bool.isRequired,onSuccess:a.default.func.isRequired,onClosed:a.default.func.isRequired};var u=i;t.default=u},41:function(e,t,n){var r=i(n(311)),o=i(n(363)),a=i(n(691)),l=n(648);function i(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-linkfield").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,l.loadComponent)(n,t);this.setComponent(r),this.setRoot(a.default.createRoot(this[0])),this._super(),this.refresh()},refresh(){const e=this.getProps();this.getInputField().val(e.value);const t=this.getComponent();this.getRoot().render(o.default.createElement(t,u({},e,{noHolder:!0})))},handleChange(e){this.getInputField().data("value",e),this.refresh()},getProps(){const e=this.getInputField();return{value:e.data("value"),ownerID:e.data("owner-id"),ownerClass:e.data("owner-class"),ownerRelation:e.data("owner-relation"),onChange:this.handleChange.bind(this),isMulti:this.data("is-multi")??!1,types:this.data("types")??[],canCreate:this.getInputField().data("can-create")??!1}},getInputField(){const t=this.data("field-id");return e(`#${t}`)},onunmatch(){const e=this.getRoot();e&&e.unmount()}})}))},686:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r,o=(r=n(86))&&r.__esModule?r:{default:r};var a=o.default.shape({key:o.default.string.isRequired,title:o.default.string.isRequired});t.default=a},159:function(e){e.exports=Backend},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},123:function(e){e.exports=ToastsActions},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 o=t[r];if(void 0!==o)return o.exports;var a=t[r]={exports:{}};return e[r](a,a.exports,n),a.exports}n(274),n(41)}(); \ No newline at end of file diff --git a/client/src/components/LinkField/LinkField.js b/client/src/components/LinkField/LinkField.js index 97d886b6..92e503f2 100644 --- a/client/src/components/LinkField/LinkField.js +++ b/client/src/components/LinkField/LinkField.js @@ -1,5 +1,5 @@ /* eslint-disable */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, createContext } from 'react'; import { bindActionCreators, compose } from 'redux'; import { connect } from 'react-redux'; import { injectGraphql } from 'lib/Injector'; @@ -13,6 +13,10 @@ import backend from 'lib/Backend'; import Config from 'lib/Config'; import PropTypes from 'prop-types'; import i18n from 'i18n'; +import url from 'url'; +import qs from 'qs'; + +export const LinkFieldContext = createContext(null); // section used in window.ss config const section = 'SilverStripe\\LinkField\\Controllers\\LinkFieldController'; @@ -23,9 +27,22 @@ const section = 'SilverStripe\\LinkField\\Controllers\\LinkFieldController'; * types - types of the Link passed from LinkField entwine * actions - object of redux actions * isMulti - whether this field handles multiple links or not - * canCreate - whether this field can create links or not + * canCreate - whether this field can create new links or not + * ownerID - ID of the owner DataObject + * ownerClass - class name of the owner DataObject + * ownerRelation - name of the relation on the owner DataObject */ -const LinkField = ({ value = null, onChange, types = [], actions, isMulti = false, canCreate }) => { +const LinkField = ({ + value = null, + onChange, + types = [], + actions, + isMulti = false, + canCreate, + ownerID, + ownerClass, + ownerRelation, +}) => { const [data, setData] = useState({}); const [editingID, setEditingID] = useState(0); @@ -94,7 +111,13 @@ const LinkField = ({ value = null, onChange, types = [], actions, isMulti = fals * Update the component when the 'Clear' button in the LinkPicker is clicked */ const onClear = (linkID) => { - const endpoint = `${Config.getSection(section).form.linkForm.deleteUrl}/${linkID}`; + let endpoint = `${Config.getSection(section).form.linkForm.deleteUrl}/${linkID}`; + const parsedURL = url.parse(endpoint); + const parsedQs = qs.parse(parsedURL.query); + parsedQs.ownerID = ownerID; + parsedQs.ownerClass = ownerClass; + parsedQs.ownerRelation = ownerRelation; + endpoint = url.format({ ...parsedURL, search: qs.stringify(parsedQs)}); // CSRF token 'X-SecurityID' headers needs to be present for destructive requests backend.delete(endpoint, {}, { 'X-SecurityID': Config.get('SecurityID') }) .then(() => { @@ -155,9 +178,14 @@ const LinkField = ({ value = null, onChange, types = [], actions, isMulti = fals const renderPicker = isMulti || Object.keys(data).length === 0; const renderModal = Boolean(editingID); - return <> - { renderPicker && } -
{ renderLinks() }
+ return + { renderPicker && } +
{ renderLinks() }
{ renderModal && } - ; +
; }; LinkField.propTypes = { @@ -177,6 +205,9 @@ LinkField.propTypes = { actions: PropTypes.object.isRequired, isMulti: PropTypes.bool, canCreate: PropTypes.bool.isRequired, + ownerID: PropTypes.number.isRequired, + ownerClass: PropTypes.string.isRequired, + ownerRelation: PropTypes.string.isRequired, }; // redux actions loaded into props - used to get toast notifications diff --git a/client/src/components/LinkModal/LinkModal.js b/client/src/components/LinkModal/LinkModal.js index 4d518ec8..d590a6cd 100644 --- a/client/src/components/LinkModal/LinkModal.js +++ b/client/src/components/LinkModal/LinkModal.js @@ -1,6 +1,7 @@ /* eslint-disable */ -import React from 'react'; +import React, { useContext } from 'react' import FormBuilderModal from 'components/FormBuilderModal/FormBuilderModal'; +import { LinkFieldContext } from 'components/LinkField/LinkField'; import url from 'url'; import qs from 'qs'; import Config from 'lib/Config'; @@ -11,13 +12,17 @@ const buildSchemaUrl = (typeKey, linkID) => { const parsedURL = url.parse(schemaUrl); const parsedQs = qs.parse(parsedURL.query); parsedQs.typeKey = typeKey; + const { ownerID, ownerClass, ownerRelation } = useContext(LinkFieldContext); + parsedQs.ownerID = ownerID; + parsedQs.ownerClass = ownerClass; + parsedQs.ownerRelation = ownerRelation; for (const prop of ['href', 'path', 'pathname']) { parsedURL[prop] = `${parsedURL[prop]}/${linkID}`; } return url.format({ ...parsedURL, search: qs.stringify(parsedQs)}); } -const LinkModal = ({ typeTitle, typeKey, linkID = 0, isOpen, onSuccess, onClosed}) => { +const LinkModal = ({ typeTitle, typeKey, linkID = 0, isOpen, onSuccess, onClosed }) => { if (!typeKey) { return false; } diff --git a/client/src/entwine/LinkField.js b/client/src/entwine/LinkField.js index c3b34fd2..da2ab2c7 100644 --- a/client/src/entwine/LinkField.js +++ b/client/src/entwine/LinkField.js @@ -45,9 +45,12 @@ jQuery.entwine('ss', ($) => { * @returns {Object} */ getProps() { - const value = this.getInputField().data('value'); + const inputField = this.getInputField(); return { - value, + value: inputField.data('value'), + ownerID: inputField.data('owner-id'), + ownerClass: inputField.data('owner-class'), + ownerRelation: inputField.data('owner-relation'), onChange: this.handleChange.bind(this), isMulti: this.data('is-multi') ?? false, types: this.data('types') ?? [], diff --git a/composer.json b/composer.json index d743c3f3..94b892db 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "silverstripe/versioned": "^2" }, "require-dev": { + "dnadesign/silverstripe-elemental": "^5", "silverstripe/recipe-testing": "^3", "squizlabs/php_codesniffer": "^3" }, diff --git a/src/Controllers/LinkFieldController.php b/src/Controllers/LinkFieldController.php index f583728d..98ed211b 100644 --- a/src/Controllers/LinkFieldController.php +++ b/src/Controllers/LinkFieldController.php @@ -2,7 +2,6 @@ namespace SilverStripe\LinkField\Controllers; -use SilverStripe\Admin\AdminRootController; use SilverStripe\Admin\LeftAndMain; use SilverStripe\Control\HTTPResponse; use SilverStripe\Forms\DefaultFormFactory; @@ -18,9 +17,9 @@ use SilverStripe\Control\HTTPRequest; use SilverStripe\Core\Injector\Injector; use SilverStripe\Forms\HiddenField; -use SilverStripe\LinkField\Form\LinkField; use SilverStripe\LinkField\Services\LinkTypeService; use SilverStripe\ORM\DataList; +use SilverStripe\ORM\DataObject; class LinkFieldController extends LeftAndMain { @@ -46,6 +45,7 @@ public function getClientConfig() $clientConfig['form']['linkForm'] = [ // schema() is defined on LeftAndMain // schemaUrl will get the $ItemID and ?typeKey dynamically suffixed in LinkModal.js + // as well as ownerID, OwnerClass and OwnerRelation 'schemaUrl' => $this->Link('schema/linkForm'), 'deleteUrl' => $this->Link('delete'), 'dataUrl' => $this->Link('data'), @@ -135,6 +135,15 @@ public function linkDelete(): HTTPResponse } // delete() will also delete any published version immediately $link->delete(); + // Update owner object if this Link is on a has_one relation on the owner + $owner = $this->ownerFromRequest(); + $ownerRelation = $this->ownerRelationFromRequest(); + $hasOne = Injector::inst()->get($owner->ClassName)->hasOne(); + if (array_key_exists($ownerRelation, $hasOne) && $owner->canEdit()) { + $owner->$ownerRelation = null; + $owner->write(); + } + // Send response $response = $this->getResponse(); $response->addHeader('Content-type', 'application/json'); $response->setBody(json_encode(['success' => true])); @@ -215,6 +224,23 @@ public function save(array $data, Form $form): HTTPResponse $link->write(); } + // Update owner object if this Link is on a has_one relation on the owner + // Only do this for has_one, not has_many, because that's stored directly on the Link record + // Get owner using ownerFromRequest() rather than $link->Owner() so that validation is run + // on the owner params before updating the database + $owner = $this->ownerFromRequest(); + $ownerRelation = $this->ownerRelationFromRequest(); + $ownerRelationID = "{$ownerRelation}ID"; + $hasOne = Injector::inst()->get($owner->ClassName)->hasOne(); + if ($operation === 'create' + && array_key_exists($ownerRelation, $hasOne) + && $owner->$ownerRelationID !== $link->ID + && $owner->canEdit() + ) { + $owner->$ownerRelation = $link; + $owner->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 @@ -240,10 +266,22 @@ private function createLinkForm(Link $link, string $operation): Form $name = sprintf(self::FORM_NAME_TEMPLATE, $id); /** @var Form $form */ $form = $formFactory->getForm($this, $name, ['Record' => $link]); - + $owner = $this->ownerFromRequest(); + $ownerID = $owner->ID; + $ownerClassName = $owner->ClassName; + $ownerRelation = $this->ownerRelationFromRequest(); + + // Add hidden form fields for OwnerID, OwnerClass and OwnerRelation + if ($operation === 'create') { + $form->Fields()->push(HiddenField::create('OwnerID')->setValue($ownerID)); + $form->Fields()->push(HiddenField::create('OwnerClass')->setValue($ownerClassName)); + $form->Fields()->push(HiddenField::create('OwnerRelation')->setValue($ownerRelation)); + } // Set where the form is submitted to $typeKey = LinkTypeService::create()->keyByClassName($link->ClassName); - $form->setFormAction($this->Link("linkForm/$id?typeKey=$typeKey")); + $url = $this->Link("linkForm/$id?typeKey=$typeKey&ownerID=$ownerID&ownerClass=$ownerClassName" + . "&ownerRelation=$ownerRelation"); + $form->setFormAction($url); // Add save action button $title = $id @@ -358,4 +396,59 @@ private function typeKeyFromRequest(): string } return $typeKey; } + + /** + * Get the owner based on the query string params ownerID, ownerClass, ownerRelation + * OR the POST vars OwnerID, OwnerClass, OwnerRelation + */ + private function ownerFromRequest(): DataObject + { + $request = $this->getRequest(); + $ownerID = (int) ($request->getVar('ownerID') ?: $request->postVar('OwnerID')); + if ($ownerID === 0) { + $this->jsonError(404, _t('LinkField.INVALID_OWNER_ID', 'Invalid ownerID')); + } + $ownerClass = $request->getVar('ownerClass') ?: $request->postVar('OwnerClass'); + if (!is_a($ownerClass, DataObject::class, true)) { + $this->jsonError(404, _t('LinkField.INVALID_OWNER_CLASS', 'Invalid ownerClass')); + } + $ownerRelation = $this->ownerRelationFromRequest(); + /** @var DataObject $obj */ + $obj = Injector::inst()->get($ownerClass); + $hasOne = $obj->hasOne(); + $hasMany = $obj->hasMany(); + $matchedRelation = false; + foreach ([$hasOne, $hasMany] as $property) { + if (!array_key_exists($ownerRelation, $property)) { + continue; + } + $className = $property[$ownerRelation]; + if (is_a($className, Link::class, true)) { + $matchedRelation = true; + break; + } + } + if ($matchedRelation) { + /** @var DataObject $ownerClass */ + $owner = $ownerClass::get()->byID($ownerID); + if ($owner) { + return $owner; + } + } + $this->jsonError(404, _t('LinkField.INVALID_OWNER', 'Invalid Owner')); + } + + /** + * Get the owner relation based on the query string param ownerRelation + * OR the POST var OwnerRelation + */ + private function ownerRelationFromRequest(): string + { + $request = $this->getRequest(); + $ownerRelation = $request->getVar('ownerRelation') ?: $request->postVar('OwnerRelation'); + if (!$ownerRelation) { + $this->jsonError(404, _t('LinkField.INVALID_OWNER_RELATION', 'Invalid ownerRelation')); + } + return $ownerRelation; + } } diff --git a/src/Form/LinkField.php b/src/Form/LinkField.php index 3c538193..c043bd21 100644 --- a/src/Form/LinkField.php +++ b/src/Form/LinkField.php @@ -4,11 +4,11 @@ use LogicException; use SilverStripe\Forms\FormField; -use SilverStripe\ORM\DataObject; -use SilverStripe\ORM\DataObjectInterface; use SilverStripe\LinkField\Models\Link; use SilverStripe\LinkField\Form\Traits\AllowedLinkClassesTrait; use SilverStripe\LinkField\Form\Traits\LinkFieldGetOwnerTrait; +use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DataObjectInterface; /** * Allows CMS users to edit a Link object. @@ -33,35 +33,6 @@ public function setValue($value, $data = null) return parent::setValue($id, $data); } - /** - * @param DataObject|DataObjectInterface $record - A DataObject such as a Page - * @return $this - */ - public function saveInto(DataObjectInterface $record) - { - // Check required relation details are available - $fieldname = $this->getName(); - if (!$fieldname) { - throw new LogicException('LinkField must have a name'); - } - - $linkID = $this->dataValue(); - $dbColumn = $fieldname . 'ID'; - $record->$dbColumn = $linkID; - - // Store the record as the owner of the link. - // Required for permission checks, etc. - $link = Link::get()->byID($linkID); - if ($link) { - $link->OwnerID = $record->ID; - $link->OwnerClass = $record->ClassName; - $link->OwnerRelation = $fieldname; - $link->write(); - } - - return $this; - } - public function getSchemaStateDefaults() { $data = parent::getSchemaStateDefaults(); @@ -74,6 +45,10 @@ protected function getDefaultAttributes(): array $attributes = parent::getDefaultAttributes(); $attributes['data-value'] = $this->Value(); $attributes['data-can-create'] = $this->getOwner()->canEdit(); + $ownerFields = $this->getOwnerFields(); + $attributes['data-owner-id'] = $ownerFields['ID']; + $attributes['data-owner-class'] = $ownerFields['Class']; + $attributes['data-owner-relation'] = $ownerFields['Relation']; return $attributes; } @@ -81,6 +56,10 @@ public function getSchemaDataDefaults() { $data = parent::getSchemaDataDefaults(); $data['types'] = json_decode($this->getTypesProps()); + $ownerFields = $this->getOwnerFields(); + $data['ownerID'] = $ownerFields['ID']; + $data['ownerClass'] = $ownerFields['Class']; + $data['ownerRelation'] = $ownerFields['Relation']; return $data; } } diff --git a/src/Form/MultiLinkField.php b/src/Form/MultiLinkField.php index ea8fbfef..414976d5 100644 --- a/src/Form/MultiLinkField.php +++ b/src/Form/MultiLinkField.php @@ -5,14 +5,10 @@ use LogicException; use SilverStripe\Forms\FormField; use SilverStripe\LinkField\Form\Traits\AllowedLinkClassesTrait; -use SilverStripe\ORM\DataObjectInterface; +use SilverStripe\LinkField\Form\Traits\LinkFieldGetOwnerTrait; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\Relation; -use SilverStripe\ORM\RelationList; use SilverStripe\ORM\SS_List; -use SilverStripe\ORM\UnsavedRelationList; -use SilverStripe\LinkField\Form\Traits\LinkFieldGetOwnerTrait; -use SilverStripe\LinkField\Models\Link; /** * Allows CMS users to edit a Link object. @@ -41,33 +37,15 @@ public function setValue($value, $data = null) return parent::setValue($ids, $data); } - public function saveInto(DataObjectInterface $record) - { - $fieldName = $this->getName(); - if (!$fieldName) { - throw new LogicException('LinkField must have a name'); - } - - $relation = $record->hasMethod($fieldName) ? $record->$fieldName() : null; - if (!$relation) { - throw new LogicException("{$record->ClassName} is missing the relation '$fieldName'"); - } - - // Use RelationList rather than Relation here since some Relation classes don't allow setting value - but RelationList does. - if (!($relation instanceof RelationList || $relation instanceof UnsavedRelationList)) { - throw new LogicException("'$fieldName()' method on {$record->ClassName} doesn't return a relation list"); - } else { - $relation->setByIDList($this->getValueArray() ?? []); - } - - return $this; - } - public function getSchemaDataDefaults() { $data = parent::getSchemaDataDefaults(); $data['isMulti'] = true; $data['types'] = json_decode($this->getTypesProps()); + $ownerFields = $this->getOwnerFields(); + $data['ownerID'] = $ownerFields['ID']; + $data['ownerClass'] = $ownerFields['Class']; + $data['ownerRelation'] = $ownerFields['Relation']; return $data; } @@ -84,6 +62,10 @@ protected function getDefaultAttributes(): array $attributes = parent::getDefaultAttributes(); $attributes['data-value'] = $this->getValueArray(); $attributes['data-can-create'] = $this->getOwner()->canEdit(); + $ownerFields = $this->getOwnerFields(); + $attributes['data-owner-id'] = $ownerFields['ID']; + $attributes['data-owner-class'] = $ownerFields['Class']; + $attributes['data-owner-relation'] = $ownerFields['Relation']; return $attributes; } diff --git a/src/Form/Traits/LinkFieldGetOwnerTrait.php b/src/Form/Traits/LinkFieldGetOwnerTrait.php index 0b12bd2a..8740c844 100644 --- a/src/Form/Traits/LinkFieldGetOwnerTrait.php +++ b/src/Form/Traits/LinkFieldGetOwnerTrait.php @@ -5,6 +5,8 @@ use LogicException; use SilverStripe\ORM\DataObject; use SilverStripe\Forms\Form; +use DNADesign\Elemental\Models\BaseElement; +use DNADesign\Elemental\Controllers\ElementalAreaController; trait LinkFieldGetOwnerTrait { @@ -18,4 +20,20 @@ private function getOwner(): DataObject } return $owner; } + + private function getOwnerFields(): array + { + $owner = $this->getOwner(); + $relation = $this->getName(); + // Elemental content block + if (class_exists(BaseElement::class) && is_a($owner, BaseElement::class)) { + $arr = ElementalAreaController::removeNamespacesFromFields([$relation => ''], $owner->ID); + $relation = array_keys($arr)[0]; + } + return [ + 'ID' => $owner->ID, + 'Class' => $owner::class, + 'Relation' => $relation, + ]; + } } diff --git a/tests/php/Controllers/LinkFieldControllerTest.php b/tests/php/Controllers/LinkFieldControllerTest.php index d8372934..67ce5d78 100644 --- a/tests/php/Controllers/LinkFieldControllerTest.php +++ b/tests/php/Controllers/LinkFieldControllerTest.php @@ -2,11 +2,13 @@ namespace SilverStripe\LinkField\Tests\Controllers; +use SilverStripe\CMS\Model\SiteTree; use SilverStripe\Dev\FunctionalTest; use SilverStripe\LinkField\Tests\Controllers\LinkFieldControllerTest\TestPhoneLink; use SilverStripe\Core\Config\Config; use SilverStripe\Security\SecurityToken; use SilverStripe\Control\HTTPRequest; +use SilverStripe\LinkField\Tests\Models\LinkTest\LinkOwner; class LinkFieldControllerTest extends FunctionalTest { @@ -14,6 +16,7 @@ class LinkFieldControllerTest extends FunctionalTest protected static $extra_dataobjects = [ TestPhoneLink::class, + LinkOwner::class, ]; private $securityTokenWasEnabled = false; @@ -50,11 +53,17 @@ public function testLinkFormGetSchema( string $expectedMessage ): void { TestPhoneLink::$fail = $fail; + $owner = $this->getFixtureLinkOwner(); + $ownerID = $owner->ID; + $ownerClass = urlencode($owner->ClassName); + $ownerRelation = 'Link'; $id = $this->getID($idType); if ($id === -1) { - $url = "/admin/linkfield/schema/linkForm?typeKey=$typeKey"; + $url = "/admin/linkfield/schema/linkForm?typeKey=$typeKey&ownerID=$ownerID&ownerClass=$ownerClass" + . "&ownerRelation=$ownerRelation"; } else { - $url = "/admin/linkfield/schema/linkForm/$id?typeKey=$typeKey"; + $url = "/admin/linkfield/schema/linkForm/$id?typeKey=$typeKey&ownerID=$ownerID&ownerClass=$ownerClass" + . "&ownerRelation=$ownerRelation"; } $headers = $this->formSchemaHeader(); $response = $this->get($url, null, $headers); @@ -66,13 +75,27 @@ public function testLinkFormGetSchema( } else { $formSchema = json_decode($response->getBody(), true); $this->assertSame("admin/linkfield/schema/linkForm/$id", $formSchema['id']); - $this->assertSame("admin/linkfield/linkForm/$id?typeKey=testphone", $formSchema['schema']['action']); + $expectedAction = "admin/linkfield/linkForm/$id?typeKey=testphone&ownerID=$ownerID&ownerClass=$ownerClass" + . "&ownerRelation=$ownerRelation"; + $this->assertSame($expectedAction, $formSchema['schema']['action']); // schema is nested and retains 'Root' and 'Main' tab hierarchy $this->assertSame('Phone', $formSchema['schema']['fields'][0]['children'][0]['children'][2]['name']); $this->assertSame('action_save', $formSchema['schema']['actions'][0]['name']); // state node is flattened, unlike schema node $this->assertSame($expectedValue, $formSchema['state']['fields'][4]['value']); $this->assertFalse(array_key_exists('errors', $formSchema)); + if ($idType === 'new-record') { + $this->assertSame('OwnerID', $formSchema['state']['fields'][6]['name']); + $this->assertSame($ownerID, $formSchema['state']['fields'][6]['value']); + $this->assertSame('OwnerClass', $formSchema['state']['fields'][7]['name']); + $this->assertSame($owner->ClassName, $formSchema['state']['fields'][7]['value']); + $this->assertSame('OwnerRelation', $formSchema['state']['fields'][8]['name']); + $this->assertSame($ownerRelation, $formSchema['state']['fields'][8]['value']); + } else { + $this->assertNotSame('OwnerID', $formSchema['state']['fields'][6]['name']); + $this->assertFalse(array_key_exists(7, $formSchema['state']['fields'])); + $this->assertFalse(array_key_exists(8, $formSchema['state']['fields'])); + } } } @@ -151,6 +174,11 @@ public function testLinkFormPost( string $expectedLinkType ): void { TestPhoneLink::$fail = $fail; + $owner = $this->getFixtureLinkOwner(); + $ownerID = $owner->ID; + $ownerClass = urlencode($owner->ClassName); + $ownerRelation = 'Link'; + $ownerLinkID = $owner->LinkID; $id = $this->getID($idType); if ($dataType === 'valid') { $data = $this->getFixtureLink()->jsonSerialize(); @@ -166,7 +194,8 @@ public function testLinkFormPost( if ($fail) { $data['Fail'] = $fail; } - $url = "/admin/linkfield/linkForm/$id?typeKey=$typeKey"; + $url = "/admin/linkfield/linkForm/$id?typeKey=$typeKey&ownerID=$ownerID&ownerClass=$ownerClass" + . "&ownerRelation=$ownerRelation"; $headers = $this->formSchemaHeader(); if ($fail !== 'csrf-token') { $headers = array_merge($headers, $this->csrfTokenheader()); @@ -194,9 +223,13 @@ public function testLinkFormPost( if ($fail) { $this->assertSame("admin/linkfield/schema/linkfield/$newID", $formSchema['id']); } else { - $this->assertSame("admin/linkfield/linkForm/$newID?typeKey=testphone", $formSchema['id']); + $expectedUrl = "admin/linkfield/linkForm/$newID?typeKey=testphone&ownerID=$ownerID" + . "&ownerClass=$ownerClass&ownerRelation=$ownerRelation"; + $this->assertSame($expectedUrl, $formSchema['id']); } - $this->assertSame("admin/linkfield/linkForm/$newID?typeKey=testphone", $formSchema['schema']['action']); + $expectedUrl = "admin/linkfield/linkForm/$newID?typeKey=testphone&ownerID=$ownerID&ownerClass=$ownerClass" + . "&ownerRelation=$ownerRelation"; + $this->assertSame($expectedUrl, $formSchema['schema']['action']); // schema is nested and retains 'Root' and 'Main' tab hierarchy $this->assertSame('Phone', $formSchema['schema']['fields'][0]['children'][0]['children'][2]['name']); $this->assertSame('action_save', $formSchema['schema']['actions'][0]['name']); @@ -207,11 +240,23 @@ public function testLinkFormPost( // Phone was note updated on PhoneLink dataobject $link = TestPhoneLink::get()->byID($newID); $this->assertSame($link->Phone, '0123456789'); + // LinkOwner.Link relation was not updated (refetch dataobject first) + $owner = $this->getFixtureLinkOwner(); + $this->assertSame($owner->LinkID, $ownerLinkID); + if ($idType === 'new-record') { + $this->assertsame($newID, $ownerLinkID); + } } else { $this->assertEmpty($formSchema['errors']); // Phone was updated on PhoneLink dataobject $link = TestPhoneLink::get()->byID($newID); $this->assertSame($link->Phone, '9876543210'); + // LinkOwner.Link relation was updated (refetch dataobject first) + $owner = $this->getFixtureLinkOwner(); + $this->assertSame($newID, $owner->LinkID); + if ($idType === 'new-record') { + $this->assertNotSame($newID, $ownerLinkID); + } } } } @@ -347,9 +392,14 @@ public function provideLinkFormPost(): array public function testLinkFormReadonly(string $idType, string $fail, bool $expected): void { TestPhoneLink::$fail = $fail; + $owner = $this->getFixtureLinkOwner(); + $ownerID = $owner->ID; + $ownerClass = urlencode($owner->ClassName); + $ownerRelation = 'Link'; $id = $this->getID($idType); $typeKey = 'testphone'; - $url = "/admin/linkfield/schema/linkForm/$id?typeKey=$typeKey"; + $url = "/admin/linkfield/schema/linkForm/$id?typeKey=$typeKey&ownerID=$ownerID&ownerClass=$ownerClass" + . "&ownerRelation=$ownerRelation"; $headers = $this->formSchemaHeader(); $body = $this->get($url, null, $headers)->getBody(); $json = json_decode($body, true); @@ -453,12 +503,17 @@ public function testLinkDelete( string $expectedMessage ): void { TestPhoneLink::$fail = $fail; + $owner = $this->getFixtureLinkOwner(); + $ownerID = $owner->ID; + $ownerClass = urlencode($owner->ClassName); + $ownerRelation = 'Link'; + $ownerLinkID = $owner->LinkID; $id = $this->getID($idType); $fixtureID = $this->getFixtureLink()->ID; if ($id === -1) { - $url = "/admin/linkfield/delete"; + $url = "/admin/linkfield/delete?ownerID=$ownerID&ownerClass=$ownerClass&ownerRelation=$ownerRelation"; } else { - $url = "/admin/linkfield/delete/$id"; + $url = "/admin/linkfield/delete/$id?ownerID=$ownerID&ownerClass=$ownerClass&ownerRelation=$ownerRelation"; } $headers = []; if ($fail !== 'csrf-token') { @@ -471,8 +526,12 @@ public function testLinkDelete( $jsonError = json_decode($response->getBody(), true); $this->assertSame($expectedMessage, $jsonError['errors'][0]['value']); $this->assertNotNull(TestPhoneLink::get()->byID($fixtureID)); + $owner = $this->getFixtureLinkOwner(); + $this->assertSame($ownerLinkID, $owner->LinkID); } else { $this->assertNull(TestPhoneLink::get()->byID($fixtureID)); + $owner = $this->getFixtureLinkOwner(); + $this->assertSame(0, $owner->LinkID); } $this->assertTrue(true); } @@ -530,6 +589,11 @@ private function getFixtureLink(): TestPhoneLink return $this->objFromFixture(TestPhoneLink::class, 'TestPhoneLink01'); } + private function getFixtureLinkOwner(): LinkOwner + { + return $this->objFromFixture(LinkOwner::class, 'TestLinkOwner01'); + } + private function getID(string $idType): mixed { $link = $this->getFixtureLink(); diff --git a/tests/php/Controllers/LinkFieldControllerTest.yml b/tests/php/Controllers/LinkFieldControllerTest.yml index 6d96f850..5b4b633f 100644 --- a/tests/php/Controllers/LinkFieldControllerTest.yml +++ b/tests/php/Controllers/LinkFieldControllerTest.yml @@ -2,3 +2,6 @@ SilverStripe\LinkField\Tests\Controllers\LinkFieldControllerTest\TestPhoneLink: TestPhoneLink01: Title: My phone link Phone: 0123456789 +SilverStripe\LinkField\Tests\Models\LinkTest\LinkOwner: + TestLinkOwner01: + Link: =>SilverStripe\LinkField\Tests\Controllers\LinkFieldControllerTest\TestPhoneLink.TestPhoneLink01 diff --git a/tests/php/Form/LinkFieldTest.php b/tests/php/Form/LinkFieldTest.php index 4fb94aec..28be216c 100644 --- a/tests/php/Form/LinkFieldTest.php +++ b/tests/php/Form/LinkFieldTest.php @@ -3,41 +3,37 @@ namespace SilverStripe\LinkField\Tests\Form; use SilverStripe\Dev\SapphireTest; +use SilverStripe\Forms\FieldList; use SilverStripe\LinkField\Form\LinkField; -use SilverStripe\LinkField\Models\Link; -use SilverStripe\LinkField\Tests\Models\LinkTest\LinkOwner; +use SilverStripe\LinkField\Tests\Form\LinkFieldTest\TestBlock; +use SilverStripe\LinkField\Tests\Controllers\LinkFieldControllerTest\TestPhoneLink; +use SilverStripe\Forms\Form; +use ReflectionObject; class LinkFieldTest extends SapphireTest { - protected $usesDatabase = true; + protected static $fixture_file = 'LinkFieldTest.yml'; protected static $extra_dataobjects = [ - LinkOwner::class, + TestBlock::class, + TestPhoneLink::class, ]; - /** - * When we save a link into the has_one of a record, we also need to save - * the Owner has_one on the link itself. - */ - public function testSaveInto() + public function testElementalNamespaceRemoved(): void { - // Prepare fixtures (need new records for this) - $field = new LinkField('Link'); - $link = new Link(); - $link->write(); - $owner = new LinkOwner(); - $owner->write(); - - // Save link into owner - $field->setValue($link->ID); - $field->saveInto($owner); - // Get the link again - the new values are in the DB. - $link = Link::get()->byID($link->ID); - - // Validate - $this->assertSame($link->ID, $owner->LinkID); - $this->assertSame($owner->ID, $link->OwnerID); - $this->assertSame($owner->ClassName, $link->OwnerClass); - $this->assertSame('Link', $link->OwnerRelation); + $form = new Form(); + $field = new LinkField('PageElements_1_MyLink'); + $form->setFields(new FieldList([$field])); + $block = $this->objFromFixture(TestBlock::class, 'TestBlock01'); + $form->loadDataFrom($block); + $reflector = new ReflectionObject($field); + $method = $reflector->getMethod('getOwnerFields'); + $method->setAccessible(true); + $res = $method->invoke($field); + $this->assertEquals([ + 'ID' => $block->ID, + 'Class' => TestBlock::class, + 'Relation' => 'MyLink', + ], $res); } } diff --git a/tests/php/Form/LinkFieldTest.yml b/tests/php/Form/LinkFieldTest.yml new file mode 100644 index 00000000..46ba0010 --- /dev/null +++ b/tests/php/Form/LinkFieldTest.yml @@ -0,0 +1,7 @@ +SilverStripe\LinkField\Tests\Controllers\LinkFieldControllerTest\TestPhoneLink: + TestPhoneLink01: + Title: My phone link 01 + Phone: 0123456790 +SilverStripe\LinkField\Tests\Form\LinkFieldTest\TestBlock: + TestBlock01: + MyLink: =>SilverStripe\LinkField\Tests\Controllers\LinkFieldControllerTest\TestPhoneLink.TestPhoneLink01 diff --git a/tests/php/Form/LinkFieldTest/TestBlock.php b/tests/php/Form/LinkFieldTest/TestBlock.php new file mode 100644 index 00000000..a40cc1ac --- /dev/null +++ b/tests/php/Form/LinkFieldTest/TestBlock.php @@ -0,0 +1,20 @@ + Link::class, + ]; + + private static $has_many = [ + 'MyLinks' => Link::class, + ]; +}