From 282240b7f973674aa55edd863b6d29667835a9a1 Mon Sep 17 00:00:00 2001 From: Paul Derscheid Date: Wed, 25 Feb 2026 18:14:38 +0100 Subject: [PATCH] Bug 41129: Fix booking modal reopen race and add regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sponsored-by: Büchereizentrale Schleswig-Holstein --- .../en/includes/modals/booking/island.inc | 37 ++-- .../vue/components/Bookings/BookingModal.vue | 1 + .../Circulation/bookingsModalBasic_spec.ts | 161 ++++++++++++++++++ 3 files changed, 183 insertions(+), 16 deletions(-) diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/island.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/island.inc index ed79cb5b300..417aa898a64 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/island.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/island.inc @@ -10,6 +10,10 @@ hydrate(); const island = document.querySelector("booking-modal-island"); + const islandReady = (async () => { + await customElements.whenDefined("booking-modal-island"); + return document.querySelector("booking-modal-island"); + })(); const parseJson = value => { if (!value) return []; @@ -21,8 +25,8 @@ } }; - const normalizeProps = source => { - if (!island || !source) return; + const normalizeProps = (targetIsland, source) => { + if (!targetIsland || !source) return; const bookingId = source.booking ?? source.bookingId ?? null; const itemId = source.itemnumber ?? source.itemId ?? null; @@ -36,32 +40,33 @@ const biblionumber = source.biblionumber ?? source.biblio_id ?? source.biblioId; - island.bookingId = bookingId; - island.itemId = itemId; - island.patronId = patronId; - island.pickupLibraryId = pickupLibraryId; - island.startDate = startDate; - island.endDate = endDate; - island.itemtypeId = itemtypeId; - island.selectedDateRange = startDate + targetIsland.bookingId = bookingId; + targetIsland.itemId = itemId; + targetIsland.patronId = patronId; + targetIsland.pickupLibraryId = pickupLibraryId; + targetIsland.startDate = startDate; + targetIsland.endDate = endDate; + targetIsland.itemtypeId = itemtypeId; + targetIsland.selectedDateRange = startDate ? [startDate, endDate || startDate] : []; const extendedAttributes = source.extendedAttributes ?? source.extended_attributes ?? []; - island.extendedAttributes = Array.isArray(extendedAttributes) + targetIsland.extendedAttributes = Array.isArray(extendedAttributes) ? extendedAttributes : parseJson(extendedAttributes); if (biblionumber) { - island.biblionumber = biblionumber; + targetIsland.biblionumber = biblionumber; } }; - const openModal = props => { - if (!island) return; - normalizeProps(props || {}); - island.open = true; + const openModal = async props => { + const targetIsland = await islandReady; + if (!targetIsland) return; + normalizeProps(targetIsland, props || {}); + targetIsland.open = true; }; if (island) { diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue index fa1770320c7..5e50fcfbf3d 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue @@ -770,6 +770,7 @@ onMounted(() => { keyboard: false, }); modalElement.value.addEventListener("hidden.bs.modal", () => { + modalState.isOpen = false; emit("close"); resetModalState(); }); diff --git a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts b/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts index a446c3e1f5c..5d2909300e0 100644 --- a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts +++ b/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts @@ -767,6 +767,167 @@ describe("Booking Modal Basic Tests", () => { }); }); + it("should refresh edit modal state across consecutive openings", () => { + const today = dayjs().startOf("day"); + let secondPatron; + let firstBookingId; + let secondBookingId; + + const firstBooking = { + start: today.add(8, "day"), + end: today.add(10, "day"), + patron_id: testData.patron.patron_id, + patron_label: testData.patron.surname, + }; + + cy.task("buildSampleObject", { + object: "patron", + values: { + firstname: "Jane", + surname: "Roe", + cardnumber: `TEST${Date.now()}B`, + category_id: "PT", + library_id: testData.libraries[0].library_id, + }, + }).then(mockPatron => { + secondPatron = mockPatron; + + return cy.task("query", { + sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + values: [ + mockPatron.patron_id, + mockPatron.firstname, + mockPatron.surname, + mockPatron.cardnumber, + mockPatron.category_id, + mockPatron.library_id, + "1990-01-01", + ], + }); + }); + + cy.then(() => + cy + .task("query", { + sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status) + VALUES (?, ?, ?, ?, ?, ?, '1')`, + values: [ + testData.biblio.biblio_id, + testData.items[0].item_id, + firstBooking.patron_id, + firstBooking.start.format("YYYY-MM-DD HH:mm:ss"), + firstBooking.end.format("YYYY-MM-DD HH:mm:ss"), + testData.libraries[0].library_id, + ], + }) + .then(result => { + firstBookingId = result.insertId; + }) + ); + + cy.then(() => { + const secondBooking = { + start: today.add(15, "day"), + end: today.add(17, "day"), + patron_id: secondPatron.patron_id, + patron_label: secondPatron.surname, + }; + + return cy + .task("query", { + sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status) + VALUES (?, ?, ?, ?, ?, ?, '1')`, + values: [ + testData.biblio.biblio_id, + testData.items[1].item_id, + secondBooking.patron_id, + secondBooking.start.format("YYYY-MM-DD HH:mm:ss"), + secondBooking.end.format("YYYY-MM-DD HH:mm:ss"), + testData.libraries[0].library_id, + ], + }) + .then(result => { + secondBookingId = result.insertId; + }) + .then(() => secondBooking); + }).then(secondBooking => { + cy.visit( + `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}` + ); + cy.get("booking-modal-island .modal").should("exist"); + + // First open: booking A + cy.window().then(win => { + win.openBookingModal({ + booking: String(firstBookingId), + patron: String(firstBooking.patron_id), + itemnumber: String(testData.items[0].item_id), + pickup_library: testData.libraries[0].library_id, + start_date: firstBooking.start.startOf("day").toISOString(), + end_date: firstBooking.end.endOf("day").toISOString(), + biblionumber: String(testData.biblio.biblio_id), + }); + }); + + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); + cy.vueSelectShouldHaveValue( + "booking_patron", + firstBooking.patron_label + ); + + cy.get("booking-modal-island .modal .btn-close").first().click(); + cy.get("booking-modal-island .modal").should("not.be.visible"); + cy.get("body").should("not.have.class", "modal-open"); + + // Second open: booking B (must not stay stale with booking A data) + cy.window().then(win => { + win.openBookingModal({ + booking: String(secondBookingId), + patron: String(secondBooking.patron_id), + itemnumber: String(testData.items[1].item_id), + pickup_library: testData.libraries[0].library_id, + start_date: secondBooking.start + .startOf("day") + .toISOString(), + end_date: secondBooking.end.endOf("day").toISOString(), + biblionumber: String(testData.biblio.biblio_id), + }); + }); + + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); + cy.vueSelectShouldHaveValue( + "booking_patron", + secondBooking.patron_label + ); + }); + + cy.then(() => { + if (firstBookingId) { + cy.task("query", { + sql: "DELETE FROM bookings WHERE booking_id = ?", + values: [firstBookingId], + }); + } + if (secondBookingId) { + cy.task("query", { + sql: "DELETE FROM bookings WHERE booking_id = ?", + values: [secondBookingId], + }); + } + if (secondPatron) { + cy.task("query", { + sql: "DELETE FROM borrowers WHERE borrowernumber = ?", + values: [secondPatron.patron_id], + }); + } + }); + }); + it("should handle booking failure gracefully", () => { /** * Comprehensive Error Handling and Recovery Test -- 2.53.0