From 4e78a1f83b1969b57c843c9cdbefa648ede2f67c Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Mon, 19 May 2025 13:29:49 +0100 Subject: [PATCH] Bug 39916: Add flatpickr helpers to cypress This adds helper methods for cypress to work with flatpickr date pickers: - openFlatpickr() - open the calendar - selectFlatpickrDate() - select a single date - selectFlatpickrDateRange() - select date range - clearFlatpickr() - clear the selected dates - getFlatpickrDate() - get specific date element - navigateToFlatpickrMonth() - navigate calendar These helpers simplify date picker testing when using flatpickr. --- t/cypress/support/e2e.js | 1 + t/cypress/support/flatpickr.js | 565 +++++++++++++++++++++++++++++++++ 2 files changed, 566 insertions(+) create mode 100644 t/cypress/support/flatpickr.js diff --git a/t/cypress/support/e2e.js b/t/cypress/support/e2e.js index 07f8e58e4fd..8722aa5c65a 100644 --- a/t/cypress/support/e2e.js +++ b/t/cypress/support/e2e.js @@ -26,6 +26,7 @@ // Import Select2 helpers import "./select2"; +import "./flatpickr.js"; function get_fallback_login_value(param) { var env_var = param == "username" ? "KOHA_USER" : "KOHA_PASS"; diff --git a/t/cypress/support/flatpickr.js b/t/cypress/support/flatpickr.js new file mode 100644 index 00000000000..7a313d5cc8c --- /dev/null +++ b/t/cypress/support/flatpickr.js @@ -0,0 +1,565 @@ +// flatpickrHelpers.js - Enhanced Reusable Cypress functions for Flatpickr date pickers + +/** + * Enhanced helper functions for interacting with Flatpickr date picker components in Cypress tests + * Uses click-driven interactions with improved reliability and native retry mechanisms + * Supports all standard Flatpickr operations including date selection, range selection, + * navigation, hover interactions, and assertions. + * + * CHAINABILITY: + * All Flatpickr helper commands are fully chainable. You can: + * - Chain multiple Flatpickr operations (open, navigate, select) + * - Chain Flatpickr commands with standard Cypress commands + * - Split complex interactions into multiple steps for better reliability + * + * Examples: + * cy.get('#myDatepicker') + * .openFlatpickr() + * .selectFlatpickrDate('2023-05-15'); + * + * cy.get('#rangePicker') + * .openFlatpickr() + * .selectFlatpickrDateRange('2023-06-01', '2023-06-15') + * .should('have.value', '2023-06-01 to 2023-06-15'); + */ + +// --- Internal Utility Functions --- + +const monthNames = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +/** + * Generates a Cypress selector for a specific day element within the Flatpickr calendar + * based on its aria-label. This is a low-level internal helper. + */ +const _getFlatpickrDateSelector = date => { + const month = monthNames[date.getMonth()]; + const day = date.getDate(); + const year = date.getFullYear(); + const formattedLabel = `${month} ${day}, ${year}`; + return `.flatpickr-day[aria-label="${formattedLabel}"]`; +}; + +/** + * Ensures the Flatpickr calendar is open. If not, it clicks the input to open it. + * Uses Cypress's built-in retry mechanism for reliability. + */ +const ensureCalendarIsOpen = ($el, timeout = 10000) => { + return $el.then($input => { + const inputToClick = $input.is(":visible") + ? $input + : $input.parents().find(".flatpickr-input:visible").first(); + + if (!inputToClick.length) { + throw new Error( + `Flatpickr: Could not find visible input element for selector '${$input.selector}' to open calendar.` + ); + } + + // Use Cypress's retry mechanism to check if calendar is already open + return cy.get("body").then(() => { + return cy.get(".flatpickr-calendar").then($calendar => { + const isVisible = + $calendar.length > 0 && + $calendar.hasClass("open") && + $calendar.is(":visible"); + + if (!isVisible) { + cy.wrap(inputToClick).click(); + } + + // Wait for calendar to be open and visible with retry + return cy + .get(".flatpickr-calendar.open", { timeout }) + .should("be.visible") + .then(() => cy.wrap($input)); + }); + }); + }); +}; + +/** + * Ensures the specified date is visible in the current calendar view. + * Navigates to the correct month/year if necessary. + */ +const ensureDateIsVisible = (targetDate, $input, timeout = 10000) => { + const targetYear = targetDate.getFullYear(); + const targetMonth = targetDate.getMonth(); + + return cy + .get(".flatpickr-calendar.open", { timeout }) + .should("be.visible") + .then($calendar => { + 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 currentMonth = fpInstance.currentMonth; + const currentYear = fpInstance.currentYear; + + // Check if we need to navigate + if (currentMonth !== targetMonth || currentYear !== targetYear) { + return navigateToMonthAndYear(targetDate, $input, timeout); + } + + // Already in correct month/year, just verify the date is visible + const selector = _getFlatpickrDateSelector(targetDate); + return cy.get(selector, { timeout: 5000 }).should("be.visible"); + }); +}; + +/** + * Navigates the Flatpickr calendar to the target month and year. + * Uses native retry mechanisms and verifies target date is in view. + */ +const navigateToMonthAndYear = (targetDate, $input, timeout = 10000) => { + const targetYear = targetDate.getFullYear(); + const targetMonth = targetDate.getMonth(); + + return cy + .get(".flatpickr-calendar.open", { timeout }) + .should("be.visible") + .then($calendar => { + 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 currentMonth = fpInstance.currentMonth; + const currentYear = fpInstance.currentYear; + + const monthDiff = + (targetYear - currentYear) * 12 + (targetMonth - currentMonth); + + if (monthDiff === 0) { + // Already in correct month, verify target date is visible + const selector = _getFlatpickrDateSelector(targetDate); + return cy.get(selector, { timeout: 5000 }).should("be.visible"); + } + + // Use flatpickr's changeMonth method for faster navigation + fpInstance.changeMonth(monthDiff, true); + + // Verify navigation succeeded by checking target date is now visible + const selector = _getFlatpickrDateSelector(targetDate); + return cy + .get(selector, { timeout: 5000 }) + .should("be.visible") + .should($el => { + // Ensure the element is actually the date we want + expect($el).to.have.length(1); + expect($el.attr("aria-label")).to.contain( + targetDate.getDate().toString() + ); + }); + }); +}; + +// --- User-Facing Helper Commands --- + +/** + * Helper to open a Flatpickr calendar. + */ +Cypress.Commands.add( + "openFlatpickr", + { prevSubject: "optional" }, + (subject, selector, timeout = 10000) => { + const $el = subject ? cy.wrap(subject) : cy.get(selector); + return ensureCalendarIsOpen($el, timeout); + } +); + +/** + * Helper to close an open Flatpickr calendar. + */ +Cypress.Commands.add( + "closeFlatpickr", + { prevSubject: "optional" }, + (subject, selector) => { + const chain = subject + ? cy.wrap(subject) + : selector + ? cy.get(selector) + : cy.get("body"); + + return chain.then($el => { + // Click outside to close, using body coordinates that should always be safe + cy.get("body").click(0, 0); + + // Wait for calendar to close with retry mechanism + cy.get(".flatpickr-calendar.open").should("not.exist", { + timeout: 5000, + }); + + return cy.wrap($el); + }); + } +); + +/** + * 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 dateObj = + typeof targetDate === "string" + ? new Date(targetDate) + : targetDate; + return navigateToMonthAndYear(dateObj, $input, timeout).then(() => + cy.wrap($input) + ); + }); + } +); + +/** + * Helper to get the Flatpickr mode ('single', 'range', 'multiple'). + */ +Cypress.Commands.add("getFlatpickrMode", { 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 ${$input.selector}. Make sure it's initialized with flatpickr.` + ); + } + return fpInstance.config.mode; + }); +}); + +/** + * Helper to select a specific date in a Flatpickr. + */ +Cypress.Commands.add( + "selectFlatpickrDate", + { prevSubject: true }, + (subject, date, timeout = 10000) => { + return ensureCalendarIsOpen(cy.wrap(subject), timeout).then($input => { + const dateObj = typeof date === "string" ? new Date(date) : date; + + return ensureDateIsVisible(dateObj, $input, timeout).then(() => { + // Click the date - break chain to avoid DOM detachment + cy.get(_getFlatpickrDateSelector(dateObj)) + .should("be.visible") + .click(); + + // Re-query and validate selection based on mode + return cy + .wrap($input) + .getFlatpickrMode() + .then(mode => { + if (mode === "single") { + const expectedDate = `${dateObj.getFullYear()}-${(dateObj.getMonth() + 1).toString().padStart(2, "0")}-${dateObj.getDate().toString().padStart(2, "0")}`; + + cy.wrap($input).should("have.value", expectedDate); + cy.get(".flatpickr-calendar.open").should( + "not.exist", + { timeout: 5000 } + ); + return cy.wrap($input); + } else if (mode === "range") { + // In range mode, first selection keeps calendar open - re-query element + // Add small wait to allow for complex date recalculations (e.g., booking availability) + cy.wait(200); + cy.get(_getFlatpickrDateSelector(dateObj)).should( + "have.class", + "selected" + ); + cy.get(".flatpickr-calendar.open").should( + "be.visible" + ); + return cy.wrap($input); + } + + return cy.wrap($input); + }); + }); + }); + } +); + +/** + * Helper to select a date range in a Flatpickr range picker. + */ +Cypress.Commands.add( + "selectFlatpickrDateRange", + { prevSubject: true }, + (subject, startDate, endDate, timeout = 10000) => { + return ensureCalendarIsOpen(cy.wrap(subject), timeout).then($input => { + const startDateObj = + typeof startDate === "string" ? new Date(startDate) : startDate; + const endDateObj = + typeof endDate === "string" ? new Date(endDate) : endDate; + + // Validate range mode first + return cy + .wrap($input) + .getFlatpickrMode() + .then(mode => { + if (mode !== "range") { + throw new Error( + `Flatpickr: This flatpickr instance is not in range mode. Current mode: ${mode}. Cannot select range.` + ); + } + + // Select start date - break chain to avoid DOM detachment + return ensureDateIsVisible( + startDateObj, + $input, + timeout + ).then(() => { + cy.get(_getFlatpickrDateSelector(startDateObj)) + .should("be.visible") + .click(); + + // Add small wait to allow for complex date recalculations (e.g., booking availability) + cy.wait(200); + // Re-query element after DOM update + cy.get(_getFlatpickrDateSelector(startDateObj)) + .should("have.class", "selected") + .and("have.class", "startRange"); + + // Ensure calendar stays open + cy.get(".flatpickr-calendar.open").should("be.visible"); + + // Navigate to end date and select it + return ensureDateIsVisible( + endDateObj, + $input, + timeout + ).then(() => { + cy.get(_getFlatpickrDateSelector(endDateObj)) + .should("be.visible") + .click(); + + // Validate final range selection + const expectedRange = `${startDateObj.getFullYear()}-${(startDateObj.getMonth() + 1).toString().padStart(2, "0")}-${startDateObj.getDate().toString().padStart(2, "0")} to ${endDateObj.getFullYear()}-${(endDateObj.getMonth() + 1).toString().padStart(2, "0")}-${endDateObj.getDate().toString().padStart(2, "0")}`; + + cy.wrap($input).should("have.value", expectedRange); + cy.get(".flatpickr-calendar.open").should( + "not.exist", + { timeout: 5000 } + ); + + return cy.wrap($input); + }); + }); + }); + }); + } +); + +/** + * Helper to hover over a specific date in a Flatpickr calendar. + */ +Cypress.Commands.add( + "hoverFlatpickrDate", + { prevSubject: true }, + (subject, date, timeout = 10000) => { + return ensureCalendarIsOpen(cy.wrap(subject), timeout).then($input => { + const dateObj = typeof date === "string" ? new Date(date) : date; + + return ensureDateIsVisible(dateObj, $input, timeout).then(() => { + cy.get(_getFlatpickrDateSelector(dateObj)) + .should("be.visible") + .trigger("mouseover"); + + return cy.wrap($input); + }); + }); + } +); + +// --- Enhanced Assertion Commands --- + +/** + * Helper to get a specific Flatpickr day element by its date. + */ +Cypress.Commands.add( + "getFlatpickrDate", + { prevSubject: true }, + (subject, date, timeout = 10000) => { + const dateObj = typeof date === "string" ? new Date(date) : date; + + if (!(dateObj instanceof Date && !isNaN(dateObj.getTime()))) { + throw new Error( + `getFlatpickrDate: Invalid date provided. Received: ${date}` + ); + } + + return ensureCalendarIsOpen(cy.wrap(subject), timeout).then($input => { + return ensureDateIsVisible(dateObj, $input, timeout).then(() => { + // Instead of returning the element directly, return a function that re-queries + // This ensures subsequent .should() calls get a fresh element reference + const selector = _getFlatpickrDateSelector(dateObj); + return cy + .get(selector, { timeout: 5000 }) + .should("be.visible") + .should($el => { + // Ensure the element is actually the date we want + expect($el).to.have.length(1); + expect($el.attr("aria-label")).to.contain( + dateObj.getDate().toString() + ); + }); + }); + }); + } +); + +/** + * 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 clear a Flatpickr input by setting its value to empty. + * Works with hidden inputs by using the Flatpickr API directly. + */ +Cypress.Commands.add("clearFlatpickr", { prevSubject: true }, subject => { + return cy.wrap(subject).then($input => { + // Wait for flatpickr to be initialized and then clear it + return cy + .wrap($input) + .should($el => { + expect($el[0]).to.have.property("_flatpickr"); + }) + .then(() => { + $input[0]._flatpickr.clear(); + return cy.wrap(subject); + }); + }); +}); -- 2.49.0