refactored confirmation dialogs to use a unified confirmAction modal
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
@ -1,15 +1,20 @@
|
||||
import { apiPatch } from './api.js';
|
||||
import { showToast } from './app.js';
|
||||
import { confirmAction } from './confirm-modal.js';
|
||||
|
||||
export function isClientArchived(c) {
|
||||
return Number(c.archived) === 1;
|
||||
}
|
||||
|
||||
export async function confirmAndPatchClientArchive(clientCode, archived) {
|
||||
const msg = archived
|
||||
? `Archive client "${clientCode}"? They will be hidden from assignments, follow-up, and the mobile app. You can restore them later from the Clients page.`
|
||||
: `Restore client "${clientCode}" to active lists?`;
|
||||
if (!confirm(msg)) return false;
|
||||
if (!(await confirmAction({
|
||||
title: archived ? 'Archive client' : 'Restore client',
|
||||
message: archived
|
||||
? `Archive client "${clientCode}"? They will be hidden from assignments, follow-up, and the mobile app. You can restore them later from the Clients page.`
|
||||
: `Restore client "${clientCode}" to active lists?`,
|
||||
confirmLabel: archived ? 'Archive' : 'Restore',
|
||||
variant: archived ? 'danger' : 'default',
|
||||
}))) return false;
|
||||
|
||||
try {
|
||||
await apiPatch('clients.php', { clientCode, archived });
|
||||
|
||||
148
website/js/confirm-modal.js
Normal file
148
website/js/confirm-modal.js
Normal file
@ -0,0 +1,148 @@
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s ?? '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function formatMessageHTML(message) {
|
||||
return String(message)
|
||||
.split(/\n\n+/)
|
||||
.map((block) => `<p>${esc(block).replace(/\n/g, '<br>')}</p>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderChoicesHTML(choices) {
|
||||
const name = 'confirm_modal_choice';
|
||||
return `
|
||||
<fieldset class="password-mode-fieldset confirm-modal-choices">
|
||||
<div class="password-mode-options">
|
||||
${choices.map((choice, i) => `
|
||||
<label class="password-mode-option">
|
||||
<input type="radio" name="${name}" value="${esc(choice.value)}"${i === 0 ? ' checked' : ''}>
|
||||
<span class="password-mode-option-title">${esc(choice.label)}</span>
|
||||
${choice.description
|
||||
? `<span class="password-mode-option-desc">${esc(choice.description)}</span>`
|
||||
: ''}
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
</fieldset>`;
|
||||
}
|
||||
|
||||
/** @type {((value: boolean | string | null) => void) | null} */
|
||||
let resolveConfirm = null;
|
||||
|
||||
function ensureConfirmModal() {
|
||||
let modal = document.getElementById('confirmModal');
|
||||
if (modal) return modal;
|
||||
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'confirmModal';
|
||||
modal.className = 'modal-overlay';
|
||||
modal.hidden = true;
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-labelledby', 'confirmModalTitle');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog confirm-modal-dialog">
|
||||
<h2 id="confirmModalTitle"></h2>
|
||||
<div class="confirm-modal-message modal-subtitle" id="confirmModalMessage"></div>
|
||||
<div id="confirmModalChoices"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-sm" data-confirm-cancel>Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" data-confirm-submit>Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) finishConfirm(null);
|
||||
});
|
||||
modal.querySelector('[data-confirm-cancel]').addEventListener('click', () => finishConfirm(null));
|
||||
modal.querySelector('[data-confirm-submit]').addEventListener('click', () => {
|
||||
const choicesEl = modal.querySelector('#confirmModalChoices');
|
||||
const hasChoices = choicesEl.childElementCount > 0;
|
||||
if (hasChoices) {
|
||||
const selected = modal.querySelector('input[name="confirm_modal_choice"]:checked');
|
||||
finishConfirm(selected ? selected.value : null);
|
||||
} else {
|
||||
finishConfirm(true);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const el = document.getElementById('confirmModal');
|
||||
if (!el || el.hidden) return;
|
||||
if (e.key === 'Escape') finishConfirm(null);
|
||||
});
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
function finishConfirm(value) {
|
||||
const modal = document.getElementById('confirmModal');
|
||||
if (!modal || modal.hidden) return;
|
||||
modal.hidden = true;
|
||||
document.body.classList.remove('modal-open');
|
||||
const resolve = resolveConfirm;
|
||||
resolveConfirm = null;
|
||||
if (resolve) resolve(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a confirmation dialog.
|
||||
*
|
||||
* Without `choices`: resolves to `true` (confirmed) or `false` (cancelled).
|
||||
* With `choices`: resolves to the selected choice value (string) or `null` (cancelled).
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.title
|
||||
* @param {string} options.message
|
||||
* @param {string} [options.confirmLabel='Confirm']
|
||||
* @param {string} [options.cancelLabel='Cancel']
|
||||
* @param {'default'|'danger'} [options.variant='default']
|
||||
* @param {{ value: string, label: string, description?: string }[]} [options.choices]
|
||||
* @returns {Promise<boolean|string|null>}
|
||||
*/
|
||||
export function confirmAction(options) {
|
||||
return new Promise((resolve) => {
|
||||
if (resolveConfirm) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = ensureConfirmModal();
|
||||
const hasChoices = Array.isArray(options.choices) && options.choices.length > 0;
|
||||
const variant = options.variant === 'danger' ? 'danger' : 'default';
|
||||
|
||||
modal.querySelector('#confirmModalTitle').textContent = options.title || 'Confirm';
|
||||
modal.querySelector('#confirmModalMessage').innerHTML = formatMessageHTML(options.message || '');
|
||||
|
||||
const choicesEl = modal.querySelector('#confirmModalChoices');
|
||||
choicesEl.innerHTML = hasChoices ? renderChoicesHTML(options.choices) : '';
|
||||
|
||||
const cancelBtn = modal.querySelector('[data-confirm-cancel]');
|
||||
const submitBtn = modal.querySelector('[data-confirm-submit]');
|
||||
cancelBtn.textContent = options.cancelLabel || 'Cancel';
|
||||
submitBtn.textContent = options.confirmLabel || (hasChoices ? 'Continue' : 'Confirm');
|
||||
submitBtn.className = `btn btn-sm ${variant === 'danger' ? 'btn-danger' : 'btn-primary'}`;
|
||||
|
||||
modal.classList.toggle('confirm-modal--danger', variant === 'danger');
|
||||
|
||||
resolveConfirm = (value) => {
|
||||
if (hasChoices) {
|
||||
resolve(value);
|
||||
} else {
|
||||
resolve(value === true);
|
||||
}
|
||||
};
|
||||
|
||||
modal.hidden = false;
|
||||
document.body.classList.add('modal-open');
|
||||
(hasChoices
|
||||
? modal.querySelector('input[name="confirm_modal_choice"]')
|
||||
: submitBtn
|
||||
)?.focus();
|
||||
});
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { apiGet, apiPost } from '../api.js';
|
||||
import { pageHeaderHTML, showToast } from '../app.js';
|
||||
import { confirmAction } from '../confirm-modal.js';
|
||||
|
||||
const CLIENT_PAGE_SIZE = 50;
|
||||
|
||||
@ -421,7 +422,11 @@ async function doAssign() {
|
||||
if (!coachID) { showToast('Select a target counselor', 'error'); return; }
|
||||
|
||||
const coachName = coaches.find(c => c.coachID === coachID)?.username || coachID;
|
||||
if (!confirm(`Assign ${selected.length} client(s) to ${coachName}?`)) return;
|
||||
if (!(await confirmAction({
|
||||
title: 'Assign clients',
|
||||
message: `Assign ${selected.length} client(s) to ${coachName}?`,
|
||||
confirmLabel: 'Assign',
|
||||
}))) return;
|
||||
|
||||
const btn = document.getElementById('assignBtn');
|
||||
btn.disabled = true;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
|
||||
import { pageHeaderHTML, showToast } from '../app.js';
|
||||
import { confirmAction } from '../confirm-modal.js';
|
||||
import {
|
||||
isClientArchived,
|
||||
confirmAndPatchClientArchive,
|
||||
@ -818,7 +819,12 @@ async function setClientArchived(clientCode, archived) {
|
||||
}
|
||||
|
||||
async function deleteClient(clientCode) {
|
||||
if (!confirm(`Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`)) return;
|
||||
if (!(await confirmAction({
|
||||
title: 'Delete client',
|
||||
message: `Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`,
|
||||
confirmLabel: 'Delete',
|
||||
variant: 'danger',
|
||||
}))) return;
|
||||
try {
|
||||
await apiDelete('clients.php', { clientCode });
|
||||
clientsList = clientsList.filter(c => c.clientCode !== clientCode);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin } from '../api.js';
|
||||
import { getRole, pageHeaderHTML, showToast } from '../app.js';
|
||||
import { confirmAction } from '../confirm-modal.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
|
||||
@ -166,11 +167,12 @@ export function devPage() {
|
||||
});
|
||||
|
||||
document.getElementById('devRemoveBtn').addEventListener('click', async () => {
|
||||
if (!confirm(
|
||||
'Remove all dev test data?\n\n'
|
||||
+ 'Deletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\n'
|
||||
+ 'Real accounts are not affected.'
|
||||
)) {
|
||||
if (!(await confirmAction({
|
||||
title: 'Remove dev test data',
|
||||
message: 'Remove all dev test data?\n\nDeletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\nReal accounts are not affected.',
|
||||
confirmLabel: 'Remove',
|
||||
variant: 'danger',
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('devRemoveBtn');
|
||||
@ -206,11 +208,12 @@ export function devPage() {
|
||||
});
|
||||
|
||||
document.getElementById('devWipeBtn').addEventListener('click', async () => {
|
||||
if (!confirm(
|
||||
'Remove ALL clients, completions, counselors, and supervisors?\n\n'
|
||||
+ 'Admin accounts and questionnaires will NOT be deleted.\n'
|
||||
+ 'This cannot be undone.'
|
||||
)) {
|
||||
if (!(await confirmAction({
|
||||
title: 'Wipe operational data',
|
||||
message: 'Remove ALL clients, completions, counselors, and supervisors?\n\nAdmin accounts and questionnaires will NOT be deleted.\nThis cannot be undone.',
|
||||
confirmLabel: 'Remove all',
|
||||
variant: 'danger',
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('devWipeBtn');
|
||||
@ -533,10 +536,12 @@ function wireSecuritySettings() {
|
||||
|
||||
revokeBtn?.addEventListener('click', async () => {
|
||||
if (revokeInput.value.trim() !== REVOKE_ALL_CONFIRM) return;
|
||||
if (!confirm(
|
||||
'Revoke every active session?\n\n'
|
||||
+ 'All users (including you) will be signed out on website and mobile.'
|
||||
)) {
|
||||
if (!(await confirmAction({
|
||||
title: 'Revoke all sessions',
|
||||
message: 'Revoke every active session?\n\nAll users (including you) will be signed out on website and mobile.',
|
||||
confirmLabel: 'Revoke all',
|
||||
variant: 'danger',
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
revokeBtn.disabled = true;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
|
||||
import { canEdit, homeNavButton, showToast } from '../app.js';
|
||||
import { confirmAction } from '../confirm-modal.js';
|
||||
import { navigate } from '../router.js';
|
||||
import {
|
||||
SOURCE_LANG,
|
||||
@ -1668,7 +1669,12 @@ async function deleteQuestion(q) {
|
||||
return;
|
||||
}
|
||||
const msg = 'Remove this question? If client data exists it will be retired (not deleted) so upload history stays intact.';
|
||||
if (!confirm(msg)) return;
|
||||
if (!(await confirmAction({
|
||||
title: 'Remove question',
|
||||
message: msg,
|
||||
confirmLabel: 'Remove',
|
||||
variant: 'danger',
|
||||
}))) return;
|
||||
try {
|
||||
const data = await apiDelete('questions.php', { questionID: q.questionID });
|
||||
questions = questions.filter(x => x.questionID !== q.questionID);
|
||||
@ -1702,7 +1708,12 @@ async function updateOption(q, answerOptionID, fields) {
|
||||
}
|
||||
|
||||
async function deleteOption(q, answerOptionID) {
|
||||
if (!confirm('Remove this answer option?')) return;
|
||||
if (!(await confirmAction({
|
||||
title: 'Remove answer option',
|
||||
message: 'Remove this answer option?',
|
||||
confirmLabel: 'Remove',
|
||||
variant: 'danger',
|
||||
}))) return;
|
||||
try {
|
||||
const data = await apiDelete('answer_options.php', { answerOptionID });
|
||||
if (!data.retired) {
|
||||
@ -1976,7 +1987,12 @@ function bindLanguageManager(languages) {
|
||||
document.querySelectorAll('.lang-chip-remove').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const lc = btn.dataset.lc;
|
||||
if (!confirm(`Remove language "${lc}" and ALL its translations?`)) return;
|
||||
if (!(await confirmAction({
|
||||
title: 'Remove language',
|
||||
message: `Remove language "${lc}" and ALL its translations?`,
|
||||
confirmLabel: 'Remove',
|
||||
variant: 'danger',
|
||||
}))) return;
|
||||
try {
|
||||
await apiDelete('translations.php', { type: 'language', languageCode: lc });
|
||||
showToast(`Language "${lc}" removed`, 'success');
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
|
||||
import { canEdit, pageHeaderHTML, showToast, getRole } from '../app.js';
|
||||
import { confirmAction } from '../confirm-modal.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
export async function questionnairesPage() {
|
||||
@ -111,7 +112,12 @@ function renderCategoryKeysPanel(categoryKeys) {
|
||||
if (count > 0) {
|
||||
msg += `\n\n${count} questionnaire(s) will have their category key cleared.`;
|
||||
}
|
||||
if (!confirm(msg)) return;
|
||||
if (!(await confirmAction({
|
||||
title: 'Delete category key',
|
||||
message: msg,
|
||||
confirmLabel: 'Delete',
|
||||
variant: 'danger',
|
||||
}))) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await apiPost('questionnaires.php', { action: 'deleteCategoryKey', categoryKey: key });
|
||||
@ -297,7 +303,12 @@ function renderGrid(questionnaires, categoryKeys = []) {
|
||||
grid.querySelectorAll('.delete-q-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm('Delete this questionnaire and all its data?')) return;
|
||||
if (!(await confirmAction({
|
||||
title: 'Delete questionnaire',
|
||||
message: 'Delete this questionnaire and all its data?',
|
||||
confirmLabel: 'Delete',
|
||||
variant: 'danger',
|
||||
}))) return;
|
||||
try {
|
||||
await apiDelete('questionnaires.php', { questionnaireID: btn.dataset.id });
|
||||
showToast('Questionnaire deleted', 'success');
|
||||
@ -491,7 +502,12 @@ function renderScoringProfilesSection(profiles, questionnaires) {
|
||||
});
|
||||
list.querySelectorAll('.delete-profile-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete scoring profile "${btn.dataset.name}"?`)) return;
|
||||
if (!(await confirmAction({
|
||||
title: 'Delete scoring profile',
|
||||
message: `Delete scoring profile "${btn.dataset.name}"?`,
|
||||
confirmLabel: 'Delete',
|
||||
variant: 'danger',
|
||||
}))) return;
|
||||
try {
|
||||
await apiDelete('scoring_profiles.php', { profileID: btn.dataset.id });
|
||||
showToast('Profile deleted', 'success');
|
||||
@ -761,12 +777,32 @@ function bindImportBundle() {
|
||||
}
|
||||
|
||||
const count = bundle.questionnaires.length;
|
||||
if (!confirm(`Import ${count} questionnaire(s) from this file?`)) return;
|
||||
const replace = confirm(
|
||||
'If a questionnaire ID from the file already exists:\n\n' +
|
||||
'OK — replace that questionnaire\n' +
|
||||
'Cancel — import as a new copy (new ID)'
|
||||
);
|
||||
if (!(await confirmAction({
|
||||
title: 'Import questionnaires',
|
||||
message: `Import ${count} questionnaire(s) from this file?`,
|
||||
confirmLabel: 'Import',
|
||||
}))) return;
|
||||
|
||||
const mode = await confirmAction({
|
||||
title: 'Existing questionnaire IDs',
|
||||
message: 'If a questionnaire ID from the file already exists, how should it be handled?',
|
||||
choices: [
|
||||
{
|
||||
value: 'replace',
|
||||
label: 'Replace existing',
|
||||
description: 'Overwrite the questionnaire with that ID.',
|
||||
},
|
||||
{
|
||||
value: 'copy',
|
||||
label: 'Import as new copy',
|
||||
description: 'Keep the existing questionnaire and import with a new ID.',
|
||||
},
|
||||
],
|
||||
confirmLabel: 'Continue',
|
||||
cancelLabel: 'Cancel import',
|
||||
});
|
||||
if (!mode) return;
|
||||
const replace = mode === 'replace';
|
||||
|
||||
btn.disabled = true;
|
||||
const prev = btn.textContent;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete, apiDownloadFetch } from '../api.js';
|
||||
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
|
||||
import { confirmAction } from '../confirm-modal.js';
|
||||
import {
|
||||
SOURCE_LANG,
|
||||
normalizeEntry,
|
||||
@ -308,7 +309,12 @@ function renderLanguagePanel(languages) {
|
||||
panel.querySelectorAll('.lang-chip-remove').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const lc = btn.dataset.lc;
|
||||
if (!confirm(`Remove "${lc}" and all its translations?`)) return;
|
||||
if (!(await confirmAction({
|
||||
title: 'Remove language',
|
||||
message: `Remove "${lc}" and all its translations?`,
|
||||
confirmLabel: 'Remove',
|
||||
variant: 'danger',
|
||||
}))) return;
|
||||
try {
|
||||
await apiDelete('translations.php', { type: 'language', languageCode: lc });
|
||||
showToast(`Language "${lc}" removed`, 'success');
|
||||
@ -671,7 +677,11 @@ function bindTranslationBundleActions() {
|
||||
}
|
||||
|
||||
const count = bundle.entries.length;
|
||||
if (!confirm(`Import translations for ${count} entries from this file?`)) return;
|
||||
if (!(await confirmAction({
|
||||
title: 'Import translations',
|
||||
message: `Import translations for ${count} entries from this file?`,
|
||||
confirmLabel: 'Import',
|
||||
}))) return;
|
||||
|
||||
importBtn.disabled = true;
|
||||
const prev = importBtn.textContent;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
|
||||
import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js';
|
||||
import { confirmAction } from '../confirm-modal.js';
|
||||
import { openAdminResetPasswordModal } from '../password-modal.js';
|
||||
|
||||
// Roles an admin can create; supervisors can only create coaches
|
||||
@ -319,10 +320,12 @@ async function reassignCoachSupervisor(selectEl) {
|
||||
}
|
||||
|
||||
async function revokeUserSessions(userID, username) {
|
||||
if (!confirm(
|
||||
`Sign out "${username}" on all devices?\n\n`
|
||||
+ 'They must log in again on the website and mobile app.'
|
||||
)) {
|
||||
if (!(await confirmAction({
|
||||
title: 'Sign out everywhere',
|
||||
message: `Sign out "${username}" on all devices?\n\nThey must log in again on the website and mobile app.`,
|
||||
confirmLabel: 'Sign out',
|
||||
variant: 'danger',
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@ -336,7 +339,12 @@ async function revokeUserSessions(userID, username) {
|
||||
}
|
||||
|
||||
async function deleteUser(userID, username) {
|
||||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
||||
if (!(await confirmAction({
|
||||
title: 'Delete user',
|
||||
message: `Delete user "${username}"? This cannot be undone.`,
|
||||
confirmLabel: 'Delete',
|
||||
variant: 'danger',
|
||||
}))) return;
|
||||
try {
|
||||
const data = await apiDelete('users.php', { userID });
|
||||
if (data.success) {
|
||||
|
||||
Reference in New Issue
Block a user