Files
PowderCoatingLogix/src/PowderCoating.Web/wwwroot/js/quick-add.js
T
2026-04-23 21:38:24 -04:00

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