From af30f856e1aa0b8afc012dce39b15738d628c2fa Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Tue, 28 Nov 2023 16:51:13 +1300 Subject: [PATCH] NEW Add MultiLinkField for managing many-type relations --- .eslintrc.js | 8 +- babel.config.json | 6 - client/dist/js/bundle.js | 2 +- client/dist/styles/bundle.css | 2 +- client/src/components/LinkField/LinkField.js | 158 +++++++++++------ client/src/components/LinkModal/LinkModal.js | 2 +- .../src/components/LinkPicker/LinkPicker.js | 58 +++--- .../src/components/LinkPicker/LinkPicker.scss | 115 ++++++------ .../components/LinkPicker/LinkPickerMenu.js | 2 +- .../components/LinkPicker/LinkPickerTitle.js | 24 ++- client/src/containers/LinkModalContainer.js | 40 +++++ client/src/entwine/LinkField.js | 18 +- package.json | 8 +- src/Controllers/LinkFieldController.php | 68 ++++++- src/Form/LinkField.php | 7 + src/Form/MultiLinkField.php | 167 ++++++++++++++++++ .../LinkField/Form/MultiLinkField.ss | 2 + tests/php/Form/MultiLinkFieldTest.php | 69 ++++++++ yarn.lock | 22 +-- 19 files changed, 593 insertions(+), 185 deletions(-) delete mode 100644 babel.config.json create mode 100644 client/src/containers/LinkModalContainer.js create mode 100644 src/Form/MultiLinkField.php create mode 100644 templates/SilverStripe/LinkField/Form/MultiLinkField.ss create mode 100644 tests/php/Form/MultiLinkFieldTest.php diff --git a/.eslintrc.js b/.eslintrc.js index 4b81cffb..c12e9d17 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1 +1,7 @@ -module.exports = require('@silverstripe/eslint-config/.eslintrc'); +module.exports = { + extends: '@silverstripe/eslint-config', + // Allows null coalescing and optional chaining operators. + parserOptions: { + ecmaVersion: 2020 + }, +}; diff --git a/babel.config.json b/babel.config.json deleted file mode 100644 index bac856cb..00000000 --- a/babel.config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "presets": [ - "@babel/preset-env", - "@babel/preset-react" - ] - } \ No newline at end of file diff --git a/client/dist/js/bundle.js b/client/dist/js/bundle.js index 7981fd31..c4ec5475 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=o(n(521)),i=o(n(154));function o(e){return e&&e.__esModule?e:{default:e}}document.addEventListener("DOMContentLoaded",(()=>{(0,r.default)(),(0,i.default)()}))},521:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=u(n(648)),i=u(n(809)),o=u(n(852)),l=u(n(117)),a=u(n(606));function u(e){return e&&e.__esModule?e:{default:e}}var d=()=>{r.default.component.registerMany({LinkPicker:i.default,LinkField:o.default,"LinkModal.FormBuilderModal":l.default,"LinkModal.InsertMediaModal":a.default})};t.default=d},154:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=o(n(648)),i=o(n(689));function o(e){return e&&e.__esModule?e:{default:e}}var l=()=>{r.default.query.register("readLinkTypes",i.default)};t.default=l},852:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=k(n(363)),i=n(827),o=n(624),l=n(648),a=y(n(42)),u=y(n(809)),d=k(n(123)),s=y(n(159)),f=y(n(510)),c=y(n(86)),p=y(n(754));function y(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={},i=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var o in e)if("default"!==o&&Object.prototype.hasOwnProperty.call(e,o)){var l=i?Object.getOwnPropertyDescriptor(e,o):null;l&&(l.get||l.set)?Object.defineProperty(r,o,l):r[o]=e[o]}return r.default=e,n&&n.set(e,r),r}const v="SilverStripe\\LinkField\\Controllers\\LinkFieldController",_=e=>{let{value:t,onChange:n,types:i,actions:o}=e;const a=t,[d,c]=(0,r.useState)(""),[y,m]=(0,r.useState)({}),[k,_]=(0,r.useState)(!1),g=y.Title||"",h=i.hasOwnProperty(d)?i[d]:{},b=d?i[d]:h,O=b&&b.hasOwnProperty("handlerName")?b.handlerName:"FormBuilderModal",M=(0,l.loadComponent)(`LinkModal.${O}`),C={title:g,description:y.description,typeTitle:h.title||"",onEdit:()=>{_(!0)},onClear:()=>{const e=`${f.default.getSection(v).form.linkForm.deleteUrl}/${a}`;s.default.delete(e,{},{"X-SecurityID":f.default.get("SecurityID")}).then((()=>{o.toasts.success(p.default._t("LinkField.DELETE_SUCCESS","Deleted link"))})).catch((()=>{o.toasts.error(p.default._t("LinkField.DELETE_ERROR","Failed to delete link"))})),c(""),m({}),n(0)},onSelect:e=>{c(e),_(!0)},types:Object.values(i)},E={typeTitle:h.title||"",typeKey:d,editing:k,onSubmit:async(e,t,r)=>{const i=await r();if(!i.id.match(/\/schema\/linkfield\/([0-9]+)/)){const e=i.id.match(/\/linkForm\/([0-9]+)/),t=parseInt(e[1],10);_(!1),n(t),o.toasts.success(p.default._t("LinkField.SAVE_SUCCESS","Saved link"))}return Promise.resolve()},onClosed:()=>{_(!1)},linkID:a};return(0,r.useEffect)((()=>{if(!k&&a){const e=`${f.default.getSection(v).form.linkForm.dataUrl}/${a}`;s.default.get(e).then((e=>e.json())).then((e=>{m(e),c(e.typeKey)}))}}),[k,a]),r.default.createElement(r.default.Fragment,null,r.default.createElement(u.default,C),r.default.createElement(M,E))};_.propTypes={value:c.default.number.isRequired,onChange:c.default.func.isRequired};var g=(0,i.compose)((0,l.injectGraphql)("readLinkTypes"),a.default,(0,o.connect)(null,(e=>({actions:{toasts:(0,i.bindActionCreators)(d,e)}}))))(_);t.default=g},606:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;d(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={},i=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var o in e)if("default"!==o&&Object.prototype.hasOwnProperty.call(e,o)){var l=i?Object.getOwnPropertyDescriptor(e,o):null;l&&(l.get||l.set)?Object.defineProperty(r,o,l):r[o]=e[o]}r.default=e,n&&n.set(e,r);return r}(n(363)),i=d(n(475)),o=n(624),l=d(n(686)),a=d(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 d(e){return e&&e.__esModule?e:{default:e}}function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t{let{type:t,editing:n,data:o,actions:l,onSubmit:a,...u}=e;if(!t)return!1;(0,r.useEffect)((()=>{n?l.initModal():l.reset()}),[n]);const d=o?{ID:o.FileID,Description:o.Title,TargetBlank:!!o.OpenInNew}:{};return r.default.createElement(i.default,s({isOpen:n,type:"insert-link",title:!1,bodyClassName:"modal__dialog",className:"insert-link__dialog-wrapper--internal",fileAttributes:d,onInsert:e=>{let{ID:n,Description:r,TargetBlank:i}=e;return a({FileID:n,Title:r,OpenInNew:i,typeKey:t.key},"",(()=>{}))}},u))};f.propTypes={type:l.default.isRequired,editing:a.default.bool.isRequired,data:a.default.object.isRequired,actions:a.default.object.isRequired,onClick:a.default.func.isRequired};var c=(0,o.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=d(n(363)),i=d(n(912)),o=d(n(872)),l=d(n(902)),a=d(n(510)),u=d(n(86));function d(e){return e&&e.__esModule?e:{default:e}}const s=(e,t)=>{const{schemaUrl:n}=a.default.getSection("SilverStripe\\LinkField\\Controllers\\LinkFieldController").form.linkForm,r=o.default.parse(n),i=l.default.parse(r.query);i.typeKey=e;for(const e of["href","path","pathname"])r[e]=`${r[e]}/${t}`;return o.default.format({...r,search:l.default.stringify(i)})},f=e=>{let{typeTitle:t,typeKey:n,linkID:o,editing:l,onSubmit:a,onClosed:u}=e;return!!n&&r.default.createElement(i.default,{title:t,isOpen:l,schemaUrl:s(n,o),identifier:"Link.EditingLinkInfo",onSubmit:a,onClosed:u})};f.propTypes={typeTitle:u.default.string.isRequired,typeKey:u.default.string.isRequired,linkID:u.default.number.isRequired,editing:u.default.bool.isRequired,onSubmit:u.default.func.isRequired,onClosed:u.default.func.isRequired};var c=f;t.default=c},809:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=t.Component=void 0;var r=u(n(363)),i=u(n(86)),o=u(n(820)),l=u(n(97)),a=u(n(734));function u(e){return e&&e.__esModule?e:{default:e}}const d=e=>{let{title:t,description:n,typeTitle:i,types:u,onSelect:d,onEdit:s,onClear:f}=e;return r.default.createElement("div",{className:(0,o.default)("link-picker","form-control",{"link-picker--selected":!!i})},!i&&r.default.createElement(l.default,{types:u,onSelect:d}),i&&r.default.createElement(a.default,{title:t,description:n,typeTitle:i,onClear:f,onClick:()=>s()}))};t.Component=d,d.propTypes={...l.default.propTypes,title:i.default.string,description:i.default.string,typeTitle:i.default.string.isRequired,onEdit:i.default.func.isRequired,onClear:i.default.func.isRequired,onSelect:i.default.func.isRequired};var s=d;t.default=s},97:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=d(n(754)),i=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={},i=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var o in e)if("default"!==o&&Object.prototype.hasOwnProperty.call(e,o)){var l=i?Object.getOwnPropertyDescriptor(e,o):null;l&&(l.get||l.set)?Object.defineProperty(r,o,l):r[o]=e[o]}r.default=e,n&&n.set(e,r);return r}(n(363)),o=d(n(86)),l=n(127),a=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,a]=(0,i.useState)(!1);return i.default.createElement(l.Dropdown,{isOpen:o,toggle:()=>a((e=>!e)),className:"link-picker__menu"},i.default.createElement(l.DropdownToggle,{className:"link-picker__menu-toggle font-icon-link",caret:!0},r.default._t("LinkField.ADD_LINK","Add Link")),i.default.createElement(l.DropdownMenu,null,t.map((e=>{let{key:t,title:r}=e;return i.default.createElement(l.DropdownItem,{key:t,onClick:()=>n(t)},r)}))))};s.propTypes={types:o.default.arrayOf(a.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=a(n(754)),i=a(n(363)),o=a(n(86)),l=n(127);function a(e){return e&&e.__esModule?e:{default:e}}const u=e=>t=>{t.nativeEvent.stopImmediatePropagation(),t.preventDefault(),t.nativeEvent.preventDefault(),t.stopPropagation(),e&&e()},d=e=>{let{title:t,description:n,typeTitle:o,onClear:a,onClick:d}=e;return i.default.createElement("div",{className:"link-picker__link"},i.default.createElement(l.Button,{className:"link-picker__button font-icon-link",color:"secondary",onClick:u(d)},i.default.createElement("div",{className:"link-picker__link-detail"},i.default.createElement("div",{className:"link-picker__title"},t),i.default.createElement("small",{className:"link-picker__type"},o,": ",i.default.createElement("span",{className:"link-picker__url"},n)))),i.default.createElement(l.Button,{className:"link-picker__clear",color:"link",onClick:u(a)},r.default._t("LinkField.CLEAR","Clear")))};d.propTypes={title:o.default.string,description:o.default.string,typeTitle:o.default.string.isRequired,onClear:o.default.func.isRequired,onClick:o.default.func.isRequired};var s=d;t.default=s},41:function(e,t,n){var r=a(n(311)),i=a(n(363)),o=a(n(691)),l=n(648);function a(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(o.default.createRoot(this[0])),this._super(),this.refresh()},refresh(){const e=this.getProps(),t=this.getComponent();this.getRoot().render(i.default.createElement(t,u({},e,{noHolder:!0})))},handleChange(t){const n=e(this).data("field-id");e("#"+n).val(t),this.refresh()},getProps(){const t=e(this).data("field-id");return{value:Number(e("#"+t).val()),onChange:this.handleChange.bind(this)}},onunmatch(){this.getRoot().unmount()}})}))},689:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=n(648);const i={props(e){const{data:{error:t,readLinkTypes:n,loading:r}}=e,i=t&&t.graphQLErrors&&t.graphQLErrors.map((e=>e.message));return{loading:r,types:n?n.reduce(((e,t)=>({...e,[t.key]:t})),{}):{},graphQLErrors:i}}},{READ:o}=r.graphqlTemplates;var l={apolloConfig:i,templateName:o,pluralName:"LinkTypes",pagination:!1,params:{keys:"[ID]"},args:{root:{keys:"keys"}},fields:["key","title"]};t.default=l},686:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r,i=(r=n(86))&&r.__esModule?r:{default:r};var o=i.default.shape({key:i.default.string.isRequired,title:i.default.string.isRequired});t.default=o},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 i=t[r];if(void 0!==i)return i.exports;var o=t[r]={exports:{}};return e[r](o,o.exports,n),o.exports}n(274),n(41)}(); \ No newline at end of file +!function(){"use strict";var e={274:function(e,t,n){var r=l(n(521)),o=l(n(154));function l(e){return e&&e.__esModule?e:{default:e}}document.addEventListener("DOMContentLoaded",(()=>{(0,r.default)(),(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)),l=u(n(852)),a=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:l.default,"LinkModal.FormBuilderModal":a.default,"LinkModal.InsertMediaModal":i.default})};t.default=s},154:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=l(n(648)),o=l(n(689));function l(e){return e&&e.__esModule?e:{default:e}}var a=()=>{r.default.query.register("readLinkTypes",o.default)};t.default=a},852:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=g(n(363)),o=n(827),l=n(624),a=n(648),i=k(n(42)),u=k(n(809)),s=k(n(734)),d=k(n(686)),f=k(n(697)),c=g(n(123)),p=k(n(159)),y=k(n(510)),v=k(n(86)),m=k(n(754));function k(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 l in e)if("default"!==l&&Object.prototype.hasOwnProperty.call(e,l)){var a=o?Object.getOwnPropertyDescriptor(e,l):null;a&&(a.get||a.set)?Object.defineProperty(r,l,a):r[l]=e[l]}return r.default=e,n&&n.set(e,r),r}const O="SilverStripe\\LinkField\\Controllers\\LinkFieldController",h=e=>{var t;let{value:n=null,onChange:o,types:l,actions:a,isMulti:i=!1}=e;const[d,c]=(0,r.useState)({}),[v,k]=(0,r.useState)(null);let _=n;Array.isArray(_)||("number"==typeof _&&(_=[_]),_||(_=[])),(0,r.useEffect)((()=>{if(!v&&_.length>0){const e=[];for(const t of _)e.push(`itemID[]=${t}`);const t=`${y.default.getSection(O).form.linkForm.dataUrl}/?${e.join("&")}`;p.default.get(t).then((e=>e.json())).then((e=>{c(e)}))}}),[v,n&&n.length]);const g=()=>{k(null)},h=e=>{k(null);const t=[..._];t.includes(e)||t.push(e),o(i?t:t[0]),a.toasts.success(m.default._t("LinkField.SAVE_SUCCESS","Saved link"))},b=e=>{const t=`${y.default.getSection(O).form.linkForm.deleteUrl}/${e}`;p.default.delete(t,{},{"X-SecurityID":y.default.get("SecurityID")}).then((()=>{a.toasts.success(m.default._t("LinkField.DELETE_SUCCESS","Deleted link"))})).catch((()=>{a.toasts.error(m.default._t("LinkField.DELETE_ERROR","Failed to delete link"))}));const n={...d};delete n[e],c(n),o(i?Object.keys(n):0)},M=i||0===Object.keys(d).length,j=Boolean(v);return r.default.createElement(r.default.Fragment,null,M&&r.default.createElement(u.default,{onModalSuccess:h,onModalClosed:g,types:l}),r.default.createElement("div",null," ",(()=>{const e=[];for(const i of _){var t,n,o,a;if(!d[i])continue;const u=l.hasOwnProperty(null===(t=d[i])||void 0===t?void 0:t.typeKey)?l[null===(n=d[i])||void 0===n?void 0:n.typeKey]:{};e.push(r.default.createElement(s.default,{key:i,id:i,title:null===(o=d[i])||void 0===o?void 0:o.Title,description:null===(a=d[i])||void 0===a?void 0:a.description,typeTitle:u.title||"",onClear:b,onClick:()=>{k(i)}}))}return e})()," "),j&&r.default.createElement(f.default,{types:l,typeKey:null===(t=d[v])||void 0===t?void 0:t.typeKey,isOpen:Boolean(v),onSuccess:h,onClosed:g,linkID:v}))};h.propTypes={value:v.default.oneOfType([v.default.arrayOf(v.default.number),v.default.number]),onChange:v.default.func.isRequired,types:v.default.objectOf(d.default).isRequired,actions:v.default.object.isRequired,isMulti:v.default.bool};var b=(0,o.compose)((0,a.injectGraphql)("readLinkTypes"),i.default,(0,l.connect)(null,(e=>({actions:{toasts:(0,o.bindActionCreators)(c,e)}}))))(h);t.default=b},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 l in e)if("default"!==l&&Object.prototype.hasOwnProperty.call(e,l)){var a=o?Object.getOwnPropertyDescriptor(e,l):null;a&&(a.get||a.set)?Object.defineProperty(r,l,a):r[l]=e[l]}r.default=e,n&&n.set(e,r);return r}(n(363)),o=s(n(475)),l=n(624),a=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:l,actions:a,onSubmit:i,...u}=e;if(!t)return!1;(0,r.useEffect)((()=>{n?a.initModal():a.reset()}),[n]);const s=l?{ID:l.FileID,Description:l.Title,TargetBlank:!!l.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:a.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,l.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=s(n(363)),o=s(n(912)),l=s(n(872)),a=s(n(902)),i=s(n(510)),u=s(n(86));function s(e){return e&&e.__esModule?e:{default:e}}const d=(e,t)=>{const{schemaUrl:n}=i.default.getSection("SilverStripe\\LinkField\\Controllers\\LinkFieldController").form.linkForm,r=l.default.parse(n),o=a.default.parse(r.query);o.typeKey=e;for(const e of["href","path","pathname"])r[e]=`${r[e]}/${t}`;return l.default.format({...r,search:a.default.stringify(o)})},f=e=>{let{typeTitle:t,typeKey:n,linkID:l=0,isOpen:a,onSuccess:i,onClosed:u}=e;if(!n)return!1;return r.default.createElement(o.default,{title:t,isOpen:a,schemaUrl:d(n,l),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})};f.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 c=f;t.default=c},809:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=t.Component=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=d(t);if(n&&n.has(e))return n.get(e);var r={},o=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var l in e)if("default"!==l&&Object.prototype.hasOwnProperty.call(e,l)){var a=o?Object.getOwnPropertyDescriptor(e,l):null;a&&(a.get||a.set)?Object.defineProperty(r,l,a):r[l]=e[l]}r.default=e,n&&n.set(e,r);return r}(n(363)),o=s(n(86)),l=s(n(820)),a=s(n(97)),i=s(n(686)),u=s(n(697));function s(e){return e&&e.__esModule?e:{default:e}}function d(e){if("function"!=typeof WeakMap)return null;var t=new WeakMap,n=new WeakMap;return(d=function(e){return e?n:t})(e)}const f=e=>{let{types:t,onModalSuccess:n,onModalClosed:o}=e;const[i,s]=(0,r.useState)(""),d=""!==i,f=(0,l.default)("link-picker","form-control"),c=Object.values(t);return r.default.createElement("div",{className:f},r.default.createElement(a.default,{types:c,onSelect:e=>{s(e)}}),d&&r.default.createElement(u.default,{types:t,typeKey:i,isOpen:d,onSuccess:e=>{s(""),n(e)},onClosed:()=>{"function"==typeof o&&o(),s("")}}))};t.Component=f,f.propTypes={types:o.default.objectOf(i.default).isRequired,onModalSuccess:o.default.func.isRequired,onModalClosed:o.default.func};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 l in e)if("default"!==l&&Object.prototype.hasOwnProperty.call(e,l)){var a=o?Object.getOwnPropertyDescriptor(e,l):null;a&&(a.get||a.set)?Object.defineProperty(r,l,a):r[l]=e[l]}r.default=e,n&&n.set(e,r);return r}(n(363)),l=s(n(86)),a=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[l,i]=(0,o.useState)(!1);return o.default.createElement(a.Dropdown,{isOpen:l,toggle:()=>i((e=>!e)),className:"link-picker__menu"},o.default.createElement(a.DropdownToggle,{className:"link-picker__menu-toggle font-icon-plus-1",caret:!0},r.default._t("LinkField.ADD_LINK","Add Link")),o.default.createElement(a.DropdownMenu,null,t.map((e=>{let{key:t,title:r}=e;return o.default.createElement(a.DropdownItem,{key:t,onClick:()=>n(t)},r)}))))};d.propTypes={types:l.default.arrayOf(i.default).isRequired,onSelect:l.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=i(n(754)),o=i(n(363)),l=i(n(86)),a=n(127);function i(e){return e&&e.__esModule?e:{default:e}}const u=e=>t=>{t.nativeEvent.stopImmediatePropagation(),t.preventDefault(),t.nativeEvent.preventDefault(),t.stopPropagation(),e&&e()},s=e=>{let{id:t,title:n,description:l,typeTitle:i,onClear:s,onClick:d}=e;return o.default.createElement("div",{className:classnames("link-picker__link","form-control")},o.default.createElement(a.Button,{className:"link-picker__button font-icon-link",color:"secondary",onClick:u(d)},o.default.createElement("div",{className:"link-picker__link-detail"},o.default.createElement("div",{className:"link-picker__title"},n),o.default.createElement("small",{className:"link-picker__type"},i,": ",o.default.createElement("span",{className:"link-picker__url"},l)))),o.default.createElement(a.Button,{className:"link-picker__clear",color:"link",onClick:u((()=>s(t)))},r.default._t("LinkField.CLEAR","Clear")))};s.propTypes={id:l.default.number.isRequired,title:l.default.string,description:l.default.string,typeTitle:l.default.string.isRequired,onClear:l.default.func.isRequired,onClick:l.default.func.isRequired};var d=s;t.default=d},697:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=i(n(363)),o=n(648),l=i(n(86)),a=i(n(686));function i(e){return e&&e.__esModule?e:{default:e}}const u=e=>{let{types:t,typeKey:n,linkID:l=0,isOpen:a,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:l,isOpen:a,onSuccess:i,onClosed:u})};u.propTypes={types:l.default.objectOf(a.default).isRequired,typeKey:l.default.string.isRequired,linkID:l.default.number,isOpen:l.default.bool.isRequired,onSuccess:l.default.func.isRequired,onClosed:l.default.func.isRequired};var s=u;t.default=s},41:function(e,t,n){var r=i(n(311)),o=i(n(363)),l=i(n(691)),a=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,a.loadComponent)(n,t);this.setComponent(r),this.setRoot(l.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(){return{value:this.getInputField().data("value"),onChange:this.handleChange.bind(this),isMulti:this.data("is-multi")??!1}},getInputField(){const t=this.data("field-id");return e(`#${t}`)},onunmatch(){const e=this.getRoot();e&&e.unmount()}})}))},689:function(e,t,n){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var r=n(648);const o={props(e){const{data:{error:t,readLinkTypes:n,loading:r}}=e,o=t&&t.graphQLErrors&&t.graphQLErrors.map((e=>e.message));return{loading:r,types:n?n.reduce(((e,t)=>({...e,[t.key]:t})),{}):{},graphQLErrors:o}}},{READ:l}=r.graphqlTemplates;var a={apolloConfig:o,templateName:l,pluralName:"LinkTypes",pagination:!1,params:{keys:"[ID]"},args:{root:{keys:"keys"}},fields:["key","title"]};t.default=a},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 l=o.default.shape({key:o.default.string.isRequired,title:o.default.string.isRequired});t.default=l},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 l=t[r]={exports:{}};return e[r](l,l.exports,n),l.exports}n(274),n(41)}(); \ No newline at end of file diff --git a/client/dist/styles/bundle.css b/client/dist/styles/bundle.css index 5c415c8c..b8ebcc27 100644 --- a/client/dist/styles/bundle.css +++ b/client/dist/styles/bundle.css @@ -1 +1 @@ -.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} +.link-picker__link,.link-picker{display:flex;height:auto;width:100%;min-height:54px;background:#fff;padding:0}.link-picker{align-items:stretch;cursor:pointer;box-shadow:none}.link-picker:not(:last-child){margin-bottom:10px}.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{align-items:center;text-align:left;margin-right:0;justify-content:space-between}.link-picker__link:not(:last-child){border-bottom:0;border-bottom-left-radius:0;border-bottom-right-radius:0}.link-picker__link:not(:first-child){border-top:0;border-top-left-radius:0;border-top-right-radius:0}.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} diff --git a/client/src/components/LinkField/LinkField.js b/client/src/components/LinkField/LinkField.js index a6c1d67b..9ba0cc74 100644 --- a/client/src/components/LinkField/LinkField.js +++ b/client/src/components/LinkField/LinkField.js @@ -2,10 +2,12 @@ import React, { useState, useEffect } from 'react'; import { bindActionCreators, compose } from 'redux'; import { connect } from 'react-redux'; -import { injectGraphql, loadComponent } from 'lib/Injector'; +import { injectGraphql } from 'lib/Injector'; import fieldHolder from 'components/FieldHolder/FieldHolder'; import LinkPicker from 'components/LinkPicker/LinkPicker'; import LinkPickerTitle from 'components/LinkPicker/LinkPickerTitle'; +import LinkType from 'types/LinkType'; +import LinkModalContainer from 'containers/LinkModalContainer'; import * as toastsActions from 'state/toasts/ToastsActions'; import backend from 'lib/Backend'; import Config from 'lib/Config'; @@ -20,23 +22,63 @@ const section = 'SilverStripe\\LinkField\\Controllers\\LinkFieldController'; * onChange - callback function passed from JsonField - used to update the underlying form field * types - injected by the GraphQL query * actions - object of redux actions + * isMulti - whether this field handles multiple links or not */ -const LinkField = ({ value, onChange, types, actions }) => { - const linkID = value; +const LinkField = ({ value = null, onChange, types, actions, isMulti = false }) => { const [data, setData] = useState({}); - const [editing, setEditing] = useState(false); + const [editingID, setEditingID] = useState(null); + // Ensure we have a valid array + let linkIDs = value; + if (!Array.isArray(linkIDs)) { + if (typeof linkIDs === 'number') { + linkIDs = [linkIDs]; + } + if (!linkIDs) { + linkIDs = []; + } + } + + // Read data from endpoint and update component state + // This happens any time a link is added or removed and triggers a re-render + useEffect(() => { + if (!editingID && linkIDs.length > 0) { + const query = []; + for (const linkID of linkIDs) { + query.push(`itemID[]=${linkID}`); + } + const endpoint = `${Config.getSection(section).form.linkForm.dataUrl}/?${query.join('&')}`; + backend.get(endpoint) + .then(response => response.json()) + .then(responseJson => { + setData(responseJson); + }); + } + }, [editingID, value && value.length]); + + /** + * Unset the editing ID when the editing modal is closed + */ const onModalClosed = () => { - setEditing(false); + setEditingID(null); }; + /** + * Update the component when the modal successfully saves a link + */ const onModalSuccess = (value) => { // update component state - setEditing(false); + setEditingID(null); + + const ids = [...linkIDs]; + if (!ids.includes(value)) { + ids.push(value); + } - // update parent JsonField data id - this is required to update the underlying form field - // so that the Page (or other parent DataObject) gets the Link relation ID set - onChange(value); + // Update value in the underlying form field + // so that the Page (or other parent DataObject) gets the Link relation set. + // Also likely required in react context for dirty form state, etc. + onChange(isMulti ? ids : ids[0]); // success toast actions.toasts.success( @@ -48,10 +90,10 @@ const LinkField = ({ value, onChange, types, actions }) => { } /** - * Call back used by LinkPicker when the 'Clear' button is clicked + * Update the component when the 'Clear' button in the LinkPicker is clicked */ - const onClear = (id) => { - const endpoint = `${Config.getSection(section).form.linkForm.deleteUrl}/${id}`; + const onClear = (linkID) => { + const endpoint = `${Config.getSection(section).form.linkForm.deleteUrl}/${linkID}`; // CSRF token 'X-SecurityID' headers needs to be present for destructive requests backend.delete(endpoint, {}, { 'X-SecurityID': Config.get('SecurityID') }) .then(() => { @@ -72,63 +114,65 @@ const LinkField = ({ value, onChange, types, actions }) => { }); // update component state - setData({}); + const newData = {...data}; + delete newData[linkID]; + setData(newData); - // update parent JsonField data ID used to update the underlying form field - onChange(0); + // update parent JsonField data IDs used to update the underlying form field + onChange(isMulti ? Object.keys(newData) : 0); }; - const title = data.Title || ''; - const type = types.hasOwnProperty(data.typeKey) ? types[data.typeKey] : {}; - const handlerName = type && type.hasOwnProperty('handlerName') - ? type.handlerName - : 'FormBuilderModal'; - const LinkModal = loadComponent(`LinkModal.${handlerName}`); - - const pickerProps = { - onModalSuccess, - onModalClosed, - types + /** + * Render all of the links currently in the field data + */ + const renderLinks = () => { + const links = []; + + for (const linkID of linkIDs) { + // Only render items we have data for + const linkData = data[linkID]; + if (!linkData) { + continue; + } + + const type = types.hasOwnProperty(data[linkID]?.typeKey) ? types[data[linkID]?.typeKey] : {}; + links.push( { setEditingID(linkID); }} + />); + } + return links; }; - const modalProps = { - typeTitle: type.title || '', - typeKey: data.typeKey, - isOpen: editing, - onSuccess: onModalSuccess, - onClosed: onModalClosed, - linkID - }; - - // read data from endpoint and update component state - useEffect(() => { - if (!editing && linkID) { - const endpoint = `${Config.getSection(section).form.linkForm.dataUrl}/${linkID}`; - backend.get(endpoint) - .then(response => response.json()) - .then(responseJson => { - setData(responseJson); - }); - } - }, [editing, linkID]); + const renderPicker = isMulti || Object.keys(data).length === 0; + const renderModal = Boolean(editingID); return <> - {!type.title && } - {type.title && { setEditing(true); }} - />} - { editing && } + { renderPicker && } +
{ renderLinks() }
+ { renderModal && + } ; }; LinkField.propTypes = { - value: PropTypes.number.isRequired, + value: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), onChange: PropTypes.func.isRequired, + types: PropTypes.objectOf(LinkType).isRequired, + actions: PropTypes.object.isRequired, + isMulti: PropTypes.bool, }; // 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 734e974e..4d518ec8 100644 --- a/client/src/components/LinkModal/LinkModal.js +++ b/client/src/components/LinkModal/LinkModal.js @@ -47,7 +47,7 @@ const LinkModal = ({ typeTitle, typeKey, linkID = 0, isOpen, onSuccess, onClosed return { +/** + * Component which allows users to choose a type of link to create, and opens a modal form for it. + */ +const LinkPicker = ({ types, onModalSuccess, onModalClosed }) => { const [typeKey, setTypeKey] = useState(''); - const doSelect = (key) => { - if (typeof onSelect === 'function') { - onSelect(key); - } + /** + * When a link type is selected, set the type key so we can open the modal. + */ + const handleSelect = (key) => { setTypeKey(key); } - const onClosed = () => { + /** + * Callback for when the modal is closed by the user + */ + const handleClosed = () => { if (typeof onModalClosed === 'function') { onModalClosed(); } setTypeKey(''); } - const onSuccess = (value) => { + /** + * Callback for when the modal successfully saves a link + */ + const handleSuccess = (value) => { setTypeKey(''); onModalSuccess(value); } - const type = types.hasOwnProperty(typeKey) ? types[typeKey] : {}; - const modalType = typeKey ? types[typeKey] : type; - const handlerName = modalType && modalType.hasOwnProperty('handlerName') - ? modalType.handlerName - : 'FormBuilderModal'; - const LinkModal = loadComponent(`LinkModal.${handlerName}`); - - const isOpen = Boolean(typeKey); - - const modalProps = { - typeTitle: type.title || '', - typeKey, - isOpen, - onSuccess: onSuccess, - onClosed: onClosed, - }; + const shouldOpenModal = typeKey !== ''; + const className = classnames('link-picker', 'form-control'); + const typeArray = Object.values(types); return ( -
- - { isOpen && } +
+ + { shouldOpenModal && + }
); }; LinkPicker.propTypes = { types: PropTypes.objectOf(LinkType).isRequired, - onSelect: PropTypes.func, onModalSuccess: PropTypes.func.isRequired, onModalClosed: PropTypes.func, }; diff --git a/client/src/components/LinkPicker/LinkPicker.scss b/client/src/components/LinkPicker/LinkPicker.scss index 29ea23e7..ee68a8ea 100644 --- a/client/src/components/LinkPicker/LinkPicker.scss +++ b/client/src/components/LinkPicker/LinkPicker.scss @@ -1,79 +1,94 @@ -.link-picker { +%link-row { display: flex; height: auto; + width: 100%; min-height: 54px; background: white; - width: 100%; + padding: 0; +} + +.link-picker { + @extend %link-row; + align-items: stretch; cursor: pointer; - padding: 0; box-shadow: none; + // Add separation between the picker and the multi-link display + &:not(:last-child) { + margin-bottom: 10px; + } + &.font-icon-link::before { margin: $spacer-xs; } +} - &__menu { - flex-grow: 1; - } +.link-picker__menu { + flex-grow: 1; +} - &__menu-toggle { - width: 100%; - height: 100%; - text-align: left; +.link-picker__menu-toggle { + width: 100%; + height: 100%; + text-align: left; - &::before { - padding: $spacer-xs; - } + &::before { + padding: $spacer-xs; } +} - &--selected { +.link-picker__link { + @extend %link-row; - } + align-items: center; + text-align: left; + margin-right: 0; + justify-content: space-between; - &__link { - - display: flex; - align-items: center; - width: 100%; - text-align: left; - border: none; - margin-right: 0; - justify-content: space-between; - - &:hover, &:focus { - background: $gray-100; - text-decoration: none; - color: inherit; - } + &:not(:last-child) { + border-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } - &__button { - display: flex; - align-items: center; - flex-grow: 1; - height: 100%; - text-align: left; - border: none; - margin-right: 0; - &::before { - font-size: 1.231rem; - padding: .76925rem; - margin-right: 6px; - flex-grow: 0; - } + &:not(:first-child) { + border-top: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; } - &__link-detail { - flex-grow: 1; + &:hover, &:focus { + background: $gray-100; + text-decoration: none; + color: inherit; } +} - &__clear { +.link-picker__button { + display: flex; + align-items: center; + flex-grow: 1; + height: 100%; + text-align: left; + border: none; + margin-right: 0; + &::before { + font-size: 1.231rem; + padding: .76925rem; + margin-right: 6px; flex-grow: 0; } +} - &__url { - color: $link-color; - } +.link-picker__link-detail { + flex-grow: 1; +} + +.link-picker__clear { + flex-grow: 0; +} +.link-picker__url { + color: $link-color; } diff --git a/client/src/components/LinkPicker/LinkPickerMenu.js b/client/src/components/LinkPicker/LinkPickerMenu.js index 3413d4c4..75eb38ac 100644 --- a/client/src/components/LinkPicker/LinkPickerMenu.js +++ b/client/src/components/LinkPicker/LinkPickerMenu.js @@ -15,7 +15,7 @@ const LinkPickerMenu = ({ types, onSelect }) => { toggle={toggle} className="link-picker__menu" > - {i18n._t('LinkField.ADD_LINK', 'Add Link')} + {i18n._t('LinkField.ADD_LINK', 'Add Link')} {types.map(({key, title}) => onSelect(key)}>{title} diff --git a/client/src/components/LinkPicker/LinkPickerTitle.js b/client/src/components/LinkPicker/LinkPickerTitle.js index d7e26859..9e069fca 100644 --- a/client/src/components/LinkPicker/LinkPickerTitle.js +++ b/client/src/components/LinkPicker/LinkPickerTitle.js @@ -13,19 +13,17 @@ const stopPropagation = (fn) => (e) => { } const LinkPickerTitle = ({ id, title, description, typeTitle, onClear, onClick }) => ( -
-
- - -
+
+ +
); diff --git a/client/src/containers/LinkModalContainer.js b/client/src/containers/LinkModalContainer.js new file mode 100644 index 00000000..5232a378 --- /dev/null +++ b/client/src/containers/LinkModalContainer.js @@ -0,0 +1,40 @@ +/* eslint-disable */ +import React from 'react'; +import { loadComponent } from 'lib/Injector'; +import PropTypes from 'prop-types'; +import LinkType from 'types/LinkType'; + +/** + * Contains the LinkModal and determines which modal component to render based on the link type. + */ +const LinkModalContainer = ({ types, typeKey, linkID = 0, isOpen, onSuccess, onClosed}) => { + if (!typeKey) { + return false; + } + + const type = types.hasOwnProperty(typeKey) ? types[typeKey] : {}; + const handlerName = type && type.hasOwnProperty('handlerName') + ? type.handlerName + : 'FormBuilderModal'; + const LinkModal = loadComponent(`LinkModal.${handlerName}`); + + return ; +} + +LinkModalContainer.propTypes = { + types: PropTypes.objectOf(LinkType).isRequired, + typeKey: PropTypes.string.isRequired, + linkID: PropTypes.number, + isOpen: PropTypes.bool.isRequired, + onSuccess: PropTypes.func.isRequired, + onClosed: PropTypes.func.isRequired, +}; + +export default LinkModalContainer; diff --git a/client/src/entwine/LinkField.js b/client/src/entwine/LinkField.js index dd7d84db..0c8b789b 100644 --- a/client/src/entwine/LinkField.js +++ b/client/src/entwine/LinkField.js @@ -28,14 +28,14 @@ jQuery.entwine('ss', ($) => { refresh() { const props = this.getProps(); + this.getInputField().val(props.value); const ReactField = this.getComponent(); const Root = this.getRoot(); Root.render(); }, handleChange(value) { - const fieldID = $(this).data('field-id'); - $('#' + fieldID).val(value); + this.getInputField().data('value', value); this.refresh(); }, @@ -45,14 +45,22 @@ jQuery.entwine('ss', ($) => { * @returns {Object} */ getProps() { - const fieldID = $(this).data('field-id'); - const value = Number($('#' + fieldID).val()); + const value = this.getInputField().data('value'); return { value, - onChange: this.handleChange.bind(this) + onChange: this.handleChange.bind(this), + isMulti: this.data('is-multi') ?? false, }; }, + /** + * Get the field that represents the linkfield. + */ + getInputField() { + const fieldID = this.data('field-id'); + return $(`#${fieldID}`); + }, + /** * Remove the component when unmatching */ diff --git a/package.json b/package.json index 066560f6..80feff3b 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,8 @@ }, "devDependencies": { "@babel/runtime": "^7.20.0", - "@silverstripe/eslint-config": "^1.0.0-alpha6", - "@silverstripe/webpack-config": "^2.0.0-alpha9", + "@silverstripe/eslint-config": "^1.0.0", + "@silverstripe/webpack-config": "^2.0.0", "babel-jest": "^29.2.2", "jest-cli": "^29.2.2", "jest-environment-jsdom": "^29.3.1", @@ -60,8 +60,8 @@ "dependencies": { "@apollo/client": "^3.7.1", "bootstrap": "^4.6.2", - "core-js": "^3.26.0", "classnames": "^2.2.5", + "core-js": "^3.26.0", "prop-types": "^15.8.1", "qs": "^6.11.0", "react": "^18.2.0", @@ -81,4 +81,4 @@ "browserslist": [ "defaults" ] -} \ No newline at end of file +} diff --git a/src/Controllers/LinkFieldController.php b/src/Controllers/LinkFieldController.php index d5490860..944d72bc 100644 --- a/src/Controllers/LinkFieldController.php +++ b/src/Controllers/LinkFieldController.php @@ -16,8 +16,10 @@ use SilverStripe\ORM\ValidationException; use SilverStripe\ORM\ValidationResult; use SilverStripe\Control\Controller; +use SilverStripe\Control\HTTPRequest; use SilverStripe\Core\Injector\Injector; use SilverStripe\Forms\HiddenField; +use SilverStripe\ORM\DataList; class LinkFieldController extends LeftAndMain { @@ -85,20 +87,35 @@ public function linkForm(): Form * Get data for a Link * /admin/linkfield/data/ */ - public function linkData(): HTTPResponse + public function linkData(HTTPRequest $request): HTTPResponse { - $link = $this->linkFromRequest(); - if (!$link->canView()) { - $this->jsonError(403, _t('LinkField.UNAUTHORIZED', 'Unauthorized')); + $data = []; + if ($request->param('ItemID')) { + $link = $this->linkFromRequest(); + $data = $this->getLinkData($link); + } else { + $links = $this->linksFromRequest(); + foreach ($links as $link) { + $data[$link->ID] = $this->getLinkData($link); + } } + $response = $this->getResponse(); $response->addHeader('Content-type', 'application/json'); - $data = $link->jsonSerialize(); - $data['description'] = $link->getDescription(); $response->setBody(json_encode($data)); return $response; } + private function getLinkData(Link $link): array + { + if (!$link->canView()) { + $this->jsonError(403, _t('LinkField.UNAUTHORIZED', 'Unauthorized')); + } + $data = $link->jsonSerialize(); + $data['description'] = $link->getDescription(); + return $data; + } + /** * Delete a Link * /admin/linkfield/delete/ @@ -274,6 +291,22 @@ private function linkFromRequest(): Link return $link; } + /** + * Get all Link objects based on the itemID query string argument + */ + private function linksFromRequest(): DataList + { + $itemIDs = $this->itemIDsFromRequest(); + if (empty($itemIDs)) { + $this->jsonError(404, _t('LinkField.INVALID_ID', 'Invalid ID')); + } + $links = Link::get()->byIDs($itemIDs); + if (!$links->exists()) { + $this->jsonError(404, _t('LinkField.INVALID_ID', 'Invalid ID')); + } + return $links; + } + /** * Get the $ItemID request param */ @@ -287,6 +320,29 @@ private function itemIDFromRequest(): int return (int) $itemID; } + /** + * Get the value of the itemID request query string argument + */ + private function itemIDsFromRequest(): array + { + $request = $this->getRequest(); + $itemIDs = $request->getVar('itemID'); + + if (!is_array($itemIDs)) { + $this->jsonError(404, _t('LinkField.INVALID_ID', 'Invalid ID')); + } + + $idsAsInt = []; + foreach ($itemIDs as $id) { + if (!is_int($id) && !ctype_digit($id)) { + $this->jsonError(404, _t('LinkField.INVALID_ID', 'Invalid ID')); + } + $idsAsInt[] = (int) $id; + } + + return $idsAsInt; + } + /** * Get the ?typeKey request querystring param */ diff --git a/src/Form/LinkField.php b/src/Form/LinkField.php index 87c42ee8..e31eab16 100644 --- a/src/Form/LinkField.php +++ b/src/Form/LinkField.php @@ -46,4 +46,11 @@ public function saveInto(DataObjectInterface $record) return $this; } + + protected function getDefaultAttributes(): array + { + $attributes = parent::getDefaultAttributes(); + $attributes['data-value'] = $this->Value(); + return $attributes; + } } diff --git a/src/Form/MultiLinkField.php b/src/Form/MultiLinkField.php new file mode 100644 index 00000000..f4874b95 --- /dev/null +++ b/src/Form/MultiLinkField.php @@ -0,0 +1,167 @@ +loadFrom($data); + return $this; + } + + $ids = $this->convertValueToArray($value); + 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; + return $data; + } + + public function getSchemaStateDefaults() + { + $data = parent::getSchemaStateDefaults(); + $data['value'] = $this->getValueArray(); + return $data; + } + + protected function getDefaultAttributes(): array + { + $attributes = parent::getDefaultAttributes(); + $attributes['data-value'] = $this->getValueArray(); + return $attributes; + } + + /** + * Extracts the value of this field, normalised as a non-associative array. + */ + private function getValueArray(): array + { + return $this->convertValueToArray($this->Value()); + } + + /** + * converts the value to an array if possible. + * @throws LogicException if the type cannot be converted into an array. + */ + private function convertValueToArray(mixed $value): array + { + // Prepare string by removing whitespace from the ends + // A comma separated list of IDs will be turned into an array of IDs + // Anything else will either get caught in the empty check or the !is_iterable check + if (is_string($value)) { + $value = $this->convertCommaSeparatedString(trim($value)); + } + if (empty($value)) { + return []; + } + if ($value instanceof SS_List) { + return $value->column('ID'); + } + if (!is_iterable($value)) { + return [$value]; + } + if (is_iterable($value) && !is_array($value)) { + return [...$value]; + } + if (is_array($value)) { + return array_values($value); + } + // Theoretically this is unreachable - but let's have an exception just in case. + throw new LogicException('Unexpected value type ' . gettype($value)); + } + + /** + * converts a comma-separated string of integers into an array. + * If any value is not an integer, it returns the original string. + */ + private function convertCommaSeparatedString(string $string): string|array + { + // Split by comma and remove any whitespace between items + $commaSeparated = array_map(fn ($string) => trim($string), explode(',', $string)); + + // Stop cooercing if any value isn't an integer and just return the raw string instead. + foreach ($commaSeparated as $index => $id) { + if (!ctype_digit((string) $id) || $id != (int) $id) { + return $string; + } + $commaSeparated[$index] = (int) $id; + } + + return $commaSeparated; + } + + /** + * Load the value from the dataobject into this field + */ + private function loadFrom(DataObject $record): void + { + $fieldName = $this->getName(); + if (empty($fieldName)) { + return; + } + + $relation = $record->hasMethod($fieldName) + ? $record->$fieldName() + : null; + + if (!$relation) { + throw new LogicException("{$record->ClassName} is missing the relation '$fieldName'"); + } + + // Use Relation here rather than RelationList to allow for eagerloaded data or other shenanigans + if (!$relation instanceof Relation) { + throw new LogicException("'$fieldName()' method on {$record->ClassName} doesn't return a relation"); + } + + // Load ids from relation + $value = array_values($relation->getIDList() ?? []); + parent::setValue($value); + } +} diff --git a/templates/SilverStripe/LinkField/Form/MultiLinkField.ss b/templates/SilverStripe/LinkField/Form/MultiLinkField.ss new file mode 100644 index 00000000..6a3ef5d7 --- /dev/null +++ b/templates/SilverStripe/LinkField/Form/MultiLinkField.ss @@ -0,0 +1,2 @@ + +
diff --git a/tests/php/Form/MultiLinkFieldTest.php b/tests/php/Form/MultiLinkFieldTest.php new file mode 100644 index 00000000..5865f3c8 --- /dev/null +++ b/tests/php/Form/MultiLinkFieldTest.php @@ -0,0 +1,69 @@ + [ + 'value' => '', + 'expected' => [], + ], + 'non-comma-separated numeric string' => [ + 'value' => 'this is a string', + 'expected' => ['this is a string'], + ], + 'non-comma-separated non-numeric string' => [ + 'value' => ' 1, a, 2', + 'expected' => ['1, a, 2'], + ], + 'comma-separated string' => [ + 'value' => '1,2,3,4', + 'expected' => [1, 2, 3, 4], + ], + 'comma-separated string with spaces' => [ + 'value' => ' 1,2 , 3, 4 ', + 'expected' => [1, 2, 3, 4], + ], + 'number' => [ + 'value' => 1234, + 'expected' => [1234], + ], + 'arraylist' => [ + 'value' => new ArrayList([['ID' => 1], ['ID' => 54]]), + 'expected' => [1, 54], + ], + 'non-array iterable' => [ + 'value' => new ArrayIterator([1, 'string', []]), + 'expected' => [1, 'string', []], + ], + 'empty array' => [ + 'value' => [], + 'expected' => [], + ], + 'array with values' => [ + 'value' => [1, 'string', []], + 'expected' => [1, 'string', []], + ], + ]; + } + + /** + * @dataProvider provideConvertValueToArray + */ + public function testConvertValueToArray(mixed $value, array $expected): void + { + $field = new MultiLinkField(''); + $reflectionMethod = new ReflectionMethod($field, 'convertValueToArray'); + $reflectionMethod->setAccessible(true); + $this->assertSame($expected, $reflectionMethod->invoke($field, $value)); + } +} diff --git a/yarn.lock b/yarn.lock index 04a92b6d..002976da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1671,10 +1671,10 @@ resolved "https://registry.yarnpkg.com/@sect/modernizr-loader/-/modernizr-loader-1.0.4.tgz#1f7e21d0730850ea6ab25adb02f781471b072413" integrity sha512-rzi5ssSnhRFAdQpHZXmmrn6M6djAbyS290EqcIhvpVWGqwY4rkr9L/qGo0U9tNPDah0y1mxtFeBcP1lRQcP2/A== -"@silverstripe/eslint-config@^1.0.0-alpha6": - version "1.0.0-alpha6" - resolved "https://registry.yarnpkg.com/@silverstripe/eslint-config/-/eslint-config-1.0.0-alpha6.tgz#1f243b003fddf3503a4abea37f35a8a5968cc96e" - integrity sha512-+P7UzhMRSmc7UlRYCiSXwjauLFYU11oBPwHl/bpacJ7xUcFY3Jt3CgcDt6d+XLvAJO8zMRsG9RcOm5MnxsyCsg== +"@silverstripe/eslint-config@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@silverstripe/eslint-config/-/eslint-config-1.1.0.tgz#3bf3d233b4ccfec4eeca362a621968ba2f70af59" + integrity sha512-7Y3zjAQzNyWceDDvd+cK0NdeI7MP0LJdL7JeF+JUBOmT14hOaBWvGrmcQLmYhZb2sTwh6JEgQI0+9ExVr/60nQ== dependencies: eslint "^8.26.0" eslint-config-airbnb "^19.0.4" @@ -1684,10 +1684,10 @@ eslint-plugin-react "^7.31.10" eslint-webpack-plugin "^3.2.0" -"@silverstripe/webpack-config@^2.0.0-alpha9": - version "2.0.0-alpha9" - resolved "https://registry.yarnpkg.com/@silverstripe/webpack-config/-/webpack-config-2.0.0-alpha9.tgz#b2e309735f958fd3905d1f09d9bc4014d0a3ba79" - integrity sha512-8AsoC+eYrIQO/5KD4xah+qV4h5nPz16DdfUQZoDr5WQH03QDTsZMEjemrMYqDEEJtMKYs/SDkK1ZnlfRo3ubsw== +"@silverstripe/webpack-config@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@silverstripe/webpack-config/-/webpack-config-2.0.0.tgz#278a72a1adbc6fa2362497d60424c78fba58e8e1" + integrity sha512-m1qGRxlsdhWL567cWe7IZNBUCzeyg3T1Y9yY9Y6XClwAqlg1oIO9uLfvfauA4dbtECrzU5n1AkaaU6kMRtN6Aw== dependencies: "@babel/core" "^7.19.6" "@babel/preset-env" "^7.19.4" @@ -2683,9 +2683,9 @@ camelcase@^6.2.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001449: - version "1.0.30001458" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001458.tgz#871e35866b4654a7d25eccca86864f411825540c" - integrity sha512-lQ1VlUUq5q9ro9X+5gOEyH7i3vm+AYVT1WDCVB69XOZ17KZRhnZ9J0Sqz7wTHQaLBJccNCHq8/Ww5LlOIZbB0w== + version "1.0.30001565" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001565.tgz" + integrity sha512-xrE//a3O7TP0vaJ8ikzkD2c2NgcVUvsEe2IvFTntV4Yd1Z9FVzh+gW+enX96L0psrbaFMcVcH2l90xNuGDWc8w== chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: version "1.1.3"