From ef1c806afaf567dffe93c7df2b2ad5c70acac0c6 Mon Sep 17 00:00:00 2001 From: Paul Derscheid Date: Wed, 25 Feb 2026 13:21:51 +0100 Subject: [PATCH] Bug 41129: Remove dead code and consolidate styles and tests Dead code: - Remove uncalled SweepLineProcessor methods (~155 lines) - Remove unused constants, exports, and wrapper functions - Remove unused Cypress flatpickr helper commands - Remove erroneous Vue compiler macro import in BookingTooltip CSS: - Unify two :root blocks and two - - diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs index ae60ee8de64..2671b841add 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs @@ -87,9 +87,6 @@ export function useAvailability(storeRefs, optionsRef) { const disableFnRef = computed( () => availability.value.disable || (() => false) ); - const unavailableByDateRef = computed( - () => availability.value.unavailableByDate || {} - ); - return { availability, disableFnRef, unavailableByDateRef }; + return { availability, disableFnRef }; } diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js index 2c97e2a450e..fc7c1cd3205 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js @@ -161,7 +161,7 @@ export async function fetchPatrons(term, page = 1) { if (errorData.error) { error.message += ` - ${errorData.error}`; } - } catch (e) {} + } catch (_) { /* response body may not be JSON */ } throw error; } @@ -349,7 +349,7 @@ export async function createBooking(bookingData) { if (errorData.error) { errorMessage += ` - ${errorData.error}`; } - } catch (e) {} + } catch (_) { /* response body may not be JSON */ } /** @type {Error & { status?: number }} */ const error = Object.assign(new Error(errorMessage), { status: response.status, @@ -405,7 +405,7 @@ export async function updateBooking(bookingId, bookingData) { if (errorData.error) { errorMessage += ` - ${errorData.error}`; } - } catch (e) {} + } catch (_) { /* response body may not be JSON */ } /** @type {Error & { status?: number }} */ const error = Object.assign(new Error(errorMessage), { status: response.status, diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/events.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/events.mjs index 28b5171a57d..7bcfe000c69 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/events.mjs +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/events.mjs @@ -413,48 +413,6 @@ function ensureFeedbackBar(fp) { return /** @type {HTMLDivElement} */ (bar); } -/** @type {number|null} */ -let _feedbackHideTimer = null; - -/** - * Update the feedback bar inside the flatpickr calendar container. - * Hides are deferred so that rapid mouseout→mouseover between adjacent - * days doesn't trigger a visible flicker. - * - * @param {HTMLDivElement} bar - * @param {{ message: string, variant: string } | null} feedback - */ -function updateFeedbackBar(bar, feedback) { - if (!feedback) { - if (_feedbackHideTimer == null) { - _feedbackHideTimer = setTimeout(() => { - _feedbackHideTimer = null; - bar.classList.remove( - "booking-hover-feedback--visible", - "booking-hover-feedback--info", - "booking-hover-feedback--warning", - "booking-hover-feedback--danger" - ); - }, 16); - } - return; - } - if (_feedbackHideTimer != null) { - clearTimeout(_feedbackHideTimer); - _feedbackHideTimer = null; - } - bar.textContent = feedback.message; - bar.classList.remove( - "booking-hover-feedback--info", - "booking-hover-feedback--warning", - "booking-hover-feedback--danger" - ); - bar.classList.add( - "booking-hover-feedback--visible", - `booking-hover-feedback--${feedback.variant}` - ); -} - /** * Create Flatpickr `onDayCreate` handler. * @@ -478,6 +436,48 @@ export function createOnDayCreate( tooltipX, tooltipY ) { + /** @type {number|null} */ + let _feedbackHideTimer = null; + + /** + * Update the feedback bar inside the flatpickr calendar container. + * Hides are deferred so that rapid mouseout→mouseover between adjacent + * days doesn't trigger a visible flicker. + * + * @param {HTMLDivElement} bar + * @param {{ message: string, variant: string } | null} feedback + */ + function updateFeedbackBar(bar, feedback) { + if (!feedback) { + if (_feedbackHideTimer == null) { + _feedbackHideTimer = setTimeout(() => { + _feedbackHideTimer = null; + bar.classList.remove( + "booking-hover-feedback--visible", + "booking-hover-feedback--info", + "booking-hover-feedback--warning", + "booking-hover-feedback--danger" + ); + }, 16); + } + return; + } + if (_feedbackHideTimer != null) { + clearTimeout(_feedbackHideTimer); + _feedbackHideTimer = null; + } + bar.textContent = feedback.message; + bar.classList.remove( + "booking-hover-feedback--info", + "booking-hover-feedback--warning", + "booking-hover-feedback--danger" + ); + bar.classList.add( + "booking-hover-feedback--visible", + `booking-hover-feedback--${feedback.variant}` + ); + } + return function ( ...[ , diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/highlighting.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/highlighting.mjs index 18057b51255..3cf6d744261 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/highlighting.mjs +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/highlighting.mjs @@ -122,19 +122,6 @@ export function fixDateAvailability( } } -/** - * Fix incorrect target-end unavailability via a CSS-based override. - * Wrapper for backward compatibility. - * - * @param {import('flatpickr/dist/types/instance').Instance} _instance - * @param {NodeListOf|Element[]} dayElements - * @param {Date} targetEndDate - * @returns {void} - */ -function fixTargetEndDateAvailability(_instance, dayElements, targetEndDate) { - fixDateAvailability(dayElements, targetEndDate, "target end date"); -} - /** * Apply constraint highlighting to the Flatpickr calendar. * @@ -259,10 +246,10 @@ export function applyCalendarHighlighting(instance, highlightingData) { if (highlightingData.constraintMode === CONSTRAINT_MODE_END_DATE_ONLY) { applyClickPrevention(instance); - fixTargetEndDateAvailability( - instance, + fixDateAvailability( dayElements, - highlightingData.targetEndDate + highlightingData.targetEndDate, + "target end date" ); const targetEndElem = Array.from(dayElements).find( diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs index 805f1ae125c..476a5b6c812 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs @@ -6,7 +6,6 @@ */ import { BookingDate, startOfDayTs, endOfDayTs } from "../BookingDate.mjs"; -import { MAX_SEARCH_DAYS } from "../constants.mjs"; /** * Event types for the sweep line algorithm @@ -161,169 +160,4 @@ export class SweepLineProcessor { return unavailableByDate; } - - /** - * Process intervals and return aggregated statistics - * @param {Array} intervals - * @param {Date|import("dayjs").Dayjs} viewStart - * @param {Date|import("dayjs").Dayjs} viewEnd - * @returns {Object} Statistics about the date range - */ - getDateRangeStatistics(intervals, viewStart, viewEnd) { - const stats = { - totalDays: 0, - daysWithBookings: 0, - daysWithCheckouts: 0, - fullyBookedDays: 0, - peakBookingCount: 0, - peakDate: null, - itemUtilization: new Map(), - }; - - const startDate = BookingDate.from(viewStart).toDayjs(); - const endDate = BookingDate.from(viewEnd, { preserveTime: true }).toDayjs().endOf("day"); - - stats.totalDays = endDate.diff(startDate, "day") + 1; - - for ( - let date = startDate; - date.isSameOrBefore(endDate, "day"); - date = date.add(1, "day") - ) { - const dayStart = date.valueOf(); - const dayEnd = date.endOf("day").valueOf(); - - let bookingCount = 0; - let checkoutCount = 0; - const itemsInUse = new Set(); - - intervals.forEach(interval => { - if (interval.start <= dayEnd && interval.end >= dayStart) { - if (interval.type === "booking") { - bookingCount++; - itemsInUse.add(interval.itemId); - } else if (interval.type === "checkout") { - checkoutCount++; - itemsInUse.add(interval.itemId); - } - } - }); - - if (bookingCount > 0) stats.daysWithBookings++; - if (checkoutCount > 0) stats.daysWithCheckouts++; - - const totalCount = bookingCount + checkoutCount; - if (totalCount > stats.peakBookingCount) { - stats.peakBookingCount = totalCount; - stats.peakDate = date.format("YYYY-MM-DD"); - } - - itemsInUse.forEach(itemId => { - if (!stats.itemUtilization.has(itemId)) { - stats.itemUtilization.set(itemId, 0); - } - stats.itemUtilization.set( - itemId, - stats.itemUtilization.get(itemId) + 1 - ); - }); - } - - return stats; - } - - /** - * Find the next available date for a specific item - * @param {Array} intervals - * @param {string} itemId - * @param {Date|import('dayjs').Dayjs} startDate - * @param {number} maxDaysToSearch - * @returns {Date|null} - */ - findNextAvailableDate( - intervals, - itemId, - startDate, - maxDaysToSearch = MAX_SEARCH_DAYS - ) { - const start = BookingDate.from(startDate).toDayjs(); - const itemIntervals = intervals.filter( - interval => interval.itemId === itemId - ); - - itemIntervals.sort((a, b) => a.start - b.start); - - for (let i = 0; i < maxDaysToSearch; i++) { - const checkDate = start.add(i, "day"); - const dateStart = checkDate.valueOf(); - const dateEnd = checkDate.endOf("day").valueOf(); - - const isAvailable = !itemIntervals.some( - interval => - interval.start <= dateEnd && interval.end >= dateStart - ); - - if (isAvailable) { - return checkDate.toDate(); - } - } - - return null; - } - - /** - * Find gaps (available periods) for an item - * @param {Array} intervals - * @param {string} itemId - * @param {Date|import('dayjs').Dayjs} viewStart - * @param {Date|import('dayjs').Dayjs} viewEnd - * @param {number} minGapDays - Minimum gap size to report - * @returns {Array<{start: Date, end: Date, days: number}>} - */ - findAvailableGaps(intervals, itemId, viewStart, viewEnd, minGapDays = 1) { - const gaps = []; - const itemIntervals = intervals - .filter(interval => interval.itemId === itemId) - .sort((a, b) => a.start - b.start); - - const rangeStart = BookingDate.from(viewStart).valueOf(); - const rangeEnd = BookingDate.from(viewEnd, { preserveTime: true }).toDayjs().endOf("day").valueOf(); - - let lastEnd = rangeStart; - - itemIntervals.forEach(interval => { - if (interval.end < rangeStart || interval.start > rangeEnd) { - return; - } - - const gapStart = Math.max(lastEnd, rangeStart); - const gapEnd = Math.min(interval.start, rangeEnd); - - if (gapEnd > gapStart) { - const gapDays = BookingDate.from(gapEnd).diff(BookingDate.from(gapStart), "day"); - if (gapDays >= minGapDays) { - gaps.push({ - start: new Date(gapStart), - end: new Date(gapEnd - 1), // End of previous day - days: gapDays, - }); - } - } - - lastEnd = Math.max(lastEnd, interval.end + 1); // Start of next day - }); - - if (lastEnd < rangeEnd) { - const gapDays = BookingDate.from(rangeEnd).diff(BookingDate.from(lastEnd), "day"); - if (gapDays >= minGapDays) { - gaps.push({ - start: new Date(lastEnd), - end: new Date(rangeEnd), - days: gapDays, - }); - } - } - - return gaps; - } } diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs index 979b83b0463..474151c5f2b 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs @@ -7,10 +7,6 @@ export const CONSTRAINT_MODE_END_DATE_ONLY = "end_date_only"; export const CONSTRAINT_MODE_NORMAL = "normal"; -// Selection semantics (logging, diagnostics) -export const SELECTION_ANY_AVAILABLE = "ANY_AVAILABLE"; -export const SELECTION_SPECIFIC_ITEM = "SPECIFIC_ITEM"; - // UI class names (used across calendar/adapters/composables) export const CLASS_BOOKING_CONSTRAINED_RANGE_MARKER = "booking-constrained-range-marker"; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/utils/functions.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/utils/functions.mjs index 9f0cb3fea5e..3b8ec913fb6 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/vue/utils/functions.mjs +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/utils/functions.mjs @@ -18,23 +18,3 @@ export function debounce(fn, delay) { timeout = setTimeout(() => fn.apply(this, args), delay); }; } - -/** - * Creates a throttled version of a function that only invokes - * at most once per `limit` milliseconds. - * - * @template {(...args: any[]) => any} T - * @param {T} fn - The function to throttle - * @param {number} limit - Minimum time between invocations in milliseconds - * @returns {(...args: Parameters) => void} - */ -export function throttle(fn, limit) { - let inThrottle; - return function (...args) { - if (!inThrottle) { - fn.apply(this, args); - inThrottle = true; - setTimeout(() => (inThrottle = false), limit); - } - }; -} diff --git a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts b/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts index 6a1eb3b6db6..ceb370e3b45 100644 --- a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts +++ b/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts @@ -892,4 +892,202 @@ describe("Booking Modal Basic Tests", () => { "✓ CONFIRMED: Error handling and recovery workflow working correctly" ); }); + + it("should reset modal state after canceling", () => { + cy.visit( + `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}` + ); + + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); + + // Fill some fields + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` + ); + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); + + // Close modal and wait for Bootstrap transition to fully complete + 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"); + + // Reopen and verify state is reset + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal.show", { timeout: 10000 }).should( + "be.visible" + ); + + cy.vueSelectShouldBeEnabled("booking_patron"); + cy.vueSelectShouldBeDisabled("pickup_library_id"); + cy.vueSelectShouldBeDisabled("booking_itemtype"); + cy.vueSelectShouldBeDisabled("booking_item_id"); + cy.get("#booking_period").should("be.disabled"); + cy.get('button[form="form-booking"][type="submit"]').should( + "be.disabled" + ); + }); + + it("should show capacity warning for zero-day circulation rules", () => { + cy.intercept( + "GET", + `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*` + ).as("getPickupLocations"); + cy.intercept("GET", "/api/v1/circulation_rules*", { + body: [ + { + library_id: testData.libraries[0].library_id, + item_type_id: "BK", + patron_category_id: testData.patron.category_id, + issuelength: 0, + renewalsallowed: 0, + renewalperiod: 0, + bookings_lead_period: 0, + bookings_trail_period: 0, + calculated_period_days: 0, + }, + ], + }).as("getCirculationRules"); + + cy.visit( + `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}` + ); + + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); + + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` + ); + cy.wait("@getPickupLocations"); + + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); + + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 1); + cy.wait("@getCirculationRules"); + + cy.get("booking-modal-island .modal .alert-warning") + .scrollIntoView() + .should("be.visible") + .and("contain", "Bookings are not permitted"); + cy.get("#booking_period").should("be.disabled"); + cy.get('button[form="form-booking"][type="submit"]').should( + "be.disabled" + ); + }); + + it("should show error on 409 conflict response", () => { + cy.intercept( + "GET", + `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*` + ).as("getPickupLocations"); + cy.intercept("GET", "/api/v1/circulation_rules*").as( + "getCirculationRules" + ); + cy.intercept("POST", "/api/v1/bookings", { + statusCode: 409, + body: { error: "Booking conflict detected" }, + }).as("conflictBooking"); + + cy.visit( + `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}` + ); + + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); + + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` + ); + cy.wait("@getPickupLocations"); + + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); + + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 1); + cy.wait("@getCirculationRules"); + + cy.get("#booking_period").should("not.be.disabled"); + const startDate = dayjs().add(5, "day"); + const endDate = dayjs().add(10, "day"); + cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate); + + cy.get('button[form="form-booking"][type="submit"]') + .should("not.be.disabled") + .click(); + cy.wait("@conflictBooking"); + + cy.get("booking-modal-island .modal .alert-danger").should("exist"); + cy.get("booking-modal-island .modal").should("be.visible"); + }); + + it("should show error on 500 server error response", () => { + cy.intercept( + "GET", + `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*` + ).as("getPickupLocations"); + cy.intercept("GET", "/api/v1/circulation_rules*").as( + "getCirculationRules" + ); + cy.intercept("POST", "/api/v1/bookings", { + statusCode: 500, + body: { error: "Internal server error" }, + }).as("serverError"); + + cy.visit( + `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}` + ); + + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); + + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` + ); + cy.wait("@getPickupLocations"); + + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); + + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 1); + cy.wait("@getCirculationRules"); + + cy.get("#booking_period").should("not.be.disabled"); + const startDate = dayjs().add(5, "day"); + const endDate = dayjs().add(10, "day"); + cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate); + + cy.get('button[form="form-booking"][type="submit"]') + .should("not.be.disabled") + .click(); + cy.wait("@serverError"); + + cy.get("booking-modal-island .modal .alert-danger").should("exist"); + cy.get("booking-modal-island .modal").should("be.visible"); + }); }); diff --git a/t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts b/t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts index c1eb23a0999..cb2cc362b98 100644 --- a/t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts +++ b/t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts @@ -554,26 +554,43 @@ describe("Booking Modal Date Picker Tests", () => { }); }); - // For dates in the currently visible month, also verify the DOM class - expectedBoldDates - .filter(d => d.month() === clearZoneStart.month()) - .forEach(boldDate => { - cy.get("@dateTestFlatpickr") - .getFlatpickrDate(boldDate.toDate()) - .should("have.class", "booking-loan-boundary"); - cy.log( - `✓ Day ${boldDate.format("YYYY-MM-DD")}: Has 'booking-loan-boundary' class (bold)` - ); - }); + // For dates in the currently visible month, also verify the DOM class. + // Query calendarContainer directly to avoid navigation races (the + // booking modal's onMonthChange handler can jump the calendar away). + cy.get("@dateTestFlatpickr").should($el => { + const fp = $el[0]._flatpickr; + // Ensure calendar shows the start date's month + if (fp.currentMonth !== clearZoneStart.month() || fp.currentYear !== clearZoneStart.year()) { + fp.jumpToDate(clearZoneStart.toDate()); + throw new Error("Jumped to target month, retrying assertion"); + } - // Verify that only expected dates are bold in the current view - cy.get(".flatpickr-day.booking-loan-boundary").each($el => { - const ariaLabel = $el.attr("aria-label"); - const date = dayjs(ariaLabel, "MMMM D, YYYY"); - const isExpected = expectedBoldDates.some(expected => - date.isSame(expected, "day") + expectedBoldDates + .filter(d => d.month() === clearZoneStart.month()) + .forEach(boldDate => { + const label = `${boldDate.format("MMMM D, YYYY")}`; + const el = fp.calendarContainer.querySelector( + `.flatpickr-day[aria-label="${label}"]` + ); + expect(el, `Day ${boldDate.format("YYYY-MM-DD")} should exist`).to.not.be.null; + expect( + el.classList.contains("booking-loan-boundary"), + `Day ${boldDate.format("YYYY-MM-DD")} should have booking-loan-boundary class` + ).to.be.true; + }); + + // Verify no unexpected bold dates in the current view + const boldElements = fp.calendarContainer.querySelectorAll( + ".flatpickr-day.booking-loan-boundary" ); - expect(isExpected, `Unexpected bold date: ${ariaLabel}`).to.be.true; + boldElements.forEach(boldEl => { + const ariaLabel = boldEl.getAttribute("aria-label"); + const date = dayjs(ariaLabel, "MMMM D, YYYY"); + const isExpected = expectedBoldDates.some(expected => + date.isSame(expected, "day") + ); + expect(isExpected, `Unexpected bold date: ${ariaLabel}`).to.be.true; + }); }); cy.log( diff --git a/t/cypress/integration/Circulation/bookingsModal_spec.ts b/t/cypress/integration/Circulation/bookingsModal_spec.ts deleted file mode 100644 index ccf9cbe9bb6..00000000000 --- a/t/cypress/integration/Circulation/bookingsModal_spec.ts +++ /dev/null @@ -1,873 +0,0 @@ -import dayjs = require("dayjs"); - -interface BookingTestContext { - biblio: any; - patron: any; - bookingsToCleanup: Array<{ booking_id: number }>; -} - -interface BookingInterceptOverrides { - bookableItems?: Cypress.RouteHandler; - loadBookings?: Cypress.RouteHandler; - loadCheckouts?: Cypress.RouteHandler; - pickupLocations?: Cypress.RouteHandler; - searchPatrons?: Cypress.RouteHandler; - circulationRules?: Cypress.RouteHandler; -} - -const ensureBookableInventory = (biblio: any) => { - const itemTypeId = biblio.item_type.item_type_id; - const itemNumbers = biblio.items.map((item: any) => item.item_id); - - const updateItemType = cy.task("query", { - sql: "UPDATE itemtypes SET bookable=1 WHERE itemtype=?", - values: [itemTypeId], - }); - - if (!itemNumbers.length) { - return updateItemType; - } - - const placeholders = itemNumbers.map(() => "?").join(","); - return updateItemType.then(() => - cy.task("query", { - sql: `UPDATE items SET bookable=1 WHERE itemnumber IN (${placeholders})`, - values: itemNumbers, - }) - ); -}; - -const ensureBookingCapacity = ({ - libraryId, - categoryId, - itemTypeId, -}: { - libraryId: string; - categoryId: string; - itemTypeId: string; -}) => { - const rules = [ - { name: "issuelength", value: 7 }, - { name: "renewalsallowed", value: 1 }, - { name: "renewalperiod", value: 7 }, - { name: "bookings_lead_period", value: 0 }, - { name: "bookings_trail_period", value: 0 }, - ]; - const ruleNames = rules.map(rule => rule.name); - const deletePlaceholders = ruleNames.map(() => "?").join(","); - const insertPlaceholders = rules.map(() => "(?, ?, ?, ?, ?)").join(","); - const insertValues: Array = []; - rules.forEach(rule => { - insertValues.push( - libraryId, - categoryId, - itemTypeId, - rule.name, - String(rule.value) - ); - }); - - return cy - .task("query", { - sql: `DELETE FROM circulation_rules WHERE branchcode=? AND categorycode=? AND itemtype=? AND rule_name IN (${deletePlaceholders})`, - values: [libraryId, categoryId, itemTypeId, ...ruleNames], - }) - .then(() => - cy.task("query", { - sql: `INSERT INTO circulation_rules (branchcode, categorycode, itemtype, rule_name, rule_value) VALUES ${insertPlaceholders}`, - values: insertValues, - }) - ); -}; - -const deleteBookings = (bookingIds: number[]) => { - if (!bookingIds.length) { - return cy.wrap(null); - } - - const placeholders = bookingIds.map(() => "?").join(","); - return cy.task("query", { - sql: `DELETE FROM bookings WHERE booking_id IN (${placeholders})`, - values: bookingIds, - }); -}; - -const waitForBookingIslandReady = () => { - cy.get("booking-modal-island").should("exist"); - cy.get("booking-modal-island .modal").should("exist"); - return cy.wait(100); -}; - -const ensurePatronSearchQueryBuilder = () => - cy.window().then(win => { - const globalWin = win as Window & { - buildPatronSearchQuery?: ( - term: string, - options?: Record - ) => any; - }; - if (typeof globalWin.buildPatronSearchQuery === "function") return; - globalWin.buildPatronSearchQuery = ( - term: string, - options: Record = {} - ) => { - if (!term) return []; - const table_prefix = options.table_prefix || "me"; - const search_fields = options.search_fields - ? options.search_fields - .split(",") - .map((field: string) => field.trim()) - : ["surname", "firstname", "cardnumber", "userid"]; - - const queries: Record[] = []; - search_fields.forEach(field => { - queries.push({ - [`${table_prefix}.${field}`]: { - like: `%${term}%`, - }, - }); - }); - - return [{ "-or": queries }]; - }; - }); - -const prepareBookingModalPage = () => - waitForBookingIslandReady().then(() => ensurePatronSearchQueryBuilder()); - -const setDateRange = (startDate: dayjs.Dayjs, endDate: dayjs.Dayjs) => { - cy.get(".modal.show #booking_period").click({ force: true }); - cy.get(".flatpickr-calendar", { timeout: 5000 }).should("be.visible"); - - const startDateStr = startDate.format("MMMM D, YYYY"); - cy.get(".flatpickr-calendar") - .find(`.flatpickr-day[aria-label="${startDateStr}"]`) - .click({ force: true }); - - const endDateStr = endDate.format("MMMM D, YYYY"); - cy.get(".flatpickr-calendar") - .find(`.flatpickr-day[aria-label="${endDateStr}"]`) - .click({ force: true }); - - cy.wait(500); -}; - -const interceptBookingModalData = ( - biblionumber: number | string, - overrides: BookingInterceptOverrides = {} -) => { - const itemsUrl = `/api/v1/biblios/${biblionumber}/items*`; - if (overrides.bookableItems) { - cy.intercept("GET", itemsUrl, overrides.bookableItems).as( - "bookableItems" - ); - } else { - cy.intercept("GET", itemsUrl).as("bookableItems"); - } - - const bookingsUrl = `/api/v1/biblios/${biblionumber}/bookings*`; - if (overrides.loadBookings) { - cy.intercept("GET", bookingsUrl, overrides.loadBookings).as( - "loadBookings" - ); - } else { - cy.intercept("GET", bookingsUrl).as("loadBookings"); - } - - const checkoutsUrl = `/api/v1/biblios/${biblionumber}/checkouts*`; - if (overrides.loadCheckouts) { - cy.intercept("GET", checkoutsUrl, overrides.loadCheckouts).as( - "loadCheckouts" - ); - } else { - cy.intercept("GET", checkoutsUrl).as("loadCheckouts"); - } - - const pickupsUrl = `/api/v1/biblios/${biblionumber}/pickup_locations*`; - if (overrides.pickupLocations) { - cy.intercept("GET", pickupsUrl, overrides.pickupLocations).as( - "pickupLocations" - ); - } else { - cy.intercept("GET", pickupsUrl).as("pickupLocations"); - } - - const searchUrl = /\/api\/v1\/patrons\?.*q=.*/; - if (overrides.searchPatrons) { - cy.intercept("GET", searchUrl, overrides.searchPatrons).as( - "searchPatrons" - ); - } else { - cy.intercept("GET", searchUrl).as("searchPatrons"); - } - - const rulesUrl = /\/api\/v1\/circulation_rules.*/; - if (overrides.circulationRules) { - cy.intercept("GET", rulesUrl, overrides.circulationRules).as( - "circulationRules" - ); - } else { - cy.intercept("GET", rulesUrl).as("circulationRules"); - } -}; - -const openBookingModalFromList = (biblionumber: number | string) => { - cy.window().its("openBookingModal").should("be.a", "function"); - cy.contains("button[data-booking-modal]", /Place booking/i).should( - "be.visible" - ); - - cy.window().then(win => { - const globalWin = win as Window & { - openBookingModal?: (props: Record) => void; - }; - if (typeof globalWin.openBookingModal === "function") { - globalWin.openBookingModal({ biblionumber: String(biblionumber) }); - } else { - throw new Error("window.openBookingModal is not available"); - } - }); - - cy.wait(["@bookableItems", "@loadBookings"], { timeout: 20000 }); - cy.get(".modal", { timeout: 15000 }) - .should("exist") - .and("have.class", "show") - .and("be.visible"); -}; - -const selectVsOption = (text: string) => { - cy.get(".modal.show .vs__dropdown-menu li:not(.vs__no-options)", { - timeout: 10000, - }) - .contains(text) - .scrollIntoView() - .click({ force: true }); -}; - -const selectPatronAndInventory = ( - patron: any, - pickupLibrary: any, - item: any, - options: { skipItemSelection?: boolean } = {} -) => { - const searchTerm = patron.cardnumber.slice(0, 4); - - cy.get(".modal.show #booking_patron") - .clear() - .type(searchTerm, { delay: 0 }); - cy.wait("@searchPatrons"); - cy.wait(200); - selectVsOption(patron.cardnumber); - - cy.wait("@pickupLocations"); - cy.get(".modal.show #pickup_library_id").should( - "not.have.attr", - "disabled" - ); - cy.get(".modal.show #pickup_library_id").click({ force: true }); - cy.wait(200); - selectVsOption(pickupLibrary.name); - - if (!options.skipItemSelection) { - cy.get(".modal.show #booking_item_id").click({ force: true }); - cy.wait(200); - selectVsOption(item.external_id); - cy.wait("@circulationRules"); - } -}; - -describe("BookingModal integration", () => { - // Prevent app-level warnings (e.g. from console.warn → error in e2e.js) from failing tests - Cypress.on("uncaught:exception", () => false); - - beforeEach(function (this: BookingTestContext) { - cy.login(); - cy.title().should("eq", "Koha staff interface"); - - return cy - .task("insertSampleBiblio", { item_count: 1 }) - .then(biblio => { - this.biblio = biblio; - return ensureBookableInventory(biblio); - }) - .then(() => - cy.task("insertSamplePatron", { - library: this.biblio.libraries[0], - }) - ) - .then(patron => { - this.patron = patron; - this.bookingsToCleanup = []; - return ensureBookingCapacity({ - libraryId: this.biblio.libraries[0].library_id, - categoryId: patron.patron.category_id, - itemTypeId: this.biblio.item_type.item_type_id, - }); - }); - }); - - afterEach(function (this: BookingTestContext) { - const bookingIds = - this.bookingsToCleanup?.map(booking => booking.booking_id) || []; - if (bookingIds.length) { - deleteBookings(bookingIds); - } - - const cleanupTargets = []; - if (this.patron) cleanupTargets.push(this.patron); - if (this.biblio) cleanupTargets.push(this.biblio); - if (cleanupTargets.length) { - cy.task("deleteSampleObjects", cleanupTargets); - } - }); - - it("Creates a booking", function (this: BookingTestContext) { - const ctx = this as BookingTestContext; - const biblionumber = ctx.biblio.biblio.biblio_id; - const patron = ctx.patron.patron; - const pickupLibrary = ctx.biblio.libraries[0]; - const item = ctx.biblio.items[0]; - const startDate = dayjs().add(3, "day").startOf("day"); - const endDate = startDate.add(2, "day"); - - cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); - cy.get("#bookings_table").should("exist"); - prepareBookingModalPage(); - - interceptBookingModalData(biblionumber); - cy.intercept("POST", "/api/v1/bookings").as("createBooking"); - - openBookingModalFromList(biblionumber); - - cy.get(".modal-title").should("contain", "Place booking"); - cy.contains(".step-header", "Select Patron").should("exist"); - cy.get("button[form='form-booking']").should("be.disabled"); - cy.get("#pickup_library_id").should("have.attr", "disabled"); - - selectPatronAndInventory(patron, pickupLibrary, item); - cy.get("#booking_period").should("not.be.disabled"); - setDateRange(startDate, endDate); - cy.wait(1000); - - cy.get("button[form='form-booking']", { timeout: 15000 }) - .should("not.be.disabled") - .contains("Place booking") - .click(); - - cy.wait("@createBooking").then(({ response }) => { - expect(response?.statusCode).to.eq(201); - if (response?.body) { - ctx.bookingsToCleanup.push(response.body); - } - }); - - cy.get("body").should("not.have.class", "modal-open"); - cy.contains("#bookings_table tbody tr", item.external_id).should( - "exist" - ); - cy.contains("#bookings_table tbody tr", patron.cardnumber).should( - "exist" - ); - }); - - it("Updates a booking", function (this: BookingTestContext) { - const ctx = this as BookingTestContext; - const biblionumber = ctx.biblio.biblio.biblio_id; - const patron = ctx.patron.patron; - const pickupLibrary = ctx.biblio.libraries[0]; - const item = ctx.biblio.items[0]; - const initialStart = dayjs().add(5, "day").startOf("day"); - const initialEnd = initialStart.add(1, "day"); - const updatedStart = initialStart.add(3, "day"); - const updatedEnd = updatedStart.add(2, "day"); - - cy.task("apiPost", { - endpoint: "/api/v1/bookings", - body: { - biblio_id: biblionumber, - patron_id: patron.patron_id, - pickup_library_id: pickupLibrary.library_id, - item_id: item.item_id, - start_date: initialStart.toISOString(), - end_date: initialEnd.toISOString(), - }, - }).then(booking => { - ctx.bookingsToCleanup.push(booking); - cy.wrap(booking).as("existingBookingRecord"); - }); - - cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); - cy.get("#bookings_table").should("exist"); - prepareBookingModalPage(); - - cy.get("@existingBookingRecord").then((booking: any) => { - interceptBookingModalData(biblionumber); - cy.intercept("GET", /\/api\/v1\/patrons\/\d+/).as( - "prefillPatron" - ); - cy.intercept("GET", /\/api\/v1\/circulation_rules.*/).as( - "circulationRules" - ); - cy.intercept("PUT", `/api/v1/bookings/${booking.booking_id}`).as( - "updateBooking" - ); - - cy.contains("#bookings_table tbody tr", `(${booking.booking_id})`, { - timeout: 10000, - }) - .should("exist") - .find("button.edit-action") - .click(); - }); - - cy.wait(["@bookableItems", "@loadBookings"]); - cy.wait("@prefillPatron").its("response.statusCode").should("eq", 200); - cy.wait("@pickupLocations", { timeout: 20000 }); - cy.wait("@circulationRules", { timeout: 20000 }); - cy.get(".modal.show", { timeout: 10000 }).should("be.visible"); - - cy.get(".modal-title").should("contain", "Edit booking"); - cy.get("button[form='form-booking']").should( - "contain", - "Update booking" - ); - cy.contains(".vs__selected", patron.cardnumber).should("exist"); - cy.contains(".vs__selected", pickupLibrary.name).should("exist"); - cy.contains(".vs__selected", item.external_id).should("exist"); - - cy.get("#booking_period").then($input => { - const fp = ($input[0] as any)?._flatpickr; - expect(fp?.selectedDates?.length).to.eq(2); - expect(dayjs(fp.selectedDates[0]).isSame(initialStart, "day")).to.be - .true; - }); - - setDateRange(updatedStart, updatedEnd); - cy.wait(1000); - - cy.get("button[form='form-booking']", { timeout: 30000 }) - .should("not.be.disabled") - .and("contain", "Update booking") - .click(); - - cy.wait("@updateBooking").its("response.statusCode").should("eq", 200); - - cy.get("@existingBookingRecord").then((booking: any) => { - cy.task("query", { - sql: "SELECT start_date, end_date FROM bookings WHERE booking_id=?", - values: [booking.booking_id], - }).then(rows => { - expect(rows).to.have.length(1); - expect(dayjs(rows[0].start_date).isSame(updatedStart, "day")).to - .be.true; - expect(dayjs(rows[0].end_date).isSame(updatedEnd, "day")).to.be - .true; - }); - }); - }); - - it("Resets the modal state after canceling", function (this: BookingTestContext) { - const ctx = this as BookingTestContext; - const biblionumber = ctx.biblio.biblio.biblio_id; - const patron = ctx.patron.patron; - const pickupLibrary = ctx.biblio.libraries[0]; - const item = ctx.biblio.items[0]; - const startDate = dayjs().add(2, "day").startOf("day"); - const endDate = startDate.add(1, "day"); - - cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); - cy.get("#bookings_table").should("exist"); - prepareBookingModalPage(); - - interceptBookingModalData(biblionumber); - - openBookingModalFromList(biblionumber); - cy.get("button[form='form-booking']").should("be.disabled"); - cy.get("#pickup_library_id").should("have.attr", "disabled"); - - selectPatronAndInventory(patron, pickupLibrary, item); - setDateRange(startDate, endDate); - cy.wait(1000); - - cy.get("button[form='form-booking']", { timeout: 15000 }).should( - "not.be.disabled" - ); - - cy.contains(".modal.show button", /Cancel/i).click(); - cy.get(".modal.show").should("not.exist"); - cy.get("body").should("not.have.class", "modal-open"); - - openBookingModalFromList(biblionumber); - cy.get(".modal.show").within(() => { - cy.get("#booking_patron").should("have.value", ""); - cy.get("#pickup_library_id").should("have.attr", "disabled"); - cy.get("#booking_period").should("have.value", ""); - cy.get("button[form='form-booking']").should("be.disabled"); - }); - }); - - it("Shows capacity warning for zero-day circulation rules", function (this: BookingTestContext) { - const ctx = this as BookingTestContext; - const biblionumber = ctx.biblio.biblio.biblio_id; - const patron = ctx.patron.patron; - const pickupLibrary = ctx.biblio.libraries[0]; - const item = ctx.biblio.items[0]; - - cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); - cy.get("#bookings_table").should("exist"); - prepareBookingModalPage(); - - interceptBookingModalData(biblionumber, { - circulationRules: req => { - req.reply({ - statusCode: 200, - body: [ - { - library_id: pickupLibrary.library_id, - item_type_id: item.item_type_id, - patron_category_id: patron.category_id, - issuelength: 0, - renewalsallowed: 0, - renewalperiod: 0, - bookings_lead_period: 0, - bookings_trail_period: 0, - calculated_period_days: 0, - }, - ], - }); - }, - }); - - openBookingModalFromList(biblionumber); - selectPatronAndInventory(patron, pickupLibrary, item); - - cy.get(".modal.show .alert-warning") - .scrollIntoView() - .should("be.visible") - .and("contain", "Bookings are not permitted for this combination"); - cy.get("#booking_period").should("be.disabled"); - cy.get("button[form='form-booking']").should("be.disabled"); - }); - - it("Creates a booking without selecting a specific item", function (this: BookingTestContext) { - const ctx = this as BookingTestContext; - const biblionumber = ctx.biblio.biblio.biblio_id; - const patron = ctx.patron.patron; - const pickupLibrary = ctx.biblio.libraries[0]; - const startDate = dayjs().add(3, "day").startOf("day"); - const endDate = startDate.add(2, "day"); - - cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); - cy.get("#bookings_table").should("exist"); - prepareBookingModalPage(); - - interceptBookingModalData(biblionumber); - cy.intercept("POST", "/api/v1/bookings").as("createBooking"); - - openBookingModalFromList(biblionumber); - - selectPatronAndInventory(patron, pickupLibrary, null, { - skipItemSelection: true, - }); - cy.get("#booking_period").should("not.be.disabled"); - setDateRange(startDate, endDate); - cy.wait(1000); - - cy.get("button[form='form-booking']", { timeout: 15000 }) - .should("not.be.disabled") - .contains("Place booking") - .click(); - - cy.wait("@createBooking").then(({ response }) => { - expect(response?.statusCode).to.eq(201); - // With only 1 bookable item the modal auto-assigns item_id client-side. - // With 2+ items the server performs optimal selection. - // Either way the response must contain a resolved item_id. - expect(response?.body.item_id).to.not.be.null; - if (response?.body) { - ctx.bookingsToCleanup.push(response.body); - } - }); - - cy.get("body").should("not.have.class", "modal-open"); - }); - - it("Shows error message when booking creation fails with 400", function (this: BookingTestContext) { - const ctx = this as BookingTestContext; - const biblionumber = ctx.biblio.biblio.biblio_id; - const patron = ctx.patron.patron; - const pickupLibrary = ctx.biblio.libraries[0]; - const item = ctx.biblio.items[0]; - const startDate = dayjs().add(3, "day").startOf("day"); - const endDate = startDate.add(2, "day"); - - cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); - cy.get("#bookings_table").should("exist"); - prepareBookingModalPage(); - - interceptBookingModalData(biblionumber); - cy.intercept("POST", "/api/v1/bookings", { - statusCode: 400, - body: { - error: "Invalid booking period", - }, - }).as("createBooking"); - - openBookingModalFromList(biblionumber); - selectPatronAndInventory(patron, pickupLibrary, item); - setDateRange(startDate, endDate); - cy.wait(1000); - - cy.get("button[form='form-booking']", { timeout: 15000 }) - .should("not.be.disabled") - .click(); - - cy.wait("@createBooking"); - cy.get(".modal.show .alert-danger, .modal.show .alert-warning") - .scrollIntoView() - .should("be.visible"); - cy.get(".modal.show").should("exist"); - }); - - it("Shows error message when booking creation fails with 409 conflict", function (this: BookingTestContext) { - const ctx = this as BookingTestContext; - const biblionumber = ctx.biblio.biblio.biblio_id; - const patron = ctx.patron.patron; - const pickupLibrary = ctx.biblio.libraries[0]; - const item = ctx.biblio.items[0]; - const startDate = dayjs().add(3, "day").startOf("day"); - const endDate = startDate.add(2, "day"); - - cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); - cy.get("#bookings_table").should("exist"); - prepareBookingModalPage(); - - interceptBookingModalData(biblionumber); - cy.intercept("POST", "/api/v1/bookings", { - statusCode: 409, - body: { - error: "Booking conflict detected", - }, - }).as("createBooking"); - - openBookingModalFromList(biblionumber); - selectPatronAndInventory(patron, pickupLibrary, item); - setDateRange(startDate, endDate); - cy.wait(1000); - - cy.get("button[form='form-booking']", { timeout: 15000 }) - .should("not.be.disabled") - .click(); - - cy.wait("@createBooking"); - cy.get(".modal.show .alert-danger, .modal.show .alert-warning") - .scrollIntoView() - .should("be.visible"); - cy.get(".modal.show").should("exist"); - }); - - it("Shows error message when booking creation fails with 500", function (this: BookingTestContext) { - const ctx = this as BookingTestContext; - const biblionumber = ctx.biblio.biblio.biblio_id; - const patron = ctx.patron.patron; - const pickupLibrary = ctx.biblio.libraries[0]; - const item = ctx.biblio.items[0]; - const startDate = dayjs().add(3, "day").startOf("day"); - const endDate = startDate.add(2, "day"); - - cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); - cy.get("#bookings_table").should("exist"); - prepareBookingModalPage(); - - interceptBookingModalData(biblionumber); - cy.intercept("POST", "/api/v1/bookings", { - statusCode: 500, - body: { - error: "Internal server error", - }, - }).as("createBooking"); - - openBookingModalFromList(biblionumber); - selectPatronAndInventory(patron, pickupLibrary, item); - setDateRange(startDate, endDate); - cy.wait(1000); - - cy.get("button[form='form-booking']", { timeout: 15000 }) - .should("not.be.disabled") - .click(); - - cy.wait("@createBooking"); - cy.get(".modal.show .alert-danger, .modal.show .alert-warning") - .scrollIntoView() - .should("be.visible"); - cy.get(".modal.show").should("exist"); - }); - - it("Shows message when patron search returns no results", function (this: BookingTestContext) { - const ctx = this as BookingTestContext; - const biblionumber = ctx.biblio.biblio.biblio_id; - - cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); - cy.get("#bookings_table").should("exist"); - prepareBookingModalPage(); - - interceptBookingModalData(biblionumber, { - searchPatrons: { - statusCode: 200, - body: [], - headers: { - "X-Base-Total-Count": "0", - "X-Total-Count": "0", - }, - }, - }); - - openBookingModalFromList(biblionumber); - - cy.get(".modal.show #booking_patron") - .clear() - .type("NONEXISTENT", { delay: 0 }); - cy.wait("@searchPatrons"); - cy.wait(500); - - cy.get(".modal.show .vs__no-options").should("be.visible"); - }); - - it("Shows no options when no pickup locations are available", function (this: BookingTestContext) { - const ctx = this as BookingTestContext; - const biblionumber = ctx.biblio.biblio.biblio_id; - const patron = ctx.patron.patron; - - cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); - cy.get("#bookings_table").should("exist"); - prepareBookingModalPage(); - - interceptBookingModalData(biblionumber, { - pickupLocations: { - statusCode: 200, - body: [], - headers: { - "X-Base-Total-Count": "0", - "X-Total-Count": "0", - }, - }, - }); - - openBookingModalFromList(biblionumber); - - const searchTerm = patron.cardnumber.slice(0, 4); - cy.get(".modal.show #booking_patron") - .clear() - .type(searchTerm, { delay: 0 }); - cy.wait("@searchPatrons"); - cy.wait(200); - selectVsOption(patron.cardnumber); - - cy.wait("@pickupLocations"); - cy.wait(500); - - cy.get(".modal.show #pickup_library_id").click({ force: true }); - cy.wait(200); - cy.get(".modal.show .vs__no-options").should("be.visible"); - }); - - it("Shows conflicting dates when an item is already booked", function (this: BookingTestContext) { - const ctx = this as BookingTestContext; - const biblionumber = ctx.biblio.biblio.biblio_id; - const patron = ctx.patron.patron; - const pickupLibrary = ctx.biblio.libraries[0]; - const item = ctx.biblio.items[0]; - const conflictStart = dayjs().add(5, "day").startOf("day"); - const conflictEnd = conflictStart.add(2, "day"); - - cy.task("apiPost", { - endpoint: "/api/v1/bookings", - body: { - biblio_id: biblionumber, - patron_id: patron.patron_id, - pickup_library_id: pickupLibrary.library_id, - item_id: item.item_id, - start_date: conflictStart.toISOString(), - end_date: conflictEnd.toISOString(), - }, - }).then(booking => { - ctx.bookingsToCleanup.push(booking); - }); - - cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); - cy.get("#bookings_table").should("exist"); - prepareBookingModalPage(); - - interceptBookingModalData(biblionumber); - - openBookingModalFromList(biblionumber); - selectPatronAndInventory(patron, pickupLibrary, item); - - cy.get(".modal.show #booking_period").click({ force: true }); - cy.get(".flatpickr-calendar", { timeout: 5000 }).should("be.visible"); - - const conflictDateStr = conflictStart.format("MMMM D, YYYY"); - cy.get(".flatpickr-calendar") - .find(`.flatpickr-day[aria-label="${conflictDateStr}"]`) - .should("have.class", "flatpickr-disabled"); - }); - - it("Creates a booking when item type is auto-selected", function (this: BookingTestContext) { - const ctx = this as BookingTestContext; - const biblionumber = ctx.biblio.biblio.biblio_id; - const patron = ctx.patron.patron; - const pickupLibrary = ctx.biblio.libraries[0]; - const startDate = dayjs().add(3, "day").startOf("day"); - const endDate = startDate.add(2, "day"); - - cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); - cy.get("#bookings_table").should("exist"); - prepareBookingModalPage(); - - interceptBookingModalData(biblionumber); - cy.intercept("POST", "/api/v1/bookings").as("createBooking"); - - openBookingModalFromList(biblionumber); - - const searchTerm = patron.cardnumber.slice(0, 4); - cy.get(".modal.show #booking_patron") - .clear() - .type(searchTerm, { delay: 0 }); - cy.wait("@searchPatrons"); - cy.wait(200); - selectVsOption(patron.cardnumber); - - cy.wait("@pickupLocations"); - cy.get(".modal.show #pickup_library_id").should( - "not.have.attr", - "disabled" - ); - cy.get(".modal.show #pickup_library_id").click({ force: true }); - cy.wait(200); - selectVsOption(pickupLibrary.name); - - cy.wait("@circulationRules"); - cy.wait(500); - - cy.get("#booking_period").should("not.be.disabled"); - setDateRange(startDate, endDate); - cy.wait(1000); - - cy.get("button[form='form-booking']", { timeout: 15000 }) - .should("not.be.disabled") - .contains("Place booking") - .click(); - - cy.wait("@createBooking").then(({ response }) => { - expect(response?.statusCode).to.eq(201); - // With auto-selected item type and 1 item, the modal auto-assigns. - // The response must contain a resolved item_id. - expect(response?.body.item_id).to.not.be.null; - if (response?.body) { - ctx.bookingsToCleanup.push(response.body); - } - }); - - cy.get("body").should("not.have.class", "modal-open"); - }); -}); diff --git a/t/cypress/support/flatpickr.js b/t/cypress/support/flatpickr.js index 54654e04c66..8b1ff653cd1 100644 --- a/t/cypress/support/flatpickr.js +++ b/t/cypress/support/flatpickr.js @@ -196,40 +196,6 @@ Cypress.Commands.add( } ); -/** - * Helper to close an open Flatpickr calendar. - */ -Cypress.Commands.add("closeFlatpickr", { prevSubject: true }, subject => { - return cy.wrap(subject).then($input => { - // Wait for flatpickr to be initialized and then close it - return cy - .wrap($input) - .should($el => { - expect($el[0]).to.have.property("_flatpickr"); - }) - .then(() => { - $input[0]._flatpickr.close(); - return cy.wrap(subject); - }); - }); -}); - -/** - * Helper to navigate to a specific month and year in a Flatpickr calendar. - */ -Cypress.Commands.add( - "navigateToFlatpickrMonth", - { prevSubject: true }, - (subject, targetDate, timeout = 10000) => { - return ensureCalendarIsOpen(cy.wrap(subject), timeout).then($input => { - const dayjsDate = dayjs(targetDate); - return navigateToMonthAndYear(dayjsDate, $input, timeout).then(() => - cy.wrap($input) - ); - }); - } -); - /** * Helper to get the Flatpickr mode ('single', 'range', 'multiple'). */ @@ -408,8 +374,6 @@ Cypress.Commands.add( } ); -// --- Enhanced Assertion Commands --- - /** * Helper to get a specific Flatpickr day element by its date. */ @@ -445,151 +409,6 @@ Cypress.Commands.add( } ); -/** - * Assertion helper to check if a Flatpickr date is disabled. - */ -Cypress.Commands.add( - "flatpickrDateShouldBeDisabled", - { prevSubject: true }, - (subject, date) => { - return cy - .wrap(subject) - .getFlatpickrDate(date) - .should("have.class", "flatpickr-disabled") - .then(() => cy.wrap(subject)); - } -); - -/** - * Assertion helper to check if a Flatpickr date is enabled. - */ -Cypress.Commands.add( - "flatpickrDateShouldBeEnabled", - { prevSubject: true }, - (subject, date) => { - return cy - .wrap(subject) - .getFlatpickrDate(date) - .should("not.have.class", "flatpickr-disabled") - .then(() => cy.wrap(subject)); - } -); - -/** - * Assertion helper to check if a Flatpickr date is selected. - */ -Cypress.Commands.add( - "flatpickrDateShouldBeSelected", - { prevSubject: true }, - (subject, date) => { - return cy - .wrap(subject) - .getFlatpickrDate(date) - .should("have.class", "selected") - .then(() => cy.wrap(subject)); - } -); - -/** - * Assertion helper to check if a Flatpickr date is not selected. - */ -Cypress.Commands.add( - "flatpickrDateShouldNotBeSelected", - { prevSubject: true }, - (subject, date) => { - return cy - .wrap(subject) - .getFlatpickrDate(date) - .should("not.have.class", "selected") - .then(() => cy.wrap(subject)); - } -); - -/** - * Assertion helper to check if a Flatpickr date is today. - */ -Cypress.Commands.add( - "flatpickrDateShouldBeToday", - { prevSubject: true }, - (subject, date) => { - return cy - .wrap(subject) - .getFlatpickrDate(date) - .should("have.class", "today") - .then(() => cy.wrap(subject)); - } -); - -/** - * Assertion helper to check if a Flatpickr date is in a range (has inRange class). - */ -Cypress.Commands.add( - "flatpickrDateShouldBeInRange", - { prevSubject: true }, - (subject, date) => { - return cy - .wrap(subject) - .getFlatpickrDate(date) - .should("have.class", "inRange") - .then(() => cy.wrap(subject)); - } -); - -/** - * Assertion helper to check if a Flatpickr date is the start of a range. - */ -Cypress.Commands.add( - "flatpickrDateShouldBeRangeStart", - { prevSubject: true }, - (subject, date) => { - return cy - .wrap(subject) - .getFlatpickrDate(date) - .should("have.class", "startRange") - .then(() => cy.wrap(subject)); - } -); - -/** - * Assertion helper to check if a Flatpickr date is the end of a range. - */ -Cypress.Commands.add( - "flatpickrDateShouldBeRangeEnd", - { prevSubject: true }, - (subject, date) => { - return cy - .wrap(subject) - .getFlatpickrDate(date) - .should("have.class", "endRange") - .then(() => cy.wrap(subject)); - } -); - -/** - * Helper to get the selected dates from a Flatpickr instance. - * Returns the selected dates as an array of YYYY-MM-DD formatted strings. - */ -Cypress.Commands.add( - "getFlatpickrSelectedDates", - { prevSubject: true }, - subject => { - return cy.wrap(subject).then($input => { - const fpInstance = $input[0]._flatpickr; - if (!fpInstance) { - throw new Error( - `Flatpickr: Cannot find flatpickr instance on element. Make sure it's initialized with flatpickr.` - ); - } - - const selectedDates = fpInstance.selectedDates.map(date => - dayjs(date).format("YYYY-MM-DD") - ); - - return selectedDates; - }); - } -); - /** * Helper to clear a Flatpickr input by setting its value to empty. * Works with hidden inputs by using the Flatpickr API directly. -- 2.53.0