Skip to content

Commit db55fda

Browse files
authored
Merge pull request #84 from markrai/ui/dialog-confirmation-and-consistency
UI/dialog confirmation and consistency
2 parents cb73ae0 + d6a2faf commit db55fda

23 files changed

Lines changed: 1144 additions & 227 deletions

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@
22

33
> **Upgrades:** No breaking changes in **3.7.x** / **3.8.x** / **3.9.x** / **3.10.x** / **3.11.x** / **3.12.x** / **3.13.x** / **3.14.x** / **3.15.x** / **3.16.x** unless noted below.
44
5+
## [3.16.2] - 2026-05-26
6+
7+
### Improvements
8+
9+
- **Shared confirm and prompt dialogs** - `showConfirmDialog` and new `showPromptDialog` use the app `dialog` styling (header, footer, danger confirm) with intent-based close handling so outside-click dismissal and programmatic `dialog.close()` resolve to the correct choice instead of false negatives.
10+
11+
- **Todo dialog unsaved changes** - Closing the todo editor (X, Escape, outside click, or app-level close) prompts to discard when title, notes, tags, status, estimation, assignee, or sprint differ from the snapshot taken when the dialog opened.
12+
13+
- **Settings workflow tab** - Switching away from Workflow with a dirty lane draft prompts to discard unsaved changes before re-rendering.
14+
15+
- **Project rename** - Board and Projects rename flows use `showPromptDialog` instead of the browser `prompt()`.
16+
17+
- **Member management** - Demote and remove-member actions on the board use `showConfirmDialog` instead of `window.confirm()`.
18+
19+
### Frontend
20+
21+
- **Modal outside click** - Backdrop/outside closes dispatch a cancellable `scrumboy:dialog-request-close` event so dialogs with dirty-state guards can intercept close attempts.
22+
23+
### Tests
24+
25+
- **Todo close guard** - Dirty detection, discard/cancel paths, and interaction with the close-request event.
26+
- **Utils dialogs** - Confirm/prompt intent resolution and `confirmDelete` wrapper.
27+
- **Projects** - Rename prompt and delete confirmation wiring.
28+
- **Modal outside click** - Close-request event behavior.
29+
30+
### Tooling
31+
32+
- **`check-delete-confirms`** - CI guard now rejects raw `alert()`, `confirm()`, and `prompt()` in maintained frontend sources (not only delete confirms).
33+
534
## [3.16.1] - 2026-05-26
635

736
### Fixed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<p align="center">
22
<img width="372" src="internal/httpapi/web/githublogo.png" alt="scrumboy logo" />
33
<br />
4-
<img src="https://img.shields.io/badge/version-v3.16.1-blue" alt="version" />
4+
<img src="https://img.shields.io/badge/version-v3.16.2-blue" alt="version" />
55
<a href="LICENSE">
66
<img src="https://img.shields.io/badge/license-AGPL--v3-orange" alt="license" />
77
</a>

internal/httpapi/web/app.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { apiFetch } from './dist/api.js';
1313
import { navigate, router } from './dist/router.js';
1414
import { getRoute, getProjectId, getBoard, getAuthStatusAvailable, getMobileTab, getSlug, getTag, getSearch, getSprintIdFromUrl, getProjectView, getProjectsTab, getProjects, getSettingsProjectId, getEditingTodo, getAvailableTags, getAutocompleteSuggestion, getAvailableTagsMap, getTagColors, getUser, getSettingsActiveTab, getBackupImportBtn, getBackupData, getBackupPreview, getAuthStatusChecked } from './dist/state/selectors.js';
1515
import { setProjectId, setBoard, setSlug, setTag, setMobileTab, setProjects, setProjectsTab, setProjectView, setEditingTodo, setAvailableTags, setAvailableTagsMap, setAutocompleteSuggestion, setTagColors, setSettingsProjectId, setSettingsActiveTab, setBackupImportBtn, setBackupData, setBackupPreview } from './dist/state/mutations.js';
16-
import { openTodoDialog, renderTagsChips, setupTagAutocomplete, removeTag, renderTagAutocomplete, getTagsFromChips, resetAssigneeSelect, getTodoFormPermissions } from './dist/dialogs/todo.js';
16+
import { openTodoDialog, renderTagsChips, setupTagAutocomplete, removeTag, renderTagAutocomplete, getTagsFromChips, resetAssigneeSelect, getTodoFormPermissions, requestTodoDialogClose } from './dist/dialogs/todo.js';
1717
import { buildTodoCreatePayload, buildTodoPatchPayload } from './dist/dialogs/todo-submit.js';
1818
import { renderSettingsModal, invalidateTagsCache } from './dist/dialogs/settings.js';
1919
import { initDnD, columnsSpec, dragInProgress, dragJustEnded } from './dist/features/drag-drop.js';
@@ -77,10 +77,10 @@ app.addEventListener("click", async (e) => {
7777
// renderSettingsModal, updateTagColor, and deleteTag moved to modules/dialogs/settings.ts
7878
// getTagColor and handleProjectImageUpload moved to modules/views/board.ts
7979

80-
closeTodoBtn.addEventListener("click", () => {
80+
closeTodoBtn.addEventListener("click", async () => {
8181
setAutocompleteSuggestion(null);
8282
renderTagAutocomplete();
83-
todoDialog.close();
83+
await requestTodoDialogClose({ reason: "button" });
8484
});
8585
closeSettingsBtn.addEventListener("click", () => settingsDialog.close());
8686

@@ -113,7 +113,7 @@ deleteTodoBtn.addEventListener("click", async () => {
113113
await apiFetch(`/api/board/${getSlug()}/todos/${todo.localId}`, { method: "DELETE" });
114114
setEditingTodo(null);
115115
onTodoDialogClosed();
116-
todoDialog.close();
116+
await requestTodoDialogClose({ force: true, reason: "delete" });
117117
await loadBoardBySlug(getSlug(), getTag(), getSearch(), getSprintIdFromUrl());
118118
} catch (err) {
119119
showToast(err.message);
@@ -196,7 +196,7 @@ todoForm.addEventListener("submit", async (e) => {
196196
showToast("Todo created");
197197
}
198198

199-
todoDialog.close();
199+
await requestTodoDialogClose({ force: true, reason: "save" });
200200
// Invalidate tags cache so Settings modal shows newly created tags
201201
invalidateTagsCache();
202202
await loadBoardBySlug(getSlug(), getTag(), getSearch(), getSprintIdFromUrl());

internal/httpapi/web/dist/core/modal-outside-click.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
let pointerStartedInsideContent = false;
2626
let topAtPointerDown = null;
2727
let initialized = false;
28+
export const DIALOG_CLOSE_REQUEST_EVENT = "scrumboy:dialog-request-close";
2829
function getTopOpenDialog() {
2930
const openDialogs = Array.from(document.querySelectorAll("dialog[open]"));
3031
if (openDialogs.length === 0)
@@ -38,6 +39,13 @@ function getDialogContentBox(dialog) {
3839
return (dialog.querySelector("[data-dialog-content-root]") ||
3940
dialog.querySelector(".dialog__form, .dialog__content"));
4041
}
42+
function requestDialogClose(dialog, reason) {
43+
const closeRequest = new CustomEvent(DIALOG_CLOSE_REQUEST_EVENT, {
44+
cancelable: true,
45+
detail: { reason },
46+
});
47+
return dialog.dispatchEvent(closeRequest);
48+
}
4149
function onPointerDown(ev) {
4250
const t = ev.target;
4351
topAtPointerDown = getTopOpenDialog();
@@ -76,13 +84,17 @@ function onDocumentClick(ev) {
7684
if (!content)
7785
return;
7886
if (t === top) {
79-
top.close();
87+
if (requestDialogClose(top, "outside")) {
88+
top.close();
89+
}
8090
return;
8191
}
8292
if (content.contains(t)) {
8393
return;
8494
}
85-
top.close();
95+
if (requestDialogClose(top, "outside")) {
96+
top.close();
97+
}
8698
}
8799
export function initModalOutsideClickClose() {
88100
if (initialized)

internal/httpapi/web/dist/dialogs/settings.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1384,7 +1384,8 @@ export async function renderSettingsModal(options) {
13841384
const userId = e.currentTarget.getAttribute("data-user-id");
13851385
if (!userId)
13861386
return;
1387-
if (!confirm("Demote this user from admin to regular user?")) {
1387+
const confirmed = await showConfirmDialog("Demote this user from admin to regular user?", "Demote user?", "Demote");
1388+
if (!confirmed) {
13881389
return;
13891390
}
13901391
try {

internal/httpapi/web/dist/dialogs/todo.js

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import { addTagBtn, closeTodoBtn, deleteTodoBtn, shareTodoBtn, todoBody, todoBodyPreview, todoBodyPreviewTab, todoBodyToggle, todoBodyWriteTab, todoDialog, todoDialogTitle, todoEstimationField, todoEstimationPoints, todoStatus, todoTags, todoTitle, } from '../dom/elements.js';
22
import { apiFetch } from '../api.js';
3+
import { DIALOG_CLOSE_REQUEST_EVENT } from '../core/modal-outside-click.js';
34
import { renderMarkdownPreviewInto } from '../markdown-preview.js';
45
import { getBoard, getBoardMembers, getMarkdownNotesEnabled, getSlug, getTagColors, getUser } from '../state/selectors.js';
56
import { setAvailableTags, setAvailableTagsMap, setEditingTodo, setTagColors } from '../state/mutations.js';
6-
import { escapeHTML, isAnonymousBoard, showToast } from '../utils.js';
7+
import { escapeHTML, isAnonymousBoard, showConfirmDialog, showToast } from '../utils.js';
78
import { normalizeSprints } from '../sprints.js';
89
import { bindShareTodoButton, bindTodoDialogLinkLifecycle, initializeTodoDialogLinks, resetTodoDialogLinks, } from './todo-links.js';
910
import { computeTodoDialogPermissions, setTodoFormPermissions, } from './todo-permissions.js';
10-
import { renderTagsChips, resetTodoTagAutocompleteBindings, setupTagAutocomplete, } from './todo-tags.js';
11+
import { getTagsFromChips, renderTagsChips, resetTodoTagAutocompleteBindings, setupTagAutocomplete, } from './todo-tags.js';
1112
export { getTodoFormPermissions, } from './todo-permissions.js';
1213
export { getTagsFromChips, normalizeTagName, removeTag, renderTagAutocomplete, renderTagsChips, setupTagAutocomplete, } from './todo-tags.js';
1314
let todoNotesMode = "markdown";
1415
let todoNotesPreviewBound = false;
16+
let todoDialogCloseGuardsBound = false;
17+
let todoDialogBaseline = null;
18+
let todoDialogClosePromptOpen = false;
1519
export function resolveColumnKey(raw) {
1620
const v = (raw || "").trim();
1721
if (!v)
@@ -147,9 +151,108 @@ function bindTodoNotesPreviewControls() {
147151
});
148152
}
149153
}
154+
function readTodoDialogSnapshot() {
155+
const assignee = document.getElementById("todoAssignee");
156+
const sprint = document.getElementById("todoSprint");
157+
return {
158+
title: todoTitle?.value ?? "",
159+
body: todoBody?.value ?? "",
160+
tags: getTagsFromChips(),
161+
status: todoStatus?.value ?? "",
162+
estimation: todoEstimationPoints?.value ?? "",
163+
assignee: assignee?.value ?? "",
164+
sprint: sprint?.value ?? "",
165+
};
166+
}
167+
function captureTodoDialogBaseline() {
168+
todoDialogBaseline = readTodoDialogSnapshot();
169+
}
170+
function isTodoDialogDirty() {
171+
if (!todoDialogBaseline) {
172+
return false;
173+
}
174+
const current = readTodoDialogSnapshot();
175+
return (current.title !== todoDialogBaseline.title ||
176+
current.body !== todoDialogBaseline.body ||
177+
current.status !== todoDialogBaseline.status ||
178+
current.estimation !== todoDialogBaseline.estimation ||
179+
current.assignee !== todoDialogBaseline.assignee ||
180+
current.sprint !== todoDialogBaseline.sprint ||
181+
current.tags.length !== todoDialogBaseline.tags.length ||
182+
current.tags.some((tag, idx) => tag !== todoDialogBaseline?.tags[idx]));
183+
}
184+
function resetTodoDialogCloseState() {
185+
todoDialogBaseline = null;
186+
todoDialogClosePromptOpen = false;
187+
}
188+
async function closeTodoDialogInternal(options = {}) {
189+
const dialog = todoDialog;
190+
if (!dialog || !dialog.open) {
191+
return true;
192+
}
193+
if (options.force || !isTodoDialogDirty()) {
194+
dialog.close();
195+
return true;
196+
}
197+
if (todoDialogClosePromptOpen) {
198+
return false;
199+
}
200+
todoDialogClosePromptOpen = true;
201+
try {
202+
const discard = await showConfirmDialog("You have unsaved changes. Discard them?", "Unsaved changes", "Discard");
203+
if (!discard) {
204+
return false;
205+
}
206+
dialog.close();
207+
return true;
208+
}
209+
finally {
210+
todoDialogClosePromptOpen = false;
211+
}
212+
}
213+
function bindTodoDialogCloseGuards() {
214+
if (todoDialogCloseGuardsBound || !todoDialog) {
215+
return;
216+
}
217+
todoDialogCloseGuardsBound = true;
218+
todoDialog.addEventListener("cancel", (event) => {
219+
if (todoDialogClosePromptOpen) {
220+
event.preventDefault();
221+
return;
222+
}
223+
if (!isTodoDialogDirty()) {
224+
return;
225+
}
226+
event.preventDefault();
227+
void closeTodoDialogInternal({ reason: "cancel" });
228+
});
229+
todoDialog.addEventListener(DIALOG_CLOSE_REQUEST_EVENT, (event) => {
230+
if (todoDialogClosePromptOpen) {
231+
event.preventDefault();
232+
return;
233+
}
234+
if (!isTodoDialogDirty()) {
235+
return;
236+
}
237+
event.preventDefault();
238+
const detail = event.detail;
239+
void closeTodoDialogInternal({ reason: detail?.reason === "outside" ? "outside" : "button" });
240+
});
241+
todoDialog.addEventListener("close", () => {
242+
resetTodoDialogCloseState();
243+
});
244+
}
245+
export function requestTodoDialogClose(options = {}) {
246+
bindTodoDialogCloseGuards();
247+
return closeTodoDialogInternal(options);
248+
}
249+
export function __isTodoDialogDirtyForTest() {
250+
return isTodoDialogDirty();
251+
}
150252
export async function openTodoDialog(opts) {
151253
const { mode, todo, status, onNavigateToLinkedTodo } = opts;
152254
setEditingTodo(mode === "edit" ? todo : null);
255+
bindTodoDialogCloseGuards();
153256
bindTodoDialogLinkLifecycle();
154257
bindTodoNotesPreviewControls();
155258
const board = getBoard();
@@ -412,6 +515,7 @@ export async function openTodoDialog(opts) {
412515
setupTagAutocomplete();
413516
}
414517
bindShareTodoButton();
518+
captureTodoDialogBaseline();
415519
todoDialog.showModal();
416520
let userChoseFocus = false;
417521
const ac = new AbortController();

internal/httpapi/web/dist/utils.js

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ export function showConfirmDialog(message, title = "Confirm", confirmLabel = "Co
227227
const dialog = document.createElement('dialog');
228228
dialog.className = 'dialog';
229229
dialog.innerHTML = `
230-
<div class="dialog__form">
230+
<div class="dialog__form" data-dialog-content-root>
231231
<div class="dialog__header">
232232
<div class="dialog__title">${escapeHTML(title)}</div>
233233
<button class="btn btn--ghost" type="button" id="confirmDialogClose" aria-label="Close">✕</button>
@@ -282,7 +282,95 @@ export function showConfirmDialog(message, title = "Confirm", confirmLabel = "Co
282282
catch (err) {
283283
// Extremely rare (detached from DOM / not an HTMLDialogElement); surface
284284
// the error instead of silently hanging the promise.
285-
settle(false);
285+
settled = true;
286+
if (dialog.parentNode)
287+
dialog.remove();
288+
reject(err);
289+
}
290+
});
291+
}
292+
/**
293+
* Shows a prompt dialog with a single text input and resolves to the entered
294+
* value on submit, or null on any cancel/close path.
295+
*/
296+
export function showPromptDialog(options = {}) {
297+
const { title = "Prompt", label = "Value", initialValue = "", confirmLabel = "Save", placeholder = "", maxLength, } = options;
298+
return new Promise((resolve, reject) => {
299+
const dialog = document.createElement("dialog");
300+
dialog.className = "dialog";
301+
const maxLengthAttr = typeof maxLength === "number" && Number.isFinite(maxLength) && maxLength > 0
302+
? ` maxlength="${Math.trunc(maxLength)}"`
303+
: "";
304+
dialog.innerHTML = `
305+
<form method="dialog" class="dialog__form" data-dialog-content-root id="promptDialogForm">
306+
<div class="dialog__header">
307+
<div class="dialog__title">${escapeHTML(title)}</div>
308+
<button class="btn btn--ghost" type="button" id="promptDialogClose" aria-label="Close">✕</button>
309+
</div>
310+
<div class="dialog__content">
311+
<label class="field">
312+
<div class="field__label">${escapeHTML(label)}</div>
313+
<input
314+
class="input"
315+
type="text"
316+
id="promptDialogInput"
317+
value="${escapeHTML(initialValue)}"
318+
placeholder="${escapeHTML(placeholder)}"${maxLengthAttr}
319+
autocomplete="off"
320+
/>
321+
</label>
322+
</div>
323+
<div class="dialog__footer">
324+
<div class="spacer"></div>
325+
<button class="btn btn--ghost" type="button" id="promptDialogCancel">Cancel</button>
326+
<button class="btn" type="submit" id="promptDialogConfirm">${escapeHTML(confirmLabel)}</button>
327+
</div>
328+
</form>
329+
`;
330+
document.body.appendChild(dialog);
331+
let intent = null;
332+
let settled = false;
333+
const settle = (value) => {
334+
if (settled)
335+
return;
336+
settled = true;
337+
if (dialog.parentNode)
338+
dialog.remove();
339+
resolve(value);
340+
};
341+
dialog.addEventListener("close", () => settle(intent), { once: true });
342+
const input = dialog.querySelector("#promptDialogInput");
343+
const form = dialog.querySelector("#promptDialogForm");
344+
const onCancelClose = () => {
345+
intent = null;
346+
dialog.close();
347+
};
348+
form.addEventListener("submit", (event) => {
349+
event.preventDefault();
350+
intent = input.value;
351+
dialog.close();
352+
});
353+
const closeBtn = dialog.querySelector("#promptDialogClose");
354+
closeBtn.addEventListener("click", onCancelClose);
355+
const cancelBtn = dialog.querySelector("#promptDialogCancel");
356+
cancelBtn.addEventListener("click", onCancelClose);
357+
dialog.addEventListener("cancel", () => {
358+
intent = null;
359+
});
360+
try {
361+
dialog.showModal();
362+
input.focus();
363+
try {
364+
input.setSelectionRange(0, input.value.length);
365+
}
366+
catch {
367+
// Ignore selection issues in non-text input implementations.
368+
}
369+
}
370+
catch (err) {
371+
settled = true;
372+
if (dialog.parentNode)
373+
dialog.remove();
286374
reject(err);
287375
}
288376
});

0 commit comments

Comments
 (0)