refactored confirmation dialogs to use a unified confirmAction modal
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-14 18:02:03 +02:00
parent 6ae29f1648
commit b06c4bf3ee
10 changed files with 295 additions and 39 deletions

View File

@ -1900,6 +1900,23 @@ body.modal-open {
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.confirm-modal-message {
margin: 0 0 16px;
line-height: 1.5;
}
.confirm-modal-message p {
margin: 0 0 10px;
}
.confirm-modal-message p:last-child {
margin-bottom: 0;
}
.confirm-modal-choices {
margin-bottom: 16px;
}
.confirm-modal--danger .confirm-modal-dialog {
border-color: color-mix(in srgb, var(--danger-border) 40%, var(--border));
}
/* Options builder (inside add-question form) */ /* Options builder (inside add-question form) */
.options-builder { .options-builder {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);

View File

@ -1,15 +1,20 @@
import { apiPatch } from './api.js'; import { apiPatch } from './api.js';
import { showToast } from './app.js'; import { showToast } from './app.js';
import { confirmAction } from './confirm-modal.js';
export function isClientArchived(c) { export function isClientArchived(c) {
return Number(c.archived) === 1; return Number(c.archived) === 1;
} }
export async function confirmAndPatchClientArchive(clientCode, archived) { export async function confirmAndPatchClientArchive(clientCode, archived) {
const msg = archived if (!(await confirmAction({
? `Archive client "${clientCode}"? They will be hidden from assignments, follow-up, and the mobile app. You can restore them later from the Clients page.` title: archived ? 'Archive client' : 'Restore client',
: `Restore client "${clientCode}" to active lists?`; message: archived
if (!confirm(msg)) return false; ? `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 { try {
await apiPatch('clients.php', { clientCode, archived }); await apiPatch('clients.php', { clientCode, archived });

148
website/js/confirm-modal.js Normal file
View 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();
});
}

View File

@ -1,5 +1,6 @@
import { apiGet, apiPost } from '../api.js'; import { apiGet, apiPost } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js'; import { pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
const CLIENT_PAGE_SIZE = 50; const CLIENT_PAGE_SIZE = 50;
@ -421,7 +422,11 @@ async function doAssign() {
if (!coachID) { showToast('Select a target counselor', 'error'); return; } if (!coachID) { showToast('Select a target counselor', 'error'); return; }
const coachName = coaches.find(c => c.coachID === coachID)?.username || coachID; 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'); const btn = document.getElementById('assignBtn');
btn.disabled = true; btn.disabled = true;

View File

@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js'; import { pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import { import {
isClientArchived, isClientArchived,
confirmAndPatchClientArchive, confirmAndPatchClientArchive,
@ -818,7 +819,12 @@ async function setClientArchived(clientCode, archived) {
} }
async function deleteClient(clientCode) { 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 { try {
await apiDelete('clients.php', { clientCode }); await apiDelete('clients.php', { clientCode });
clientsList = clientsList.filter(c => c.clientCode !== clientCode); clientsList = clientsList.filter(c => c.clientCode !== clientCode);

View File

@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin } from '../api.js'; import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin } from '../api.js';
import { getRole, pageHeaderHTML, showToast } from '../app.js'; import { getRole, pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS'; const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
@ -166,11 +167,12 @@ export function devPage() {
}); });
document.getElementById('devRemoveBtn').addEventListener('click', async () => { document.getElementById('devRemoveBtn').addEventListener('click', async () => {
if (!confirm( if (!(await confirmAction({
'Remove all dev test data?\n\n' title: 'Remove dev test data',
+ 'Deletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\n' message: 'Remove all dev test data?\n\nDeletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\nReal accounts are not affected.',
+ 'Real accounts are not affected.' confirmLabel: 'Remove',
)) { variant: 'danger',
}))) {
return; return;
} }
const btn = document.getElementById('devRemoveBtn'); const btn = document.getElementById('devRemoveBtn');
@ -206,11 +208,12 @@ export function devPage() {
}); });
document.getElementById('devWipeBtn').addEventListener('click', async () => { document.getElementById('devWipeBtn').addEventListener('click', async () => {
if (!confirm( if (!(await confirmAction({
'Remove ALL clients, completions, counselors, and supervisors?\n\n' title: 'Wipe operational data',
+ 'Admin accounts and questionnaires will NOT be deleted.\n' message: 'Remove ALL clients, completions, counselors, and supervisors?\n\nAdmin accounts and questionnaires will NOT be deleted.\nThis cannot be undone.',
+ 'This cannot be undone.' confirmLabel: 'Remove all',
)) { variant: 'danger',
}))) {
return; return;
} }
const btn = document.getElementById('devWipeBtn'); const btn = document.getElementById('devWipeBtn');
@ -533,10 +536,12 @@ function wireSecuritySettings() {
revokeBtn?.addEventListener('click', async () => { revokeBtn?.addEventListener('click', async () => {
if (revokeInput.value.trim() !== REVOKE_ALL_CONFIRM) return; if (revokeInput.value.trim() !== REVOKE_ALL_CONFIRM) return;
if (!confirm( if (!(await confirmAction({
'Revoke every active session?\n\n' title: 'Revoke all sessions',
+ 'All users (including you) will be signed out on website and mobile.' message: 'Revoke every active session?\n\nAll users (including you) will be signed out on website and mobile.',
)) { confirmLabel: 'Revoke all',
variant: 'danger',
}))) {
return; return;
} }
revokeBtn.disabled = true; revokeBtn.disabled = true;

View File

@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js'; import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
import { canEdit, homeNavButton, showToast } from '../app.js'; import { canEdit, homeNavButton, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
import { import {
SOURCE_LANG, SOURCE_LANG,
@ -1668,7 +1669,12 @@ async function deleteQuestion(q) {
return; return;
} }
const msg = 'Remove this question? If client data exists it will be retired (not deleted) so upload history stays intact.'; 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 { try {
const data = await apiDelete('questions.php', { questionID: q.questionID }); const data = await apiDelete('questions.php', { questionID: q.questionID });
questions = questions.filter(x => x.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) { 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 { try {
const data = await apiDelete('answer_options.php', { answerOptionID }); const data = await apiDelete('answer_options.php', { answerOptionID });
if (!data.retired) { if (!data.retired) {
@ -1976,7 +1987,12 @@ function bindLanguageManager(languages) {
document.querySelectorAll('.lang-chip-remove').forEach(btn => { document.querySelectorAll('.lang-chip-remove').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const lc = btn.dataset.lc; 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 { try {
await apiDelete('translations.php', { type: 'language', languageCode: lc }); await apiDelete('translations.php', { type: 'language', languageCode: lc });
showToast(`Language "${lc}" removed`, 'success'); showToast(`Language "${lc}" removed`, 'success');

View File

@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { canEdit, pageHeaderHTML, showToast, getRole } from '../app.js'; import { canEdit, pageHeaderHTML, showToast, getRole } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
export async function questionnairesPage() { export async function questionnairesPage() {
@ -111,7 +112,12 @@ function renderCategoryKeysPanel(categoryKeys) {
if (count > 0) { if (count > 0) {
msg += `\n\n${count} questionnaire(s) will have their category key cleared.`; 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; btn.disabled = true;
try { try {
const result = await apiPost('questionnaires.php', { action: 'deleteCategoryKey', categoryKey: key }); 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 => { grid.querySelectorAll('.delete-q-btn').forEach(btn => {
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {
e.stopPropagation(); 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 { try {
await apiDelete('questionnaires.php', { questionnaireID: btn.dataset.id }); await apiDelete('questionnaires.php', { questionnaireID: btn.dataset.id });
showToast('Questionnaire deleted', 'success'); showToast('Questionnaire deleted', 'success');
@ -491,7 +502,12 @@ function renderScoringProfilesSection(profiles, questionnaires) {
}); });
list.querySelectorAll('.delete-profile-btn').forEach(btn => { list.querySelectorAll('.delete-profile-btn').forEach(btn => {
btn.addEventListener('click', async () => { 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 { try {
await apiDelete('scoring_profiles.php', { profileID: btn.dataset.id }); await apiDelete('scoring_profiles.php', { profileID: btn.dataset.id });
showToast('Profile deleted', 'success'); showToast('Profile deleted', 'success');
@ -761,12 +777,32 @@ function bindImportBundle() {
} }
const count = bundle.questionnaires.length; const count = bundle.questionnaires.length;
if (!confirm(`Import ${count} questionnaire(s) from this file?`)) return; if (!(await confirmAction({
const replace = confirm( title: 'Import questionnaires',
'If a questionnaire ID from the file already exists:\n\n' + message: `Import ${count} questionnaire(s) from this file?`,
'OK — replace that questionnaire\n' + confirmLabel: 'Import',
'Cancel — import as a new copy (new ID)' }))) 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; btn.disabled = true;
const prev = btn.textContent; const prev = btn.textContent;

View File

@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPut, apiDelete, apiDownloadFetch } from '../api.js'; import { apiGet, apiPost, apiPut, apiDelete, apiDownloadFetch } from '../api.js';
import { canEdit, pageHeaderHTML, showToast } from '../app.js'; import { canEdit, pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import { import {
SOURCE_LANG, SOURCE_LANG,
normalizeEntry, normalizeEntry,
@ -308,7 +309,12 @@ function renderLanguagePanel(languages) {
panel.querySelectorAll('.lang-chip-remove').forEach(btn => { panel.querySelectorAll('.lang-chip-remove').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const lc = btn.dataset.lc; 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 { try {
await apiDelete('translations.php', { type: 'language', languageCode: lc }); await apiDelete('translations.php', { type: 'language', languageCode: lc });
showToast(`Language "${lc}" removed`, 'success'); showToast(`Language "${lc}" removed`, 'success');
@ -671,7 +677,11 @@ function bindTranslationBundleActions() {
} }
const count = bundle.entries.length; 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; importBtn.disabled = true;
const prev = importBtn.textContent; const prev = importBtn.textContent;

View File

@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js'; import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import { openAdminResetPasswordModal } from '../password-modal.js'; import { openAdminResetPasswordModal } from '../password-modal.js';
// Roles an admin can create; supervisors can only create coaches // Roles an admin can create; supervisors can only create coaches
@ -319,10 +320,12 @@ async function reassignCoachSupervisor(selectEl) {
} }
async function revokeUserSessions(userID, username) { async function revokeUserSessions(userID, username) {
if (!confirm( if (!(await confirmAction({
`Sign out "${username}" on all devices?\n\n` title: 'Sign out everywhere',
+ 'They must log in again on the website and mobile app.' 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; return;
} }
try { try {
@ -336,7 +339,12 @@ async function revokeUserSessions(userID, username) {
} }
async function deleteUser(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 { try {
const data = await apiDelete('users.php', { userID }); const data = await apiDelete('users.php', { userID });
if (data.success) { if (data.success) {