Bugzilla – Attachment 193913 Details for
Bug 41129
Migrate place_booking.js to a Vue island.
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 41129: Remove dead code and consolidate styles and tests
Bug-41129-Remove-dead-code-and-consolidate-styles-.patch (text/plain), 74.88 KB, created by
Paul Derscheid
on 2026-02-25 15:43:20 UTC
(
hide
)
Description:
Bug 41129: Remove dead code and consolidate styles and tests
Filename:
MIME Type:
Creator:
Paul Derscheid
Created:
2026-02-25 15:43:20 UTC
Size:
74.88 KB
patch
obsolete
>From ef1c806afaf567dffe93c7df2b2ad5c70acac0c6 Mon Sep 17 00:00:00 2001 >From: Paul Derscheid <paul.derscheid@lmscloud.de> >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 <style> blocks in BookingModal.vue >- Centralise marker dot color variants in BookingModal.vue; remove > duplicates from BookingPeriodStep and BookingTooltip >- Replace hardcoded #28a745 with --booking-constraint-marker variable > >Tests: >- Consolidate booking modal specs into bookingsModalBasic_spec.ts >- Scope _feedbackHideTimer per createOnDayCreate instance >- Replace debug console.log with console.warn in list.tt >- Document catch blocks in staff-interface.js >--- > .../prog/en/modules/bookings/list.tt | 2 +- > .../vue/components/Bookings/BookingModal.vue | 67 +- > .../components/Bookings/BookingPeriodStep.vue | 32 +- > .../components/Bookings/BookingTooltip.vue | 21 - > .../Bookings/composables/useAvailability.mjs | 5 +- > .../lib/adapters/api/staff-interface.js | 6 +- > .../Bookings/lib/adapters/calendar/events.mjs | 84 +- > .../lib/adapters/calendar/highlighting.mjs | 19 +- > .../algorithms/sweep-line-processor.mjs | 166 ---- > .../Bookings/lib/booking/constants.mjs | 4 - > .../prog/js/vue/utils/functions.mjs | 20 - > .../Circulation/bookingsModalBasic_spec.ts | 198 ++++ > .../bookingsModalDatePicker_spec.ts | 53 +- > .../Circulation/bookingsModal_spec.ts | 873 ------------------ > t/cypress/support/flatpickr.js | 181 ---- > 15 files changed, 307 insertions(+), 1424 deletions(-) > delete mode 100644 t/cypress/integration/Circulation/bookingsModal_spec.ts > >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt >index 1b4a07ca33b..7aca5a6ee38 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt >@@ -205,7 +205,7 @@ > timeline = new vis.Timeline(container, bookingsSet, itemsSet, options); > }, > function (jqXHR, textStatus, errorThrown) { >- console.log("Fetch failed"); >+ console.warn("Timeline data fetch failed:", textStatus, errorThrown); > } > ); > >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 3158577372e..a31e4fba0cf 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 >@@ -767,7 +767,7 @@ onUnmounted(() => { > </script> > > <style> >-/* Global variables for external libraries (flatpickr) and cross-block usage */ >+/* Design System: CSS Custom Properties */ > :root { > /* Success colors for constraint highlighting */ > --booking-success-hue: 134; >@@ -776,62 +776,54 @@ onUnmounted(() => { > --booking-success-border: hsl(var(--booking-success-hue), 70%, 40%); > --booking-success-border-hover: hsl(var(--booking-success-hue), 75%, 30%); > --booking-success-text: hsl(var(--booking-success-hue), 80%, 20%); >+ --booking-constraint-marker: hsl(var(--booking-success-hue), 61%, 41%); > >- /* Border width used by flatpickr */ >+ /* Border width */ > --booking-border-width: 1px; > >- /* Variables used by second style block (booking markers, calendar states) */ >+ /* Booking markers */ > --booking-marker-size: max(4px, 0.25em); > --booking-marker-grid-gap: 0.25rem; > --booking-marker-grid-offset: -0.75rem; > >- /* Color hues used in second style block */ >+ /* Color hues */ > --booking-warning-hue: 45; > --booking-danger-hue: 354; > --booking-info-hue: 195; > --booking-neutral-hue: 210; > --booking-holiday-hue: 0; > >- /* Colors derived from hues (used in second style block) */ >+ /* Colors derived from hues */ > --booking-warning-bg: hsl(var(--booking-warning-hue), 100%, 85%); >- --booking-neutral-600: hsl(var(--booking-neutral-hue), 10%, 45%); >- --booking-holiday-bg: hsl(var(--booking-holiday-hue), 0%, 85%); >- --booking-holiday-text: hsl(var(--booking-holiday-hue), 0%, 40%); >- >- /* Spacing used in second style block */ >- --booking-space-xs: 0.125rem; >- >- /* Typography used in second style block */ >- --booking-text-xs: 0.7rem; >- >- /* Border radius used in second style block and other components */ >- --booking-border-radius-sm: 0.25rem; >- --booking-border-radius-md: 0.5rem; >- --booking-border-radius-full: 50%; >-} >- >-/* Design System: CSS Custom Properties (First Style Block Only) */ >-:root { >- /* Colors not used in second style block */ > --booking-warning-bg-hover: hsl(var(--booking-warning-hue), 100%, 70%); > --booking-neutral-100: hsl(var(--booking-neutral-hue), 15%, 92%); > --booking-neutral-300: hsl(var(--booking-neutral-hue), 15%, 75%); > --booking-neutral-500: hsl(var(--booking-neutral-hue), 10%, 55%); >+ --booking-neutral-600: hsl(var(--booking-neutral-hue), 10%, 45%); >+ --booking-holiday-bg: hsl(var(--booking-holiday-hue), 0%, 85%); >+ --booking-holiday-text: hsl(var(--booking-holiday-hue), 0%, 40%); > >- /* Spacing Scale (first block only) */ >+ /* Spacing Scale */ >+ --booking-space-xs: 0.125rem; > --booking-space-sm: 0.25rem; /* 4px */ > --booking-space-md: 0.5rem; /* 8px */ > --booking-space-lg: 1rem; /* 16px */ > --booking-space-xl: 1.5rem; /* 24px */ > --booking-space-2xl: 2rem; /* 32px */ > >- /* Typography Scale (first block only) */ >+ /* Typography Scale */ >+ --booking-text-xs: 0.7rem; > --booking-text-sm: 0.8125rem; > --booking-text-base: 1rem; > --booking-text-lg: 1.1rem; > --booking-text-xl: 1.3rem; > --booking-text-2xl: 2rem; > >+ /* Border radius */ >+ --booking-border-radius-sm: 0.25rem; >+ --booking-border-radius-md: 0.5rem; >+ --booking-border-radius-full: 50%; >+ > /* Layout */ > --booking-modal-max-height: calc(100vh - var(--booking-space-2xl)); > --booking-input-min-width: 15rem; >@@ -939,25 +931,8 @@ hr { > margin-left: var(--booking-space-lg); > } > >-/* Legend colors match actual calendar markers exactly */ >-.calendar-legend .booking-marker-dot--booked { >- background: var(--booking-warning-bg) !important; >-} >- >-.calendar-legend .booking-marker-dot--checked-out { >- background: hsl(var(--booking-danger-hue), 60%, 85%) !important; >-} >- >-.calendar-legend .booking-marker-dot--lead { >- background: hsl(var(--booking-info-hue), 60%, 85%) !important; >-} >+/* === Date Picker & Marker System === */ > >-.calendar-legend .booking-marker-dot--trail { >- background: var(--booking-warning-bg) !important; >-} >-</style> >- >-<style> > .booking-date-picker { > display: flex; > align-items: stretch; >@@ -1053,6 +1028,10 @@ hr { > background: var(--booking-holiday-bg); > } > >+.booking-marker-dot--constraint { >+ background: var(--booking-constraint-marker); >+} >+ > /* Calendar Day States */ > .booked { > background: var(--booking-warning-bg) !important; >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue >index 2c8f1807fe0..5638a2b3332 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue >@@ -68,8 +68,7 @@ > {{ $__("Closed") }} > <span > v-if="dateRangeConstraint && hasSelectedDates" >- class="booking-marker-dot ml-3" >- style="background-color: #28a745" >+ class="booking-marker-dot booking-marker-dot--constraint ml-3" > ></span> > <span v-if="dateRangeConstraint && hasSelectedDates" class="ml-1"> > {{ $__("Required end date") }} >@@ -302,35 +301,6 @@ const clearDateRange = (): void => { > margin-top: var(--booking-space-lg); > } > >-.booking-marker-dot { >- display: inline-block; >- width: calc(var(--booking-marker-size) * 3); >- height: calc(var(--booking-marker-size) * 3); >- border-radius: var(--booking-border-radius-full); >- margin-right: var(--booking-space-sm); >- border: var(--booking-border-width) solid hsla(0, 0%, 0%, 0.15); >-} >- >-.booking-marker-dot--booked { >- background-color: var(--booking-warning-bg); >-} >- >-.booking-marker-dot--lead { >- background-color: hsl(var(--booking-info-hue), 60%, 85%); >-} >- >-.booking-marker-dot--trail { >- background-color: var(--booking-warning-bg); >-} >- >-.booking-marker-dot--checked-out { >- background-color: hsl(var(--booking-danger-hue), 60%, 85%); >-} >- >-.booking-marker-dot--holiday { >- background-color: var(--booking-holiday-bg); >-} >- > .alert { > padding: calc(var(--booking-space-lg) * 0.75) var(--booking-space-lg); > border: var(--booking-border-width) solid transparent; >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue >index b3d615769fa..7663f2399d8 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue >@@ -31,7 +31,6 @@ > </template> > > <script setup lang="ts"> >-import { defineProps, withDefaults } from "vue"; > import { $__ } from "../../i18n"; > import { getMarkerTypeLabel } from "./lib/ui/marker-labels.mjs"; > import type { CalendarMarker } from "./types/bookings"; >@@ -72,24 +71,4 @@ withDefaults( > margin: 0 var(--booking-space-xs) 0 0; > vertical-align: middle; > } >- >-.booking-marker-dot--booked { >- background: var(--booking-warning-bg); >-} >- >-.booking-marker-dot--checked-out { >- background: hsl(var(--booking-danger-hue), 60%, 85%); >-} >- >-.booking-marker-dot--lead { >- background: hsl(var(--booking-info-hue), 60%, 85%); >-} >- >-.booking-marker-dot--trail { >- background: var(--booking-warning-bg); >-} >- >-.booking-marker-dot--holiday { >- background: var(--booking-holiday-bg); >-} > </style> >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>|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<T>) => 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<string | number> = []; >- 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<string, any> >- ) => any; >- }; >- if (typeof globalWin.buildPatronSearchQuery === "function") return; >- globalWin.buildPatronSearchQuery = ( >- term: string, >- options: Record<string, any> = {} >- ) => { >- 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<string, unknown>[] = []; >- 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<string, any>) => 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
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 41129
:
188546
|
188551
|
189348
|
189350
|
193909
|
193910
|
193911
|
193912
|
193913
|
193925
|
193926
|
193927
|
193928
|
193929