mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-25 21:19:45 +00:00
ui: themed confirm/alert dialogs in place of the native ones
static/ui.js provides promise-based rgConfirm/rgAlert that show a themed
modal matching the app, injected self-contained so it works on any page
including the standalone client. Every native alert()/confirm() is
replaced: delete user and revoke token (admin), revoke token (tokens),
delete recording (recordings), popup-blocked (client), and terminate
session, delete entry, delete folder and the SSH host-key probe
(connections). Destructive actions get a red confirm button, the host-key
trust prompt an accent one.
From an independent review of the module: rgConfirm/rgAlert resolve any
already-open dialog before replacing it, so a second call can't leave the
first promise hanging; Enter acts on the focused button (Cancel cancels),
and Tab is trapped between the two buttons.
Part of #254
tests: themed confirm/alert dialogs replace the native ones
(cherry picked from commit f8779d083b)
This commit is contained in:
committed by
Dave Kempe
parent
b18dc3a158
commit
97ba471ea8
+19
-14
@@ -298,10 +298,12 @@
|
||||
delBtn.textContent = 'delete';
|
||||
delBtn.addEventListener('click', (function(email) {
|
||||
return function() {
|
||||
if (!confirm('Delete user ' + email + '?')) return;
|
||||
fetch('/api/users/' + encodeURIComponent(email), {
|
||||
method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin'
|
||||
}).then(function() { loadUsers(); });
|
||||
rgConfirm('Delete user ' + email + '?', { danger: true, confirmText: 'Delete' }).then(function(ok) {
|
||||
if (!ok) return;
|
||||
fetch('/api/users/' + encodeURIComponent(email), {
|
||||
method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin'
|
||||
}).then(function() { loadUsers(); });
|
||||
});
|
||||
};
|
||||
})(u.email));
|
||||
actTd.appendChild(delBtn);
|
||||
@@ -456,16 +458,18 @@
|
||||
revokeBtn.textContent = 'revoke';
|
||||
revokeBtn.addEventListener('click', (function(id, name, email) {
|
||||
return function() {
|
||||
if (!confirm('Revoke token "' + name + '" for ' + email + '?')) return;
|
||||
fetch('/api/admin/user-tokens/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: apiHeaders(),
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) return res.json().then(function(d) { showError(d.error); });
|
||||
loadAllTokens();
|
||||
loadAuditLog();
|
||||
rgConfirm('Revoke token "' + name + '" for ' + email + '?', { danger: true, confirmText: 'Revoke' }).then(function(ok) {
|
||||
if (!ok) return;
|
||||
fetch('/api/admin/user-tokens/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: apiHeaders(),
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) return res.json().then(function(d) { showError(d.error); });
|
||||
loadAllTokens();
|
||||
loadAuditLog();
|
||||
});
|
||||
});
|
||||
};
|
||||
})(t.id, t.name, t.email || ''));
|
||||
@@ -653,5 +657,6 @@
|
||||
// Auto-refresh
|
||||
setInterval(function() { loadUsers(); loadMappings(); loadAllTokens(); }, 10000);
|
||||
</script>
|
||||
<script src="/ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-1
@@ -879,7 +879,7 @@
|
||||
var win = window.open('', 'rustguac-mon-' + index,
|
||||
'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no');
|
||||
if (!win) {
|
||||
alert('Popup blocked. Allow popups for this site to open an extra monitor.');
|
||||
rgAlert('Popup blocked. Allow popups for this site to open an extra monitor.');
|
||||
renderMonitorControls();
|
||||
return;
|
||||
}
|
||||
@@ -2038,5 +2038,6 @@
|
||||
client.connect(connectData);
|
||||
}
|
||||
</script>
|
||||
<script src="/ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+45
-37
@@ -1951,20 +1951,22 @@
|
||||
ev.stopPropagation();
|
||||
var sid = termBtn.getAttribute('data-terminate');
|
||||
var label = termBtn.getAttribute('data-name') || sid;
|
||||
if (!confirm('Terminate session "' + label + '"? Any unsaved work will be lost.')) return;
|
||||
termBtn.disabled = true;
|
||||
termBtn.textContent = 'Ending...';
|
||||
fetch('/api/sessions/' + sid, {
|
||||
method: 'DELETE',
|
||||
headers: apiHeaders(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function(res) {
|
||||
if (!res.ok) return res.text().then(function(t) { throw new Error(t || 'terminate failed'); });
|
||||
loadActiveSessions();
|
||||
}).catch(function(err) {
|
||||
termBtn.disabled = false;
|
||||
termBtn.textContent = 'Terminate';
|
||||
showError('Terminate failed: ' + err.message);
|
||||
rgConfirm('Terminate session "' + label + '"? Any unsaved work will be lost.', { danger: true, confirmText: 'Terminate' }).then(function(ok) {
|
||||
if (!ok) return;
|
||||
termBtn.disabled = true;
|
||||
termBtn.textContent = 'Ending...';
|
||||
fetch('/api/sessions/' + sid, {
|
||||
method: 'DELETE',
|
||||
headers: apiHeaders(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function(res) {
|
||||
if (!res.ok) return res.text().then(function(t) { throw new Error(t || 'terminate failed'); });
|
||||
loadActiveSessions();
|
||||
}).catch(function(err) {
|
||||
termBtn.disabled = false;
|
||||
termBtn.textContent = 'Terminate';
|
||||
showError('Terminate failed: ' + err.message);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3639,14 +3641,16 @@
|
||||
var name = btn.getAttribute('data-delete-entry');
|
||||
var scope = btn.getAttribute('data-scope');
|
||||
var folder = btn.getAttribute('data-folder');
|
||||
if (!confirm('Delete entry "' + name + '"?')) return;
|
||||
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name), {
|
||||
method: 'DELETE',
|
||||
headers: apiHeaders(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function(res) {
|
||||
if (!res.ok) return res.text().then(function(t) { showError(t); });
|
||||
loadEntries(scope, folder, true);
|
||||
rgConfirm('Delete entry "' + name + '"?', { danger: true, confirmText: 'Delete' }).then(function(ok) {
|
||||
if (!ok) return;
|
||||
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name), {
|
||||
method: 'DELETE',
|
||||
headers: apiHeaders(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function(res) {
|
||||
if (!res.ok) return res.text().then(function(t) { showError(t); });
|
||||
loadEntries(scope, folder, true);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -3982,8 +3986,9 @@
|
||||
var warning = hasSubtree
|
||||
? 'Delete folder "' + selPath + '" AND all its subfolders and entries?\n\nThis recursively removes the entire subtree and cannot be undone.'
|
||||
: 'Delete folder "' + selPath + '" and all its entries?\n\nThis cannot be undone.';
|
||||
if (!confirm(warning)) return;
|
||||
fetch('/api/addressbook/folders/' + encodeURIComponent(selectedFolder.scope) + '/' + encodeURIComponent(selPath), {
|
||||
rgConfirm(warning, { title: 'Delete folder', danger: true, confirmText: 'Delete' }).then(function(ok) {
|
||||
if (!ok) return;
|
||||
fetch('/api/addressbook/folders/' + encodeURIComponent(selectedFolder.scope) + '/' + encodeURIComponent(selPath), {
|
||||
method: 'DELETE',
|
||||
headers: apiHeaders(),
|
||||
credentials: 'same-origin'
|
||||
@@ -4005,6 +4010,7 @@
|
||||
document.getElementById('entries-header').style.display = 'none';
|
||||
document.getElementById('entries-content').innerHTML = '<p class="empty">Select a folder to view entries.</p>';
|
||||
loadFolders();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4341,7 +4347,7 @@
|
||||
function probeHostKey(hopIndex) {
|
||||
var hop = hopList[hopIndex];
|
||||
if (!hop || !hop.hostname || !hop.hostname.trim()) {
|
||||
alert('Enter a hostname first.');
|
||||
rgAlert('Enter a hostname first.');
|
||||
return;
|
||||
}
|
||||
var btn = document.querySelector('.hop-verify-btn[data-hop="' + hopIndex + '"]');
|
||||
@@ -4355,22 +4361,23 @@
|
||||
}).then(function(r) { return r.json().then(function(d) { return {ok: r.ok, data: d}; }); })
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
alert('Probe failed: ' + (res.data.error || 'unknown error'));
|
||||
rgAlert('Probe failed: ' + (res.data.error || 'unknown error'));
|
||||
if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; }
|
||||
return;
|
||||
}
|
||||
var msg = 'Host key for ' + hop.hostname.trim() + ':' + (hop.port || 22) + ':\n\n' +
|
||||
res.data.fingerprint + ' (' + res.data.algorithm + ')\n\n' +
|
||||
'Trust this key?';
|
||||
if (confirm(msg)) {
|
||||
hopList[hopIndex].host_key = res.data.host_key;
|
||||
hopList[hopIndex].host_key_fingerprint = res.data.fingerprint;
|
||||
renderHops();
|
||||
} else {
|
||||
if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; }
|
||||
}
|
||||
var msg = hop.hostname.trim() + ':' + (hop.port || 22) + '\n\n' +
|
||||
res.data.fingerprint + ' (' + res.data.algorithm + ')';
|
||||
rgConfirm(msg, { title: 'Trust host key?', confirmText: 'Trust', safe: true }).then(function(ok) {
|
||||
if (ok) {
|
||||
hopList[hopIndex].host_key = res.data.host_key;
|
||||
hopList[hopIndex].host_key_fingerprint = res.data.fingerprint;
|
||||
renderHops();
|
||||
} else {
|
||||
if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; }
|
||||
}
|
||||
});
|
||||
}).catch(function(e) {
|
||||
alert('Probe failed: ' + e.message);
|
||||
rgAlert('Probe failed: ' + e.message);
|
||||
if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; }
|
||||
});
|
||||
}
|
||||
@@ -6147,5 +6154,6 @@
|
||||
setTimeout(function() { openMyCredentials(); }, 250);
|
||||
}
|
||||
</script>
|
||||
<script src="/ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+13
-11
@@ -508,17 +508,18 @@
|
||||
}
|
||||
var deleteName = e.target.getAttribute('data-delete-rec');
|
||||
if (deleteName) {
|
||||
if (!confirm('Delete recording ' + deleteName + '?')) return;
|
||||
fetch('/api/recordings/' + encodeURIComponent(deleteName), {
|
||||
method: 'DELETE',
|
||||
headers: apiHeaders(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function() {
|
||||
refreshRecordings();
|
||||
// Close player if this recording is playing
|
||||
if (document.getElementById('player-title').textContent === deleteName) {
|
||||
closePlayer();
|
||||
}
|
||||
rgConfirm('Delete recording ' + deleteName + '?', { danger: true, confirmText: 'Delete' }).then(function(ok) {
|
||||
if (!ok) return;
|
||||
fetch('/api/recordings/' + encodeURIComponent(deleteName), {
|
||||
method: 'DELETE',
|
||||
headers: apiHeaders(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function() {
|
||||
refreshRecordings();
|
||||
if (document.getElementById('player-title').textContent === deleteName) {
|
||||
closePlayer();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -683,5 +684,6 @@
|
||||
setInterval(refreshRecordings, 15000);
|
||||
setInterval(refreshTypescripts, 15000);
|
||||
</script>
|
||||
<script src="/ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-9
@@ -227,15 +227,17 @@
|
||||
revokeBtn.textContent = 'revoke';
|
||||
revokeBtn.addEventListener('click', (function(id, name) {
|
||||
return function() {
|
||||
if (!confirm('Revoke token "' + name + '"? This cannot be undone.')) return;
|
||||
fetch('/api/me/tokens/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: apiHeaders(),
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) return res.json().then(function(d) { showError(d.error); });
|
||||
loadTokens();
|
||||
rgConfirm('Revoke token "' + name + '"? This cannot be undone.', { danger: true, confirmText: 'Revoke' }).then(function(ok) {
|
||||
if (!ok) return;
|
||||
fetch('/api/me/tokens/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: apiHeaders(),
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) return res.json().then(function(d) { showError(d.error); });
|
||||
loadTokens();
|
||||
});
|
||||
});
|
||||
};
|
||||
})(t.id, t.name));
|
||||
@@ -336,5 +338,6 @@
|
||||
}
|
||||
}, 10000);
|
||||
</script>
|
||||
<script src="/ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Themed dialogs — replacements for the browser's native alert() / confirm().
|
||||
*
|
||||
* Self-contained: injects its own styles and a dialog node, using the theme
|
||||
* CSS tokens that every page defines, so it works anywhere this is loaded
|
||||
* (including the standalone client page). Promise-based, since a styled modal
|
||||
* can't block the thread the way the native ones do.
|
||||
*
|
||||
* rgConfirm('Delete this entry?', { danger: true }).then(function (ok) { ... });
|
||||
* rgAlert('Popup blocked.').then(function () { ... });
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.rgConfirm) return;
|
||||
|
||||
function injectStyle() {
|
||||
if (document.getElementById('rg-dialog-style')) return;
|
||||
var css = [
|
||||
'#rg-dialog-overlay{display:none;position:fixed;inset:0;z-index:4000;background:rgba(0,0,0,.6);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);justify-content:center;align-items:center;font-family:monospace}',
|
||||
'#rg-dialog-overlay.open{display:flex}',
|
||||
'#rg-dialog{background:var(--surface,#16213e);border:1px solid var(--border,#333);border-radius:8px;box-shadow:0 20px 60px rgba(0,0,0,.6);min-width:340px;max-width:min(520px,calc(100vw - 32px));padding:var(--s-5,28px);animation:rg-dialog-in 120ms ease-out both}',
|
||||
'@keyframes rg-dialog-in{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}',
|
||||
'#rg-dialog-title{color:var(--primary,#e94560);font-size:var(--fz-lg,20px);font-weight:bold;letter-spacing:.06em;text-transform:uppercase;margin:0 0 var(--s-3,14px);padding-bottom:var(--s-2,8px);border-bottom:1px solid var(--border,#333)}',
|
||||
'#rg-dialog-message{color:var(--text,#e0e0e0);font-size:var(--fz-md,18px);line-height:1.55;white-space:pre-wrap;word-break:break-word}',
|
||||
'#rg-dialog-actions{display:flex;justify-content:flex-end;gap:var(--s-2,8px);margin-top:var(--s-5,28px)}',
|
||||
'#rg-dialog-actions button{height:var(--ctl-md,44px);padding:0 var(--s-4,20px);border-radius:var(--radius,2px);font-family:monospace;font-size:var(--fz-md,18px);font-weight:bold;cursor:pointer;border:1px solid transparent;box-sizing:border-box;line-height:1}',
|
||||
'#rg-dialog-ok{background:var(--primary,#e94560);border-color:var(--primary,#e94560);color:var(--text-on-primary,#fff)}',
|
||||
'#rg-dialog-ok:hover{background:var(--primary-hover,#c73652);border-color:var(--primary-hover,#c73652)}',
|
||||
'#rg-dialog-overlay.safe #rg-dialog-ok{background:var(--accent,#5bc0be);border-color:var(--accent,#5bc0be);color:var(--bg,#1a1a2e)}',
|
||||
'#rg-dialog-overlay.safe #rg-dialog-ok:hover{background:var(--accent-hover,#4aa3a1);border-color:var(--accent-hover,#4aa3a1)}',
|
||||
'#rg-dialog-cancel{background:transparent;color:var(--text-muted,#aaa);border-color:var(--border,#333);font-weight:normal}',
|
||||
'#rg-dialog-cancel:hover{background:var(--input,#0f3460);color:var(--text,#e0e0e0);border-color:var(--text-muted,#aaa)}',
|
||||
'#rg-dialog button:focus-visible{outline:2px solid var(--accent,#5bc0be);outline-offset:2px}'
|
||||
].join('');
|
||||
var st = document.createElement('style'); st.id = 'rg-dialog-style'; st.textContent = css;
|
||||
(document.head || document.documentElement).appendChild(st);
|
||||
}
|
||||
|
||||
var overlay, titleEl, msgEl, okBtn, cancelBtn, current = null, prevFocus = null;
|
||||
|
||||
function build() {
|
||||
if (overlay) return;
|
||||
injectStyle();
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'rg-dialog-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.innerHTML =
|
||||
'<div id="rg-dialog" aria-labelledby="rg-dialog-title">' +
|
||||
'<div id="rg-dialog-title"></div>' +
|
||||
'<div id="rg-dialog-message"></div>' +
|
||||
'<div id="rg-dialog-actions">' +
|
||||
'<button type="button" id="rg-dialog-cancel"></button>' +
|
||||
'<button type="button" id="rg-dialog-ok"></button>' +
|
||||
'</div></div>';
|
||||
document.body.appendChild(overlay);
|
||||
titleEl = overlay.querySelector('#rg-dialog-title');
|
||||
msgEl = overlay.querySelector('#rg-dialog-message');
|
||||
okBtn = overlay.querySelector('#rg-dialog-ok');
|
||||
cancelBtn = overlay.querySelector('#rg-dialog-cancel');
|
||||
okBtn.addEventListener('click', function () { done(true); });
|
||||
cancelBtn.addEventListener('click', function () { done(false); });
|
||||
overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) done(false); });
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!current) return;
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); done(false); }
|
||||
else if (e.key === 'Enter') {
|
||||
// Act on the focused button, so a user who tabbed to Cancel
|
||||
// and pressed Enter cancels instead of confirming.
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
done(document.activeElement !== cancelBtn);
|
||||
} else if (e.key === 'Tab') {
|
||||
// Trap focus between the two buttons.
|
||||
e.preventDefault();
|
||||
var toCancel = document.activeElement === okBtn && !e.shiftKey ? true
|
||||
: document.activeElement === cancelBtn && e.shiftKey ? false : null;
|
||||
var target = (cancelBtn.style.display === 'none') ? okBtn
|
||||
: (toCancel === true ? cancelBtn : toCancel === false ? okBtn
|
||||
: (document.activeElement === okBtn ? cancelBtn : okBtn));
|
||||
try { target.focus(); } catch (x) {}
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
function done(result) {
|
||||
if (!current) return;
|
||||
var resolve = current.resolve, isConfirm = current.isConfirm;
|
||||
current = null;
|
||||
overlay.classList.remove('open');
|
||||
if (prevFocus && prevFocus.focus) { try { prevFocus.focus(); } catch (e) {} }
|
||||
resolve(isConfirm ? result : undefined);
|
||||
}
|
||||
|
||||
function show(opts) {
|
||||
build();
|
||||
// If a dialog is already open, resolve its promise (as cancel) before
|
||||
// replacing it, so the earlier caller never hangs.
|
||||
if (current) { var c = current; current = null; try { c.resolve(c.isConfirm ? false : undefined); } catch (e) {} }
|
||||
return new Promise(function (resolve) {
|
||||
current = { resolve: resolve, isConfirm: !!opts.isConfirm };
|
||||
titleEl.textContent = opts.title || (opts.isConfirm ? 'Confirm' : 'Notice');
|
||||
msgEl.textContent = opts.message == null ? '' : String(opts.message);
|
||||
okBtn.textContent = opts.confirmText || (opts.isConfirm ? 'Confirm' : 'OK');
|
||||
if (opts.isConfirm) { cancelBtn.style.display = ''; cancelBtn.textContent = opts.cancelText || 'Cancel'; }
|
||||
else { cancelBtn.style.display = 'none'; }
|
||||
// danger (destructive) keeps the primary red; safe actions go accent.
|
||||
overlay.classList.toggle('safe', !!opts.safe && !opts.danger);
|
||||
prevFocus = document.activeElement;
|
||||
overlay.classList.add('open');
|
||||
setTimeout(function () { try { okBtn.focus(); } catch (e) {} }, 0);
|
||||
});
|
||||
}
|
||||
|
||||
window.rgConfirm = function (message, opts) {
|
||||
opts = opts || {};
|
||||
return show({ isConfirm: true, message: message, title: opts.title, confirmText: opts.confirmText, cancelText: opts.cancelText, danger: opts.danger, safe: opts.safe });
|
||||
};
|
||||
window.rgAlert = function (message, opts) {
|
||||
opts = opts || {};
|
||||
return show({ isConfirm: false, message: message, title: opts.title, confirmText: opts.okText || opts.confirmText });
|
||||
};
|
||||
})();
|
||||
@@ -31,6 +31,7 @@ One spec per change, named after the branch that introduced it:
|
||||
| `sessions-adhoc-modal` | ad-hoc form opens as a modal |
|
||||
| `facet-filters` | shared facets narrow lists, persist, combine with search |
|
||||
| `folder-display-name` | display name over the key, modal field round-trips |
|
||||
| `themed-dialogs` | `rgConfirm`/`rgAlert` replace native dialogs |
|
||||
|
||||
The fixture suppresses the first-visit onboarding tour (`options.onboarding`
|
||||
re-enables it) and defaults to the admin role; pass `role` to test gating.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Themed confirm/alert dialogs (ui.js) replace the browser's native ones:
|
||||
// a destructive action asks in an in-page modal with a red confirm button,
|
||||
// Escape cancels and sends nothing, OK performs the request; Enter acts on
|
||||
// the focused button and Tab stays inside the dialog.
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { installApi, settle } from '../lib/api.mjs';
|
||||
|
||||
test('deleting a recording asks in a themed dialog; Escape cancels, OK deletes', async ({ page }) => {
|
||||
const api = await installApi(page, { role: 'admin' });
|
||||
// A native confirm() would block the test: make sure none is ever raised.
|
||||
page.on('dialog', d => { throw new Error('native dialog raised: ' + d.message()); });
|
||||
await page.goto('/recordings.html');
|
||||
await settle(page);
|
||||
const del = page.locator('button[data-delete-rec]').first();
|
||||
await expect(del).toBeVisible();
|
||||
const name = await del.getAttribute('data-delete-rec');
|
||||
|
||||
await del.click();
|
||||
const dialog = page.locator('#rg-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page.locator('#rg-dialog-message')).toContainText(name);
|
||||
await expect(page.locator('#rg-dialog-ok')).toBeVisible();
|
||||
await expect(page.locator('#rg-dialog-cancel')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeHidden();
|
||||
expect(api.calls.filter(c => c.method === 'DELETE').length).toBe(0);
|
||||
|
||||
await del.click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.locator('#rg-dialog-ok').click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect.poll(() => api.calls.some(c => c.method === 'DELETE' && c.path === '/api/recordings/' + encodeURIComponent(name))).toBe(true);
|
||||
});
|
||||
|
||||
test('Tab is trapped between the two buttons and Enter acts on the focused one', async ({ page }) => {
|
||||
const api = await installApi(page, { role: 'admin' });
|
||||
await page.goto('/recordings.html');
|
||||
await settle(page);
|
||||
await page.locator('button[data-delete-rec]').first().click();
|
||||
const dialog = page.locator('#rg-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
const ids = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
ids.push(await page.evaluate(() => document.activeElement.id));
|
||||
}
|
||||
for (const id of ids) expect(['rg-dialog-ok', 'rg-dialog-cancel']).toContain(id);
|
||||
// Land on Cancel, press Enter: cancelled, nothing sent.
|
||||
while ((await page.evaluate(() => document.activeElement.id)) !== 'rg-dialog-cancel') await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(dialog).toBeHidden();
|
||||
expect(api.calls.filter(c => c.method === 'DELETE').length).toBe(0);
|
||||
});
|
||||
|
||||
test('connections: deleting an entry and terminating a session use the dialog too', async ({ page }) => {
|
||||
const api = await installApi(page, { role: 'admin' });
|
||||
page.on('dialog', d => { throw new Error('native dialog raised: ' + d.message()); });
|
||||
await page.goto('/connections.html');
|
||||
await settle(page);
|
||||
await page.locator('.folder-row').filter({ hasText: /beta/ }).first().click();
|
||||
await expect(page.locator('#entries-title')).toHaveText(/beta/);
|
||||
const row = page.locator('.main .entries-table tbody tr').filter({ hasText: 'app01' });
|
||||
await row.locator('button', { hasText: /^delete$/i }).first().click();
|
||||
const dialog = page.locator('#rg-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page.locator('#rg-dialog-message')).toContainText('app01');
|
||||
await page.locator('#rg-dialog-ok').click();
|
||||
await expect.poll(() => api.calls.some(c => c.method === 'DELETE' && c.path.endsWith('/entries/app01'))).toBe(true);
|
||||
});
|
||||
Reference in New Issue
Block a user