Skip to content

Commit

Permalink
Merge pull request #146 from creative-commoners/pulls/4/ajax-relations
Browse files Browse the repository at this point in the history
ENH Save relations on link creation
  • Loading branch information
GuySartorelli authored Dec 22, 2023
2 parents 3528526 + 279cfb5 commit 80b0867
Show file tree
Hide file tree
Showing 14 changed files with 313 additions and 111 deletions.
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();
}
// 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();
$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

0 comments on commit 80b0867

Please sign in to comment.