Skip to content

Commit

Permalink
SPIKE Inline validation
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Feb 26, 2024
1 parent 136f1ea commit 3f8cb66
Show file tree
Hide file tree
Showing 5 changed files with 187 additions and 21 deletions.
2 changes: 1 addition & 1 deletion client/dist/js/bundle.js

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions client/src/boot/registerTransforms.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import addElementToArea from 'state/editor/addElementMutation';
import ArchiveAction from 'components/ElementActions/ArchiveAction';
import DuplicateAction from 'components/ElementActions/DuplicateAction';
import PublishAction from 'components/ElementActions/PublishAction';
import SaveAction from 'components/ElementActions/SaveAction';
import UnpublishAction from 'components/ElementActions/UnpublishAction';

export default () => {
Expand Down Expand Up @@ -75,7 +74,6 @@ export default () => {

// Add elemental editor actions
Injector.transform('element-actions', (updater) => {
updater.component('ElementActions', SaveAction, 'ElementActionsWithSave');
updater.component('ElementActions', PublishAction, 'ElementActionsWithPublish');
updater.component('ElementActions', UnpublishAction, 'ElementActionsWithUnpublish');
updater.component('ElementActions', DuplicateAction, 'ElementActionsWithDuplicate');
Expand Down
6 changes: 5 additions & 1 deletion client/src/components/ElementEditor/InlineEditForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@ class InlineEditForm extends PureComponent {
const classNames = classnames('element-editor-editform', extraClass);
const schemaUrl = loadElementSchemaValue('schemaUrl', elementId);

// formTag needs to be a form rather than a div so that the php FormAction that turns into
// a <button type="submit>" submits this <form>, rather than the <form> for the parent page EditForm
const formTag = 'form';

const formProps = {
formTag: 'div',
formTag,
schemaUrl,
identifier: 'element',
refetchSchemaOnMount: !formHasState,
Expand Down
170 changes: 153 additions & 17 deletions src/Controllers/ElementalAreaController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
use SilverStripe\Core\Convert;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Forms\Form;
use SilverStripe\ORM\ValidationException;
use SilverStripe\Security\SecurityToken;
use SilverStripe\ORM\ValidationResult;
use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\FieldList;
use SilverStripe\Control\Controller;
use SilverStripe\Forms\DefaultFormFactory;

/**
* Controller for "ElementalArea" - handles loading and saving of in-line edit forms in an elemental area in admin
Expand Down Expand Up @@ -97,11 +103,37 @@ public function getElementForm($elementID)
['Record' => $element]
);

$urlSegment = $this->config()->get('url_segment');
$form->setFormAction("admin/$urlSegment/api/saveForm/$elementID");
$form->setEncType('application/json');

if (!$element->canEdit()) {
$form->makeReadonly();
}

$form->addExtraClass('element-editor-editform__form');
$form->addExtraClass('bypass-entwine');

// copying from linkfield controller
$id = $elementID;

$title = 'Save'; // todo make translatable
$actions = FieldList::create([
FormAction::create('save', $title)
->setSchemaData(['data' => ['buttonStyle' => 'primary']])
]);
$form->setActions($actions);

// Set the form request handler to return a FormSchema response during a POST request
// This will override the default FormRequestHandler::getAjaxErrorResponse() which isn't useful
$form->setValidationResponseCallback(function (ValidationResult $errors) use ($form, $id) {
$schemaId = Controller::join_links(
$this->Link('schema'),
$this->config()->get('url_segment'),
$id
);
return $this->getSchemaResponse($schemaId, $form, $errors);
});

return $form;
}
Expand All @@ -115,13 +147,20 @@ public function getElementForm($elementID)
*/
public function apiSaveForm(HTTPRequest $request)
{
$id = $this->urlParams['ID'] ?? 0;
// Validate required input data
if (!isset($this->urlParams['ID'])) {
if ($id === 0) {
$this->jsonError(400);
return null;
}

$data = json_decode($request->getBody(), true);
// previously was json being sent by the SaveAction.js request
//$data = json_decode($request->getBody(), true);

// now will be form urlencoded data coming from the form
// created by formbuilder
$data = $request->postVars();

if (empty($data)) {
$this->jsonError(400);
return null;
Expand All @@ -139,40 +178,137 @@ public function apiSaveForm(HTTPRequest $request)
}

/** @var BaseElement $element */
$element = BaseElement::get()->byID($this->urlParams['ID']);
$element = BaseElement::get()->byID($id);
// Ensure the element can be edited by the current user
if (!$element || !$element->canEdit()) {
$this->jsonError(403);
return null;
}

// Remove the pseudo namespaces that were added by the form factory
$data = $this->removeNamespacesFromFields($data, $element->ID);
$dataWithoutNamespaces = $this->removeNamespacesFromFields($data, $element->ID);

try {
$updated = false;

$element->updateFromFormData($data);
// Check if anything will actually be changed before writing
if ($element->isChanged()) {
$element->write();
// Track changes so we can return to the client
$updated = true;
// create a temporary Form to use for validation - will contain existing dataobject values
$form = $this->getElementForm($id);
// remove element namespaces from fields so that something like RequiredFields('Title') works
// element namespaces are added in DNADesign\Elemental\Forms\EditFormFactory
foreach ($form->Fields()->flattenFields() as $field) {
$rx = '#^PageElements_[0-9]+_#';
$namespacedName = $field->getName();
if (!preg_match($rx, $namespacedName)) {
continue;
}
$regularName = preg_replace($rx, '', $namespacedName);
// If there's an existing field with the same name, remove it
// this is probably a workaround for EditFormFactory creating too many fields?
// e.g. for element #2 there's a "Title" field and a "PageElements_2_Title" field
// same with "SecurityID" and "PageElements_2_SecurityID"
// possibly this would be better to just remove fields if they match the rx, not sure,
// this approach seems more conservative
if ($form->Fields()->flattenFields()->fieldByName($regularName)) {
$form->Fields()->removeByName($regularName);
}
// update the name of the field
$field->setName($regularName);
}
// merge submitted data into the form
$form->loadDataFrom($dataWithoutNamespaces);

// validate the Form
$validationResult = $form->validationResult();

// validate the DataObject
$element->updateFromFormData($dataWithoutNamespaces);
$validationResult->combineAnd($element->validate());

// todo? FormField validation? what happens when I add a UrlField?
// handle validation failure and sent json formschema as response
if (!$validationResult->isValid()) {
// Re-add prefixes to field names
$prefixedValidationResult = ValidationResult::create();
foreach ($validationResult->getMessages() as $message) {
$prefixedValidationResult->addFieldMessage(
'PageElements_' . $id . '_' . $message['fieldName'],
// $message['fieldName'],
$message['message'],
'validation' // $message['messageType'], // <<< there isn't a constant for this
);
}
} catch (Exception $ex) {
Injector::inst()->get(LoggerInterface::class)->debug($ex->getMessage());
// add headers to the request here so you don't need to do it in the client
// in the future I'd like these be the default response from formschema if
// the header wasn't defined
$request->addHeader('X-Formschema-Request', 'auto,schema,state,errors');
// generate schema response
$url = $this->getRequest()->getURL(); // admin/elemntal-area/api/saveForm/3

// get a new form with namespaces and populate with raw data that includes namespaces
$form = $this->getElementForm($id);
$form->loadDataFrom($data);

$response = $this->getSchemaResponse($url, $form, $prefixedValidationResult);
// returning a 400 means that FormBuilder.js::handleSubmit() submitFn()
// that will end up in the catch() .. throw error block and the error
// will just end up in the javascript console
// $response->setStatusCode(400);
//
// return a 200 for now just to get things to work even though it's
// clearly the wrong code. Will require a PR to admin to fix this
$response->setStatusCode(200);
return $response;
}

$this->jsonError(500);
return null;
// write the data object
$updated = false;
if ($element->isChanged()) {
$element->write();
// Track changes so we can return to the client
$updated = true;
}

// create and send success json response
$body = json_encode([
'status' => 'success',
'updated' => $updated,
]);
return HTTPResponse::create($body)->addHeader('Content-Type', 'application/json');
}

/**
* Override LeftAndMain::jsonError() to allow multiple error messages
*
* This is fairly ridicious, it's really for demo purposes
* We could use this though we'd be better off updating LeftAndMain::jsonError() to support multiple errors
*/
public function jsonError($errorCode, $errorMessage = null)
{
try {
parent::jsonError($errorCode, $errorMessage);
} catch (HTTPResponse_Exception $e) {
// JeftAndMain::jsonError() will always throw this exception
if (!is_array($errorMessage)) {
// single error, no need to update
throw $e;
}
// multiple errors
$response = $e->getResponse();
$json = json_decode($response->getBody(), true);
$errors = [];
foreach ($errorMessage as $message) {
$errors[] = [
'type' => 'error',
'code' => $errorCode,
'value' => $message
];
}
$json['errors'] = $errors;
$body = json_encode($json);
$response->setBody($body);
$e->setResponse($response);
throw $e;
}
}

/**
* Provides action control for form fields that are request handlers when they're used in an in-line edit form.
*
Expand Down
28 changes: 28 additions & 0 deletions src/Forms/EditFormFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use SilverStripe\Forms\DefaultFormFactory;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\HTMLEditor\HTMLEditorField;
use SilverStripe\ORM\DataObject;
use SilverStripe\Forms\RequiredFields;

class EditFormFactory extends DefaultFormFactory
{
Expand Down Expand Up @@ -55,6 +57,32 @@ protected function getFormFields(RequestHandler $controller = null, $name, $cont
return $fields;
}

protected function getFormValidator(RequestHandler $controller = null, $name, $context = [])
{
$compositeValidator = parent::getFormValidator($controller, $name, $context);
if (!$compositeValidator) {
return null;
}
$id = $context['Record']->ID;
foreach ($compositeValidator->getValidators() as $validator) {
if (is_a($validator, RequiredFields::class)) {
$requiredFields = $validator->getRequired();
foreach ($requiredFields as $requiredField) {
// Add more required fields with appendend field prefixes
// this is done so that front end validation works, at least for RequiredFields
// you'll end up with two sets of required fields:
// - Title -- used for backend validation when inline saving an element
// - PageElements_<ElementID>_Title -- used for frontend js validation onchange()
// note that if a required field is "missing" from submitted data, this is not a
// problem so it's OK to add extra fields here just for frontend validation
$prefixedRequiredField = "PageElements_{$id}_$requiredField";
$validator->addRequiredField($prefixedRequiredField);
}
}
}
return $compositeValidator;
}

/**
* Given a {@link FieldList}, give all fields a unique name so they can be used in the same context as
* other elemental edit forms and the page (or other DataObject) that owns them.
Expand Down

0 comments on commit 3f8cb66

Please sign in to comment.