diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..2004851c --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,10 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + ci: + uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1 diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index cef78936..00000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,12 +0,0 @@ -inherit: true - -tools: - external_code_coverage: false - -checks: - php: - code_rating: true - duplication: true - -filter: - paths: [code/*, tests/*] diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 187d7832..00000000 --- a/.travis.yml +++ /dev/null @@ -1,39 +0,0 @@ -version: ~> 1.0 - -import: - - silverstripe/silverstripe-travis-shared:config/provision/standard.yml - -jobs: - include: - - php: 7.3 - env: - - DB=MYSQL - - REQUIRE_INSTALLER="$REQUIRE_RECIPE 4.x-dev" - - PHPUNIT_TEST=1 - - PHPCS_TEST=1 - - PHPSTAN_TEST=1 - - php: 7.3 - env: - - DB=PGSQL - - REQUIRE_INSTALLER="$REQUIRE_RECIPE 4.x-dev" - - PHPUNIT_TEST=1 - - COW_TEST=1 - - php: 7.4 - env: - - DB=MYSQL - - PDO=1 - - REQUIRE_INSTALLER="$REQUIRE_RECIPE 4.x-dev" - - PHPUNIT_COVERAGE_TEST=1 - - php: 7.4 - env: - - DB=MYSQL - - REQUIRE_INSTALLER="$REQUIRE_RECIPE 4.x-dev" - - PHPUNIT_TEST=1 - - REQUIRE_GRAPHQL="4.x-dev" - - php: 8.0 - env: - - DB=MYSQL - - REQUIRE_INSTALLER="$REQUIRE_RECIPE 4.x-dev" - - PHPUNIT_TEST=1 - - REQUIRE_GRAPHQL="4.x-dev" - - COMPOSER_INSTALL_ARG=--ignore-platform-reqs diff --git a/README.md b/README.md index 5d822803..b8d79420 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,41 @@ # Silverstripe link module -Experimental module looking at how we could implement a link field and a link data object. +This module provides a Link model and CMS interface for managing different types of links. Including: + +* Emails +* External links +* Links to pages within the CMS +* Links to assets within the CMS +* Phone numbers ## Installation Installation via composer. -### Stable version (GraphQL v3) - -`composer require silverstripe/linkfield 1.x-dev` - -### Experimental version (GraphQL v4) +### GraphQL v4 - Silverstripe 4 -`composer require silverstripe/linkfield 2.x-dev` +`composer require silverstripe/linkfield` -### Known issues +### GraphQL v3 - Silverstripe 4 -You may need to add the repository URL into your `composer.json` via the `repositories` field (example below). - -```json -"repositories": { - "silverstripe/linkfield": { - "type": "git", - "url": "https://github.com/silverstripe/silverstripe-linkfield.git" - } -}, -``` +`composer require silverstripe/linkfield:^1` ## Sample usage ```php DBLink::class ]; - private static $has_one = [ + private static array $has_one = [ 'HasOneLink' => Link::class, ]; @@ -50,10 +43,32 @@ class Page extends SiteTree { $fields = parent::getCMSFields(); - $fields->insertBefore('Title', LinkField::create('HasOneLink')); - $fields->insertBefore('Title', LinkField::create('DbLink')); + $fields->addFieldsToTab( + 'Root.Main', + [ + LinkField::create('HasOneLink'), + LinkField::create('DbLink'), + ] + ) return $fields; } } ``` + +## 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`. + +## Migrating from Shae Dawson's Linkable module + +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) diff --git a/_config/config.yml b/_config/config.yml index 4de82235..2875c0b8 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -4,16 +4,16 @@ Name: linkfield SilverStripe\Admin\LeftAndMain: extensions: - - SilverStripe\Link\Extensions\LeftAndMain + - SilverStripe\LinkField\Extensions\LeftAndMain SilverStripe\Admin\ModalController: extensions: - - SilverStripe\Link\Extensions\ModalController + - SilverStripe\LinkField\Extensions\ModalController SilverStripe\Forms\TreeDropdownField: extensions: - - SilverStripe\Link\Extensions\AjaxField + - SilverStripe\LinkField\Extensions\AjaxField SilverStripe\CMS\Forms\AnchorSelectorField: extensions: - - SilverStripe\Link\Extensions\AjaxField + - SilverStripe\LinkField\Extensions\AjaxField diff --git a/_config/types.yml b/_config/types.yml index ea32716c..29b5dcbb 100644 --- a/_config/types.yml +++ b/_config/types.yml @@ -2,17 +2,20 @@ Name: linkfield-types --- -SilverStripe\Link\Type\Registry: +SilverStripe\LinkField\Type\Registry: types: cms: - classname: SilverStripe\Link\Models\SiteTreeLink + classname: SilverStripe\LinkField\Models\SiteTreeLink enabled: true external: - classname: SilverStripe\Link\Models\ExternalLink + classname: SilverStripe\LinkField\Models\ExternalLink enabled: true file: - classname: SilverStripe\Link\Models\FileLink + classname: SilverStripe\LinkField\Models\FileLink enabled: true email: - classname: SilverStripe\Link\Models\EmailLink + classname: SilverStripe\LinkField\Models\EmailLink + enabled: true + phone: + classname: SilverStripe\LinkField\Models\PhoneLink enabled: true diff --git a/_graphql/queries.yml b/_graphql/queries.yml index 703ab009..9775dadb 100644 --- a/_graphql/queries.yml +++ b/_graphql/queries.yml @@ -1,6 +1,6 @@ 'readLinkDescription(dataStr: String!)': type: LinkDescription - resolver: ['SilverStripe\Link\GraphQL\LinkDescriptionResolver', 'resolve'] + resolver: ['SilverStripe\LinkField\GraphQL\LinkDescriptionResolver', 'resolve'] 'readLinkTypes(keys: [ID])': type: '[LinkType]' - resolver: ['SilverStripe\Link\GraphQL\LinkTypeResolver', 'resolve'] + resolver: ['SilverStripe\LinkField\GraphQL\LinkTypeResolver', 'resolve'] diff --git a/client/dist/js/bundle.js b/client/dist/js/bundle.js index 49fabd9f..5329a554 100644 --- a/client/dist/js/bundle.js +++ b/client/dist/js/bundle.js @@ -1,1136 +1 @@ -/******/ (function(modules) { // webpackBootstrap -/******/ // The module cache -/******/ var installedModules = {}; -/******/ -/******/ // The require function -/******/ function __webpack_require__(moduleId) { -/******/ -/******/ // Check if module is in cache -/******/ if(installedModules[moduleId]) { -/******/ return installedModules[moduleId].exports; -/******/ } -/******/ // Create a new module (and put it into the cache) -/******/ var module = installedModules[moduleId] = { -/******/ i: moduleId, -/******/ l: false, -/******/ exports: {} -/******/ }; -/******/ -/******/ // Execute the module function -/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); -/******/ -/******/ // Flag the module as loaded -/******/ module.l = true; -/******/ -/******/ // Return the exports of the module -/******/ return module.exports; -/******/ } -/******/ -/******/ -/******/ // expose the modules object (__webpack_modules__) -/******/ __webpack_require__.m = modules; -/******/ -/******/ // expose the module cache -/******/ __webpack_require__.c = installedModules; -/******/ -/******/ // identity function for calling harmony imports with the correct context -/******/ __webpack_require__.i = function(value) { return value; }; -/******/ -/******/ // define getter function for harmony exports -/******/ __webpack_require__.d = function(exports, name, getter) { -/******/ if(!__webpack_require__.o(exports, name)) { -/******/ Object.defineProperty(exports, name, { -/******/ configurable: false, -/******/ enumerable: true, -/******/ get: getter -/******/ }); -/******/ } -/******/ }; -/******/ -/******/ // getDefaultExport function for compatibility with non-harmony modules -/******/ __webpack_require__.n = function(module) { -/******/ var getter = module && module.__esModule ? -/******/ function getDefault() { return module['default']; } : -/******/ function getModuleExports() { return module; }; -/******/ __webpack_require__.d(getter, 'a', getter); -/******/ return getter; -/******/ }; -/******/ -/******/ // Object.prototype.hasOwnProperty.call -/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; -/******/ -/******/ // __webpack_public_path__ -/******/ __webpack_require__.p = ""; -/******/ -/******/ // Load entry module and return exports -/******/ return __webpack_require__(__webpack_require__.s = "./client/src/bundles/bundle.js"); -/******/ }) -/************************************************************************/ -/******/ ({ - -/***/ "./client/src/boot/index.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -var _Config = __webpack_require__(6); - -var _Config2 = _interopRequireDefault(_Config); - -var _registerReducers = __webpack_require__("./client/src/boot/registerReducers.js"); - -var _registerReducers2 = _interopRequireDefault(_registerReducers); - -var _registerComponents = __webpack_require__("./client/src/boot/registerComponents.js"); - -var _registerComponents2 = _interopRequireDefault(_registerComponents); - -var _registerQueries = __webpack_require__("./client/src/boot/registerQueries.js"); - -var _registerQueries2 = _interopRequireDefault(_registerQueries); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -document.addEventListener('DOMContentLoaded', function () { - (0, _registerComponents2.default)(); - - (0, _registerQueries2.default)(); - - (0, _registerReducers2.default)(); -}); - -/***/ }), - -/***/ "./client/src/boot/registerComponents.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _Injector = __webpack_require__(0); - -var _Injector2 = _interopRequireDefault(_Injector); - -var _LinkPicker = __webpack_require__("./client/src/components/LinkPicker/LinkPicker.js"); - -var _LinkPicker2 = _interopRequireDefault(_LinkPicker); - -var _LinkField = __webpack_require__("./client/src/components/LinkField/LinkField.js"); - -var _LinkField2 = _interopRequireDefault(_LinkField); - -var _LinkModal = __webpack_require__("./client/src/components/LinkModal/LinkModal.js"); - -var _LinkModal2 = _interopRequireDefault(_LinkModal); - -var _FileLinkModal = __webpack_require__("./client/src/components/LinkModal/FileLinkModal.js"); - -var _FileLinkModal2 = _interopRequireDefault(_FileLinkModal); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -var registerComponents = function registerComponents() { - _Injector2.default.component.registerMany({ - LinkPicker: _LinkPicker2.default, - LinkField: _LinkField2.default, - 'LinkModal.FormBuilderModal': _LinkModal2.default, - 'LinkModal.InsertMediaModal': _FileLinkModal2.default - }); -}; - -exports.default = registerComponents; - -/***/ }), - -/***/ "./client/src/boot/registerQueries.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _Injector = __webpack_require__(0); - -var _Injector2 = _interopRequireDefault(_Injector); - -var _readLinkTypes = __webpack_require__("./client/src/state/linkTypes/readLinkTypes.js"); - -var _readLinkTypes2 = _interopRequireDefault(_readLinkTypes); - -var _readLinkDescription = __webpack_require__("./client/src/state/linkDescription/readLinkDescription.js"); - -var _readLinkDescription2 = _interopRequireDefault(_readLinkDescription); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -var registerQueries = function registerQueries() { - _Injector2.default.query.register('readLinkTypes', _readLinkTypes2.default); - _Injector2.default.query.register('readLinkDescription', _readLinkDescription2.default); -}; -exports.default = registerQueries; - -/***/ }), - -/***/ "./client/src/boot/registerReducers.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _Injector = __webpack_require__(0); - -var _Injector2 = _interopRequireDefault(_Injector); - -var _redux = __webpack_require__(8); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -var registerReducers = function registerReducers() {}; - -exports.default = registerReducers; - -/***/ }), - -/***/ "./client/src/bundles/bundle.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -__webpack_require__("./client/src/boot/index.js"); -__webpack_require__("./client/src/entwine/JsonField.js"); - -/***/ }), - -/***/ "./client/src/components/LinkField/LinkField.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; - -var _slicedToArray = function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; }(); - -var _i18n = __webpack_require__(3); - -var _i18n2 = _interopRequireDefault(_i18n); - -var _react = __webpack_require__(1); - -var _react2 = _interopRequireDefault(_react); - -var _reactRedux = __webpack_require__(7); - -var _redux = __webpack_require__(8); - -var _reactApollo = __webpack_require__(13); - -var _Injector = __webpack_require__(0); - -var _FieldHolder = __webpack_require__(9); - -var _FieldHolder2 = _interopRequireDefault(_FieldHolder); - -var _propTypes = __webpack_require__(2); - -var _propTypes2 = _interopRequireDefault(_propTypes); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -function _objectWithoutProperties(obj, keys) { var target = {}; for (var i in obj) { if (keys.indexOf(i) >= 0) continue; if (!Object.prototype.hasOwnProperty.call(obj, i)) continue; target[i] = obj[i]; } return target; } - -var LinkField = function LinkField(_ref) { - var id = _ref.id, - loading = _ref.loading, - Loading = _ref.Loading, - data = _ref.data, - LinkPicker = _ref.LinkPicker, - onChange = _ref.onChange, - types = _ref.types, - linkDescription = _ref.linkDescription, - props = _objectWithoutProperties(_ref, ['id', 'loading', 'Loading', 'data', 'LinkPicker', 'onChange', 'types', 'linkDescription']); - - if (loading) { - return _react2.default.createElement(Loading, null); - } - - var _useState = (0, _react.useState)(false), - _useState2 = _slicedToArray(_useState, 2), - editing = _useState2[0], - setEditing = _useState2[1]; - - var _useState3 = (0, _react.useState)(''), - _useState4 = _slicedToArray(_useState3, 2), - newTypeKey = _useState4[0], - setNewTypeKey = _useState4[1]; - - var onClear = function onClear(event) { - typeof onChange === 'function' && onChange(event, { id: id, value: {} }); - }; - - var typeKey = data.typeKey; - - var type = types[typeKey]; - var modalType = newTypeKey ? types[newTypeKey] : type; - - var linkProps = { - title: data ? data.Title : '', - link: type ? { type: type, title: data.Title, description: linkDescription } : undefined, - onEdit: function onEdit() { - setEditing(true); - }, - onClear: onClear, - onSelect: function onSelect(key) { - setNewTypeKey(key); - setEditing(true); - }, - types: Object.values(types) - }; - - var onModalSubmit = function onModalSubmit(data, action, submitFn) { - console.dir({ data: data, action: action, submitFn: submitFn, onChange: onChange }); - - var SecurityID = data.SecurityID, - action_insert = data.action_insert, - value = _objectWithoutProperties(data, ['SecurityID', 'action_insert']); - - typeof onChange === 'function' && onChange(event, { id: id, value: value }); - setEditing(false); - setNewTypeKey(''); - return Promise.resolve(); - }; - - var modalProps = { - type: modalType, - editing: editing, - onSubmit: onModalSubmit, - onClosed: function onClosed() { - setEditing(false); - }, - data: data - }; - - var handlerName = modalType ? modalType.handlerName : 'FormBuilderModal'; - var LinkModal = (0, _Injector.loadComponent)('LinkModal.' + handlerName); - - return _react2.default.createElement( - _react.Fragment, - null, - _react2.default.createElement(LinkPicker, linkProps), - _react2.default.createElement(LinkModal, modalProps) - ); -}; - -var stringifyData = function stringifyData(Component) { - return function (_ref2) { - var data = _ref2.data, - value = _ref2.value, - props = _objectWithoutProperties(_ref2, ['data', 'value']); - - var dataValue = value || data; - if (typeof dataValue === 'string') { - dataValue = JSON.parse(dataValue); - } - return _react2.default.createElement(Component, _extends({ dataStr: JSON.stringify(dataValue) }, props, { data: dataValue })); - }; -}; - -exports.default = (0, _redux.compose)((0, _Injector.inject)(['LinkPicker', 'Loading']), (0, _Injector.injectGraphql)('readLinkTypes'), stringifyData, (0, _Injector.injectGraphql)('readLinkDescription'), _reactApollo.withApollo, _FieldHolder2.default)(LinkField); - -/***/ }), - -/***/ "./client/src/components/LinkModal/FileLinkModal.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; - -var _i18n = __webpack_require__(3); - -var _i18n2 = _interopRequireDefault(_i18n); - -var _react = __webpack_require__(1); - -var _react2 = _interopRequireDefault(_react); - -var _InsertMediaModal = __webpack_require__(11); - -var _InsertMediaModal2 = _interopRequireDefault(_InsertMediaModal); - -var _reactRedux = __webpack_require__(7); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -function _objectWithoutProperties(obj, keys) { var target = {}; for (var i in obj) { if (keys.indexOf(i) >= 0) continue; if (!Object.prototype.hasOwnProperty.call(obj, i)) continue; target[i] = obj[i]; } return target; } - -var FileLinkModal = function FileLinkModal(_ref) { - var type = _ref.type, - editing = _ref.editing, - data = _ref.data, - actions = _ref.actions, - onSubmit = _ref.onSubmit, - props = _objectWithoutProperties(_ref, ['type', 'editing', 'data', 'actions', 'onSubmit']); - - if (!type) { - return false; - } - - (0, _react.useEffect)(function () { - if (editing) { - actions.initModal(); - } else { - actions.reset(); - } - }, [editing]); - - var attrs = data ? { - ID: data.FileID, - Description: data.Title, - TargetBlank: data.OpenInNew ? true : false - } : {}; - - var onInsert = function onInsert(_ref2) { - var ID = _ref2.ID, - Description = _ref2.Description, - TargetBlank = _ref2.TargetBlank; - - return onSubmit({ - FileID: ID, - Title: Description, - OpenInNew: TargetBlank, - typeKey: type.key - }, '', function () {}); - }; - - return _react2.default.createElement(_InsertMediaModal2.default, _extends({ - isOpen: editing, - type: 'insert-link', - title: false, - bodyClassName: 'modal__dialog', - className: 'insert-link__dialog-wrapper--internal', - fileAttributes: attrs, - onInsert: onInsert - }, props)); -}; - -function mapStateToProps() { - return {}; -} - -function mapDispatchToProps(dispatch) { - return { - actions: { - initModal: function initModal() { - return dispatch({ - type: 'INIT_FORM_SCHEMA_STACK', - payload: { formSchema: { type: 'insert-link', nextType: 'admin' } } - }); - }, - reset: function reset() { - return dispatch({ type: 'RESET' }); - } - } - }; -} - -exports.default = (0, _reactRedux.connect)(mapStateToProps, mapDispatchToProps)(FileLinkModal); - -/***/ }), - -/***/ "./client/src/components/LinkModal/LinkModal.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; - -var _i18n = __webpack_require__(3); - -var _i18n2 = _interopRequireDefault(_i18n); - -var _react = __webpack_require__(1); - -var _react2 = _interopRequireDefault(_react); - -var _propTypes = __webpack_require__(2); - -var _propTypes2 = _interopRequireDefault(_propTypes); - -var _FormBuilderModal = __webpack_require__(10); - -var _FormBuilderModal2 = _interopRequireDefault(_FormBuilderModal); - -var _url = __webpack_require__(12); - -var _url2 = _interopRequireDefault(_url); - -var _qs = __webpack_require__(16); - -var _qs2 = _interopRequireDefault(_qs); - -var _Config = __webpack_require__(6); - -var _Config2 = _interopRequireDefault(_Config); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -function _objectWithoutProperties(obj, keys) { var target = {}; for (var i in obj) { if (keys.indexOf(i) >= 0) continue; if (!Object.prototype.hasOwnProperty.call(obj, i)) continue; target[i] = obj[i]; } return target; } - -var leftAndMain = 'SilverStripe\\Admin\\LeftAndMain'; - -var buildSchemaUrl = function buildSchemaUrl(key, data) { - var schemaUrl = _Config2.default.getSection(leftAndMain).form.DynamicLink.schemaUrl; - - var parsedURL = _url2.default.parse(schemaUrl); - var parsedQs = _qs2.default.parse(parsedURL.query); - parsedQs.key = key; - if (data) { - parsedQs.data = JSON.stringify(data); - } - return _url2.default.format(_extends({}, parsedURL, { search: _qs2.default.stringify(parsedQs) })); -}; - -var LinkModal = function LinkModal(_ref) { - var type = _ref.type, - editing = _ref.editing, - data = _ref.data, - props = _objectWithoutProperties(_ref, ['type', 'editing', 'data']); - - if (!type) { - return false; - } - - return _react2.default.createElement(_FormBuilderModal2.default, _extends({ - title: type.title, - isOpen: editing, - schemaUrl: buildSchemaUrl(type.key, data), - identifier: 'Link.EditingLinkInfo' - }, props)); -}; - -exports.default = LinkModal; - -/***/ }), - -/***/ "./client/src/components/LinkPicker/LinkPicker.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.Component = undefined; - -var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; - -var _i18n = __webpack_require__(3); - -var _i18n2 = _interopRequireDefault(_i18n); - -var _react = __webpack_require__(1); - -var _react2 = _interopRequireDefault(_react); - -var _Injector = __webpack_require__(0); - -var _propTypes = __webpack_require__(2); - -var _propTypes2 = _interopRequireDefault(_propTypes); - -var _reactstrap = __webpack_require__(4); - -var _classnames = __webpack_require__(5); - -var _classnames2 = _interopRequireDefault(_classnames); - -var _LinkPickerMenu = __webpack_require__("./client/src/components/LinkPicker/LinkPickerMenu.js"); - -var _LinkPickerMenu2 = _interopRequireDefault(_LinkPickerMenu); - -var _LinkPickerTitle = __webpack_require__("./client/src/components/LinkPicker/LinkPickerTitle.js"); - -var _LinkPickerTitle2 = _interopRequireDefault(_LinkPickerTitle); - -var _LinkType = __webpack_require__("./client/src/types/LinkType.js"); - -var _LinkType2 = _interopRequireDefault(_LinkType); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -var LinkPicker = function LinkPicker(_ref) { - var types = _ref.types, - onSelect = _ref.onSelect, - link = _ref.link, - onEdit = _ref.onEdit, - onClear = _ref.onClear; - return _react2.default.createElement( - 'div', - { - className: (0, _classnames2.default)('link-picker', 'form-control', { 'link-picker--selected': link }) }, - link === undefined && _react2.default.createElement(_LinkPickerMenu2.default, { types: types, onSelect: onSelect }), - link && _react2.default.createElement(_LinkPickerTitle2.default, _extends({}, link, { onClear: onClear, onClick: function onClick() { - return link && onEdit && onEdit(link); - } })) - ); -}; - -LinkPicker.propTypes = _extends({}, _LinkPickerMenu2.default.propTypes, { - link: _propTypes2.default.shape(_LinkPickerTitle2.default.propTypes), - onEdit: _propTypes2.default.func, - onClear: _propTypes2.default.func -}); - -exports.Component = LinkPicker; -exports.default = LinkPicker; - -/***/ }), - -/***/ "./client/src/components/LinkPicker/LinkPickerMenu.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _slicedToArray = function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; }(); - -var _i18n = __webpack_require__(3); - -var _i18n2 = _interopRequireDefault(_i18n); - -var _react = __webpack_require__(1); - -var _react2 = _interopRequireDefault(_react); - -var _Injector = __webpack_require__(0); - -var _propTypes = __webpack_require__(2); - -var _propTypes2 = _interopRequireDefault(_propTypes); - -var _reactstrap = __webpack_require__(4); - -var _classnames = __webpack_require__(5); - -var _classnames2 = _interopRequireDefault(_classnames); - -var _LinkType = __webpack_require__("./client/src/types/LinkType.js"); - -var _LinkType2 = _interopRequireDefault(_LinkType); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -var LinkPickerMenu = function LinkPickerMenu(_ref) { - var types = _ref.types, - onSelect = _ref.onSelect; - - var _useState = (0, _react.useState)(false), - _useState2 = _slicedToArray(_useState, 2), - isOpen = _useState2[0], - setIsOpen = _useState2[1]; - - var toggle = function toggle() { - return setIsOpen(function (prevState) { - return !prevState; - }); - }; - - return _react2.default.createElement( - _reactstrap.Dropdown, - { - isOpen: isOpen, - toggle: toggle, - className: 'link-picker__menu' - }, - _react2.default.createElement( - _reactstrap.DropdownToggle, - { className: 'link-picker__menu-toggle font-icon-link', caret: true }, - _i18n2.default._t('Link.ADD_LINK', 'Add Link') - ), - _react2.default.createElement( - _reactstrap.DropdownMenu, - null, - types.map(function (_ref2) { - var key = _ref2.key, - title = _ref2.title; - return _react2.default.createElement( - _reactstrap.DropdownItem, - { key: key, onClick: function onClick() { - return onSelect(key); - } }, - title - ); - }) - ) - ); -}; - -LinkPickerMenu.propTypes = { - types: _propTypes2.default.arrayOf(_LinkType2.default).isRequired, - onSelect: _propTypes2.default.func.isRequired -}; - -exports.default = LinkPickerMenu; - -/***/ }), - -/***/ "./client/src/components/LinkPicker/LinkPickerTitle.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _i18n = __webpack_require__(3); - -var _i18n2 = _interopRequireDefault(_i18n); - -var _react = __webpack_require__(1); - -var _react2 = _interopRequireDefault(_react); - -var _Injector = __webpack_require__(0); - -var _propTypes = __webpack_require__(2); - -var _propTypes2 = _interopRequireDefault(_propTypes); - -var _classnames = __webpack_require__(5); - -var _classnames2 = _interopRequireDefault(_classnames); - -var _LinkType = __webpack_require__("./client/src/types/LinkType.js"); - -var _LinkType2 = _interopRequireDefault(_LinkType); - -var _reactstrap = __webpack_require__(4); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -var stopPropagation = function stopPropagation(fn) { - return function (e) { - console.log('trying to stop propagation'); - e.nativeEvent.stopImmediatePropagation(); - e.preventDefault(); - e.nativeEvent.preventDefault(); - e.stopPropagation(); - fn && fn(); - }; -}; - -var LinkPickerTitle = function LinkPickerTitle(_ref) { - var title = _ref.title, - type = _ref.type, - description = _ref.description, - onClear = _ref.onClear, - onClick = _ref.onClick; - return _react2.default.createElement( - _reactstrap.Button, - { className: 'link-picker__link font-icon-link', color: 'secondary', onClick: stopPropagation(onClick) }, - _react2.default.createElement( - 'div', - { className: 'link-picker__link-detail' }, - _react2.default.createElement( - 'div', - { className: 'link-picker__title' }, - title - ), - _react2.default.createElement( - 'small', - { className: 'link-picker__type' }, - type.title, - ':\xA0', - _react2.default.createElement( - 'span', - { className: 'link-picker__url' }, - description - ) - ) - ), - _react2.default.createElement( - _reactstrap.Button, - { className: 'link-picker__clear', color: 'link', onClick: stopPropagation(onClear) }, - _i18n2.default._t('Link.CLEAR', 'Clear') - ) - ); -}; - -LinkPickerTitle.propTypes = { - title: _propTypes2.default.string.isRequired, - type: _LinkType2.default, - description: _propTypes2.default.string, - onClear: _propTypes2.default.func, - onClick: _propTypes2.default.func -}; - -exports.default = LinkPickerTitle; - -/***/ }), - -/***/ "./client/src/entwine/JsonField.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; - -var _jquery = __webpack_require__(15); - -var _jquery2 = _interopRequireDefault(_jquery); - -var _react = __webpack_require__(1); - -var _react2 = _interopRequireDefault(_react); - -var _reactDom = __webpack_require__(14); - -var _reactDom2 = _interopRequireDefault(_reactDom); - -var _Injector = __webpack_require__(0); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -_jquery2.default.entwine('ss', function ($) { - $('.js-injector-boot .entwine-jsonfield').entwine({ - - Component: null, - - onmatch: function onmatch() { - var cmsContent = this.closest('.cms-content').attr('id'); - var context = cmsContent ? { context: cmsContent } : {}; - - var schemaComponent = this.data('schema-component'); - var ReactField = (0, _Injector.loadComponent)(schemaComponent, context); - - this.setComponent(ReactField); - this._super(); - this.refresh(); - }, - refresh: function refresh() { - var props = this.getProps(); - var ReactField = this.getComponent(); - _reactDom2.default.render(_react2.default.createElement(ReactField, _extends({}, props, { noHolder: true })), this[0]); - }, - handleChange: function handleChange(event, _ref) { - var id = _ref.id, - value = _ref.value; - - var fieldID = $(this).data('field-id'); - $('#' + fieldID).val(JSON.stringify(value)).trigger('change'); - this.refresh(); - }, - getProps: function getProps() { - var fieldID = $(this).data('field-id'); - var dataStr = $('#' + fieldID).val(); - var value = dataStr ? JSON.parse(dataStr) : undefined; - - return { - id: fieldID, - value: value, - onChange: this.handleChange.bind(this) - }; - }, - onunmatch: function onunmatch() { - _reactDom2.default.unmountComponentAtNode(this[0]); - } - }); -}); - -/***/ }), - -/***/ "./client/src/state/linkDescription/readLinkDescription.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _Injector = __webpack_require__(0); - -var apolloConfig = { - props: function props(_props) { - var _props$data = _props.data, - error = _props$data.error, - readLinkDescription = _props$data.readLinkDescription, - networkLoading = _props$data.loading; - - var errors = error && error.graphQLErrors && error.graphQLErrors.map(function (graphQLError) { - return graphQLError.message; - }); - var linkDescription = readLinkDescription ? readLinkDescription.description : ''; - - return { - loading: networkLoading, - linkDescription: linkDescription, - graphQLErrors: errors - }; - } -}; - -var READ = _Injector.graphqlTemplates.READ; - -var query = { - apolloConfig: apolloConfig, - templateName: READ, - pluralName: 'LinkDescription', - pagination: false, - params: { - dataStr: 'String!' - }, - args: { - root: { - dataStr: 'dataStr' - } - }, - fields: ['description'] -}; -exports.default = query; - -/***/ }), - -/***/ "./client/src/state/linkTypes/readLinkTypes.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; - -var _Injector = __webpack_require__(0); - -function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } - -var apolloConfig = { - props: function props(_props) { - var _props$data = _props.data, - error = _props$data.error, - readLinkTypes = _props$data.readLinkTypes, - networkLoading = _props$data.loading; - - var errors = error && error.graphQLErrors && error.graphQLErrors.map(function (graphQLError) { - return graphQLError.message; - }); - - var types = readLinkTypes ? readLinkTypes.reduce(function (accumulator, type) { - return _extends({}, accumulator, _defineProperty({}, type.key, type)); - }, {}) : {}; - - return { - loading: networkLoading, - types: types, - graphQLErrors: errors - }; - } -}; - -var READ = _Injector.graphqlTemplates.READ; - -var query = { - apolloConfig: apolloConfig, - templateName: READ, - pluralName: 'LinkTypes', - pagination: false, - params: { - keys: '[ID]' - }, - args: { - root: { - keys: 'keys' - } - }, - fields: ['key', 'title', 'handlerName'] -}; -exports.default = query; - -/***/ }), - -/***/ "./client/src/types/LinkType.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _propTypes = __webpack_require__(2); - -var _propTypes2 = _interopRequireDefault(_propTypes); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -var LinkType = _propTypes2.default.shape({ - key: _propTypes2.default.string.isRequired, - title: _propTypes2.default.string.isRequired -}); - -exports.default = LinkType; - -/***/ }), - -/***/ 0: -/***/ (function(module, exports) { - -module.exports = Injector; - -/***/ }), - -/***/ 1: -/***/ (function(module, exports) { - -module.exports = React; - -/***/ }), - -/***/ 10: -/***/ (function(module, exports) { - -module.exports = FormBuilderModal; - -/***/ }), - -/***/ 11: -/***/ (function(module, exports) { - -module.exports = InsertMediaModal; - -/***/ }), - -/***/ 12: -/***/ (function(module, exports) { - -module.exports = NodeUrl; - -/***/ }), - -/***/ 13: -/***/ (function(module, exports) { - -module.exports = ReactApollo; - -/***/ }), - -/***/ 14: -/***/ (function(module, exports) { - -module.exports = ReactDom; - -/***/ }), - -/***/ 15: -/***/ (function(module, exports) { - -module.exports = jQuery; - -/***/ }), - -/***/ 16: -/***/ (function(module, exports) { - -module.exports = qs; - -/***/ }), - -/***/ 2: -/***/ (function(module, exports) { - -module.exports = PropTypes; - -/***/ }), - -/***/ 3: -/***/ (function(module, exports) { - -module.exports = i18n; - -/***/ }), - -/***/ 4: -/***/ (function(module, exports) { - -module.exports = Reactstrap; - -/***/ }), - -/***/ 5: -/***/ (function(module, exports) { - -module.exports = classnames; - -/***/ }), - -/***/ 6: -/***/ (function(module, exports) { - -module.exports = Config; - -/***/ }), - -/***/ 7: -/***/ (function(module, exports) { - -module.exports = ReactRedux; - -/***/ }), - -/***/ 8: -/***/ (function(module, exports) { - -module.exports = Redux; - -/***/ }), - -/***/ 9: -/***/ (function(module, exports) { - -module.exports = FieldHolder; - -/***/ }) - -/******/ }); -//# sourceMappingURL=bundle.js.map \ No newline at end of file +!function(e){function t(r){if(n[r])return n[r].exports;var i=n[r]={i:r,l:!1,exports:{}};return e[r].call(i.exports,i,i.exports,t),i.l=!0,i.exports}var n={};t.m=e,t.c=n,t.i=function(e){return e},t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:r})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s="./client/src/bundles/bundle.js")}({"./client/src/boot/index.js":function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}var i=n(6),o=(r(i),n("./client/src/boot/registerReducers.js")),a=r(o),l=n("./client/src/boot/registerComponents.js"),c=r(l),u=n("./client/src/boot/registerQueries.js"),s=r(u);document.addEventListener("DOMContentLoaded",function(){(0,c.default)(),(0,s.default)(),(0,a.default)()})},"./client/src/boot/registerComponents.js":function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var i=n(0),o=r(i),a=n("./client/src/components/LinkPicker/LinkPicker.js"),l=r(a),c=n("./client/src/components/LinkField/LinkField.js"),u=r(c),s=n("./client/src/components/LinkModal/LinkModal.js"),d=r(s),f=n("./client/src/components/LinkModal/FileLinkModal.js"),p=r(f),y=function(){o.default.component.registerMany({LinkPicker:l.default,LinkField:u.default,"LinkModal.FormBuilderModal":d.default,"LinkModal.InsertMediaModal":p.default})};t.default=y},"./client/src/boot/registerQueries.js":function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0});var i=n(0),o=r(i),a=n("./client/src/state/linkTypes/readLinkTypes.js"),l=r(a),c=n("./client/src/state/linkDescription/readLinkDescription.js"),u=r(c),s=function(){o.default.query.register("readLinkTypes",l.default),o.default.query.register("readLinkDescription",u.default)};t.default=s},"./client/src/boot/registerReducers.js":function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=n(0),i=(function(e){e&&e.__esModule}(r),n(8),function(){});t.default=i},"./client/src/bundles/bundle.js":function(e,t,n){"use strict";n("./client/src/boot/index.js"),n("./client/src/entwine/JsonField.js")},"./client/src/components/LinkField/LinkField.js":function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function i(e,t){var n={};for(var r in e)t.indexOf(r)>=0||Object.prototype.hasOwnProperty.call(e,r)&&(n[r]=e[r]);return n}Object.defineProperty(t,"__esModule",{value:!0});var o=Object.assign||function(e){for(var t=1;t=0||Object.prototype.hasOwnProperty.call(e,r)&&(n[r]=e[r]);return n}function o(){return{}}function a(e){return{actions:{initModal:function(){return e({type:"INIT_FORM_SCHEMA_STACK",payload:{formSchema:{type:"insert-link",nextType:"admin"}}})},reset:function(){return e({type:"RESET"})}}}}Object.defineProperty(t,"__esModule",{value:!0});var l=Object.assign||function(e){for(var t=1;t=0||Object.prototype.hasOwnProperty.call(e,r)&&(n[r]=e[r]);return n}Object.defineProperty(t,"__esModule",{value:!0});var o=Object.assign||function(e){for(var t=1;t { + it('sample test', () => { + const css = 'sample css'; + expect(css).toBe('sample css'); + }); +}); diff --git a/client/src/types/LinkType.js b/client/src/types/LinkType.js index b8656a58..c086ec78 100644 --- a/client/src/types/LinkType.js +++ b/client/src/types/LinkType.js @@ -1,3 +1,4 @@ +/* eslint-disable */ import PropTypes from 'prop-types'; const LinkType = PropTypes.shape({ diff --git a/composer.json b/composer.json index e8c1087e..487f21a8 100644 --- a/composer.json +++ b/composer.json @@ -3,14 +3,12 @@ "description": "Add advanced link functionality to Silverstripe.", "type": "silverstripe-vendormodule", "require": { - "silverstripe/admin": "^1.8", - "silverstripe/cms": "^4.8", - "silverstripe/asset-admin": "^1.8", - "silverstripe/graphql": "^4.x-dev", - "silverstripe/vendor-plugin": "^1" + "php": "^7.4 || ^8", + "silverstripe/cms": "^4.11", + "silverstripe/graphql": "^4" }, "require-dev": { - "sminnee/phpunit": "^5.7", + "silverstripe/recipe-testing": "^2", "squizlabs/php_codesniffer": "^3" }, "license": "BSD-3-Clause", @@ -36,8 +34,8 @@ }, "autoload": { "psr-4": { - "SilverStripe\\Link\\": "src/", - "SilverStripe\\Link\\Tests\\": "tests/php/" + "SilverStripe\\LinkField\\": "src/", + "SilverStripe\\LinkField\\Tests\\": "tests/php/" } }, "config": { diff --git a/docs/en/linkable-migration.md b/docs/en/linkable-migration.md new file mode 100644 index 00000000..7ba7b571 --- /dev/null +++ b/docs/en/linkable-migration.md @@ -0,0 +1,194 @@ +# Instructions + +## Preamble + +This migration process covers shifting data from the `Linkable` tables to the appropriate `LinkField` tables. + +This does not cover usages of `EmbeddedObject` (at least, not at this time). + +**Versioned:** If you have `Versioned` `Linkable`, then the expectation is that you will also `Version` `LinkField`. If +you have not `Versioned` `Linkable`, then the expectation is that you will **not** `Version` `LinkField`. + +**No support for internal links with query params (GET params):** Please be aware that Linkfield does not support +internal links with query params (`?`) out of the box, and therefor the migration task will **remove** any query +params that are present in the Linkable's `Anchor` field. + +## Install Silvesrtripe Linkfield + +Install the Silverstripe Linkfield module: + +```bash +$ composer require silverstripe/linkfield 1.x-dev +``` + +Or if you would like the (experimental) GraphQL 4 version: + +```bash +$ composer require silverstripe/linkfield 2.x-dev +``` + +Optionally, you can also remove the Linkable module (though, you might find it useful to keep around as a reference +while you are upgrading your code). + +Do this step at whatever point makes sense to you. + +```bash +$ composer remove sheadawson/silverstripe-linkable +``` + +## Replace app usages + +You should review how you are using the original `Link` model and `LinkField`, but if you don't have any customisations, +then replacing the old with the new **might** be quite simple. + +If you have used imports (`use` statements), then your first step might just be to search for `use [old];` and replace +with `use [new];` (since the class name references have not changed at all). + +Old: `Sheadawson\Linkable\Models\Link` +New: `SilverStripe\LinkField\Models\Link` + +Old: `Sheadawson\Linkable\Forms\LinkField` +New: `SilverStripe\LinkField\Form\LinkField` + +If you have extensions, new fields, etc, then your replacements might need to be a bit more considered. + +The other key (less easy to automate) thing that you'll need to update is that the old `LinkField` required you to +specify the related field with `ID` appended, whereas the new `LinkField` requires you to specify the field without +`ID` appended. EG. + +Old: `LinkField::create('MyLinkID')` +New: `LinkField::create('MyLink')` + +Search for instances of `LinkField::create` and `new LinkField`, and hopefully that should give you all of the places +where you need to update field name references. + +### Configuration + +Be sure to check how the old module classes are referenced in config `yml` files (eg: `app/_config`). Update +appropriately. + +### Populate module + +If you use the populate module, you will not be able to simply "replace" the namespace. Fixture definitions for the +new Linkfield module are quite different. There are entirely different models for different link types, whereas before +it was just a DB field to specify the type. + +## Replace template usages + +Before: You might have had references to `$LinkURL` or `$Link.LinkURL`. +After: These would need to be updated to `$URL` or `$Link.URL` respectively. + +Before: `$OpenInNewWindow` or `$Link.OpenInNewWindow`. +After: `$OpenInNew` or `$Link.OpenInew` respectively. + +Before: `$Link.TargetAttr` or `$TargetAttr` would output the appropriate `target="xx"`. +After: There is no direct replacement. + +This is an area where you should spend some decent effort to make sure each implementation is outputting as you expect +it to. There may be more "handy" methods that Linkable provided that no longer exist (that we haven't covered above). + +## Table structures + +It's important to understand that we are going from a single table in Linkable to multiple tables in LinkField. + +**Before:** We had 1 table with all data, and one of the field in there specified the type of the Link. +**Now:** We have 1 table for each type of Link, with a base `Link` table for all record. + +## Specify any custom configuration + +Have a look at `LinkableMigrationTask`. There are some configuration properties defined in there: + +- `$link_mapping` +- `$email_mapping` +- `$external_mapping` +- `$file_mapping` +- `$phone_mapping` +- `$sitetree_mapping` + +Each of these specifies how an original field from the `LinkableLink` table will map to one of the new LinkField tables. + +If you previously had some custom fields that needed to be available across **all** Link types, then you're (probably) +going to add this as an extension on the (base) `Link` class. This is going to mean that the new fields will be added +to the `LinkField_Link` table. This means that you need to update the configuration for `$link_mapping` so that we +correctly migrate those field values into the `LinkField_Link` table. + +If you had/have a field that you only want displayed on (say) SiteTree links, then you would want to add that extension +to `SiteTreeLink`. This would create new fields in the `LinkField_SiteTreeLink` table, which will mean you need to +also update the config for `$sitetree_mapping`. + +It's important that you get the correct mappings to the correct tables. + +### Linkable `has_one` to one of your other DataObjects + +An example for the above [Specify any custom configuration](#specify-any-custom-configuration) would be that if one +of your DataObjects `has_many` `Link`. This would require there to be a `has_one` on `Link` back to your DataObject. + +Let's say that our `Page` `has_many` `Link`: +```php +class Page extends SiteTree +{ + private static array $has_many = [ + 'Links' => Link::class, + ]; +} +``` + +This would require a corresponding `has_one` on `Link`: +```yaml +Sheadawson\Linkable\Models\Link: + has_one: + ParentPage: Page +``` + +If we inspect the `LinkableLink` table, we'll see that there is a field called `ParentPageID`. We need to tell the +migration task about this field, and where it needs to migrate to. + +Assuming you keep the same relationship name, you'll want to add the following `$link_mapping` configuration: +```yaml +SilverStripe\LinkField\Tasks\LinkableMigrationTask: + link_mapping: + ParentPageID: ParentPageID +``` + +## Adding support for internal links with query params + +No official support is provided, but you can achieve this through adding your own extensions. + +Add a new field to `SiteTreeLink` to store your query params, EG: + +```php +class SiteTreeLinkExtension extends DataExtension +{ + private static array $db = [ + 'QueryParams' => 'Varchar', + ]; +} +``` + +An extension point called `updateGetURLBeforeAnchor` is available: + +```php +class SiteTreeLinkExtension extends DataExtension +{ + ... + + public function updateGetURLBeforeAnchor(&$url): void + { + // Assumes that you save your QueryParams within prepending the ?, so we append it here + $url .= sprintf('?%s', $this->owner->QueryParams); + } +} +``` + +If you also plan to use the `LinkableMigrationTask`, then there is a configuration that you can enable to tell us where +you would like the query params from the old `AnchorLink` to be migrated. + +Please note: The migration task assumes that you will store your query params without prepending the `?` (following +the same paradigm as our `Anchor` field). + +EG: + +```yaml +SilverStripe\LinkField\Tasks\LinkableMigrationTask: + sitetree_query_params_to: QueryParams +``` diff --git a/phpcs.xml.dist b/phpcs.xml.dist index f4615578..980215a8 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -5,6 +5,11 @@ src tests + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 1d2d9665..2fdf2b19 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,10 +1,17 @@ - - - - - tests/php - + + + + + src/ + + + tests/ + + + + + tests/ + + diff --git a/src/Extensions/AjaxField.php b/src/Extensions/AjaxField.php index 03b453f2..7764c65c 100644 --- a/src/Extensions/AjaxField.php +++ b/src/Extensions/AjaxField.php @@ -1,6 +1,6 @@ 'editorAnchorLink', // Matches LeftAndMain::methodSchema args ]; - private static $allowed_actions = array( + private static array $allowed_actions = [ 'DynamicLink', - ); + ]; /** * Builds and returns the external link form @@ -58,6 +58,7 @@ public function DynamicLink() private function getContext(): array { $linkTypeKey = $this->getOwner()->controller->getRequest()->getVar('key'); + if (empty($linkTypeKey)) { throw new HTTPResponse_Exception(sprintf('key is required', __CLASS__), 400); } @@ -84,8 +85,10 @@ private function getData(): array { $data = []; $dataString = $this->getOwner()->controller->getRequest()->getVar('data'); + if ($dataString) { $parsedData = json_decode($dataString, true); + if (json_last_error() === JSON_ERROR_NONE) { $data = $parsedData; } else { diff --git a/src/Form/FormFactory.php b/src/Form/FormFactory.php index 8ceac8fb..2279da2e 100644 --- a/src/Form/FormFactory.php +++ b/src/Form/FormFactory.php @@ -1,11 +1,11 @@ getName(); + if (!$fieldname) { return $this; } @@ -45,6 +47,7 @@ public function saveInto(DataObjectInterface $record) /** @var JsonData|DataObject $jsonDataObject */ $jsonDataObjectID = $record->{"{$fieldname}ID"}; + if ($jsonDataObjectID && $jsonDataObject = $record->$fieldname) { if ($value) { $jsonDataObject = $jsonDataObject->setData($value); diff --git a/src/Form/LinkField.php b/src/Form/LinkField.php index 77377d70..bdf7e9de 100644 --- a/src/Form/LinkField.php +++ b/src/Form/LinkField.php @@ -1,6 +1,6 @@ byKey($data['typeKey']); + if (empty($type)) { return ['description' => '']; } - return ['description' => $type->generateLinkDescription($data)]; } } diff --git a/src/GraphQL/LinkTypeResolver.php b/src/GraphQL/LinkTypeResolver.php index a3cd5b77..a61f28f1 100644 --- a/src/GraphQL/LinkTypeResolver.php +++ b/src/GraphQL/LinkTypeResolver.php @@ -1,18 +1,19 @@ list(); diff --git a/src/JsonData.php b/src/JsonData.php index bf57284d..89ba7201 100644 --- a/src/JsonData.php +++ b/src/JsonData.php @@ -1,15 +1,14 @@ 'Varchar(255)' + private static array $db = [ + 'Email' => 'Varchar(255)', ]; - public function generateLinkDescription(array $data): string { return isset($data['Email']) ? $data['Email'] : ''; } - public function getCMSFields() + public function getCMSFields(): FieldList { $fields = parent::getCMSFields(); diff --git a/src/Models/ExternalLink.php b/src/Models/ExternalLink.php index 8d49ae20..cf9f1b04 100644 --- a/src/Models/ExternalLink.php +++ b/src/Models/ExternalLink.php @@ -1,20 +1,20 @@ 'Varchar' + private static array $db = [ + 'ExternalUrl' => 'Varchar', ]; - public function generateLinkDescription(array $data): string { return isset($data['ExternalUrl']) ? $data['ExternalUrl'] : ''; diff --git a/src/Models/FileLink.php b/src/Models/FileLink.php index e98cf732..0b0d99ae 100644 --- a/src/Models/FileLink.php +++ b/src/Models/FileLink.php @@ -1,24 +1,23 @@ File::class + private static array $has_one = [ + 'File' => File::class, ]; - public function generateLinkDescription(array $data): string { if (empty($data['FileID'])) { @@ -26,6 +25,7 @@ public function generateLinkDescription(array $data): string } $file = File::get()->byID($data['FileID']); + return $file ? $file->getFilename() : ''; } @@ -34,7 +34,7 @@ public function LinkTypeHandlerName(): string return 'InsertMediaModal'; } - public function getURL() + public function getURL(): string { return $this->File ? $this->File->getURL() : ''; } diff --git a/src/Models/Link.php b/src/Models/Link.php index 0ae1fd6c..3dd88933 100644 --- a/src/Models/Link.php +++ b/src/Models/Link.php @@ -1,31 +1,44 @@ 'Varchar', - 'OpenInNew' => 'Boolean' + 'OpenInNew' => 'Boolean', ]; + /** + * In-memory only property used to change link type + * This case is relevant for CMS edit form which doesn't use React driven UI + * This is a workaround as changing the ClassName directly is not fully supported in the GridField admin + */ + private ?string $linkType = null; public function defineLinkTypeRequirements() { @@ -54,11 +67,76 @@ public function scaffoldLinkFields(array $data): FieldList return $this->getCMSFields(); } + /** + * @return FieldList + * @throws ReflectionException + */ + public function getCMSFields(): FieldList + { + $fields = parent::getCMSFields(); + $linkTypes = $this->getLinkTypes(); + + if (static::class === self::class) { + // Add a link type selection field for generic links + $fields->addFieldsToTab( + 'Root.Main', + [ + $linkTypeField = DropdownField::create('LinkType', 'Link Type', $linkTypes), + ], + 'Title' + ); + + $linkTypeField->setEmptyString('-- select type --'); + } + + return $fields; + } + + /** + * @return CompositeValidator + */ + public function getCMSCompositeValidator(): CompositeValidator + { + $validator = parent::getCMSCompositeValidator(); + + if (static::class === self::class) { + // Make Link type mandatory for generic links + $validator->addValidator(RequiredFields::create([ + 'LinkType', + ])); + } + + return $validator; + } + + /** + * Form hook defined in @see Form::saveInto() + * We use this to work with an in-memory only field + * + * @param $value + */ + public function saveLinkType($value) + { + $this->linkType = $value; + } + + public function onBeforeWrite(): void + { + // Detect link type change and update the class accordingly + if ($this->linkType && DataObject::singleton($this->linkType) instanceof Link) { + $this->setClassName($this->linkType); + $this->populateDefaults(); + $this->forceChange(); + } + + parent::onBeforeWrite(); + } function setData($data): JsonData { if (is_string($data)) { $data = json_decode($data, true); + if (json_last_error() !== JSON_ERROR_NONE) { throw new InvalidArgumentException(sprintf( '%s: Decoding json string failred with "%s"', @@ -79,11 +157,13 @@ function setData($data): JsonData } $type = Registry::singleton()->byKey($data['typeKey']); + if (empty($type)) { throw new InvalidArgumentException(sprintf('%s: %s is not a registered Link Type.', __CLASS__, $data['typeKey'])); } $jsonData = $this; + if ($this->ClassName !== get_class($type)) { if ($this->isInDB()) { $jsonData = $this->newClassInstance(get_class($type)); @@ -101,9 +181,11 @@ function setData($data): JsonData return $jsonData; } + #[\ReturnTypeWillChange] public function jsonSerialize() { $typeKey = Registry::singleton()->keyByClassName(static::class); + if (empty($typeKey)) { return []; } @@ -120,11 +202,13 @@ public function jsonSerialize() public function loadLinkData(array $data): JsonData { $link = new static(); + foreach ($data as $key => $value) { if ($link->hasField($key)) { $link->setField($key, $value); } } + return $link; } @@ -140,4 +224,25 @@ public function forTemplate() { return $this->renderWith([self::class]); } + + /** + * Get all link types except the generic one + * + * @throws ReflectionException + */ + private function getLinkTypes(): array + { + $classes = ClassInfo::subclassesFor(self::class); + $types = []; + + foreach ($classes as $class) { + if ($class === self::class) { + continue; + } + + $types[$class] = ClassInfo::shortName($class); + } + + return $types; + } } diff --git a/src/Models/PhoneLink.php b/src/Models/PhoneLink.php new file mode 100644 index 00000000..855e3b1c --- /dev/null +++ b/src/Models/PhoneLink.php @@ -0,0 +1,27 @@ + 'Varchar(255)', + ]; + + public function generateLinkDescription(array $data): string + { + return isset($data['Phone']) ? $data['Phone'] : ''; + } + + public function getURL(): string + { + return $this->Phone ? sprintf('tel:%s', $this->Phone) : ''; + } +} diff --git a/src/Models/SiteTreeLink.php b/src/Models/SiteTreeLink.php index a8ffb65d..a6ee8ce6 100644 --- a/src/Models/SiteTreeLink.php +++ b/src/Models/SiteTreeLink.php @@ -1,41 +1,48 @@ 'Varchar' + private static array $db = [ + 'Anchor' => 'Varchar', ]; - private static $has_one = [ - 'Page' => SiteTree::class + private static array $has_one = [ + 'Page' => SiteTree::class, ]; - public function generateLinkDescription(array $data): string { if (empty($data['PageID'])) { return ''; } + /** @var SiteTree $page */ $page = SiteTree::get()->byID($data['PageID']); - return $page ? $page->URLSegment : ''; + if (!$page || !$page->exists()) { + return ''; + } + + return $page->URLSegment ?: ''; } - public function getCMSFields() + public function getCMSFields(): FieldList { $fields = parent::getCMSFields(); @@ -53,17 +60,59 @@ public function getCMSFields() $fields->insertAfter( 'PageID', AnchorSelectorField::create('Anchor') + ->setDescription('Do not prepend "#". EG: "option1=value&option2=value2"') ); return $fields; } - public function getURL() + public function onBeforeWrite(): void { - $url = $this->Page ? $this->Page->Link() : ''; + parent::onBeforeWrite(); + + $this->populateTitle(); + } + + public function getURL(): string + { + $url = $this->Page() ? $this->Page()->Link() : ''; + + $this->extend('updateGetURLBeforeAnchor', $url); + if ($this->Anchor) { $url .= '#' . $this->Anchor; } + return $url; } + + protected function populateTitle(): void + { + $title = $this->getTitleFromPage(); + $this->extend('updateGetTitleFromPage', $title); + $this->Title = $title; + } + + /** + * Try to populate link title from page title in case we don't have a title yet + * + * @return string|null + */ + protected function getTitleFromPage(): ?string + { + if ($this->Title) { + // If we already have a title, we can just bail out without any changes + return $this->Title; + } + + $page = $this->Page(); + + if (!$page || !$page->exists()) { + // We don't have a page to fall back to + return null; + } + + // Use page title as a default value in case CMS user didn't provide the title + return $page->Title; + } } diff --git a/src/ORM/DBJson.php b/src/ORM/DBJson.php index b9c889e0..4b6de834 100644 --- a/src/ORM/DBJson.php +++ b/src/ORM/DBJson.php @@ -1,8 +1,9 @@ getValue(); + if ($value) { $type = Registry::singleton()->byKey($value['typeKey']); + if ($type) { return $type->loadLinkData($value)->forTemplate(); } diff --git a/src/Tasks/LinkableMigrationTask.php b/src/Tasks/LinkableMigrationTask.php new file mode 100644 index 00000000..2a08d432 --- /dev/null +++ b/src/Tasks/LinkableMigrationTask.php @@ -0,0 +1,550 @@ + '%s', + self::TABLE_LIVE => '%s_Live', + self::TABLE_VERSIONS => '%s_Versions', + ]; + + protected const TABLE_MAP_EMAIL_LINK = [ + self::TABLE_BASE => '%s', + self::TABLE_LIVE => '%s_Live', + self::TABLE_VERSIONS => '%s_Versions', + ]; + + protected const TABLE_MAP_EXTERNAL_LINK = [ + self::TABLE_BASE => '%s', + self::TABLE_LIVE => '%s_Live', + self::TABLE_VERSIONS => '%s_Versions', + ]; + + protected const TABLE_MAP_FILE_LINK = [ + self::TABLE_BASE => '%s', + self::TABLE_LIVE => '%s_Live', + self::TABLE_VERSIONS => '%s_Versions', + ]; + + protected const TABLE_MAP_PHONE_LINK = [ + self::TABLE_BASE => '%s', + self::TABLE_LIVE => '%s_Live', + self::TABLE_VERSIONS => '%s_Versions', + ]; + + protected const TABLE_MAP_SITE_TREE_LINK = [ + self::TABLE_BASE => '%s', + self::TABLE_LIVE => '%s_Live', + self::TABLE_VERSIONS => '%s_Versions', + ]; + + private static array $versions_mapping_global = [ + 'RecordID' => 'RecordID', + 'Version' => 'Version', + ]; + + private static array $versions_mapping_base_only = [ + 'WasPublished' => 'WasPublished', + 'WasDeleted' => 'WasDeleted', + 'WasDraft' => 'WasDraft', + 'AuthorID' => 'AuthorID', + 'PublisherID' => 'PublisherID', + ]; + + /** + * LinkableLink field => LinkField_Link field + */ + private static array $link_mapping = [ + 'ID' => 'ID', + 'LastEdited' => 'LastEdited', + 'Created' => 'Created', + 'Title' => 'Title', + 'OpenInNewWindow' => 'OpenInNew', + ]; + + /** + * LinkableLink field => LinkField_EmailLink field + */ + private static array $email_mapping = [ + 'ID' => 'ID', + 'Email' => 'Email', + ]; + + /** + * LinkableLink field => LinkField_ExternalLink field + */ + private static array $external_mapping = [ + 'ID' => 'ID', + 'URL' => 'ExternalUrl', + ]; + + /** + * LinkableLink field => LinkField_FileLink field + */ + private static array $file_mapping = [ + 'ID' => 'ID', + 'FileID' => 'FileID', + ]; + + /** + * LinkableLink field => LinkField_PhoneLink field + */ + private static array $phone_mapping = [ + 'ID' => 'ID', + 'Phone' => 'Phone', + ]; + + /** + * LinkableLink field => LinkField_SiteTreeLink field + */ + private static array $sitetree_mapping = [ + 'ID' => 'ID', + 'SiteTreeID' => 'PageID', + // @see insertSiteTree() for the migration of the Anchor field + ]; + + /** + * @see insertSiteTree() for the migration of the Anchor field + */ + private static string $sitetree_anchor_from = 'Anchor'; + + /** + * @see insertSiteTree() for the migration of the Anchor field + */ + private static string $sitetree_anchor_to = 'Anchor'; + + /** + * @see insertSiteTree() for the migration of the Anchor field + */ + private static ?string $sitetree_query_params_from = 'Anchor'; + + /** + * @see insertSiteTree() for the migration of the Anchor field + */ + private static ?string $sitetree_query_params_to = null; + + private static $segment = 'linkable-migration-task'; + + protected $title = 'Linkable Migration Task'; + + protected $description = 'Truncate LinkField records and migrate from Linkable records'; + + /** + * @param HTTPRequest $request + * @return void + * @throws Exception + */ + public function run($request): void + { + // Check that we have matching Versioned states between Linkable and LinkField + if (!$this->versionedStatusMatches()) { + throw new Exception( + 'Linkable and LinkField do not have matching Versioned applications. Make sure that both are' + . ' either un-Versioned or Versioned' + ); + } + + // If we're un-Versioned then it's just going to be the base table + $tables = [ + self::TABLE_BASE, + ]; + + // Since we passed the versionedStatusMatches() step, then we can just check if Link is Versioned, and we can + // safely assume that the Linkable Versioned tables also exist + if (Link::singleton()->hasExtension(Versioned::class)) { + // Add the _Live and _Versions tables to the list of things we need to copy + $tables[] = self::TABLE_LIVE; + $tables[] = self::TABLE_VERSIONS; + } + + // We expect your LinkField tables to be completely clear before migration is kicked off + $this->truncateLinkFieldTables(); + + foreach ($tables as $table) { + // Grab any/all records from the desired table (base, live, versions) + $linkableResults = SQLSelect::create('*', $table)->execute(); + + // Nothing to see here + if ($linkableResults->numRecords() === 0) { + echo sprintf("Nothing to process for `%s`\r\n", $table); + + continue; + } + + echo sprintf("Processing `%s`\r\n", $table); + + // Loop through each DB record + foreach ($linkableResults as $linkableData) { + // We now need to determine what type of Link the original Linkable record was, because we're going to + // have to process each of those slightly differently + switch ($linkableData['Type']) { + case 'Email': + $this->insertEmail($linkableData, $table); + + break; + case 'URL': + $this->insertExternal($linkableData, $table); + + break; + case 'File': + $this->insertFile($linkableData, $table); + + break; + case 'Phone': + $this->insertPhone($linkableData, $table); + + break; + case 'SiteTree': + $this->insertSiteTree($linkableData, $table); + + break; + } + } + + echo sprintf("%d records inserted, finished processing `%s`\r\n", $linkableResults->numRecords(), $table); + } + } + + /** + * Check to see if there is the existence of a _Live table for Linkable (indicating that it was Versioned) + * @return bool + */ + protected function versionedStatusMatches(): bool + { + $wasVersioned = DB::query('SHOW TABLES LIKE \'LinkableLink_Live\';')->numRecords() > 0; + $isVersioned = Link::singleton()->hasExtension(Versioned::class); + + return $wasVersioned === $isVersioned; + } + + /** + * We expect your LinkField tables to be completely clear before migration is kicked off + * This method will delete all data in the new tables providing a clear start and the ability + * to repeat this dev task + * + * @return void + */ + protected function truncateLinkFieldTables(): void + { + $tables = [ + 'LinkField_Link', + 'LinkField_EmailLink', + 'LinkField_ExternalLink', + 'LinkField_FileLink', + 'LinkField_PhoneLink', + 'LinkField_SiteTreeLink', + ]; + $versioned = [ + '_Live', + '_Versions', + ]; + $isVersioned = Link::singleton()->hasExtension(Versioned::class); + + foreach ($tables as $table) { + DB::get_conn()->clearTable($table); + + if (!$isVersioned) { + continue; + } + + foreach ($versioned as $tableSuffix) { + DB::get_conn()->clearTable($table . $tableSuffix); + } + } + } + + /** + * Create assignments from the old field values to the new fields based on provided configuration + * + * @param array $config + * @param array $linkableData + * @param string $originTable + * @return array + */ + protected function getAssignmentsForMapping(array $config, array $linkableData, string $originTable): array + { + // If we're processing the _Versions table, then we need to add all the Version table field assignments + if ($originTable === self::TABLE_VERSIONS) { + $config += $this->config()->get('versions_mapping_global'); + } + + // We're now going to start assigning values to the new fields (as you've specified in your config) + $assignments = []; + + // Loop through each config + foreach ($config as $oldField => $newField) { + // Assign the new field to equal whatever value was in the original record (based on the old field name) + $assignments[$newField] = $linkableData[$oldField]; + } + + return $assignments; + } + + /** + * Create new generic link record based on provided data + * + * @param string $className + * @param array $linkableData + * @param string $originTable + * @return void + */ + protected function insertLink(string $className, array $linkableData, string $originTable): void + { + $config = $this->config()->get('link_mapping'); + + // If we're processing the _Versions table, then we need to add all the Version table field assignments that are + // specifically for the base record (such as all the "WasPublished", "WasDraft", etc fields) + if ($originTable === self::TABLE_VERSIONS) { + $config += $this->config()->get('versions_mapping_global'); + } + + // These assignments are based on our config + $assignments = $this->getAssignmentsForMapping( + $config, + $linkableData, + $originTable + ); + // We also need to add ClassName for the base table, and this is not configurable + $assignments['ClassName'] = $className; + + // Find out what the corresponding table is for the origin table + $newTable = sprintf(self::TABLE_MAP_LINK[$originTable], DataObject::getSchema()->tableName(Link::class)); + + // Insert our new record + SQLInsert::create($newTable, $assignments)->execute(); + } + + /** + * Insert new record for email type link + * + * @param array $linkableData + * @param string $originTable + * @return void + */ + protected function insertEmail(array $linkableData, string $originTable): void + { + // Insert the base record for this EmailLink + $this->insertLink(EmailLink::class, $linkableData, $originTable); + + $newTable = sprintf( + self::TABLE_MAP_EMAIL_LINK[$originTable], + DataObject::getSchema()->tableName(EmailLink::class) + ); + + $assignments = $this->getAssignmentsForMapping( + $this->config()->get('email_mapping'), + $linkableData, + $originTable + ); + + SQLInsert::create($newTable, $assignments)->execute(); + } + + /** + * Insert new record for external type link + * + * @param array $linkableData + * @param string $originTable + * @return void + */ + protected function insertExternal(array $linkableData, string $originTable): void + { + // Insert the base record for this ExternalLink + $this->insertLink(ExternalLink::class, $linkableData, $originTable); + + $newTable = sprintf( + self::TABLE_MAP_EXTERNAL_LINK[$originTable], + DataObject::getSchema()->tableName(ExternalLink::class) + ); + + $assignments = $this->getAssignmentsForMapping( + $this->config()->get('external_mapping'), + $linkableData, + $originTable + ); + + SQLInsert::create($newTable, $assignments)->execute(); + } + + /** + * Insert new record for file type link + * + * @param array $linkableData + * @param string $originTable + * @return void + */ + protected function insertFile(array $linkableData, string $originTable): void + { + // Insert the base record for this FileLink + $this->insertLink(FileLink::class, $linkableData, $originTable); + + $newTable = sprintf( + self::TABLE_MAP_FILE_LINK[$originTable], + DataObject::getSchema()->tableName(FileLink::class) + ); + + $assignments = $this->getAssignmentsForMapping( + $this->config()->get('file_mapping'), + $linkableData, + $originTable + ); + + SQLInsert::create($newTable, $assignments)->execute(); + } + + /** + * Insert new record for phone type link + * + * @param array $linkableData + * @param string $originTable + * @return void + */ + protected function insertPhone(array $linkableData, string $originTable): void + { + // Insert the base record for this PhoneLink + $this->insertLink(PhoneLink::class, $linkableData, $originTable); + + $newTable = sprintf( + self::TABLE_MAP_PHONE_LINK[$originTable], + DataObject::getSchema()->tableName(PhoneLink::class) + ); + + $assignments = $this->getAssignmentsForMapping( + $this->config()->get('phone_mapping'), + $linkableData, + $originTable + ); + + SQLInsert::create($newTable, $assignments)->execute(); + } + + /** + * Insert new record for site tree (internal) type link + * + * @param array $linkableData + * @param string $originTable + * @return void + */ + protected function insertSiteTree(array $linkableData, string $originTable): void + { + // Insert the base record for this SiteTreeLink + $this->insertLink(SiteTreeLink::class, $linkableData, $originTable); + + $newTable = sprintf( + self::TABLE_MAP_SITE_TREE_LINK[$originTable], + DataObject::getSchema()->tableName(SiteTreeLink::class) + ); + + $assignments = $this->getAssignmentsForMapping( + $this->config()->get('sitetree_mapping'), + $linkableData, + $originTable + ); + + // Special case for the Anchor field. Linkable supports query params and/or anchors, but the Linkfield module + // only supports anchors. Linkable also requires that you prepend the #, and Linkfield requires you to *not* + $anchorFrom = $this->config()->get('sitetree_anchor_from'); + $anchorTo = $this->config()->get('sitetree_anchor_to'); + + $assignments[$anchorTo] = $this->getAnchorString($linkableData[$anchorFrom]); + + // Linkable supports adding query params and anchors together in the Anchor field. This module does not. If you + // would like to add support for query params, then you will need to have created (probably through an + // extension) a new and separate field (EG: QueryParams) on SiteTreeLink. You can then update the config for + // $sitetree_query_params_to to the name of the field you created (EG: QueryParams) + $queryParamsFrom = $this->config()->get('sitetree_query_params_from'); + $queryParamsTo = $this->config()->get('sitetree_query_params_to'); + + if ($queryParamsFrom && $queryParamsTo) { + $assignments[$queryParamsTo] = $this->getQueryString($linkableData[$queryParamsFrom]); + } + + SQLInsert::create($newTable, $assignments)->execute(); + } + + protected function getAnchorString(?string $originalAnchor): ?string + { + if (!$originalAnchor) { + return null; + } + + // We know that Linkable requires users to include a hash (#) for any anchors that they want. If we don't find + // a hash then there is no anchor here + if (!str_contains($originalAnchor, '#')) { + return null; + } + + $firstChar = $originalAnchor[0] ?? null; + + // Linkable supported query params (?) and anchors (#) in the same Anchor field. We know that query params must + // be provided before anchor, so if the first char is an anchor then we can just trim that and return; + if ($firstChar === '#') { + return ltrim($originalAnchor, '#'); + } + + // The only remaining possibility is that there is a string before the hash + // Explode the string at the first #, and we would expect there to always be exactly 2 parts + $parts = explode('#', $originalAnchor, 2); + + // return the second part + return $parts[1]; + } + + /** + * This method is not used (out of the box) as part of the migration process. This has been provided so that if + * you have a need for it, you can extend this class and access it + */ + protected function getQueryString(?string $originalAnchor): ?string + { + if (!$originalAnchor) { + return null; + } + + // We know that Linkable requires users to include a ? for any query params that they want. If we don't find + // a ? then there are no query params here + if (!str_contains($originalAnchor, '?')) { + return null; + } + + // Linkable supported query params (?) and anchors (#) in the same Anchor field. We know that query params must + // be provided before anchor, so if there are no anchors in the string, then we can just trim the ? and return + if (!str_contains($originalAnchor, '#')) { + return ltrim($originalAnchor, '?'); + } + + // The only remaining possibility is that there are query params followed by an anchor + // Explode the string at the first #, and we would expect there to always be exactly 2 parts + $parts = explode('#', $originalAnchor, 2); + + // return the first part (the query params) after trimming the prepended ? + return ltrim($parts[1], '?'); + } +} diff --git a/src/Type/Registry.php b/src/Type/Registry.php index 964aa68d..8762fa6c 100644 --- a/src/Type/Registry.php +++ b/src/Type/Registry.php @@ -1,8 +1,9 @@ get('types'); + if (empty($typeDefinitions[$key])) { return null; } @@ -86,14 +87,14 @@ public function init() private function definitionToType(array $def): Type { if (empty($def['classname'])) { - throw new \LogicException(sprintf('%s: All types should reference a valid classname', __CLASS__)); + throw new LogicException(sprintf('%s: All types should reference a valid classname', __CLASS__)); } /** @var Type $type */ $type = Injector::inst()->get($def['classname']); if (!$type instanceof Type) { - throw new \LogicException(sprintf('%s: %s is not a valid link type', __CLASS__, $def['classname'])); + throw new LogicException(sprintf('%s: %s is not a valid link type', __CLASS__, $def['classname'])); } return $type; @@ -102,6 +103,7 @@ private function definitionToType(array $def): Type public function keyByClassName(string $classname): ?string { $typeDefinitions = self::config()->get('types'); + foreach ($typeDefinitions as $key => $def) { if ($def['classname'] === $classname) { return $key; diff --git a/src/Type/Type.php b/src/Type/Type.php index 7411f89c..1d0cb428 100644 --- a/src/Type/Type.php +++ b/src/Type/Type.php @@ -1,9 +1,9 @@ target="_blank"<% end_if %>>$Title diff --git a/templates/SilverStripe/Link/Form/JsonField.ss b/templates/SilverStripe/LinkField/Form/JsonField.ss similarity index 100% rename from templates/SilverStripe/Link/Form/JsonField.ss rename to templates/SilverStripe/LinkField/Form/JsonField.ss diff --git a/templates/SilverStripe/LinkField/Models/Link.ss b/templates/SilverStripe/LinkField/Models/Link.ss new file mode 100644 index 00000000..7b95b36b --- /dev/null +++ b/templates/SilverStripe/LinkField/Models/Link.ss @@ -0,0 +1 @@ +target="_blank" rel="noopener noreferrer"<% end_if %>>$Title diff --git a/tests/php/LinkModelTest.php b/tests/php/LinkModelTest.php deleted file mode 100644 index 4232a1ec..00000000 --- a/tests/php/LinkModelTest.php +++ /dev/null @@ -1,21 +0,0 @@ -objFromFixture(Link::class, 'link-1'); - - $this->assertEquals('FormBuilderModal', $model->LinkTypeHandlerName()); - } -} diff --git a/tests/php/LinkModelTest.yml b/tests/php/LinkModelTest.yml deleted file mode 100644 index 631a5bfa..00000000 --- a/tests/php/LinkModelTest.yml +++ /dev/null @@ -1,3 +0,0 @@ -SilverStripe\Link\Models\Link: - link-1: - Title: Link1 diff --git a/tests/php/Models/LinkTest.php b/tests/php/Models/LinkTest.php new file mode 100644 index 00000000..c5a2d7d0 --- /dev/null +++ b/tests/php/Models/LinkTest.php @@ -0,0 +1,85 @@ +objFromFixture(Link::class, 'link-1'); + + $this->assertEquals('FormBuilderModal', $model->LinkTypeHandlerName()); + } + + /** + * @throws ValidationException + */ + public function testSiteTreeLinkTitleFallback(): void + { + /** @var SiteTreeLink $model */ + $model = $this->objFromFixture(SiteTreeLink::class, 'page-link-1'); + + $this->assertEquals('PageLink1', $model->Title, 'We expect to get a default Link title'); + + /** @var SiteTree $page */ + $page = $this->objFromFixture(SiteTree::class, 'page-1'); + + $model->PageID = $page->ID; + $model->Title = ''; + $model->write(); + + $this->assertEquals($page->Title, $model->Title, 'We expect to get the linked Page title'); + + $customTitle = 'My custom title'; + $model->Title = $customTitle; + $model->write(); + + $this->assertEquals($customTitle, $model->Title, 'We expect to get the custom title not page title'); + } + + /** + * @param string $class + * @param bool $expected + * @throws ReflectionException + * @dataProvider linkTypeProvider + */ + public function testLinkType(string $class, bool $expected): void + { + /** @var Link $model */ + $model = DataObject::singleton($class); + $fields = $model->getCMSFields(); + $linkTypeField = $fields->fieldByName('Root.Main.LinkType'); + $expected + ? $this->assertNotNull($linkTypeField, 'We expect to a find link type field') + : $this->assertNull($linkTypeField, 'We do not expect to a find link type field'); + } + + public function linkTypeProvider(): array + { + return [ + [EmailLink::class, false], + [ExternalLink::class, false], + [FileLink::class, false], + [PhoneLink::class, false], + [SiteTreeLink::class, false], + [Link::class, true], + ]; + } +} diff --git a/tests/php/Models/LinkTest.yml b/tests/php/Models/LinkTest.yml new file mode 100644 index 00000000..92e0f883 --- /dev/null +++ b/tests/php/Models/LinkTest.yml @@ -0,0 +1,11 @@ +SilverStripe\LinkField\Models\Link: + link-1: + Title: Link1 + +SilverStripe\LinkField\Models\SiteTreeLink: + page-link-1: + Title: PageLink1 + +SilverStripe\CMS\Model\SiteTree: + page-1: + Title: Page1