Skip to content

Commit

Permalink
Merge pull request silverstripe#120 from creative-commoners/pulls/4/m…
Browse files Browse the repository at this point in the history
…ulti-link-field

NEW Add MultiLinkField
  • Loading branch information
emteknetnz authored Nov 30, 2023
2 parents 64f5829 + cc21f8a commit 9debde6
Show file tree
Hide file tree
Showing 20 changed files with 665 additions and 224 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
},
};
32 changes: 11 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,10 @@ This module provides a Link model and CMS interface for managing different types

Installation via composer.

### Silverstripe 5

```sh
composer require silverstripe/linkfield
```

### GraphQL v4 - Silverstripe 4

`composer require silverstripe/linkfield:^2`

### GraphQL v3 - Silverstripe 4

```sh
composer require silverstripe/linkfield:^1
```

## Sample usage

```php
Expand All @@ -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'),
],
);

Expand All @@ -67,13 +63,7 @@ class Page extends SiteTree
}
```

## Migrating from Version `1.0.0` or `dev-master`

Please be aware that in early versions of this module (and in untagged `dev-master`) there were no table names defined
for our `Link` classes. These have now all been defined, which may mean that you need to rename your old tables, or
migrate the data across.

EG: `SilverStripe_LinkField_Models_Link` needs to be migrated to `LinkField_Link`.
Note that you also need to add a `has_one` relation on the `Link` model to match your `has_many` here. See [official docs about `has_many`](https://docs.silverstripe.org/en/developer_guides/model/relations/#has-many)

## Migrating from Shae Dawson's Linkable module

Expand All @@ -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)
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.

183 changes: 103 additions & 80 deletions client/src/components/LinkField/LinkField.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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]);

/**
* 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(
Expand All @@ -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') })
Expand All @@ -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]),
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
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={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;
Loading

0 comments on commit 9debde6

Please sign in to comment.