-
Notifications
You must be signed in to change notification settings - Fork 15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
NEW Add MultiLinkField #120
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
}, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,22 +19,10 @@ This module provides a Link model and CMS interface for managing different types | |
|
||
Installation via composer. | ||
|
||
### Silverstripe 5 | ||
|
||
```sh | ||
composer require silverstripe/linkfield | ||
``` | ||
|
||
### GraphQL v4 - Silverstripe 4 | ||
|
||
`composer require silverstripe/linkfield:^2` | ||
|
||
### GraphQL v3 - Silverstripe 4 | ||
|
||
```sh | ||
composer require silverstripe/linkfield:^1 | ||
``` | ||
Comment on lines
-22
to
-36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This branch is only valid for CMS 5 |
||
|
||
## Sample usage | ||
|
||
```php | ||
|
@@ -43,22 +31,30 @@ use SilverStripe\CMS\Model\SiteTree; | |
use SilverStripe\LinkField\ORM\DBLink; | ||
use SilverStripe\LinkField\Models\Link; | ||
use SilverStripe\LinkField\Form\LinkField; | ||
use SilverStripe\LinkField\Form\MultiLinkField; | ||
|
||
class Page extends SiteTree | ||
{ | ||
private static array $has_one = [ | ||
'HasOneLink' => Link::class, | ||
]; | ||
|
||
private static $has_many = [ | ||
'HasManyLinks' => Link::class | ||
]; | ||
|
||
public function getCMSFields() | ||
{ | ||
$fields = parent::getCMSFields(); | ||
|
||
// Don't forget to remove the auto-scaffolded fields! | ||
$fields->removeByName(['HasOneLinkID', 'Links']); | ||
|
||
$fields->addFieldsToTab( | ||
'Root.Main', | ||
[ | ||
LinkField::create('HasOneLink'), | ||
LinkField::create('DbLink'), | ||
MultiLinkField::create('HasManyLinks'), | ||
], | ||
); | ||
|
||
|
@@ -67,13 +63,7 @@ class Page extends SiteTree | |
} | ||
``` | ||
|
||
## Migrating from Version `1.0.0` or `dev-master` | ||
|
||
Please be aware that in early versions of this module (and in untagged `dev-master`) there were no table names defined | ||
for our `Link` classes. These have now all been defined, which may mean that you need to rename your old tables, or | ||
migrate the data across. | ||
|
||
EG: `SilverStripe_LinkField_Models_Link` needs to be migrated to `LinkField_Link`. | ||
Comment on lines
-70
to
-76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These are old and don't apply to the jump from 2.x/3.x to 4.x |
||
Note that you also need to add a `has_one` relation on the `Link` model to match your `has_many` here. See [official docs about `has_many`](https://docs.silverstripe.org/en/developer_guides/model/relations/#has-many) | ||
|
||
## Migrating from Shae Dawson's Linkable module | ||
|
||
|
@@ -82,4 +72,4 @@ https://github.com/sheadawson/silverstripe-linkable | |
Shae Dawson's Linkable module was a much loved, and much used module. It is, unfortunately, no longer maintained. We | ||
have provided some steps and tasks that we hope can be used to migrate your project from Linkable to LinkField. | ||
|
||
* [Migraiton docs](docs/en/linkable-migration.md) | ||
* [Migration docs](docs/en/linkable-migration.md) |
This file was deleted.
Large diffs are not rendered by default.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This component now handles single and multi link fields. Note that I've moved the The picker is now responsible for knowing what type was picked, and passing it through to the modal. The linkfield shouldn't care about that, it only cares about the link once the link has been created. I've also moved the responsibility of rendering the modal. If it's a new link, the modal is rendered by the picker (indirectly, through the new Finally, the logic for figuring out which link modal component to render was moved into |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,9 +2,12 @@ | |
import React, { useState, useEffect } from 'react'; | ||
import { bindActionCreators, compose } from 'redux'; | ||
import { connect } from 'react-redux'; | ||
import { injectGraphql, loadComponent } from 'lib/Injector'; | ||
import { injectGraphql } from 'lib/Injector'; | ||
import fieldHolder from 'components/FieldHolder/FieldHolder'; | ||
import LinkPicker from 'components/LinkPicker/LinkPicker'; | ||
import LinkPickerTitle from 'components/LinkPicker/LinkPickerTitle'; | ||
import LinkType from 'types/LinkType'; | ||
import LinkModalContainer from 'containers/LinkModalContainer'; | ||
import * as toastsActions from 'state/toasts/ToastsActions'; | ||
import backend from 'lib/Backend'; | ||
import Config from 'lib/Config'; | ||
|
@@ -19,36 +22,63 @@ const section = 'SilverStripe\\LinkField\\Controllers\\LinkFieldController'; | |
* onChange - callback function passed from JsonField - used to update the underlying <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 [typeKey, setTypeKey] = useState(''); | ||
const LinkField = ({ value = null, onChange, types, actions, isMulti = false }) => { | ||
const [data, setData] = useState({}); | ||
const [editing, setEditing] = useState(false); | ||
const [editingID, setEditingID] = useState(0); | ||
|
||
// Ensure we have a valid array | ||
let linkIDs = value; | ||
if (!Array.isArray(linkIDs)) { | ||
if (typeof linkIDs === 'number' && linkIDs != 0) { | ||
linkIDs = [linkIDs]; | ||
} | ||
if (!linkIDs) { | ||
linkIDs = []; | ||
} | ||
} | ||
|
||
// Read data from endpoint and update component state | ||
// This happens any time a link is added or removed and triggers a re-render | ||
useEffect(() => { | ||
if (!editingID && linkIDs.length > 0) { | ||
const query = []; | ||
for (const linkID of linkIDs) { | ||
query.push(`itemIDs[]=${linkID}`); | ||
} | ||
const endpoint = `${Config.getSection(section).form.linkForm.dataUrl}?${query.join('&')}`; | ||
backend.get(endpoint) | ||
.then(response => response.json()) | ||
.then(responseJson => { | ||
setData(responseJson); | ||
}); | ||
} | ||
}, [editingID, value && value.length]); | ||
emteknetnz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
/** | ||
* Call back used by LinkModal after the form has been submitted and the response has been received | ||
* Unset the editing ID when the editing modal is closed | ||
*/ | ||
const onModalSubmit = async (modalData, action, submitFn) => { | ||
const formSchema = await submitFn(); | ||
|
||
// slightly annoyingly, on validation error formSchema at this point will not have an errors node | ||
// instead it will have the original formSchema id used for the GET request to get the formSchema i.e. | ||
// admin/linkfield/schema/linkfield/<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 = () => { | ||
setEditingID(0); | ||
}; | ||
|
||
/** | ||
* Update the component when the modal successfully saves a link | ||
*/ | ||
const onModalSuccess = (value) => { | ||
// update component state | ||
setEditing(false); | ||
setEditingID(0); | ||
|
||
// update parent JsonField data id - this is required to update the underlying <input> form field | ||
// so that the Page (or other parent DataObject) gets the Link relation ID set | ||
onChange(valueFromSchemaResponse); | ||
const ids = [...linkIDs]; | ||
if (!ids.includes(value)) { | ||
ids.push(value); | ||
} | ||
|
||
// Update value in the underlying <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( | ||
|
@@ -57,15 +87,12 @@ const LinkField = ({ value, onChange, types, actions }) => { | |
'Saved link', | ||
) | ||
); | ||
} | ||
|
||
return Promise.resolve(); | ||
}; | ||
} | ||
|
||
/** | ||
* Call back used by LinkPicker when the 'Clear' button is clicked | ||
* Update the component when the 'Clear' button in the LinkPicker is clicked | ||
*/ | ||
const onClear = () => { | ||
const onClear = (linkID) => { | ||
const endpoint = `${Config.getSection(section).form.linkForm.deleteUrl}/${linkID}`; | ||
// CSRF token 'X-SecurityID' headers needs to be present for destructive requests | ||
backend.delete(endpoint, {}, { 'X-SecurityID': Config.get('SecurityID') }) | ||
|
@@ -87,69 +114,65 @@ const LinkField = ({ value, onChange, types, actions }) => { | |
}); | ||
|
||
// update component state | ||
setTypeKey(''); | ||
setData({}); | ||
const newData = {...data}; | ||
delete newData[linkID]; | ||
setData(newData); | ||
|
||
// update parent JsonField data ID used to update the underlying <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(typeKey) ? types[typeKey] : {}; | ||
const modalType = typeKey ? types[typeKey] : type; | ||
const handlerName = modalType && modalType.hasOwnProperty('handlerName') | ||
? modalType.handlerName | ||
: 'FormBuilderModal'; | ||
const LinkModal = loadComponent(`LinkModal.${handlerName}`); | ||
|
||
const pickerProps = { | ||
title, | ||
description: data.description, | ||
typeTitle: type.title || '', | ||
onEdit: () => { | ||
setEditing(true); | ||
}, | ||
onClear, | ||
onSelect: (key) => { | ||
setTypeKey(key); | ||
setEditing(true); | ||
}, | ||
types: Object.values(types) | ||
/** | ||
* Render all of the links currently in the field data | ||
*/ | ||
const renderLinks = () => { | ||
const links = []; | ||
|
||
for (const linkID of linkIDs) { | ||
// Only render items we have data for | ||
const linkData = data[linkID]; | ||
if (!linkData) { | ||
continue; | ||
} | ||
|
||
const type = types.hasOwnProperty(data[linkID]?.typeKey) ? types[data[linkID]?.typeKey] : {}; | ||
links.push(<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, | ||
editing, | ||
onSubmit: onModalSubmit, | ||
onClosed: () => { | ||
setEditing(false); | ||
}, | ||
linkID | ||
}; | ||
|
||
// read data from endpoint and update component state | ||
useEffect(() => { | ||
if (!editing && linkID) { | ||
const endpoint = `${Config.getSection(section).form.linkForm.dataUrl}/${linkID}`; | ||
backend.get(endpoint) | ||
.then(response => response.json()) | ||
.then(responseJson => { | ||
setData(responseJson); | ||
setTypeKey(responseJson.typeKey); | ||
}); | ||
} | ||
}, [editing, linkID]); | ||
const renderPicker = isMulti || Object.keys(data).length === 0; | ||
const renderModal = Boolean(editingID); | ||
|
||
return <> | ||
<LinkPicker {...pickerProps} /> | ||
<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]), | ||
Comment on lines
-151
to
+171
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can't be required, because the value is |
||
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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved a bunch of this logic from linkfield into here - linkfield doesn't care that the modal was submitted, it only cares about success. Similarly, the modal should be responsible for knowing about validation errors - linkfield itself doesn't care about them. |
||
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={isOpen} | ||
schemaUrl={buildSchemaUrl(typeKey, linkID)} | ||
identifier='Link.EditingLinkInfo' | ||
onSubmit={onSubmit} | ||
|
@@ -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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Null coallescing (
x ?? y
) and optional chaining (x?.y
) are well supported now - the build would have failed if they weren't supported by the browsers we target, due to the browser config in package.json