Use modal dialog for confirming actions (#1168)

* Use modal dialog for confirming actions

* cleanup unused state
This commit is contained in:
Sascha Ißbrücker
2025-08-22 09:57:31 +02:00
committed by GitHub
parent 8f61fbd04a
commit 3804640574
16 changed files with 236 additions and 136 deletions

View File

@@ -1,79 +1,173 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import { FocusTrapController, isKeyboardActive } from "./focus-utils";
let confirmId = 0;
function nextConfirmId() {
return `confirm-${confirmId++}`;
}
class ConfirmButtonBehavior extends Behavior { class ConfirmButtonBehavior extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
this.onClick = this.onClick.bind(this); this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick); this.element.addEventListener("click", this.onClick);
} }
destroy() { destroy() {
this.reset(); if (this.opened) {
this.close();
}
this.element.removeEventListener("click", this.onClick); this.element.removeEventListener("click", this.onClick);
} }
onClick(event) { onClick(event) {
event.preventDefault(); event.preventDefault();
Behavior.interacting = true;
const container = document.createElement("span"); if (this.opened) {
container.className = "confirmation"; this.close();
} else {
const icon = this.element.getAttribute("ld-confirm-icon"); this.open();
if (icon) {
const iconElement = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
iconElement.style.width = "16px";
iconElement.style.height = "16px";
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
container.append(iconElement);
} }
const question = this.element.getAttribute("ld-confirm-question");
if (question) {
const questionElement = document.createElement("span");
questionElement.innerText = question;
container.append(question);
}
const buttonClasses = Array.from(this.element.classList.values())
.filter((cls) => cls.startsWith("btn"))
.join(" ");
const cancelButton = document.createElement(this.element.nodeName);
cancelButton.type = "button";
cancelButton.innerText = question ? "No" : "Cancel";
cancelButton.className = `${buttonClasses} mr-1`;
cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.element.nodeName);
confirmButton.type = this.element.type;
confirmButton.name = this.element.name;
confirmButton.value = this.element.value;
confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = buttonClasses;
confirmButton.addEventListener("click", this.reset.bind(this));
container.append(cancelButton, confirmButton);
this.container = container;
this.element.before(container);
this.element.classList.add("d-none");
} }
reset() { open() {
setTimeout(() => { const dropdown = document.createElement("div");
Behavior.interacting = false; dropdown.className = "dropdown confirm-dropdown active";
if (this.container) {
this.container.remove(); const confirmId = nextConfirmId();
this.container = null; const questionId = `${confirmId}-question`;
}
this.element.classList.remove("d-none"); const menu = document.createElement("div");
menu.className = "menu with-arrow";
menu.role = "alertdialog";
menu.setAttribute("aria-modal", "true");
menu.setAttribute("aria-labelledby", questionId);
menu.addEventListener("keydown", this.onMenuKeyDown.bind(this));
const question = document.createElement("span");
question.id = questionId;
question.textContent =
this.element.getAttribute("ld-confirm-question") || "Are you sure?";
question.style.fontWeight = "bold";
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.type = "button";
cancelButton.className = "btn";
cancelButton.tabIndex = 0;
cancelButton.addEventListener("click", () => this.close());
const confirmButton = document.createElement("button");
confirmButton.textContent = "Confirm";
confirmButton.type = "submit";
confirmButton.name = this.element.name;
confirmButton.value = this.element.value;
confirmButton.className = "btn btn-error";
confirmButton.addEventListener("click", () => this.confirm());
const arrow = document.createElement("div");
arrow.className = "menu-arrow";
menu.append(question, cancelButton, confirmButton, arrow);
dropdown.append(menu);
document.body.append(dropdown);
this.positionController = new AnchorPositionController(this.element, menu);
this.focusTrap = new FocusTrapController(menu);
this.dropdown = dropdown;
this.opened = true;
}
onMenuKeyDown(event) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.close();
}
}
confirm() {
this.element.closest("form").requestSubmit(this.element);
this.close();
}
close() {
if (!this.opened) return;
this.positionController.destroy();
this.focusTrap.destroy();
this.dropdown.remove();
this.element.focus({ focusVisible: isKeyboardActive() });
this.opened = false;
}
}
class AnchorPositionController {
constructor(anchor, overlay) {
this.anchor = anchor;
this.overlay = overlay;
this.handleScroll = this.handleScroll.bind(this);
window.addEventListener("scroll", this.handleScroll, { capture: true });
this.updatePosition();
}
handleScroll() {
if (this.debounce) {
return;
}
this.debounce = true;
requestAnimationFrame(() => {
this.updatePosition();
this.debounce = false;
}); });
} }
updatePosition() {
const anchorRect = this.anchor.getBoundingClientRect();
const overlayRect = this.overlay.getBoundingClientRect();
const bufferX = 10;
const bufferY = 30;
let left = anchorRect.left - (overlayRect.width - anchorRect.width) / 2;
const initialLeft = left;
const overflowLeft = left < bufferX;
const overflowRight =
left + overlayRect.width > window.innerWidth - bufferX;
if (overflowLeft) {
left = bufferX;
} else if (overflowRight) {
left = window.innerWidth - overlayRect.width - bufferX;
}
const delta = initialLeft - left;
this.overlay.style.setProperty("--arrow-offset", `${delta}px`);
let top = anchorRect.bottom;
const overflowBottom =
top + overlayRect.height > window.innerHeight - bufferY;
if (overflowBottom) {
top = anchorRect.top - overlayRect.height;
this.overlay.classList.remove("top-aligned");
this.overlay.classList.add("bottom-aligned");
} else {
this.overlay.classList.remove("bottom-aligned");
this.overlay.classList.add("top-aligned");
}
this.overlay.style.left = `${left}px`;
this.overlay.style.top = `${top}px`;
}
destroy() {
window.removeEventListener("scroll", this.handleScroll, { capture: true });
}
} }
registerBehavior("ld-confirm-button", ConfirmButtonBehavior); registerBehavior("ld-confirm-button", ConfirmButtonBehavior);

View File

@@ -93,6 +93,12 @@ document.addEventListener("turbo:load", () => {
return; return;
} }
// Ignore if there is a modal dialog, which should handle its own focus
const modal = document.querySelector("[aria-modal='true']");
if (modal) {
return;
}
// Check if there is an explicit focus target for the next page load // Check if there is an explicit focus target for the next page load
for (const target of afterPageLoadFocusTarget) { for (const target of afterPageLoadFocusTarget) {
const element = document.querySelector(target); const element = document.querySelector(target);

View File

@@ -54,8 +54,6 @@ export class Behavior {
destroy() {} destroy() {}
} }
Behavior.interacting = false;
export function registerBehavior(name, behavior) { export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior; behaviorRegistry[name] = behavior;
} }

View File

@@ -23,32 +23,22 @@ export class ModalBehavior extends Behavior {
this.closeButton.removeEventListener("click", this.onClose); this.closeButton.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown); document.removeEventListener("keydown", this.onKeyDown);
this.clearInert(); this.removeScrollLock();
this.focusTrap.destroy(); this.focusTrap.destroy();
} }
init() { init() {
this.setupInert(); this.setupScrollLock();
this.focusTrap = new FocusTrapController( this.focusTrap = new FocusTrapController(
this.element.querySelector(".modal-container"), this.element.querySelector(".modal-container"),
); );
} }
setupInert() { setupScrollLock() {
// Inert all other elements on the page
document
.querySelectorAll("body > *:not(.modals)")
.forEach((el) => el.setAttribute("inert", ""));
// Lock scroll on the body
document.body.classList.add("scroll-lock"); document.body.classList.add("scroll-lock");
} }
clearInert() { removeScrollLock() {
// Clear inert attribute from all elements to allow focus outside the modal again
document
.querySelectorAll("body > *")
.forEach((el) => el.removeAttribute("inert"));
// Remove scroll lock from the body
document.body.classList.remove("scroll-lock"); document.body.classList.remove("scroll-lock");
} }
@@ -85,7 +75,7 @@ export class ModalBehavior extends Behavior {
doClose() { doClose() {
this.element.remove(); this.element.remove();
this.clearInert(); this.removeScrollLock();
this.element.dispatchEvent(new CustomEvent("modal:close")); this.element.dispatchEvent(new CustomEvent("modal:close"));
} }
} }

View File

@@ -31,22 +31,17 @@
} }
/* Confirm button component */ /* Confirm button component */
span.confirmation { .confirm-dropdown.active {
display: flex; position: fixed;
align-items: baseline; z-index: 500;
gap: var(--unit-1);
color: var(--error-color) !important;
svg { & .menu {
align-self: center; position: fixed;
} display: flex;
flex-direction: column;
.btn.btn-link { box-sizing: border-box;
color: var(--error-color) !important; gap: var(--unit-2);
padding: var(--unit-2);
&:hover {
text-decoration: underline;
}
} }
} }

View File

@@ -87,4 +87,43 @@
border-bottom: solid 1px var(--secondary-border-color); border-bottom: solid 1px var(--secondary-border-color);
margin: var(--unit-2) 0; margin: var(--unit-2) 0;
} }
&.with-arrow {
overflow: visible;
--arrow-size: 16px;
--arrow-offset: 0px;
.menu-arrow {
display: block;
position: absolute;
inset-inline-start: calc(50% + var(--arrow-offset));
top: 0;
width: var(--arrow-size);
height: var(--arrow-size);
translate: -50% -50%;
rotate: 45deg;
background: inherit;
border: inherit;
clip-path: polygon(0 0, 0 100%, 100% 0);
}
&.top-aligned {
transform: translateY(
calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm))
);
}
&.bottom-aligned {
transform: translateY(
calc(calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm)) * -1)
);
.menu-arrow {
top: auto;
bottom: 0;
rotate: 225deg;
translate: -50% 50%;
}
}
}
} }

View File

@@ -106,7 +106,6 @@
& .modal-footer { & .modal-footer {
padding: var(--unit-6); padding: var(--unit-6);
padding-top: 0; padding-top: 0;
text-align: right;
} }
} }

View File

@@ -120,7 +120,7 @@
{% if bookmark_item.show_mark_as_read %} {% if bookmark_item.show_mark_as_read %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}" <button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?"> ld-confirm-button ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use> <use xlink:href="#ld-icon-unread"></use>
</svg> </svg>
@@ -130,7 +130,7 @@
{% if bookmark_item.show_unshare %} {% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}" <button type="submit" name="unshare" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?"> ld-confirm-button ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use> <use xlink:href="#ld-icon-share"></use>
</svg> </svg>

View File

@@ -32,7 +32,7 @@
<input type="hidden" name="disable_turbo" value="true"> <input type="hidden" name="disable_turbo" value="true">
<button ld-confirm-button class="btn btn-error btn-wide" <button ld-confirm-button class="btn btn-error btn-wide"
type="submit" name="remove" value="{{ details.bookmark.id }}"> type="submit" name="remove" value="{{ details.bookmark.id }}">
Delete... Delete
</button> </button>
</form> </form>
</div> </div>

View File

@@ -18,18 +18,6 @@
<path d="M21 6l0 13"></path> <path d="M21 6l0 13"></path>
</symbol> </symbol>
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-read" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 5.899 -1.096"></path>
<path d="M3 6a9 9 0 0 1 2.114 -.884m3.8 -.21c1.07 .17 2.116 .534 3.086 1.094a9 9 0 0 1 9 0"></path>
<path d="M3 6v13"></path>
<path d="M12 6v2m0 4v7"></path>
<path d="M21 6v11"></path>
<path d="M3 3l18 18"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" <symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
@@ -41,18 +29,6 @@
<path d="M8.7 13.3l6.6 3.4"></path> <path d="M8.7 13.3l6.6 3.4"></path>
</symbol> </symbol>
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-unshare" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M15.861 15.896a3 3 0 0 0 4.265 4.22m.578 -3.417a3.012 3.012 0 0 0 -1.507 -1.45"></path>
<path d="M8.7 10.7l1.336 -.688m2.624 -1.352l2.64 -1.36"></path>
<path d="M8.7 13.3l6.6 3.4"></path>
<path d="M3 3l18 18"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" <symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">

View File

@@ -501,7 +501,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
modal = self.get_index_details_modal(bookmark) modal = self.get_index_details_modal(bookmark)
delete_button = modal.find("button", {"type": "submit", "name": "remove"}) delete_button = modal.find("button", {"type": "submit", "name": "remove"})
self.assertIsNotNone(delete_button) self.assertIsNotNone(delete_button)
self.assertEqual("Delete...", delete_button.text.strip()) self.assertEqual("Delete", delete_button.text.strip())
self.assertEqual(str(bookmark.id), delete_button["value"]) self.assertEqual(str(bookmark.id), delete_button["value"])
form = delete_button.find_parent("form") form = delete_button.find_parent("form")

View File

@@ -231,7 +231,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f""" f"""
<button type="submit" name="unshare" value="{bookmark.id}" <button type="submit" name="unshare" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?"> ld-confirm-button ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use> <use xlink:href="#ld-icon-share"></use>
</svg> </svg>
@@ -247,7 +247,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f""" f"""
<button type="submit" name="mark_as_read" value="{bookmark.id}" <button type="submit" name="mark_as_read" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon" class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?"> ld-confirm-button ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use> <use xlink:href="#ld-icon-unread"></use>
</svg> </svg>

View File

@@ -140,8 +140,8 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
# Delete bookmark, verify return url # Delete bookmark, verify return url
with self.page.expect_navigation(url=self.live_server_url + url): with self.page.expect_navigation(url=self.live_server_url + url):
details_modal.get_by_text("Delete...").click() details_modal.get_by_text("Delete").click()
details_modal.get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# verify bookmark is deleted # verify bookmark is deleted
self.locate_bookmark(bookmark.title) self.locate_bookmark(bookmark.title)
@@ -173,7 +173,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
# Remove snapshot # Remove snapshot
asset_list.get_by_text("Remove", exact=False).click() asset_list.get_by_text("Remove", exact=False).click()
asset_list.get_by_text("Confirm", exact=False).click() self.locate_confirm_dialog().get_by_text("Confirm", exact=False).click()
# Snapshot is removed # Snapshot is removed
expect(snapshot).not_to_be_visible() expect(snapshot).not_to_be_visible()

View File

@@ -46,7 +46,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible) # Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible() expect(bookmark_list).not_to_be_visible()
@@ -84,7 +84,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible) # Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible() expect(bookmark_list).not_to_be_visible()
@@ -122,7 +122,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible) # Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible() expect(bookmark_list).not_to_be_visible()
@@ -160,7 +160,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible) # Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible() expect(bookmark_list).not_to_be_visible()
@@ -291,7 +291,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
# Execute bulk action # Execute bulk action
self.select_bulk_action("Mark as unread") self.select_bulk_action("Mark as unread")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible) # Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible() expect(bookmark_list).not_to_be_visible()
@@ -323,7 +323,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible) # Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible() expect(bookmark_list).not_to_be_visible()

View File

@@ -123,7 +123,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.open(reverse("linkding:bookmarks.index"), p) self.open(reverse("linkding:bookmarks.index"), p)
self.locate_bookmark("Bookmark 2").get_by_text("Remove").click() self.locate_bookmark("Bookmark 2").get_by_text("Remove").click()
self.locate_bookmark("Bookmark 2").get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"]) self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"]) self.assertVisibleTags(["Tag 1", "Tag 3"])
@@ -140,7 +140,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
expect(self.locate_bookmark("Bookmark 2")).to_have_class("unread") expect(self.locate_bookmark("Bookmark 2")).to_have_class("unread")
self.locate_bookmark("Bookmark 2").get_by_text("Unread").click() self.locate_bookmark("Bookmark 2").get_by_text("Unread").click()
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("unread") expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("unread")
self.assertReloads(0) self.assertReloads(0)
@@ -156,7 +156,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
expect(self.locate_bookmark("Bookmark 2")).to_have_class("shared") expect(self.locate_bookmark("Bookmark 2")).to_have_class("shared")
self.locate_bookmark("Bookmark 2").get_by_text("Shared").click() self.locate_bookmark("Bookmark 2").get_by_text("Shared").click()
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("shared") expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("shared")
self.assertReloads(0) self.assertReloads(0)
@@ -173,7 +173,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
).click() ).click()
self.select_bulk_action("Archive") self.select_bulk_action("Archive")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"]) self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"]) self.assertVisibleTags(["Tag 1", "Tag 3"])
@@ -191,7 +191,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
).click() ).click()
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"]) self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"]) self.assertVisibleTags(["Tag 1", "Tag 3"])
@@ -216,7 +216,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.open(reverse("linkding:bookmarks.archived"), p) self.open(reverse("linkding:bookmarks.archived"), p)
self.locate_bookmark("Archived Bookmark 2").get_by_text("Remove").click() self.locate_bookmark("Archived Bookmark 2").get_by_text("Remove").click()
self.locate_bookmark("Archived Bookmark 2").get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"]) self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"]) self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
@@ -234,7 +234,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
).click() ).click()
self.select_bulk_action("Unarchive") self.select_bulk_action("Unarchive")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"]) self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"]) self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
@@ -252,7 +252,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
).click() ).click()
self.select_bulk_action("Delete") self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click() self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"]) self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"]) self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
@@ -293,7 +293,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.open(reverse("linkding:bookmarks.shared"), p) self.open(reverse("linkding:bookmarks.shared"), p)
self.locate_bookmark("My Bookmark 2").get_by_text("Remove").click() self.locate_bookmark("My Bookmark 2").get_by_text("Remove").click()
self.locate_bookmark("My Bookmark 2").get_by_text("Confirm").click() self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks( self.assertVisibleBookmarks(
[ [

View File

@@ -92,3 +92,6 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
).click() ).click()
else: else:
self.page.locator("nav").get_by_text(main_menu_item, exact=True).click() self.page.locator("nav").get_by_text(main_menu_item, exact=True).click()
def locate_confirm_dialog(self):
return self.page.locator(".dropdown.confirm-dropdown")