From acb9c84297dda1825541c0d6512b7eae349cf3c1 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Tue, 28 Nov 2023 16:50:42 +1300 Subject: [PATCH 1/3] ENH Refactor LinkField in preparation for MultiLinkField --- client/src/components/LinkField/LinkField.js | 79 +++++++------------ client/src/components/LinkModal/LinkModal.js | 36 +++++++-- .../src/components/LinkPicker/LinkPicker.js | 76 ++++++++++++------ .../components/LinkPicker/LinkPickerTitle.js | 27 ++++--- client/src/entwine/LinkField.js | 4 +- src/Controllers/LinkFieldController.php | 10 +-- src/Form/LinkField.php | 3 +- 7 files changed, 139 insertions(+), 96 deletions(-) diff --git a/client/src/components/LinkField/LinkField.js b/client/src/components/LinkField/LinkField.js index b6d1d77b..a6c1d67b 100644 --- a/client/src/components/LinkField/LinkField.js +++ b/client/src/components/LinkField/LinkField.js @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; import { injectGraphql, loadComponent } from 'lib/Injector'; import fieldHolder from 'components/FieldHolder/FieldHolder'; import LinkPicker from 'components/LinkPicker/LinkPicker'; +import LinkPickerTitle from 'components/LinkPicker/LinkPickerTitle'; import * as toastsActions from 'state/toasts/ToastsActions'; import backend from 'lib/Backend'; import Config from 'lib/Config'; @@ -22,33 +23,20 @@ const section = 'SilverStripe\\LinkField\\Controllers\\LinkFieldController'; */ const LinkField = ({ value, onChange, types, actions }) => { const linkID = value; - const [typeKey, setTypeKey] = useState(''); const [data, setData] = useState({}); const [editing, setEditing] = useState(false); - /** - * Call back used by LinkModal after the form has been submitted and the response has been received - */ - 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 = () => { + setEditing(false); + }; + const onModalSuccess = (value) => { // update component state setEditing(false); // 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); + onChange(value); // success toast actions.toasts.success( @@ -57,16 +45,13 @@ const LinkField = ({ value, onChange, types, actions }) => { 'Saved link', ) ); - } - - return Promise.resolve(); - }; + } /** * Call back used by LinkPicker when the 'Clear' button is clicked */ - const onClear = () => { - const endpoint = `${Config.getSection(section).form.linkForm.deleteUrl}/${linkID}`; + const onClear = (id) => { + const endpoint = `${Config.getSection(section).form.linkForm.deleteUrl}/${id}`; // CSRF token 'X-SecurityID' headers needs to be present for destructive requests backend.delete(endpoint, {}, { 'X-SecurityID': Config.get('SecurityID') }) .then(() => { @@ -87,7 +72,6 @@ const LinkField = ({ value, onChange, types, actions }) => { }); // update component state - setTypeKey(''); setData({}); // update parent JsonField data ID used to update the underlying form field @@ -95,36 +79,24 @@ const LinkField = ({ value, onChange, types, actions }) => { }; 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 + 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 = { - title, - description: data.description, - typeTitle: type.title || '', - onEdit: () => { - setEditing(true); - }, - onClear, - onSelect: (key) => { - setTypeKey(key); - setEditing(true); - }, - types: Object.values(types) + onModalSuccess, + onModalClosed, + types }; const modalProps = { typeTitle: type.title || '', - typeKey, - editing, - onSubmit: onModalSubmit, - onClosed: () => { - setEditing(false); - }, + typeKey: data.typeKey, + isOpen: editing, + onSuccess: onModalSuccess, + onClosed: onModalClosed, linkID }; @@ -136,14 +108,21 @@ const LinkField = ({ value, onChange, types, actions }) => { .then(response => response.json()) .then(responseJson => { setData(responseJson); - setTypeKey(responseJson.typeKey); }); } }, [editing, linkID]); return <> - - + {!type.title && } + {type.title && { setEditing(true); }} + />} + { editing && } ; }; diff --git a/client/src/components/LinkModal/LinkModal.js b/client/src/components/LinkModal/LinkModal.js index ec1cc499..734e974e 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'; + +const LinkPicker = ({ types, onSelect, onModalSuccess, onModalClosed }) => { + const [typeKey, setTypeKey] = useState(''); + + const doSelect = (key) => { + if (typeof onSelect === 'function') { + onSelect(key); + } + setTypeKey(key); + } + + const onClosed = () => { + if (typeof onModalClosed === 'function') { + onModalClosed(); + } + setTypeKey(''); + } + + const onSuccess = (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, + }; + + return ( +
+ + { isOpen && } +
+ ); +}; 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, + onSelect: PropTypes.func, + onModalSuccess: PropTypes.func.isRequired, + onModalClosed: PropTypes.func, }; export {LinkPicker as Component}; diff --git a/client/src/components/LinkPicker/LinkPickerTitle.js b/client/src/components/LinkPicker/LinkPickerTitle.js index d925f8bc..d7e26859 100644 --- a/client/src/components/LinkPicker/LinkPickerTitle.js +++ b/client/src/components/LinkPicker/LinkPickerTitle.js @@ -12,22 +12,25 @@ 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/entwine/LinkField.js b/client/src/entwine/LinkField.js index ad7a472f..dd7d84db 100644 --- a/client/src/entwine/LinkField.js +++ b/client/src/entwine/LinkField.js @@ -58,7 +58,9 @@ jQuery.entwine('ss', ($) => { */ onunmatch() { const Root = this.getRoot(); - Root.unmount(); + if (Root) { + Root.unmount(); + } }, }); }); diff --git a/src/Controllers/LinkFieldController.php b/src/Controllers/LinkFieldController.php index 9e00462c..d5490860 100644 --- a/src/Controllers/LinkFieldController.php +++ b/src/Controllers/LinkFieldController.php @@ -60,7 +60,7 @@ public function getClientConfig() */ public function linkForm(): Form { - $id = (int) $this->itemIDFromRequest(); + $id = $this->itemIDFromRequest(); if ($id) { $link = Link::get()->byID($id); if (!$link) { @@ -142,7 +142,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 +263,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')); } @@ -277,14 +277,14 @@ private function linkFromRequest(): Link /** * 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; } /** diff --git a/src/Form/LinkField.php b/src/Form/LinkField.php index d7aeb980..87c42ee8 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(); From 1be5f0d456f2296bf460960c51482e0ff1af2761 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Tue, 28 Nov 2023 16:51:13 +1300 Subject: [PATCH 2/3] 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..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 a6c1d67b..4cc94e6a 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(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]); + + /** + * Unset the editing ID when the editing modal is closed + */ const onModalClosed = () => { - setEditing(false); + setEditingID(0); }; + /** + * Update the component when the modal successfully saves a link + */ const onModalSuccess = (value) => { // update component state - setEditing(false); + setEditingID(0); + + 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..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 { @@ -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('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; + } + /** * 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" From cc21f8acc3fe3b0ba04b6585763372596aca1065 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Wed, 29 Nov 2023 14:48:08 +1300 Subject: [PATCH 3/3] DOC Update README --- README.md | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) 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)