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

ENH Save relations on link creation #146

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
2 changes: 1 addition & 1 deletion client/dist/js/bundle.js

Large diffs are not rendered by default.

47 changes: 39 additions & 8 deletions client/src/components/LinkField/LinkField.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable */
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, createContext } from 'react';
import { bindActionCreators, compose } from 'redux';
import { connect } from 'react-redux';
import { injectGraphql } from 'lib/Injector';
Expand All @@ -13,6 +13,10 @@ import backend from 'lib/Backend';
import Config from 'lib/Config';
import PropTypes from 'prop-types';
import i18n from 'i18n';
import url from 'url';
import qs from 'qs';

export const LinkFieldContext = createContext(null);

// section used in window.ss config
const section = 'SilverStripe\\LinkField\\Controllers\\LinkFieldController';
Expand All @@ -23,9 +27,22 @@ const section = 'SilverStripe\\LinkField\\Controllers\\LinkFieldController';
* types - types of the Link passed from LinkField entwine
* actions - object of redux actions
* isMulti - whether this field handles multiple links or not
* canCreate - whether this field can create links or not
* canCreate - whether this field can create new links or not
* ownerID - ID of the owner DataObject
* ownerClass - class name of the owner DataObject
* ownerRelation - name of the relation on the owner DataObject
*/
const LinkField = ({ value = null, onChange, types = [], actions, isMulti = false, canCreate }) => {
const LinkField = ({
value = null,
onChange,
types = [],
actions,
isMulti = false,
canCreate,
ownerID,
ownerClass,
ownerRelation,
}) => {
const [data, setData] = useState({});
const [editingID, setEditingID] = useState(0);

Expand Down Expand Up @@ -94,7 +111,13 @@ const LinkField = ({ value = null, onChange, types = [], actions, isMulti = fals
* Update the component when the 'Clear' button in the LinkPicker is clicked
*/
const onClear = (linkID) => {
const endpoint = `${Config.getSection(section).form.linkForm.deleteUrl}/${linkID}`;
let endpoint = `${Config.getSection(section).form.linkForm.deleteUrl}/${linkID}`;
const parsedURL = url.parse(endpoint);
const parsedQs = qs.parse(parsedURL.query);
parsedQs.ownerID = ownerID;
parsedQs.ownerClass = ownerClass;
parsedQs.ownerRelation = ownerRelation;
endpoint = url.format({ ...parsedURL, search: qs.stringify(parsedQs)});
// CSRF token 'X-SecurityID' headers needs to be present for destructive requests
backend.delete(endpoint, {}, { 'X-SecurityID': Config.get('SecurityID') })
.then(() => {
Expand Down Expand Up @@ -155,9 +178,14 @@ const LinkField = ({ value = null, onChange, types = [], actions, isMulti = fals
const renderPicker = isMulti || Object.keys(data).length === 0;
const renderModal = Boolean(editingID);

return <>
{ renderPicker && <LinkPicker canCreate={canCreate} onModalSuccess={onModalSuccess} onModalClosed={onModalClosed} types={types} /> }
<div> { renderLinks() } </div>
return <LinkFieldContext.Provider value={{ ownerID, ownerClass, ownerRelation }}>
{ renderPicker && <LinkPicker
onModalSuccess={onModalSuccess}
onModalClosed={onModalClosed}
types={types}
canCreate={canCreate}
/> }
<div> { renderLinks() } </div>
{ renderModal && <LinkModalContainer
types={types}
typeKey={data[editingID]?.typeKey}
Expand All @@ -167,7 +195,7 @@ const LinkField = ({ value = null, onChange, types = [], actions, isMulti = fals
linkID={editingID}
/>
}
</>;
</LinkFieldContext.Provider>;
};

LinkField.propTypes = {
Expand All @@ -177,6 +205,9 @@ LinkField.propTypes = {
actions: PropTypes.object.isRequired,
isMulti: PropTypes.bool,
canCreate: PropTypes.bool.isRequired,
ownerID: PropTypes.number.isRequired,
ownerClass: PropTypes.string.isRequired,
ownerRelation: PropTypes.string.isRequired,
};

// redux actions loaded into props - used to get toast notifications
Expand Down
9 changes: 7 additions & 2 deletions client/src/components/LinkModal/LinkModal.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable */
import React from 'react';
import React, { useContext } from 'react'
import FormBuilderModal from 'components/FormBuilderModal/FormBuilderModal';
import { LinkFieldContext } from 'components/LinkField/LinkField';
import url from 'url';
import qs from 'qs';
import Config from 'lib/Config';
Expand All @@ -11,13 +12,17 @@ const buildSchemaUrl = (typeKey, linkID) => {
const parsedURL = url.parse(schemaUrl);
const parsedQs = qs.parse(parsedURL.query);
parsedQs.typeKey = typeKey;
const { ownerID, ownerClass, ownerRelation } = useContext(LinkFieldContext);
parsedQs.ownerID = ownerID;
parsedQs.ownerClass = ownerClass;
parsedQs.ownerRelation = ownerRelation;
for (const prop of ['href', 'path', 'pathname']) {
parsedURL[prop] = `${parsedURL[prop]}/${linkID}`;
}
return url.format({ ...parsedURL, search: qs.stringify(parsedQs)});
}

const LinkModal = ({ typeTitle, typeKey, linkID = 0, isOpen, onSuccess, onClosed}) => {
const LinkModal = ({ typeTitle, typeKey, linkID = 0, isOpen, onSuccess, onClosed }) => {
if (!typeKey) {
return false;
}
Expand Down
7 changes: 5 additions & 2 deletions client/src/entwine/LinkField.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ jQuery.entwine('ss', ($) => {
* @returns {Object}
*/
getProps() {
const value = this.getInputField().data('value');
const inputField = this.getInputField();
return {
value,
value: inputField.data('value'),
ownerID: inputField.data('owner-id'),
ownerClass: inputField.data('owner-class'),
ownerRelation: inputField.data('owner-relation'),
onChange: this.handleChange.bind(this),
isMulti: this.data('is-multi') ?? false,
types: this.data('types') ?? [],
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"silverstripe/versioned": "^2"
},
"require-dev": {
"dnadesign/silverstripe-elemental": "^5",
"silverstripe/recipe-testing": "^3",
"squizlabs/php_codesniffer": "^3"
},
Expand Down
101 changes: 97 additions & 4 deletions src/Controllers/LinkFieldController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace SilverStripe\LinkField\Controllers;

use SilverStripe\Admin\AdminRootController;
use SilverStripe\Admin\LeftAndMain;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Forms\DefaultFormFactory;
Expand All @@ -18,9 +17,9 @@
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Forms\HiddenField;
use SilverStripe\LinkField\Form\LinkField;
use SilverStripe\LinkField\Services\LinkTypeService;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject;

class LinkFieldController extends LeftAndMain
{
Expand All @@ -46,6 +45,7 @@ public function getClientConfig()
$clientConfig['form']['linkForm'] = [
// schema() is defined on LeftAndMain
// schemaUrl will get the $ItemID and ?typeKey dynamically suffixed in LinkModal.js
// as well as ownerID, OwnerClass and OwnerRelation
'schemaUrl' => $this->Link('schema/linkForm'),
'deleteUrl' => $this->Link('delete'),
'dataUrl' => $this->Link('data'),
Expand Down Expand Up @@ -135,6 +135,15 @@ public function linkDelete(): HTTPResponse
}
// delete() will also delete any published version immediately
$link->delete();
// Update owner object if this Link is on a has_one relation on the owner
$owner = $this->ownerFromRequest();
$ownerRelation = $this->ownerRelationFromRequest();
$hasOne = Injector::inst()->get($owner->ClassName)->hasOne();
if (array_key_exists($ownerRelation, $hasOne) && $owner->canEdit()) {
$owner->$ownerRelation = null;
$owner->write();
}
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
// Send response
$response = $this->getResponse();
$response->addHeader('Content-type', 'application/json');
$response->setBody(json_encode(['success' => true]));
Expand Down Expand Up @@ -215,6 +224,23 @@ public function save(array $data, Form $form): HTTPResponse
$link->write();
}

// Update owner object if this Link is on a has_one relation on the owner
// Only do this for has_one, not has_many, because that's stored directly on the Link record
// Get owner using ownerFromRequest() rather than $link->Owner() so that validation is run
// on the owner params before updating the database
$owner = $this->ownerFromRequest();
$ownerRelation = $this->ownerRelationFromRequest();
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
$ownerRelationID = "{$ownerRelation}ID";
$hasOne = Injector::inst()->get($owner->ClassName)->hasOne();
if ($operation === 'create'
&& array_key_exists($ownerRelation, $hasOne)
&& $owner->$ownerRelationID !== $link->ID
&& $owner->canEdit()
) {
$owner->$ownerRelation = $link;
$owner->write();
}

// Create a new Form so that it has the correct ID for the DataObject when creating
// a new DataObject, as well as anything else on the DataObject that may have been
// updated in an extension hook. We do this so that the FormSchema state is correct
Expand All @@ -240,10 +266,22 @@ private function createLinkForm(Link $link, string $operation): Form
$name = sprintf(self::FORM_NAME_TEMPLATE, $id);
/** @var Form $form */
$form = $formFactory->getForm($this, $name, ['Record' => $link]);

$owner = $this->ownerFromRequest();
$ownerID = $owner->ID;
$ownerClassName = $owner->ClassName;
$ownerRelation = $this->ownerRelationFromRequest();

// Add hidden form fields for OwnerID, OwnerClass and OwnerRelation
if ($operation === 'create') {
$form->Fields()->push(HiddenField::create('OwnerID')->setValue($ownerID));
$form->Fields()->push(HiddenField::create('OwnerClass')->setValue($ownerClassName));
$form->Fields()->push(HiddenField::create('OwnerRelation')->setValue($ownerRelation));
}
// Set where the form is submitted to
$typeKey = LinkTypeService::create()->keyByClassName($link->ClassName);
$form->setFormAction($this->Link("linkForm/$id?typeKey=$typeKey"));
$url = $this->Link("linkForm/$id?typeKey=$typeKey&ownerID=$ownerID&ownerClass=$ownerClassName"
. "&ownerRelation=$ownerRelation");
$form->setFormAction($url);

// Add save action button
$title = $id
Expand Down Expand Up @@ -358,4 +396,59 @@ private function typeKeyFromRequest(): string
}
return $typeKey;
}

/**
* Get the owner based on the query string params ownerID, ownerClass, ownerRelation
* OR the POST vars OwnerID, OwnerClass, OwnerRelation
*/
private function ownerFromRequest(): DataObject
{
$request = $this->getRequest();
$ownerID = (int) ($request->getVar('ownerID') ?: $request->postVar('OwnerID'));
if ($ownerID === 0) {
$this->jsonError(404, _t('LinkField.INVALID_OWNER_ID', 'Invalid ownerID'));
}
$ownerClass = $request->getVar('ownerClass') ?: $request->postVar('OwnerClass');
if (!is_a($ownerClass, DataObject::class, true)) {
$this->jsonError(404, _t('LinkField.INVALID_OWNER_CLASS', 'Invalid ownerClass'));
}
$ownerRelation = $this->ownerRelationFromRequest();
/** @var DataObject $obj */
$obj = Injector::inst()->get($ownerClass);
$hasOne = $obj->hasOne();
$hasMany = $obj->hasMany();
$matchedRelation = false;
foreach ([$hasOne, $hasMany] as $property) {
if (!array_key_exists($ownerRelation, $property)) {
continue;
}
$className = $property[$ownerRelation];
if (is_a($className, Link::class, true)) {
$matchedRelation = true;
break;
}
}
if ($matchedRelation) {
/** @var DataObject $ownerClass */
$owner = $ownerClass::get()->byID($ownerID);
if ($owner) {
return $owner;
}
}
$this->jsonError(404, _t('LinkField.INVALID_OWNER', 'Invalid Owner'));
}

/**
* Get the owner relation based on the query string param ownerRelation
* OR the POST var OwnerRelation
*/
private function ownerRelationFromRequest(): string
{
$request = $this->getRequest();
$ownerRelation = $request->getVar('ownerRelation') ?: $request->postVar('OwnerRelation');
if (!$ownerRelation) {
$this->jsonError(404, _t('LinkField.INVALID_OWNER_RELATION', 'Invalid ownerRelation'));
}
return $ownerRelation;
}
}
41 changes: 10 additions & 31 deletions src/Form/LinkField.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

use LogicException;
use SilverStripe\Forms\FormField;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;
use SilverStripe\LinkField\Models\Link;
use SilverStripe\LinkField\Form\Traits\AllowedLinkClassesTrait;
use SilverStripe\LinkField\Form\Traits\LinkFieldGetOwnerTrait;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;

/**
* Allows CMS users to edit a Link object.
Expand All @@ -33,35 +33,6 @@ public function setValue($value, $data = null)
return parent::setValue($id, $data);
}

/**
* @param DataObject|DataObjectInterface $record - A DataObject such as a Page
* @return $this
*/
public function saveInto(DataObjectInterface $record)
{
// Check required relation details are available
$fieldname = $this->getName();
if (!$fieldname) {
throw new LogicException('LinkField must have a name');
}

$linkID = $this->dataValue();
$dbColumn = $fieldname . 'ID';
$record->$dbColumn = $linkID;

// Store the record as the owner of the link.
// Required for permission checks, etc.
$link = Link::get()->byID($linkID);
if ($link) {
$link->OwnerID = $record->ID;
$link->OwnerClass = $record->ClassName;
$link->OwnerRelation = $fieldname;
$link->write();
}

return $this;
}

public function getSchemaStateDefaults()
{
$data = parent::getSchemaStateDefaults();
Expand All @@ -74,13 +45,21 @@ protected function getDefaultAttributes(): array
$attributes = parent::getDefaultAttributes();
$attributes['data-value'] = $this->Value();
$attributes['data-can-create'] = $this->getOwner()->canEdit();
$ownerFields = $this->getOwnerFields();
$attributes['data-owner-id'] = $ownerFields['ID'];
$attributes['data-owner-class'] = $ownerFields['Class'];
$attributes['data-owner-relation'] = $ownerFields['Relation'];
return $attributes;
}

public function getSchemaDataDefaults()
{
$data = parent::getSchemaDataDefaults();
$data['types'] = json_decode($this->getTypesProps());
$ownerFields = $this->getOwnerFields();
$data['ownerID'] = $ownerFields['ID'];
$data['ownerClass'] = $ownerFields['Class'];
$data['ownerRelation'] = $ownerFields['Relation'];
return $data;
}
}
Loading