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/README.md b/README.md index 6a482a8c..d737a86f 100644 --- a/README.md +++ b/README.md @@ -19,22 +19,10 @@ This module provides a Link model and CMS interface for managing different types Installation via composer. -### Silverstripe 5 - ```sh composer require silverstripe/linkfield ``` -### GraphQL v4 - Silverstripe 4 - -`composer require silverstripe/linkfield:^2` - -### GraphQL v3 - Silverstripe 4 - -```sh -composer require silverstripe/linkfield:^1 -``` - ## Sample usage ```php @@ -43,6 +31,7 @@ use SilverStripe\CMS\Model\SiteTree; use SilverStripe\LinkField\ORM\DBLink; use SilverStripe\LinkField\Models\Link; use SilverStripe\LinkField\Form\LinkField; +use SilverStripe\LinkField\Form\MultiLinkField; class Page extends SiteTree { @@ -50,15 +39,22 @@ class Page extends SiteTree 'HasOneLink' => Link::class, ]; + private static $has_many = [ + 'HasManyLinks' => Link::class + ]; + public function getCMSFields() { $fields = parent::getCMSFields(); + // Don't forget to remove the auto-scaffolded fields! + $fields->removeByName(['HasOneLinkID', 'Links']); + $fields->addFieldsToTab( 'Root.Main', [ LinkField::create('HasOneLink'), - LinkField::create('DbLink'), + MultiLinkField::create('HasManyLinks'), ], ); @@ -67,13 +63,7 @@ class Page extends SiteTree } ``` -## Migrating from Version `1.0.0` or `dev-master` - -Please be aware that in early versions of this module (and in untagged `dev-master`) there were no table names defined -for our `Link` classes. These have now all been defined, which may mean that you need to rename your old tables, or -migrate the data across. - -EG: `SilverStripe_LinkField_Models_Link` needs to be migrated to `LinkField_Link`. +Note that you also need to add a `has_one` relation on the `Link` model to match your `has_many` here. See [official docs about `has_many`](https://docs.silverstripe.org/en/developer_guides/model/relations/#has-many) ## Migrating from Shae Dawson's Linkable module @@ -82,4 +72,4 @@ https://github.com/sheadawson/silverstripe-linkable Shae Dawson's Linkable module was a much loved, and much used module. It is, unfortunately, no longer maintained. We have provided some steps and tasks that we hope can be used to migrate your project from Linkable to LinkField. -* [Migraiton docs](docs/en/linkable-migration.md) +* [Migration docs](docs/en/linkable-migration.md) 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..d63e64dd 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)(0);let _=n;Array.isArray(_)||("number"==typeof _&&0!=_&&(_=[_]),_||(_=[])),(0,r.useEffect)((()=>{if(!v&&_.length>0){const e=[];for(const t of _)e.push(`itemIDs[]=${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(0)},h=e=>{k(0);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 b6d1d77b..4cc94e6a 100644 --- a/client/src/components/LinkField/LinkField.js +++ b/client/src/components/LinkField/LinkField.js @@ -2,9 +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'; @@ -19,36 +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 [typeKey, setTypeKey] = useState(''); +const LinkField = ({ value = null, onChange, types, actions, isMulti = false }) => { const [data, setData] = useState({}); - const [editing, setEditing] = useState(false); + const [editingID, setEditingID] = useState(0); + + // Ensure we have a valid array + let linkIDs = value; + if (!Array.isArray(linkIDs)) { + if (typeof linkIDs === 'number' && linkIDs != 0) { + 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(`itemIDs[]=${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]); /** - * Call back used by LinkModal after the form has been submitted and the response has been received + * Unset the editing ID when the editing modal is closed */ - const onModalSubmit = async (modalData, action, submitFn) => { - const formSchema = await submitFn(); - - // slightly annoyingly, on validation error formSchema at this point will not have an errors node - // instead it will have the original formSchema id used for the GET request to get the formSchema i.e. - // admin/linkfield/schema/linkfield/ - // instead of the one used by the POST submission i.e. - // admin/linkfield/linkForm/ - const hasValidationErrors = formSchema.id.match(/\/schema\/linkfield\/([0-9]+)/); - if (!hasValidationErrors) { - // get link id from formSchema response - const match = formSchema.id.match(/\/linkForm\/([0-9]+)/); - const valueFromSchemaResponse = parseInt(match[1], 10); + const onModalClosed = () => { + setEditingID(0); + }; + /** + * Update the component when the modal successfully saves a link + */ + const onModalSuccess = (value) => { // update component state - setEditing(false); + setEditingID(0); - // 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(valueFromSchemaResponse); + const ids = [...linkIDs]; + if (!ids.includes(value)) { + ids.push(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( @@ -57,15 +87,12 @@ const LinkField = ({ value, onChange, types, actions }) => { 'Saved link', ) ); - } - - return Promise.resolve(); - }; + } /** - * 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 = () => { + 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') }) @@ -87,69 +114,65 @@ const LinkField = ({ value, onChange, types, actions }) => { }); // update component state - setTypeKey(''); - 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(typeKey) ? types[typeKey] : {}; - const modalType = typeKey ? types[typeKey] : type; - const handlerName = modalType && modalType.hasOwnProperty('handlerName') - ? modalType.handlerName - : 'FormBuilderModal'; - const LinkModal = loadComponent(`LinkModal.${handlerName}`); - - const pickerProps = { - title, - description: data.description, - typeTitle: type.title || '', - onEdit: () => { - setEditing(true); - }, - onClear, - onSelect: (key) => { - setTypeKey(key); - setEditing(true); - }, - types: Object.values(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, - editing, - onSubmit: onModalSubmit, - onClosed: () => { - setEditing(false); - }, - 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); - setTypeKey(responseJson.typeKey); - }); - } - }, [editing, linkID]); + const renderPicker = isMulti || Object.keys(data).length === 0; + const renderModal = Boolean(editingID); return <> - - + { 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 ec1cc499..4d518ec8 100644 --- a/client/src/components/LinkModal/LinkModal.js +++ b/client/src/components/LinkModal/LinkModal.js @@ -17,13 +17,37 @@ const buildSchemaUrl = (typeKey, linkID) => { return url.format({ ...parsedURL, search: qs.stringify(parsedQs)}); } -const LinkModal = ({ typeTitle, typeKey, linkID, editing, onSubmit, onClosed}) => { +const LinkModal = ({ typeTitle, typeKey, linkID = 0, isOpen, onSuccess, onClosed}) => { if (!typeKey) { return false; } + + /** + * Call back used by LinkModal after the form has been submitted and the response has been received + */ + const onSubmit = async (modalData, action, submitFn) => { + const formSchema = await submitFn(); + + // slightly annoyingly, on validation error formSchema at this point will not have an errors node + // instead it will have the original formSchema id used for the GET request to get the formSchema i.e. + // admin/linkfield/schema/linkfield/ + // instead of the one used by the POST submission i.e. + // admin/linkfield/linkForm/ + const hasValidationErrors = formSchema.id.match(/\/schema\/linkfield\/([0-9]+)/); + if (!hasValidationErrors) { + // get link id from formSchema response + const match = formSchema.id.match(/\/linkForm\/([0-9]+)/); + const valueFromSchemaResponse = parseInt(match[1], 10); + + onSuccess(valueFromSchemaResponse); + } + + return Promise.resolve(); + }; + return ( -
- {!typeTitle && } - {typeTitle && onEdit()} - />} -
-); +import LinkType from 'types/LinkType'; +import LinkModalContainer from 'containers/LinkModalContainer'; + +/** + * 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(''); + + /** + * When a link type is selected, set the type key so we can open the modal. + */ + const handleSelect = (key) => { + setTypeKey(key); + } + + /** + * Callback for when the modal is closed by the user + */ + const handleClosed = () => { + if (typeof onModalClosed === 'function') { + onModalClosed(); + } + setTypeKey(''); + } + + /** + * Callback for when the modal successfully saves a link + */ + const handleSuccess = (value) => { + setTypeKey(''); + onModalSuccess(value); + } + + const shouldOpenModal = typeKey !== ''; + const className = classnames('link-picker', 'form-control'); + const typeArray = Object.values(types); + + return ( +
+ + { shouldOpenModal && + } +
+ ); +}; LinkPicker.propTypes = { - ...LinkPickerMenu.propTypes, - title: PropTypes.string, - description: PropTypes.string, - typeTitle: PropTypes.string.isRequired, - onEdit: PropTypes.func.isRequired, - onClear: PropTypes.func.isRequired, - onSelect: PropTypes.func.isRequired, + types: PropTypes.objectOf(LinkType).isRequired, + onModalSuccess: PropTypes.func.isRequired, + onModalClosed: PropTypes.func, }; export {LinkPicker as Component}; 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 d925f8bc..9e069fca 100644 --- a/client/src/components/LinkPicker/LinkPickerTitle.js +++ b/client/src/components/LinkPicker/LinkPickerTitle.js @@ -12,8 +12,8 @@ const stopPropagation = (fn) => (e) => { fn && fn(); } -const LinkPickerTitle = ({ title, description, typeTitle, onClear, onClick }) => ( -
+const LinkPickerTitle = ({ id, title, description, typeTitle, onClear, onClick }) => ( +
- +
); LinkPickerTitle.propTypes = { + id: PropTypes.number.isRequired, title: PropTypes.string, description: PropTypes.string, typeTitle: PropTypes.string.isRequired, 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 ad7a472f..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,20 +45,30 @@ 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 */ onunmatch() { const Root = this.getRoot(); - Root.unmount(); + if (Root) { + Root.unmount(); + } }, }); }); 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 9e00462c..309d991f 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 { @@ -60,7 +62,7 @@ public function getClientConfig() */ public function linkForm(): Form { - $id = (int) $this->itemIDFromRequest(); + $id = $this->itemIDFromRequest(); if ($id) { $link = Link::get()->byID($id); if (!$link) { @@ -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/ @@ -142,7 +159,7 @@ public function save(array $data, Form $form): HTTPResponse } /** @var Link $link */ - $id = (int) $this->itemIDFromRequest(); + $id = $this->itemIDFromRequest(); if ($id) { // Editing an existing Link $operation = 'edit'; @@ -263,7 +280,7 @@ private function createLinkForm(Link $link, string $operation): Form */ private function linkFromRequest(): Link { - $itemID = (int) $this->itemIDFromRequest(); + $itemID = $this->itemIDFromRequest(); if (!$itemID) { $this->jsonError(404, _t('LinkField.INVALID_ID', 'Invalid ID')); } @@ -274,17 +291,56 @@ 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 */ - private function itemIDFromRequest(): string + private function itemIDFromRequest(): int { $request = $this->getRequest(); $itemID = (string) $request->param('ItemID'); if (!ctype_digit($itemID)) { $this->jsonError(404, _t('LinkField.INVALID_ID', 'Invalid ID')); } - return $itemID; + return (int) $itemID; + } + + /** + * Get the value of the itemID request query string argument + */ + private function itemIDsFromRequest(): array + { + $request = $this->getRequest(); + $itemIDs = $request->getVar('itemIDs'); + + 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; } /** diff --git a/src/Form/LinkField.php b/src/Form/LinkField.php index d7aeb980..e31eab16 100644 --- a/src/Form/LinkField.php +++ b/src/Form/LinkField.php @@ -2,6 +2,7 @@ namespace SilverStripe\LinkField\Form; +use LogicException; use SilverStripe\Forms\FormField; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObjectInterface; @@ -36,7 +37,7 @@ public function saveInto(DataObjectInterface $record) // Check required relation details are available $fieldname = $this->getName(); if (!$fieldname) { - return $this; + throw new LogicException('LinkField must have a name'); } $linkID = $this->dataValue(); @@ -45,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"