Skip to content
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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
},
Comment on lines +3 to +6
Copy link
Member Author

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

};
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
```
Comment on lines -22 to -36
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is only valid for CMS 5


## 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`.
Comment on lines -70 to -76
Copy link
Member Author

Choose a reason for hiding this comment

The 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

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
Copy link
Member Author

@GuySartorelli GuySartorelli Nov 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component now handles single and multi link fields.
I originally had them as two separate components, but the differences were so minimal it just made more sense to have a isMulti boolean prop and let the one component handle both scenarios.

Note that I've moved the LinkPickerTitle out of the picker, so that it can be used to render the links when linkfield is multi.

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 LinkModalContainer). The linkfield itself only renders modals for links that are being edited.

Finally, the logic for figuring out which link modal component to render was moved into LinkModalContainer so it didn't get duplicated between the linkfield and the picker components.

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]);
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(
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]),
Comment on lines -151 to +171
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't be required, because the value is undefined when no links are in the field and the field is rendered in an elemental block.
The component handles undefined perfectly so that's not a problem.

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) => {
Copy link
Member Author

Choose a reason for hiding this comment

The 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}
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