Skip to content

Commit

Permalink
ENH Refactor LinkField in preparation for MultiLinkField
Browse files Browse the repository at this point in the history
  • Loading branch information
GuySartorelli committed Nov 28, 2023
1 parent 64f5829 commit acb9c84
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 96 deletions.
79 changes: 29 additions & 50 deletions client/src/components/LinkField/LinkField.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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/<ItemID>
// instead of the one used by the POST submission i.e.
// admin/linkfield/linkForm/<LinkID>
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 <input> 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(
Expand All @@ -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(() => {
Expand All @@ -87,44 +72,31 @@ const LinkField = ({ value, onChange, types, actions }) => {
});

// update component state
setTypeKey('');
setData({});

// update parent JsonField data ID used to update the underlying <input> form field
onChange(0);
};

const title = data.Title || '';
const type = types.hasOwnProperty(typeKey) ? types[typeKey] : {};
const modalType = typeKey ? types[typeKey] : type;
const handlerName = modalType && modalType.hasOwnProperty('handlerName')
? modalType.handlerName
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
};

Expand All @@ -136,14 +108,21 @@ const LinkField = ({ value, onChange, types, actions }) => {
.then(response => response.json())
.then(responseJson => {
setData(responseJson);
setTypeKey(responseJson.typeKey);
});
}
}, [editing, linkID]);

return <>
<LinkPicker {...pickerProps} />
<LinkModal {...modalProps} />
{!type.title && <LinkPicker {...pickerProps} />}
{type.title && <LinkPickerTitle
id={linkID}
title={title}
description={data.description}
typeTitle={type.title}
onClear={onClear}
onClick={() => { setEditing(true); }}
/>}
{ editing && <LinkModal {...modalProps} /> }
</>;
};

Expand Down
36 changes: 31 additions & 5 deletions client/src/components/LinkModal/LinkModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ItemID>
// instead of the one used by the POST submission i.e.
// admin/linkfield/linkForm/<LinkID>
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 <FormBuilderModal
title={typeTitle}
isOpen={editing}
isOpen
schemaUrl={buildSchemaUrl(typeKey, linkID)}
identifier='Link.EditingLinkInfo'
onSubmit={onSubmit}
Expand All @@ -34,10 +58,12 @@ const LinkModal = ({ typeTitle, typeKey, linkID, editing, onSubmit, onClosed}) =
LinkModal.propTypes = {
typeTitle: PropTypes.string.isRequired,
typeKey: PropTypes.string.isRequired,
linkID: PropTypes.number.isRequired,
editing: PropTypes.bool.isRequired,
onSubmit: PropTypes.func.isRequired,
linkID: PropTypes.number,
isOpen: PropTypes.bool.isRequired,
onSuccess: PropTypes.func.isRequired,
onClosed: PropTypes.func.isRequired,
};

LinkModal.defaultProps

export default LinkModal;
76 changes: 54 additions & 22 deletions client/src/components/LinkPicker/LinkPicker.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,63 @@
/* eslint-disable */
import React from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { loadComponent } from 'lib/Injector';
import LinkPickerMenu from './LinkPickerMenu';
import LinkPickerTitle from './LinkPickerTitle';

const LinkPicker = ({ title, description, typeTitle, types, onSelect, onEdit, onClear }) => (
<div className={classnames('link-picker', 'form-control', {'link-picker--selected': typeTitle ? true : false})}>
{!typeTitle && <LinkPickerMenu types={types} onSelect={onSelect} /> }
{typeTitle && <LinkPickerTitle
title={title}
description={description}
typeTitle={typeTitle}
onClear={onClear}
onClick={() => onEdit()}
/>}
</div>
);
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 (
<div className={classnames('link-picker', 'form-control')}>
<LinkPickerMenu types={Object.values(types)} onSelect={doSelect} />
{ isOpen && <LinkModal {...modalProps} /> }
</div>
);
};

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};
Expand Down
27 changes: 15 additions & 12 deletions client/src/components/LinkPicker/LinkPickerTitle.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,25 @@ const stopPropagation = (fn) => (e) => {
fn && fn();
}

const LinkPickerTitle = ({ title, description, typeTitle, onClear, onClick }) => (
<div className="link-picker__link" >
<Button className="link-picker__button font-icon-link" color="secondary" onClick={stopPropagation(onClick)}>
<div className="link-picker__link-detail">
<div className="link-picker__title">{title}</div>
<small className="link-picker__type">
{typeTitle}:&nbsp;
<span className="link-picker__url">{description}</span>
</small>
</div>
</Button>
<Button className="link-picker__clear" color="link" onClick={stopPropagation(onClear)}>{i18n._t('LinkField.CLEAR', 'Clear')}</Button>
const LinkPickerTitle = ({ id, title, description, typeTitle, onClear, onClick }) => (
<div className={classnames('link-picker', 'form-control', '"link-picker--selected')}>
<div className="link-picker__link" >
<Button className="link-picker__button font-icon-link" color="secondary" onClick={stopPropagation(onClick)}>
<div className="link-picker__link-detail">
<div className="link-picker__title">{title}</div>
<small className="link-picker__type">
{typeTitle}:&nbsp;
<span className="link-picker__url">{description}</span>
</small>
</div>
</Button>
<Button className="link-picker__clear" color="link" onClick={stopPropagation(() => onClear(id))}>{i18n._t('LinkField.CLEAR', 'Clear')}</Button>
</div>
</div>
);

LinkPickerTitle.propTypes = {
id: PropTypes.number.isRequired,
title: PropTypes.string,
description: PropTypes.string,
typeTitle: PropTypes.string.isRequired,
Expand Down
4 changes: 3 additions & 1 deletion client/src/entwine/LinkField.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ jQuery.entwine('ss', ($) => {
*/
onunmatch() {
const Root = this.getRoot();
Root.unmount();
if (Root) {
Root.unmount();
}
},
});
});
10 changes: 5 additions & 5 deletions src/Controllers/LinkFieldController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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'));
}
Expand All @@ -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;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/Form/LinkField.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace SilverStripe\LinkField\Form;

use LogicException;
use SilverStripe\Forms\FormField;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;
Expand Down Expand Up @@ -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();
Expand Down

0 comments on commit acb9c84

Please sign in to comment.