219 lines
9.6 KiB
JavaScript
219 lines
9.6 KiB
JavaScript
/**
|
|
* quick-add.js — Generic inline-form quick-add modal.
|
|
*
|
|
* Usage: add these attributes to any <select>:
|
|
* data-quick-add-url="/Controller/Create" — GET loads the inline partial; POST saves
|
|
* data-quick-add-title="Add New Vendor" — optional modal title override
|
|
*
|
|
* Add a sentinel option at the top:
|
|
* <option value="__new__">+ Add New Vendor…</option>
|
|
*
|
|
* When the user picks "__new__", the modal opens, loads the Create partial (GET ?inline=true),
|
|
* intercepts the form submit (POST ?inline=true → JSON {success, id, name}), adds the new option
|
|
* to the originating select, selects it, and closes the modal.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const modalEl = document.getElementById('quickAddModal');
|
|
const modalBody = document.getElementById('quickAddModalBody');
|
|
const modalErrors = document.getElementById('quickAddModalErrors');
|
|
const modalErrorList = document.getElementById('quickAddModalErrorList');
|
|
const modalTitle = document.getElementById('quickAddModalLabel');
|
|
|
|
if (!modalEl) return;
|
|
|
|
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static' });
|
|
|
|
let _originSelect = null; // the <select> that triggered the modal
|
|
let _submitBtn = null; // the submit button inside the loaded form
|
|
let _saving = false;
|
|
|
|
// ── Wire every matching select ────────────────────────────────────────────
|
|
|
|
function wireSelect(sel) {
|
|
sel.addEventListener('change', function () {
|
|
if (this.value !== '__new__') return;
|
|
// Reset to blank so the select isn't stuck on __new__ if user cancels
|
|
this.value = '';
|
|
openQuickAdd(this);
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll('select[data-quick-add-url]').forEach(wireSelect);
|
|
|
|
// Support selects injected after DOMContentLoaded (e.g. in modals)
|
|
window.quickAddWire = wireSelect;
|
|
|
|
// ── Open ─────────────────────────────────────────────────────────────────
|
|
|
|
function openQuickAdd(selectEl) {
|
|
_originSelect = selectEl;
|
|
_saving = false;
|
|
|
|
const url = selectEl.dataset.quickAddUrl;
|
|
const title = selectEl.dataset.quickAddTitle || 'Add New';
|
|
|
|
modalTitle.textContent = title;
|
|
modalBody.innerHTML = `
|
|
<div class="d-flex align-items-center justify-content-center py-5">
|
|
<div class="spinner-border text-primary me-3" role="status"></div>
|
|
<span class="text-muted">Loading\u2026</span>
|
|
</div>`;
|
|
modalErrors.classList.add('d-none');
|
|
modalErrorList.innerHTML = '';
|
|
|
|
bsModal.show();
|
|
|
|
const inlineUrl = url.includes('?') ? url + '&inline=true' : url + '?inline=true';
|
|
fetch(inlineUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
|
.then(r => r.text())
|
|
.then(html => {
|
|
modalBody.innerHTML = html;
|
|
// Execute any <script> tags in the injected HTML — innerHTML assignment doesn't run them
|
|
modalBody.querySelectorAll('script').forEach(old => {
|
|
const s = document.createElement('script');
|
|
Array.from(old.attributes).forEach(a => s.setAttribute(a.name, a.value));
|
|
s.textContent = old.textContent;
|
|
old.replaceWith(s);
|
|
});
|
|
// Re-initialise Bootstrap popovers and validation inside the loaded fragment
|
|
modalBody.querySelectorAll('[data-bs-toggle="popover"]').forEach(el => {
|
|
new bootstrap.Popover(el, { html: true, trigger: 'focus' });
|
|
});
|
|
if (window.jQuery && $.validator) {
|
|
const form = modalBody.querySelector('form');
|
|
if (form) $.validator.unobtrusive.parse(form);
|
|
}
|
|
// Hide the Back/Cancel navigation inside the partial — not needed in a modal
|
|
modalBody.querySelectorAll('.d-flex.justify-content-end a.btn-outline-secondary').forEach(a => {
|
|
if (a.textContent.trim().startsWith('Back') || a.textContent.trim() === 'Cancel') {
|
|
a.style.display = 'none';
|
|
}
|
|
});
|
|
wireFormSubmit();
|
|
})
|
|
.catch(() => {
|
|
modalBody.innerHTML = '<div class="alert alert-danger alert-permanent m-3">Failed to load form. Please try again.</div>';
|
|
});
|
|
}
|
|
|
|
// ── Intercept the form inside the loaded partial ──────────────────────────
|
|
|
|
function wireFormSubmit() {
|
|
const form = modalBody.querySelector('form');
|
|
if (!form) return;
|
|
|
|
// Replace the form's action to include ?inline=true
|
|
const action = form.getAttribute('action') || '';
|
|
if (!action.includes('inline=true')) {
|
|
const sep = action.includes('?') ? '&' : '?';
|
|
form.setAttribute('action', action + sep + 'inline=true');
|
|
}
|
|
|
|
// Add a footer Save button to the modal (keeps modal-footer pattern consistent)
|
|
_submitBtn = document.createElement('button');
|
|
_submitBtn.type = 'button';
|
|
_submitBtn.className = 'btn btn-primary px-4';
|
|
_submitBtn.innerHTML = '<i class="bi bi-check-circle me-2"></i>Save';
|
|
_submitBtn.addEventListener('click', () => form.requestSubmit());
|
|
|
|
// Also hide the form's own submit button to avoid duplication
|
|
form.querySelectorAll('[type="submit"]').forEach(b => b.style.display = 'none');
|
|
|
|
// Inject footer
|
|
const footer = document.createElement('div');
|
|
footer.className = 'modal-footer border-top';
|
|
footer.innerHTML = '<button type="button" class="btn btn-outline-secondary px-4" data-bs-dismiss="modal">Cancel</button>';
|
|
footer.appendChild(_submitBtn);
|
|
modalEl.querySelector('.modal-content').appendChild(footer);
|
|
|
|
form.addEventListener('submit', handleSubmit);
|
|
}
|
|
|
|
// ── Submit handler ────────────────────────────────────────────────────────
|
|
|
|
async function handleSubmit(e) {
|
|
e.preventDefault();
|
|
if (_saving) return;
|
|
|
|
const form = e.target;
|
|
|
|
// jQuery unobtrusive validation
|
|
if (window.jQuery && $(form).valid && !$(form).valid()) return;
|
|
|
|
_saving = true;
|
|
if (_submitBtn) {
|
|
_submitBtn.disabled = true;
|
|
_submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving\u2026';
|
|
}
|
|
modalErrors.classList.add('d-none');
|
|
|
|
try {
|
|
const data = new FormData(form);
|
|
const resp = await fetch(form.action, { method: 'POST', body: data });
|
|
const json = await resp.json();
|
|
|
|
if (json.success) {
|
|
addOptionAndSelect(json.id, json.name);
|
|
// Remove the injected footer before hiding so it doesn't stack on re-open
|
|
const injectedFooter = modalEl.querySelector('.modal-footer.border-top');
|
|
if (injectedFooter) injectedFooter.remove();
|
|
bsModal.hide();
|
|
} else {
|
|
const msgs = (json.errors || ['An unknown error occurred.']).join('<br>');
|
|
modalErrorList.innerHTML = msgs;
|
|
modalErrors.classList.remove('d-none');
|
|
}
|
|
} catch {
|
|
modalErrorList.innerHTML = 'A network error occurred. Please try again.';
|
|
modalErrors.classList.remove('d-none');
|
|
} finally {
|
|
_saving = false;
|
|
if (_submitBtn) {
|
|
_submitBtn.disabled = false;
|
|
_submitBtn.innerHTML = '<i class="bi bi-check-circle me-2"></i>Save';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Add the new option to the originating select and select it ────────────
|
|
|
|
function addOptionAndSelect(id, name) {
|
|
if (!_originSelect) return;
|
|
|
|
// Remove sentinel option temporarily so we can check for duplicates
|
|
const existing = _originSelect.querySelector(`option[value="${id}"]`);
|
|
if (!existing) {
|
|
const opt = document.createElement('option');
|
|
opt.value = id;
|
|
opt.textContent = name;
|
|
|
|
// Insert in alphabetical order before the sentinel (value="__new__")
|
|
const sentinel = _originSelect.querySelector('option[value="__new__"]');
|
|
const options = Array.from(_originSelect.options).filter(o => o.value && o.value !== '__new__');
|
|
const after = options.find(o => o.textContent.toLowerCase() > name.toLowerCase());
|
|
if (after) {
|
|
_originSelect.insertBefore(opt, after);
|
|
} else if (sentinel) {
|
|
_originSelect.insertBefore(opt, sentinel);
|
|
} else {
|
|
_originSelect.appendChild(opt);
|
|
}
|
|
}
|
|
|
|
_originSelect.value = id;
|
|
_originSelect.dispatchEvent(new Event('change'));
|
|
}
|
|
|
|
// ── Clean up injected footer on modal close ───────────────────────────────
|
|
|
|
modalEl.addEventListener('hidden.bs.modal', function () {
|
|
const injectedFooter = modalEl.querySelector('.modal-footer.border-top');
|
|
if (injectedFooter) injectedFooter.remove();
|
|
_saving = false;
|
|
_submitBtn = null;
|
|
});
|
|
|
|
})();
|