chg: [generic] Added Modal from URL support

- Support Form submission
- Success / Fail callbacks
- Modal reloading in case of validation errors
pull/37/head
mokaddem 2020-12-15 10:40:49 +01:00
parent ae0272a62c
commit f9bf1c6f55
10 changed files with 254 additions and 119 deletions

View File

@ -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')));
}

View File

@ -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);
}

View File

@ -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', [

View File

@ -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' => '<select name="{{name}}" {{attrs}}>{{content}}</select>',
'checkbox' => '<input type="checkbox" name="{{name}}" value="{{value}}"{{attrs}}>',
'checkboxFormGroup' => '{{label}}',
'formGroup' => '<div class="col-sm-2 col-form-label" {{attrs}}>{{label}}</div><div class="col-sm-10">{{input}}</div>',
'formGroup' => '<div class="col-sm-2 col-form-label" {{attrs}}>{{label}}</div><div class="col-sm-10">{{input}}{{error}}</div>',
'nestingLabel' => '{{hidden}}<div class="col-sm-2 col-form-label">{{text}}</div><div class="col-sm-10">{{input}}</div>',
'option' => '<option value="{{value}}"{{attrs}}>{{text}}</option>',
'optgroup' => '<optgroup label="{{label}}"{{attrs}}>{{content}}</optgroup>',
'select' => '<select name="{{name}}"{{attrs}}>{{content}}</select>'
'select' => '<select name="{{name}}"{{attrs}}>{{content}}</select>',
'error' => '<div class="error-message invalid-feedback d-block">{{content}}</div>',
'errorList' => '<ul>{{content}}</ul>',
'errorItem' => '<li>{{text}}</li>',
];
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;

View File

@ -3,8 +3,8 @@
echo sprintf(
'%s',
sprintf(
'<button id="submitButton" class="btn btn-primary" onClick="%s" autofocus>%s</button>',
"$('#form-" . h($formRandomValue) . "').submit()",
'<button id="submitButton" class="btn btn-primary" data-form-id="%s" autofocus>%s</button>',
'#form-' . h($formRandomValue),
__('Submit')
)
);

View File

@ -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 @@
);
}
?>
<script>
function openModalFromURL(url) {
UI.modalFromURL(url, (data) => {
UI.reload('<?= $this->Url->build(['action' => 'index']); ?>', $('#table-container-<?= $tableRandomValue ?>'), $('#table-container-<?= $tableRandomValue ?> table.table'))
})
}
</script>

View File

@ -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(
'<div %s class="btn-group mr-2" role="group" aria-label="button-group">%s</div>',

View File

@ -66,7 +66,7 @@ $cakeDescription = 'Cerebrate';
</div>
</main>
<div id="mainModal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="mediumModalLabel" aria-hidden="true"></div>
<div id="mainToastContainer" style="position: absolute; top: 15px; right: 15px;"></div>
<div id="mainToastContainer" style="position: absolute; top: 15px; right: 15px; z-index: 1080"></div>
<div id="mainModalContainer"></div>
</body>
</html>

View File

@ -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 {

View File

@ -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 = $('<div class="modal fade" tabindex="-1" aria-hidden="true"/>')
const $modal = $('<div class="modal fade" tabindex="-1" aria-hidden="true"/>')
if (this.options.id !== false) {
$modal.attr('id', this.options.id)
$modal.attr('aria-labelledby', this.options.id)
@ -215,37 +237,42 @@ class ModalFactory {
if (this.options.modalClass !== false) {
$modal.addClass(this.options.modalClass)
}
var $modalDialog = $('<div class="modal-dialog"/>')
var $modalContent = $('<div class="modal-content"/>')
if (this.options.title !== false || this.options.titleHtml !== false) {
var $modalHeader = $('<div class="modal-header"/>')
var $modalHeaderText
if (this.options.titleHtml !== false) {
$modalHeaderText = $('<div/>').html(this.options.titleHtml);
} else {
$modalHeaderText = $('<h5 class="modal-title"/>').text(this.options.title)
let $modalDialog
if (this.options.rawHTML) {
$modalDialog = $(this.options.rawHTML)
} else {
$modalDialog = $('<div class="modal-dialog"/>')
const $modalContent = $('<div class="modal-content"/>')
if (this.options.title !== false || this.options.titleHtml !== false) {
const $modalHeader = $('<div class="modal-header"/>')
let $modalHeaderText
if (this.options.titleHtml !== false) {
$modalHeaderText = $('<div/>').html(this.options.titleHtml);
} else {
$modalHeaderText = $('<h5 class="modal-title"/>').text(this.options.title)
}
$modalHeader.append($modalHeaderText, ModalFactory.getCloseButton())
$modalContent.append($modalHeader)
}
$modalHeader.append($modalHeaderText, ModalFactory.getCloseButton())
$modalContent.append($modalHeader)
}
if (this.options.body !== false || this.options.bodyHtml !== false) {
var $modalBody = $('<div class="modal-body"/>')
var $modalBodyText
if (this.options.bodyHtml !== false) {
$modalBodyText = $('<div/>').html(this.options.bodyHtml);
} else {
$modalBodyText = $('<div/>').text(this.options.body)
if (this.options.body !== false || this.options.bodyHtml !== false) {
const $modalBody = $('<div class="modal-body"/>')
let $modalBodyText
if (this.options.bodyHtml !== false) {
$modalBodyText = $('<div/>').html(this.options.bodyHtml);
} else {
$modalBodyText = $('<div/>').text(this.options.body)
}
$modalBody.append($modalBodyText)
$modalContent.append($modalBody)
}
$modalBody.append($modalBodyText)
$modalContent.append($modalBody)
const $modalFooter = $('<div class="modal-footer"/>')
$modalFooter.append(this.getFooterBasedOnType())
$modalContent.append($modalFooter)
$modalDialog.append($modalContent)
}
var $modalFooter = $('<div class="modal-footer"/>')
$modalFooter.append(this.getFooterBasedOnType())
$modalContent.append($modalFooter)
$modalDialog.append($modalContent)
$modal.append($modalDialog)
return $modal
}
@ -270,49 +297,81 @@ class ModalFactory {
getFooterConfirm() {
let variant = this.options.type.split('-')[1]
variant = variant !== undefined ? variant : 'primary'
return [
$('<button type="button" class="btn btn-secondary" data-dismiss="modal"></button>')
const $buttonCancel = $('<button type="button" class="btn btn-secondary" data-dismiss="modal"></button>')
.text(this.options.cancelText)
.click(
(evt) => {
this.options.cancel(() => { this.hide() }, this, evt)
}
)
.attr('data-dismiss', (this.options.closeManually || !this.options.closeOnSuccess) ? '' : 'modal'),
$('<button type="button" class="btn"></button>')
.attr('data-dismiss', (this.options.closeManually || !this.options.closeOnSuccess) ? '' : 'modal')
const $buttonConfirm = $('<button type="button" class="btn"></button>')
.addClass('btn-' + variant)
.text(this.options.confirmText)
.click(
(evt) => {
let confirmFunction = this.options.confirm
if (this.options.APIConfirm) {
const tmpApi = new AJAXApi({
statusNode: evt.target
})
confirmFunction = () => { return this.options.APIConfirm(tmpApi) }
}
let confirmResult = confirmFunction(() => { this.hide() }, this, evt)
if (confirmResult === undefined) {
this.hide()
} else {
confirmResult.then(() => {
if (this.options.closeOnSuccess) {
this.hide()
}
})
.catch(() => {
this.options.error(() => { this.hide() }, this, evt)
})
}
}
)
.click(this.getConfirmationHandlerFunction())
.attr('data-dismiss', (this.options.closeManually || this.options.closeOnSuccess) ? '' : 'modal')
]
return [$buttonCancel, $buttonConfirm]
}
static getCloseButton() {
return $(ModalFactory.closeButtonHtml)
}
getConfirmationHandlerFunction() {
return (evt) => {
let confirmFunction = this.options.confirm
if (this.options.APIConfirm) {
const tmpApi = new AJAXApi({
statusNode: evt.target
})
confirmFunction = () => { return this.options.APIConfirm(tmpApi) }
}
let confirmResult = confirmFunction(() => { this.hide() }, this, evt)
if (confirmResult === undefined) {
this.hide()
} else {
confirmResult.then((data) => {
if (this.options.closeOnSuccess) {
this.hide()
}
})
.catch(() => {
this.options.error(() => { this.hide() }, this, evt)
})
}
}
}
replaceFormSubmissionByAjax() {
const $submitButton = this.$modal.find('.modal-footer #submitButton')
const formID = $submitButton.data('form-id')
let $form
if (formID) {
$form = $(formID)
} else {
$form = this.$modal.find('form')
}
this.options.APIConfirm = (tmpApi) => {
tmpApi.mergeOptions({renderedHTMLOnFailureRequested: true})
return tmpApi.postForm($form[0])
.then((data) => {
if (data.success) {
this.options.successCallback(data)
} else { // Validation error, replace modal content with new html
this.$modal.html(data.html)
this.replaceFormSubmissionByAjax()
return Promise.reject('Validation error');
}
})
.catch((errorMessage, response) => {
this.options.failCallback(errorMessage)
return Promise.reject(errorMessage);
})
}
$submitButton.click(this.getConfirmationHandlerFunction())
}
}
class OverlayFactory {