diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 07ec3aa..dacb95f 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -110,6 +110,9 @@ class AppController extends Controller $this->ACL->checkAccess(); $this->set('menu', $this->ACL->getMenu()); $this->set('ajax', $this->request->is('ajax')); + if (!empty($this->request->getHeader('X-Request-Html-On-Failure'))) { + $this->ajax_with_html_on_failure = true; + } $this->request->getParam('prefix'); $this->set('darkMode', !empty(Configure::read('Cerebrate.dark'))); } diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 20755c4..3ec060e 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -81,13 +81,16 @@ class CRUDComponent extends Component $patchEntityParams['fields'] = $params['fields']; } $data = $this->Table->patchEntity($data, $input, $patchEntityParams); - if ($this->Table->save($data)) { + $savedData = $this->Table->save($data); + if ($savedData !== false) { $message = __('{0} added.', $this->ObjectAlias); if (!empty($input['metaFields'])) { $this->saveMetaFields($data->id, $input); } if ($this->Controller->ParamHandler->isRest()) { $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json'); + } else if ($this->Controller->ParamHandler->isAjax()) { + $this->Controller->ajaxResponsePayload = $this->Controller->RestResponse->ajaxSuccessResponse($this->ObjectAlias, 'add', $savedData, $message); } else { $this->Controller->Flash->success($message); if (!empty($params['displayOnSuccess'])) { @@ -103,6 +106,7 @@ class CRUDComponent extends Component } } } else { + $this->Controller->isFailResponse = true; $validationMessage = $this->prepareValidationError($data); $message = __( '{0} could not be added.{1}', @@ -110,7 +114,8 @@ class CRUDComponent extends Component empty($validationMessage) ? '' : ' ' . __('Reason:{0}', $validationMessage) ); if ($this->Controller->ParamHandler->isRest()) { - + } else if ($this->Controller->ParamHandler->isAjax()) { + $this->Controller->ajaxResponsePayload = $this->Controller->RestResponse->ajaxFailResponse($this->ObjectAlias, 'add', $data, $message, $validationMessage); } else { $this->Controller->Flash->error($message); } diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index d1bbcbd..7d39103 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -25,6 +25,10 @@ class UsersController extends AppController $this->CRUD->add(); if ($this->ParamHandler->isRest()) { return $this->restResponsePayload; + } else if ($this->ParamHandler->isAjax() && $this->request->is(['post', 'put'])) { + if (empty($this->isFailResponse) || empty($this->ajax_with_html_on_failure)) { + return $this->ajaxResponsePayload; + } } $dropdownData = [ 'role' => $this->Users->Roles->find('list', [ diff --git a/templates/element/genericElements/Form/genericForm.php b/templates/element/genericElements/Form/genericForm.php index 2217ca7..6e642ff 100644 --- a/templates/element/genericElements/Form/genericForm.php +++ b/templates/element/genericElements/Form/genericForm.php @@ -12,6 +12,7 @@ - use these to define dynamic form fields, or anything that will feed into the regular fields via JS population * - submit: The submit button itself. By default it will simply submit to the form as defined via the 'model' field */ + $this->Form->setConfig('errorClass', 'is-invalid'); $modelForForm = empty($data['model']) ? h(\Cake\Utility\Inflector::singularize(\Cake\Utility\Inflector::classify($this->request->getParam('controller')))) : h($data['model']); @@ -35,11 +36,14 @@ 'select' => '', 'checkbox' => '', 'checkboxFormGroup' => '{{label}}', - 'formGroup' => '
{{label}}
{{input}}
', + 'formGroup' => '
{{label}}
{{input}}{{error}}
', 'nestingLabel' => '{{hidden}}
{{text}}
{{input}}
', 'option' => '', 'optgroup' => '{{content}}', - 'select' => '' + 'select' => '', + 'error' => '
{{content}}
', + 'errorList' => '', + 'errorItem' => '
  • {{text}}
  • ', ]; if (!empty($data['fields'])) { foreach ($data['fields'] as $fieldData) { @@ -49,6 +53,7 @@ } } // we reset the template each iteration as individual fields might override the defaults. + $this->Form->setConfig($default_template); $this->Form->setTemplates($default_template); if (isset($fieldData['requirements']) && !$fieldData['requirements']) { continue; diff --git a/templates/element/genericElements/Form/submitButton.php b/templates/element/genericElements/Form/submitButton.php index 3656e70..9a2a82c 100644 --- a/templates/element/genericElements/Form/submitButton.php +++ b/templates/element/genericElements/Form/submitButton.php @@ -3,8 +3,8 @@ echo sprintf( '%s', sprintf( - '', - "$('#form-" . h($formRandomValue) . "').submit()", + '', + '#form-' . h($formRandomValue), __('Submit') ) ); diff --git a/templates/element/genericElements/ListTopBar/element_simple.php b/templates/element/genericElements/ListTopBar/element_simple.php index 1713b48..3ecd987 100644 --- a/templates/element/genericElements/ListTopBar/element_simple.php +++ b/templates/element/genericElements/ListTopBar/element_simple.php @@ -2,7 +2,7 @@ if (!isset($data['requirement']) || $data['requirement']) { if (!empty($data['popover_url'])) { $onClick = sprintf( - 'onClick="populateAndLoadModal(%s)"', + 'onClick="openModalFromURL(%s)"', sprintf("'%s'", h($data['popover_url'])) ); } @@ -67,3 +67,11 @@ ); } ?> + + \ No newline at end of file diff --git a/templates/element/genericElements/ListTopBar/group_simple.php b/templates/element/genericElements/ListTopBar/group_simple.php index cdc87d7..0f286ab 100644 --- a/templates/element/genericElements/ListTopBar/group_simple.php +++ b/templates/element/genericElements/ListTopBar/group_simple.php @@ -2,7 +2,7 @@ if (!isset($data['requirement']) || $data['requirement']) { $elements = ''; foreach ($data['children'] as $element) { - $elements .= $this->element('/genericElements/ListTopBar/element_' . (empty($element['type']) ? 'simple' : h($element['type'])), array('data' => $element)); + $elements .= $this->element('/genericElements/ListTopBar/element_' . (empty($element['type']) ? 'simple' : h($element['type'])), array('data' => $element, 'tableRandomValue' => $tableRandomValue)); } echo sprintf( '
    %s
    ', diff --git a/templates/layout/default.php b/templates/layout/default.php index d42f2cd..7f2d993 100644 --- a/templates/layout/default.php +++ b/templates/layout/default.php @@ -66,7 +66,7 @@ $cakeDescription = 'Cerebrate'; -
    +
    diff --git a/webroot/js/api-helper.js b/webroot/js/api-helper.js index d6237ad..b062f42 100644 --- a/webroot/js/api-helper.js +++ b/webroot/js/api-helper.js @@ -1,19 +1,27 @@ class AJAXApi { - static genericRequestHeaders = new Headers({ + static genericRequestHeaders = { 'X-Requested-With': 'XMLHttpRequest' - }); + }; static genericRequestConfigGET = { - headers: AJAXApi.genericRequestHeaders + headers: new Headers(Object.assign({}, AJAXApi.genericRequestHeaders)) } static genericRequestConfigPOST = { - headers: AJAXApi.genericRequestHeaders, + headers: new Headers(Object.assign({}, AJAXApi.genericRequestHeaders)), redirect: 'manual', method: 'POST', } + static renderHTMLOnFailureHeader = { + name: 'X-Request-HTML-On-Failure', + value: '1' + } static defaultOptions = { provideFeedback: true, statusNode: false, + renderedHTMLOnFailureRequested: false, + errorToast: { + delay: 10000 + } } options = {} loadingOverlay = false @@ -23,12 +31,15 @@ class AJAXApi { this.mergeOptions(options) } - provideFeedback(options, isError=false) { - if (this.options.provideFeedback) { - UI.toast(options) - } else { - if (isError) { - console.error(options.body) + provideFeedback(toastOptions, isError=false, skip=false) { + const alteredToastOptions = isError ? Object.assign({}, AJAXApi.defaultOptions.errorToast, toastOptions) : toastOptions + if (!skip) { + if (this.options.provideFeedback) { + UI.toast(alteredToastOptions) + } else { + if (isError) { + console.error(alteredToastOptions.body) + } } } } @@ -56,13 +67,19 @@ class AJAXApi { return tmpApi.fetchForm(url, constAlteredOptions.skipRequestHooks) } + static async quickPostForm(form, dataToMerge={}, options={}) { + const constAlteredOptions = Object.assign({}, {}, options) + const tmpApi = new AJAXApi(constAlteredOptions) + return tmpApi.postForm(form, dataToMerge, constAlteredOptions.skipRequestHooks) + } + static async quickFetchAndPostForm(url, dataToMerge={}, options={}) { const constAlteredOptions = Object.assign({}, {}, options) const tmpApi = new AJAXApi(constAlteredOptions) return tmpApi.fetchAndPostForm(url, dataToMerge, constAlteredOptions.skipRequestHooks) } - async fetchURL(url, skipRequestHooks=false) { + async fetchURL(url, skipRequestHooks=false, skipFeedback=false) { if (!skipRequestHooks) { this.beforeRequest() } @@ -76,14 +93,14 @@ class AJAXApi { this.provideFeedback({ variant: 'success', title: 'URL fetched', - }); + }, false, skipFeedback); toReturn = data; } catch (error) { this.provideFeedback({ variant: 'danger', title: 'There has been a problem with the operation', body: error - }, true); + }, true, skipFeedback); toReturn = Promise.reject(error); } finally { if (!skipRequestHooks) { @@ -93,7 +110,7 @@ class AJAXApi { return toReturn } - async fetchForm(url, skipRequestHooks=false) { + async fetchForm(url, skipRequestHooks=false, skipFeedback=false) { if (!skipRequestHooks) { this.beforeRequest() } @@ -116,7 +133,74 @@ class AJAXApi { variant: 'danger', title: 'There has been a problem with the operation', body: error - }, true); + }, true, skipFeedback); + toReturn = Promise.reject(error); + } finally { + if (!skipRequestHooks) { + this.afterRequest() + } + } + return toReturn + } + + async postForm(form, dataToMerge={}, skipRequestHooks=false, skipFeedback=false) { + if (!skipRequestHooks) { + this.beforeRequest() + } + let toReturn + let feedbackShown = false + try { + try { + let formData = new FormData(form) + formData = AJAXApi.mergeFormData(formData, dataToMerge) + let requestConfig = AJAXApi.genericRequestConfigPOST + if (this.options.renderedHTMLOnFailureRequested) { + requestConfig.headers.append(AJAXApi.renderHTMLOnFailureHeader.name, AJAXApi.renderHTMLOnFailureHeader.value) + } + let options = { + ...requestConfig, + body: formData, + }; + const response = await fetch(form.action, options); + if (!response.ok) { + throw new Error('Network response was not ok') + } + const clonedResponse = response.clone() + try { + const data = await response.json() + if (data.success) { + this.provideFeedback({ + variant: 'success', + body: data.message + }, false, skipFeedback); + toReturn = data; + } else { + this.provideFeedback({ + variant: 'danger', + title: 'There has been a problem with the operation', + body: data.errors + }, true, skipFeedback); + feedbackShown = true + toReturn = Promise.reject(data.errors); + } + } catch (error) { // could not parse JSON + if (this.options.renderedHTMLOnFailureRequested) { + const data = await clonedResponse.text(); + toReturn = { + success: 0, + html: data, + } + } + } + } catch (error) { + this.provideFeedback({ + variant: 'danger', + title: 'There has been a problem with the operation', + body: error + }, true, feedbackShown); + toReturn = Promise.reject(error); + } + } catch (error) { toReturn = Promise.reject(error); } finally { if (!skipRequestHooks) { @@ -132,41 +216,8 @@ class AJAXApi { } let toReturn try { - const form = await this.fetchForm(url, true); - try { - let formData = new FormData(form) - formData = AJAXApi.mergeFormData(formData, dataToMerge) - let options = { - ...AJAXApi.genericRequestConfigPOST, - body: formData, - }; - const response = await fetch(form.action, options); - if (!response.ok) { - throw new Error('Network response was not ok') - } - const data = await response.json(); - if (data.success) { - this.provideFeedback({ - variant: 'success', - body: data.message - }); - toReturn = data; - } else { - this.provideFeedback({ - variant: 'danger', - title: 'There has been a problem with the operation', - body: data.errors - }, true); - toReturn = Promise.reject(error); - } - } catch (error) { - this.provideFeedback({ - variant: 'danger', - title: 'There has been a problem with the operation', - body: error - }, true); - toReturn = Promise.reject(error); - } + const form = await this.fetchForm(url, true, true); + toReturn = await this.postForm(form, dataToMerge, true, true) } catch (error) { toReturn = Promise.reject(error); } finally { diff --git a/webroot/js/bootstrap-helper.js b/webroot/js/bootstrap-helper.js index a27ac4b..f602c9c 100644 --- a/webroot/js/bootstrap-helper.js +++ b/webroot/js/bootstrap-helper.js @@ -16,6 +16,22 @@ class UIFactory { return theModal } + /* Display a modal based on provided options */ + modalFromURL (url, successCallback, failCallback) { + return AJAXApi.quickFetchURL(url).then((modalHTML) => { + const theModal = new ModalFactory({ + rawHTML: modalHTML, + replaceFormSubmissionByAjax: true, + successCallback: successCallback !== undefined ? successCallback : () => {}, + failCallback: failCallback !== undefined ? failCallback : (errorMessage) => {}, + }); + theModal.makeModal(modalHTML) + theModal.show() + theModal.$modal.data('modalObject', theModal) + return theModal + }) + } + /* Fetch HTML from the provided URL and override content of $container. $statusNode allows to specify another HTML node to display the loading */ reload (url, $container, $statusNode=null) { $container = $($container) @@ -26,7 +42,7 @@ class UIFactory { AJAXApi.quickFetchURL(url, { statusNode: $statusNode[0] }).then((data) => { - $container[0].outerHTML = data + $container.replaceWith(data) }) } } @@ -143,6 +159,7 @@ class ModalFactory { titleHtml: false, body: false, bodyHtml: false, + rawHTML: false, variant: '', modalClass: [], headerClass: [], @@ -160,6 +177,8 @@ class ModalFactory { error: function() {}, shownCallback: function() {}, hiddenCallback: function() {}, + successCallback: function() {}, + replaceFormSubmissionByAjax: false } static availableType = [ @@ -190,6 +209,9 @@ class ModalFactory { }) .on('shown.bs.modal', function () { that.options.shownCallback() + if (that.options.replaceFormSubmissionByAjax) { + that.replaceFormSubmissionByAjax() + } }) } } @@ -203,11 +225,11 @@ class ModalFactory { } isValid() { - return this.options.title !== false || this.options.body !== false || this.options.titleHtml !== false || this.options.bodyHtml !== false + return this.options.title !== false || this.options.body !== false || this.options.titleHtml !== false || this.options.bodyHtml !== false || this.options.rawHTML !== false } buildModal() { - var $modal = $('