diff --git a/src/CrudPanelManager.php b/src/CrudPanelManager.php index cb7af75a2f..e7cfe4add7 100644 --- a/src/CrudPanelManager.php +++ b/src/CrudPanelManager.php @@ -69,8 +69,9 @@ public function setupCrudPanel(string $controller, ?string $operation = null): C $crud->setOperation($operation); $primaryControllerRequest = $this->cruds[array_key_first($this->cruds)]->getRequest(); - if (! $crud->isInitialized()) { + if (! $crud->isInitialized() || ! $this->isOperationInitialized($controller::class, $operation)) { self::setActiveController($controller::class); + $crud->initialized = false; $controller->initializeCrudPanel($primaryControllerRequest, $crud); self::unsetActiveController(); $crud = $this->cruds[$controller::class]; @@ -106,6 +107,14 @@ public function getInitializedOperations(string $controller): array return $this->initializedOperations[$controller] ?? []; } + /** + * Check if a specific operation has been initialized for a controller. + */ + public function isOperationInitialized(string $controller, string $operation): bool + { + return in_array($operation, $this->getInitializedOperations($controller), true); + } + /** * Store a CrudPanel instance for a specific controller. */ diff --git a/src/app/Http/Controllers/Operations/CreateOperation.php b/src/app/Http/Controllers/Operations/CreateOperation.php index e6282ff68f..a6dc038747 100644 --- a/src/app/Http/Controllers/Operations/CreateOperation.php +++ b/src/app/Http/Controllers/Operations/CreateOperation.php @@ -60,7 +60,9 @@ public function create() $this->data['title'] = $this->crud->getTitle() ?? trans('backpack::crud.add').' '.$this->crud->entity_name; // load the view from /resources/views/vendor/backpack/crud/ if it exists, otherwise load the one in the package - return view($this->crud->getCreateView(), $this->data); + return request()->ajax() ? + view('crud::components.form.ajax_response', $this->data) : + view($this->crud->getCreateView(), $this->data); } /** diff --git a/src/app/Http/Controllers/Operations/UpdateOperation.php b/src/app/Http/Controllers/Operations/UpdateOperation.php index f0c656092c..e6eacf0a28 100644 --- a/src/app/Http/Controllers/Operations/UpdateOperation.php +++ b/src/app/Http/Controllers/Operations/UpdateOperation.php @@ -82,7 +82,9 @@ public function edit($id) $this->data['id'] = $id; // load the view from /resources/views/vendor/backpack/crud/ if it exists, otherwise load the one in the package - return view($this->crud->getEditView(), $this->data); + return request()->ajax() ? + view('crud::components.form.ajax_response', $this->data) : + view($this->crud->getEditView(), $this->data); } /** diff --git a/src/app/Library/CrudPanel/CrudButton.php b/src/app/Library/CrudPanel/CrudButton.php index 5f2f711931..1fabb2ca8d 100644 --- a/src/app/Library/CrudPanel/CrudButton.php +++ b/src/app/Library/CrudPanel/CrudButton.php @@ -295,6 +295,7 @@ public function section($stack) * The HTML itself of the button. * * @param object|null $entry The eloquent Model for the current entry or null if no current entry. + * @param CrudPanel|null $crud The CrudPanel object, if not passed it will be retrieved from the service container. * @return \Illuminate\Contracts\View\View */ public function getHtml($entry = null, ?CrudPanel $crud = null) diff --git a/src/app/View/Components/Form.php b/src/app/View/Components/Form.php new file mode 100644 index 0000000000..a174955743 --- /dev/null +++ b/src/app/View/Components/Form.php @@ -0,0 +1,61 @@ +getOperation(); + } + + $this->crud = CrudManager::setupCrudPanel($controller, $operation); + + if (isset($previousOperation)) { + $this->crud->setOperation($previousOperation); + } + + $this->operation = $operation; + $this->action = $action ?? url($this->crud->route); + $this->hasUploadFields = $this->crud->hasUploadFields($operation); + } + + /** + * Get the view / contents that represent the component. + * + * @return \Illuminate\Contracts\View\View|\Closure|string + */ + public function render() + { + return view('crud::components.form.form', [ + 'crud' => $this->crud, + 'saveAction' => $this->crud->getSaveAction(), + 'id' => $this->id, + 'operation' => $this->operation, + 'action' => $this->action, + 'method' => $this->method, + ]); + } +} diff --git a/src/app/View/Components/FormModal.php b/src/app/View/Components/FormModal.php new file mode 100644 index 0000000000..adb302ab4f --- /dev/null +++ b/src/app/View/Components/FormModal.php @@ -0,0 +1,53 @@ + $this->crud, + 'id' => $this->id, + 'operation' => $this->operation, + 'formRouteOperation' => $this->formRouteOperation, + 'hasUploadFields' => $this->hasUploadFields, + 'refreshDatatable' => $this->refreshDatatable, + 'action' => $this->action, + 'method' => $this->method, + 'title' => $this->title, + 'classes' => $this->classes, + ]); + } +} diff --git a/src/resources/assets/css/common.css b/src/resources/assets/css/common.css index 9c79cc1fc8..87b9222981 100644 --- a/src/resources/assets/css/common.css +++ b/src/resources/assets/css/common.css @@ -1,5 +1,6 @@ :root { --table-row-hover: #f2f1ff; + --btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e"); } .sidebar .nav-dropdown-items .nav-dropdown { @@ -212,6 +213,10 @@ form .select2.select2-container { overflow: visible; } +.modal .btn-close, .modal .close { + background: transparent var(--btn-close-bg) center / .75rem auto no-repeat; +} + /* SELECT 2 */ .select2-container--bootstrap .select2-selection { box-shadow: none !important; diff --git a/src/resources/assets/js/form_modal.js b/src/resources/assets/js/form_modal.js new file mode 100644 index 0000000000..8d7123e408 --- /dev/null +++ b/src/resources/assets/js/form_modal.js @@ -0,0 +1,313 @@ +(function() { + // Initialize modals immediately when the script runs + initializeAllModals(); + // Also listen for DataTable draw events which might add new modals + document.addEventListener('draw.dt', initializeAllModals); +})(); + function initializeAllModals() { + // First, track all initialized modals by their unique ID to avoid duplicates + const initializedModals = new Set(); + + document.querySelectorAll('[id^="modalTemplate"]').forEach(modalTemplate => { + // Extract controller hash from the ID + const controllerId = modalTemplate.id.replace('modalTemplate', ''); + const modalEl = modalTemplate.querySelector('.modal'); + if(!modalEl) { + console.warn(`No modal found in template with ID ${modalTemplate.id}`); + return; + } + + const modalId = modalEl.id; + + // Create a unique key for this modal + const modalKey = `${controllerId}-${modalId}`; + + // Skip if we've already processed an identical modal in this batch + if (initializedModals.has(modalKey)) { + console.log('Skipping already processed modal:', modalTemplate.id); + modalTemplate.remove(); // Remove duplicate + return; + } + + initializedModals.add(modalKey); + + // Get other elements + const formContainer = document.getElementById(`modal-form-container${controllerId}`); + const submitButton = document.getElementById(`submitForm${controllerId}`); + if (!formContainer || !submitButton) { + console.warn(`Missing form elements for controller ID ${controllerId}`); + return; + } + + // Make modal template visible (but modal stays hidden until triggered) + modalTemplate.classList.remove('d-none'); + + // Only set up the event handlers if they don't exist yet + if (!modalEl._loadHandler) { + modalEl._loadHandler = function() { + loadModalForm(controllerId, modalEl, formContainer, submitButton); + }; + modalEl.addEventListener('shown.bs.modal', modalEl._loadHandler); + } + + if (!submitButton._saveHandler) { + submitButton._saveHandler = function() { + submitModalForm(controllerId, formContainer, submitButton, modalEl); + }; + submitButton.addEventListener('click', submitButton._saveHandler); + } + + // Mark as initialized + modalTemplate.setAttribute('data-initialized', 'true'); + + // Initialize Bootstrap modal if it hasn't been initialized yet + if (typeof bootstrap !== 'undefined' && !bootstrap.Modal.getInstance(modalEl)) { + new bootstrap.Modal(modalEl); + } + }); +} + + // Load form contents via AJAX + function loadModalForm(controllerId, modalEl, formContainer, submitButton) { + + submitButton.disabled = true; + + if (formContainer && !formContainer.dataset.loaded) { + // Build URL from current path + const formUrl = formContainer.dataset.formLoadRoute || modalEl.dataset.formLoadRoute || ''; + + fetch(formUrl, { + method: 'GET', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.text()) + .then(html => { + if (!html) { + console.error(`No HTML content returned for controller ID ${controllerId}`); + return; + } + + // add the enctype to the form if it has upload fields + if (formContainer.dataset.hasUploadFields === 'true') { + html = html.replace(/
]*?)(?:action="[^"]*")?([^>]*?)>/, (match, before, after) => { + return ``; + }); + + formContainer.innerHTML = html; + formContainer.dataset.loaded = 'true'; + + // Handle any scripts that came with the response + const scriptElements = formContainer.querySelectorAll('script'); + const scriptsToLoad = []; + + scriptElements.forEach(scriptElement => { + if (scriptElement.src) { + // For external scripts with src attribute + const srcUrl = scriptElement.src; + + // Only load the script if it's not already loaded + if (!document.querySelector(`script[src="${srcUrl}"]`)) { + scriptsToLoad.push(new Promise((resolve, reject) => { + const newScript = document.createElement('script'); + + // Copy all attributes from the original script + Array.from(scriptElement.attributes).forEach(attr => { + newScript.setAttribute(attr.name, attr.value); + }); + + // Set up load and error handlers + newScript.onload = resolve; + newScript.onerror = reject; + + // Append to document to start loading + document.head.appendChild(newScript); + })); + } + + // Remove the original script tag + scriptElement.parentNode.removeChild(scriptElement); + } else { + // For inline scripts + const newScript = document.createElement('script'); + + // Copy all attributes from the original script + Array.from(scriptElement.attributes).forEach(attr => { + newScript.setAttribute(attr.name, attr.value); + }); + + // Copy the content + newScript.textContent = scriptElement.textContent; + + try { + document.head.appendChild(newScript); + }catch (e) { + console.warn('Error appending inline script:', e); + } + } + }); + + // Wait for all external scripts to load before continuing + Promise.all(scriptsToLoad) + .then(() => { + // Initialize the form fields after all scripts are loaded + if (typeof initializeFieldsWithJavascript === 'function') { + try { + initializeFieldsWithJavascript(modalEl); + } catch (e) { + console.error('Error initializing form fields:', e); + } + } + submitButton.disabled = false; + }) + .catch(error => { + submitButton.disabled = false; + }); + + }); + } +} + // Handle form submission + function submitModalForm(controllerId, formContainer, submitButton, modalEl) { + const form = formContainer.querySelector('form'); + if (!form) { + console.error('Form not found in modal'); + return; + } + + const errorsContainer = document.getElementById(`modal-form-errors${controllerId}`); + const errorsList = document.getElementById(`modal-form-errors-list${controllerId}`); + + // Clear previous errors + errorsContainer.classList.add('d-none'); + errorsList.innerHTML = ''; + form.querySelectorAll('.invalid-feedback').forEach(el => el.remove()); + form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid')); + + submitButton.disabled = true; + + const formData = new FormData(form); + // change the form data _method to the one defined in the container + if (formContainer.dataset.formMethod) { + formData.set('_method', formContainer.dataset.formMethod); + } + + // Submit form via AJAX + fetch(form.action, { + method: 'POST', + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json', + } + }) + .then(response => { + if (response.headers.get('content-type') && response.headers.get('content-type').includes('application/json')) { + return response.json().then(data => ({ ok: response.ok, data, status: response.status })); + } + return response.text().then(text => ({ ok: response.ok, data: text, status: response.status })); + }) + .then(result => { + if (result.ok) { + // Success + new Noty({ + type: 'success', + text: 'Entry saved successfully!', + timeout: 3000 + }).show(); + + // Try to close the modal + try { + const bsModal = bootstrap.Modal.getInstance(modalEl); + if (bsModal) { + bsModal.hide(); + } + } catch (e) { + console.warn('Could not close modal automatically:', e); + } + + // Notify listeners with a specific event for this modal + document.dispatchEvent(new CustomEvent(`FormModalSaved_${controllerId}`, { + detail: { controllerId: controllerId, response: result.data } + })); + + // Also dispatch the general event for backward compatibility + document.dispatchEvent(new CustomEvent('FormModalSaved', { + detail: { controllerId: controllerId, response: result.data } + })); + + // Reload the datatable if developer asked for it + if(formContainer.dataset.refreshDatatable === 'true') { + setTimeout(function() { + try { + // Find closest DataTable + const triggerButton = document.querySelector(`[data-target="#${modalEl.id}"]`); + const closestTable = triggerButton ? triggerButton.closest('.dataTable') : null; + if (closestTable && closestTable.id) { + // Access the DataTable instance using the DataTables API + const dataTable = window.DataTable.tables({ visible: true, api: true }).filter( + table => table.getAttribute('id') === closestTable.id + ); + if (dataTable) { + dataTable.ajax.reload(); + } + } + } catch (e) { + try { + // Fallback approach if first method fails + if (typeof table !== 'undefined') { + table.draw(false); + } + } catch (e2) { } + } + }, 100); + } + } else if (result.status === 422) { + // Validation errors + errorsContainer.classList.remove('d-none'); + + for (const field in result.data.errors) { + result.data.errors[field].forEach(message => { + const li = document.createElement('li'); + li.textContent = message; + errorsList.appendChild(li); + }); + + const inputField = form.querySelector(`[name="${field}"]`); + if (inputField) { + inputField.classList.add('is-invalid'); + + const formGroup = inputField.closest('.form-group'); + if (formGroup) { + result.data.errors[field].forEach(message => { + const feedback = document.createElement('div'); + feedback.className = 'invalid-feedback d-block'; + feedback.textContent = message; + formGroup.appendChild(feedback); + }); + } + } + } + } else { + errorsContainer.classList.remove('d-none'); + const li = document.createElement('li'); + li.textContent = 'An error occurred while saving the form.'; + errorsList.appendChild(li); + } + submitButton.disabled = false; + }) + .catch(error => { + console.error('Form submission error:', error); + errorsContainer.classList.remove('d-none'); + const li = document.createElement('li'); + li.textContent = 'A network error occurred.'; + errorsList.appendChild(li); + submitButton.disabled = false; + }); + } \ No newline at end of file diff --git a/src/resources/views/crud/components/datatable/datatable_logic.blade.php b/src/resources/views/crud/components/datatable/datatable_logic.blade.php index c5e5719dd7..7e918b41a4 100644 --- a/src/resources/views/crud/components/datatable/datatable_logic.blade.php +++ b/src/resources/views/crud/components/datatable/datatable_logic.blade.php @@ -553,17 +553,28 @@ function setupTableEvents(tableId, config) { }); // on DataTable draw event run all functions in the queue - $(`#${tableId}`).on('draw.dt', function() { + $(`#${tableId}`).on('draw.dt', function() { // in datatables 2.0.3 the implementation was changed to use `replaceChildren`, for that reason scripts // that came with the response are no longer executed, like the delete button script or any other ajax // button created by the developer. For that reason, we move them to the end of the body + // ensuring they are re-evaluated on each draw event. + + document.getElementById(tableId).querySelectorAll('[id^="modalTemplate"]').forEach(function(modal) { + const newModal = modal.cloneNode(true); + document.body.appendChild(newModal); + modal.remove(); + }); + document.getElementById(tableId).querySelectorAll('script').forEach(function(script) { const newScript = document.createElement('script'); newScript.text = script.text; document.body.appendChild(newScript); }); + // we also move any modal that may come with the response to the end of the body + + // Run table-specific functions and pass the tableId // to the function if (config.functionsToRunOnDataTablesDrawEvent && config.functionsToRunOnDataTablesDrawEvent.length) { diff --git a/src/resources/views/crud/components/form/ajax_response.blade.php b/src/resources/views/crud/components/form/ajax_response.blade.php new file mode 100644 index 0000000000..cb08d47ad1 --- /dev/null +++ b/src/resources/views/crud/components/form/ajax_response.blade.php @@ -0,0 +1,24 @@ +@php +\Alert::flush(); +@endphp + +{!! csrf_field() !!} +@include('crud::form_content', ['fields' => $crud->fields(), 'action' => 'edit', 'inlineCreate' => true, 'initFields' => false]) +
{{ json_encode(Basset::loaded()) }}
+ + + +@foreach (app('widgets')->toArray() as $currentWidget) +@php + $currentWidget = \Backpack\CRUD\app\Library\Widget::add($currentWidget); +@endphp + @if($currentWidget->getAttribute('inline')) + @include($currentWidget->getFinalViewPath(), ['widget' => $currentWidget->toArray()]) + @endif +@endforeach + + +@stack('after_styles') +@stack('after_scripts') \ No newline at end of file diff --git a/src/resources/views/crud/components/form/form.blade.php b/src/resources/views/crud/components/form/form.blade.php new file mode 100644 index 0000000000..1952fb4c6e --- /dev/null +++ b/src/resources/views/crud/components/form/form.blade.php @@ -0,0 +1,49 @@ +
+ @include('crud::inc.grouped_errors') + +
hasUploadFields($operation)) + enctype="multipart/form-data" + @endif + > + {!! csrf_field() !!} + @if($method !== 'post') + @formMethod($method) + @endif + + {{-- Include the form fields --}} + @include('crud::form_content', ['fields' => $crud->fields(), 'action' => $operation]) + + {{-- This makes sure that all field assets are loaded. --}} +
{{ json_encode(Basset::loaded()) }}
+ + @include('crud::inc.form_save_buttons') +
+
+ + +@push('after_scripts') + +@endpush \ No newline at end of file diff --git a/src/resources/views/crud/components/form/modal.blade.php b/src/resources/views/crud/components/form/modal.blade.php new file mode 100644 index 0000000000..c2f1a6296a --- /dev/null +++ b/src/resources/views/crud/components/form/modal.blade.php @@ -0,0 +1,364 @@ + {{-- Modal HTML (initially hidden from DOM) --}} + @php + if(isset($formRouteOperation)) { + if(!\Str::isUrl($formRouteOperation)) { + $formRouteOperation = url($crud->route . '/' . $formRouteOperation); + } + } + @endphp +@push('after_scripts') @if (request()->ajax()) @endpush @endif +
+ +
+@if (!request()->ajax()) @endpush @endif +@push('after_scripts') @if (request()->ajax()) @endpush @endif + +@if (!request()->ajax()) @endpush @endif diff --git a/src/resources/views/crud/fields/summernote.blade.php b/src/resources/views/crud/fields/summernote.blade.php index 4a8e08d4b8..4623a338d6 100644 --- a/src/resources/views/crud/fields/summernote.blade.php +++ b/src/resources/views/crud/fields/summernote.blade.php @@ -41,6 +41,10 @@ .note-editor.note-frame .note-status-output, .note-editor.note-airframe .note-status-output { height: auto; } + /* Ensure Summernote's modal elements don't interfere with our modals */ + .note-modal { + z-index: 1060 !important; /* Higher than Bootstrap's default modal z-index */ + } @endBassetBlock @endpush diff --git a/src/resources/views/crud/form_content.blade.php b/src/resources/views/crud/form_content.blade.php index 705a3785ad..9763ab3c61 100644 --- a/src/resources/views/crud/form_content.blade.php +++ b/src/resources/views/crud/form_content.blade.php @@ -1,4 +1,4 @@ -route) }}> + {{-- See if we're using tabs --}} @if ($crud->tabsEnabled() && count($crud->getTabs())) @@ -15,19 +15,19 @@ {{-- Define blade stacks so css and js can be pushed from the fields to these sections. --}} -@section('after_styles') +@push('after_styles') {{-- CRUD FORM CONTENT - crud_fields_styles stack --}} @stack('crud_fields_styles') -@endsection +@endpush -@section('after_scripts') +@push('after_scripts') {{-- CRUD FORM CONTENT - crud_fields_scripts stack --}} @stack('crud_fields_scripts') - @include('crud::inc.form_fields_script') -@endsection +@endpush diff --git a/src/resources/views/crud/inc/form_fields_script.blade.php b/src/resources/views/crud/inc/form_fields_script.blade.php index 17c7872874..82ee9d101a 100644 --- a/src/resources/views/crud/inc/form_fields_script.blade.php +++ b/src/resources/views/crud/inc/form_fields_script.blade.php @@ -6,6 +6,7 @@ * javascript manipulations, and makes it easy to do custom stuff * too, by exposing the main components (name, wrapper, input). */ + if (typeof CrudField === 'undefined') { class CrudField { constructor(name) { this.name = name; @@ -186,7 +187,7 @@ class CrudField { window.crud = { ...window.crud, - action: "{{ $action ?? "" }}", + action: "{{ $action ?? '' }}", // Subfields callbacks holder subfieldsCallbacks: [], @@ -197,4 +198,5 @@ class CrudField { // Create all fields from a given name list fields: names => names.map(window.crud.field), }; +} diff --git a/src/resources/views/crud/inc/show_fields.blade.php b/src/resources/views/crud/inc/show_fields.blade.php index 74a05da7b9..0557f5ce74 100644 --- a/src/resources/views/crud/inc/show_fields.blade.php +++ b/src/resources/views/crud/inc/show_fields.blade.php @@ -1,5 +1,8 @@ {{-- Show the inputs --}} @foreach ($fields as $field) - @include($crud->getFirstFieldView($field['type'], $field['view_namespace'] ?? false), $field) + @include($crud->getFirstFieldView($field['type'], $field['view_namespace'] ?? false), [ + 'field' => $field, + 'inlineCreate' => $inlineCreate ?? false, + ]) @endforeach diff --git a/src/resources/views/crud/widgets/form.blade.php b/src/resources/views/crud/widgets/form.blade.php new file mode 100644 index 0000000000..c2cd824b4c --- /dev/null +++ b/src/resources/views/crud/widgets/form.blade.php @@ -0,0 +1,18 @@ +@includeWhen(!empty($widget['wrapper']), backpack_view('widgets.inc.wrapper_start')) +
+ @if (isset($widget['content']['header'])) +
+
{!! $widget['content']['header'] !!}
+
+ @endif +
+ + {!! $widget['content']['body'] ?? '' !!} + +
+ +
+ +
+
+@includeWhen(!empty($widget['wrapper']), backpack_view('widgets.inc.wrapper_end')) \ No newline at end of file