Skip to content

Commit

Permalink
NEW Add MultiLinkField for managing many-type relations
Browse files Browse the repository at this point in the history
  • Loading branch information
GuySartorelli committed Nov 30, 2023
1 parent acb9c84 commit 1be5f0d
Show file tree
Hide file tree
Showing 19 changed files with 593 additions and 185 deletions.
8 changes: 7 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
module.exports = require('@silverstripe/eslint-config/.eslintrc');
module.exports = {
extends: '@silverstripe/eslint-config',
// Allows null coalescing and optional chaining operators.
parserOptions: {
ecmaVersion: 2020
},
};
6 changes: 0 additions & 6 deletions babel.config.json

This file was deleted.

2 changes: 1 addition & 1 deletion client/dist/js/bundle.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client/dist/styles/bundle.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

158 changes: 101 additions & 57 deletions client/src/components/LinkField/LinkField.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
import React, { useState, useEffect } from 'react';
import { bindActionCreators, compose } from 'redux';
import { connect } from 'react-redux';
import { injectGraphql, loadComponent } from 'lib/Injector';
import { injectGraphql } from 'lib/Injector';
import fieldHolder from 'components/FieldHolder/FieldHolder';
import LinkPicker from 'components/LinkPicker/LinkPicker';
import LinkPickerTitle from 'components/LinkPicker/LinkPickerTitle';
import LinkType from 'types/LinkType';
import LinkModalContainer from 'containers/LinkModalContainer';
import * as toastsActions from 'state/toasts/ToastsActions';
import backend from 'lib/Backend';
import Config from 'lib/Config';
Expand All @@ -20,23 +22,63 @@ const section = 'SilverStripe\\LinkField\\Controllers\\LinkFieldController';
* onChange - callback function passed from JsonField - used to update the underlying <input> form field
* types - injected by the GraphQL query
* actions - object of redux actions
* isMulti - whether this field handles multiple links or not
*/
const LinkField = ({ value, onChange, types, actions }) => {
const linkID = value;
const LinkField = ({ value = null, onChange, types, actions, isMulti = false }) => {
const [data, setData] = useState({});
const [editing, setEditing] = useState(false);
const [editingID, setEditingID] = useState(0);

// Ensure we have a valid array
let linkIDs = value;
if (!Array.isArray(linkIDs)) {
if (typeof linkIDs === 'number' && linkIDs != 0) {
linkIDs = [linkIDs];
}
if (!linkIDs) {
linkIDs = [];
}
}

// Read data from endpoint and update component state
// This happens any time a link is added or removed and triggers a re-render
useEffect(() => {
if (!editingID && linkIDs.length > 0) {
const query = [];
for (const linkID of linkIDs) {
query.push(`itemIDs[]=${linkID}`);
}
const endpoint = `${Config.getSection(section).form.linkForm.dataUrl}?${query.join('&')}`;
backend.get(endpoint)
.then(response => response.json())
.then(responseJson => {
setData(responseJson);
});
}
}, [editingID, value && value.length]);

/**
* Unset the editing ID when the editing modal is closed
*/
const onModalClosed = () => {
setEditing(false);
setEditingID(0);
};

/**
* Update the component when the modal successfully saves a link
*/
const onModalSuccess = (value) => {
// update component state
setEditing(false);
setEditingID(0);

const ids = [...linkIDs];
if (!ids.includes(value)) {
ids.push(value);
}

// update parent JsonField data id - this is required to update the underlying <input> form field
// so that the Page (or other parent DataObject) gets the Link relation ID set
onChange(value);
// Update value in the underlying <input> form field
// so that the Page (or other parent DataObject) gets the Link relation set.
// Also likely required in react context for dirty form state, etc.
onChange(isMulti ? ids : ids[0]);

// success toast
actions.toasts.success(
Expand All @@ -48,10 +90,10 @@ const LinkField = ({ value, onChange, types, actions }) => {
}

/**
* Call back used by LinkPicker when the 'Clear' button is clicked
* Update the component when the 'Clear' button in the LinkPicker is clicked
*/
const onClear = (id) => {
const endpoint = `${Config.getSection(section).form.linkForm.deleteUrl}/${id}`;
const onClear = (linkID) => {
const endpoint = `${Config.getSection(section).form.linkForm.deleteUrl}/${linkID}`;
// CSRF token 'X-SecurityID' headers needs to be present for destructive requests
backend.delete(endpoint, {}, { 'X-SecurityID': Config.get('SecurityID') })
.then(() => {
Expand All @@ -72,63 +114,65 @@ const LinkField = ({ value, onChange, types, actions }) => {
});

// update component state
setData({});
const newData = {...data};
delete newData[linkID];
setData(newData);

// update parent JsonField data ID used to update the underlying <input> form field
onChange(0);
// update parent JsonField data IDs used to update the underlying <input> form field
onChange(isMulti ? Object.keys(newData) : 0);
};

const title = data.Title || '';
const type = types.hasOwnProperty(data.typeKey) ? types[data.typeKey] : {};
const handlerName = type && type.hasOwnProperty('handlerName')
? type.handlerName
: 'FormBuilderModal';
const LinkModal = loadComponent(`LinkModal.${handlerName}`);

const pickerProps = {
onModalSuccess,
onModalClosed,
types
/**
* Render all of the links currently in the field data
*/
const renderLinks = () => {
const links = [];

for (const linkID of linkIDs) {
// Only render items we have data for
const linkData = data[linkID];
if (!linkData) {
continue;
}

const type = types.hasOwnProperty(data[linkID]?.typeKey) ? types[data[linkID]?.typeKey] : {};
links.push(<LinkPickerTitle
key={linkID}
id={linkID}
title={data[linkID]?.Title}
description={data[linkID]?.description}
typeTitle={type.title || ''}
onClear={onClear}
onClick={() => { setEditingID(linkID); }}
/>);
}
return links;
};

const modalProps = {
typeTitle: type.title || '',
typeKey: data.typeKey,
isOpen: editing,
onSuccess: onModalSuccess,
onClosed: onModalClosed,
linkID
};

// read data from endpoint and update component state
useEffect(() => {
if (!editing && linkID) {
const endpoint = `${Config.getSection(section).form.linkForm.dataUrl}/${linkID}`;
backend.get(endpoint)
.then(response => response.json())
.then(responseJson => {
setData(responseJson);
});
}
}, [editing, linkID]);
const renderPicker = isMulti || Object.keys(data).length === 0;
const renderModal = Boolean(editingID);

return <>
{!type.title && <LinkPicker {...pickerProps} />}
{type.title && <LinkPickerTitle
id={linkID}
title={title}
description={data.description}
typeTitle={type.title}
onClear={onClear}
onClick={() => { setEditing(true); }}
/>}
{ editing && <LinkModal {...modalProps} /> }
{ renderPicker && <LinkPicker onModalSuccess={onModalSuccess} onModalClosed={onModalClosed} types={types} /> }
<div> { renderLinks() } </div>
{ renderModal && <LinkModalContainer
types={types}
typeKey={data[editingID]?.typeKey}
isOpen={Boolean(editingID)}
onSuccess={onModalSuccess}
onClosed={onModalClosed}
linkID={editingID}
/>
}
</>;
};

LinkField.propTypes = {
value: PropTypes.number.isRequired,
value: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]),
onChange: PropTypes.func.isRequired,
types: PropTypes.objectOf(LinkType).isRequired,
actions: PropTypes.object.isRequired,
isMulti: PropTypes.bool,
};

// redux actions loaded into props - used to get toast notifications
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/LinkModal/LinkModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const LinkModal = ({ typeTitle, typeKey, linkID = 0, isOpen, onSuccess, onClosed

return <FormBuilderModal
title={typeTitle}
isOpen
isOpen={isOpen}
schemaUrl={buildSchemaUrl(typeKey, linkID)}
identifier='Link.EditingLinkInfo'
onSubmit={onSubmit}
Expand Down
58 changes: 30 additions & 28 deletions client/src/components/LinkPicker/LinkPicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,62 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { loadComponent } from 'lib/Injector';
import LinkPickerMenu from './LinkPickerMenu';
import LinkType from 'types/LinkType';
import LinkModalContainer from 'containers/LinkModalContainer';

const LinkPicker = ({ types, onSelect, onModalSuccess, onModalClosed }) => {
/**
* Component which allows users to choose a type of link to create, and opens a modal form for it.
*/
const LinkPicker = ({ types, onModalSuccess, onModalClosed }) => {
const [typeKey, setTypeKey] = useState('');

const doSelect = (key) => {
if (typeof onSelect === 'function') {
onSelect(key);
}
/**
* When a link type is selected, set the type key so we can open the modal.
*/
const handleSelect = (key) => {
setTypeKey(key);
}

const onClosed = () => {
/**
* Callback for when the modal is closed by the user
*/
const handleClosed = () => {
if (typeof onModalClosed === 'function') {
onModalClosed();
}
setTypeKey('');
}

const onSuccess = (value) => {
/**
* Callback for when the modal successfully saves a link
*/
const handleSuccess = (value) => {
setTypeKey('');
onModalSuccess(value);
}

const type = types.hasOwnProperty(typeKey) ? types[typeKey] : {};
const modalType = typeKey ? types[typeKey] : type;
const handlerName = modalType && modalType.hasOwnProperty('handlerName')
? modalType.handlerName
: 'FormBuilderModal';
const LinkModal = loadComponent(`LinkModal.${handlerName}`);

const isOpen = Boolean(typeKey);

const modalProps = {
typeTitle: type.title || '',
typeKey,
isOpen,
onSuccess: onSuccess,
onClosed: onClosed,
};
const shouldOpenModal = typeKey !== '';
const className = classnames('link-picker', 'form-control');
const typeArray = Object.values(types);

return (
<div className={classnames('link-picker', 'form-control')}>
<LinkPickerMenu types={Object.values(types)} onSelect={doSelect} />
{ isOpen && <LinkModal {...modalProps} /> }
<div className={className}>
<LinkPickerMenu types={typeArray} onSelect={handleSelect} />
{ shouldOpenModal && <LinkModalContainer
types={types}
typeKey={typeKey}
isOpen={shouldOpenModal}
onSuccess={handleSuccess}
onClosed={handleClosed}
/>
}
</div>
);
};

LinkPicker.propTypes = {
types: PropTypes.objectOf(LinkType).isRequired,
onSelect: PropTypes.func,
onModalSuccess: PropTypes.func.isRequired,
onModalClosed: PropTypes.func,
};
Expand Down
Loading

0 comments on commit 1be5f0d

Please sign in to comment.