diff --git a/e2e/support/config.js b/e2e/support/config.js index 97f59cccedf736b429e0e1a68e76758a24039662..e12112ae637dba4b36208d83e89a8469ea6eed9c 100644 --- a/e2e/support/config.js +++ b/e2e/support/config.js @@ -1,3 +1,5 @@ +import path from "node:path"; + import { removeDirectory, verifyDownloadTasks, @@ -21,6 +23,23 @@ const targetVersion = process.env["CROSS_VERSION_TARGET"]; const feHealthcheckEnabled = process.env["CYPRESS_FE_HEALTHCHECK"] === "true"; +// docs say that tsconfig paths should handle aliases, but they don't +const assetsResolverPlugin = { + name: "assetsResolver", + setup(build) { + // Redirect all paths starting with "assets/" to "resources/" + build.onResolve({ filter: /^assets\// }, args => { + return { + path: path.join( + __dirname, + "../../resources/frontend_client/app", + args.path, + ), + }; + }); + }, +}; + const defaultConfig = { // This is the functionality of the old cypress-plugins.js file setupNodeEvents(on, config) { @@ -36,7 +55,7 @@ const defaultConfig = { loader: { ".svg": "text", }, - plugins: [NodeModulesPolyfillPlugin()], + plugins: [NodeModulesPolyfillPlugin(), assetsResolverPlugin], sourcemap: "inline", }), ); diff --git a/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts b/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..aedde7d6368604f7ce6582ca930a4022213b4ad6 --- /dev/null +++ b/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts @@ -0,0 +1,32 @@ +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import relativeTime from "dayjs/plugin/relativeTime"; + +dayjs.extend(duration); +dayjs.extend(relativeTime); + +/** Intercept routes for caching tests */ +export const interceptRoutes = () => { + cy.intercept("POST", "/api/dataset").as("dataset"); + cy.intercept("POST", "/api/card/*/query").as("cardQuery"); + cy.intercept("PUT", "/api/cache").as("putCacheConfig"); + cy.intercept("DELETE", "/api/cache").as("deleteCacheConfig"); + cy.intercept("GET", "/api/cache?model=*&id=*").as("getCacheConfig"); + cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as( + "dashcardQuery", + ); + cy.intercept("POST", "/api/persist/enable").as("enablePersistence"); + cy.intercept("POST", "/api/persist/disable").as("disablePersistence"); + cy.intercept("POST", "/api/cache/invalidate?include=overrides&database=*").as( + "invalidateCacheForSampleDatabase", + ); +}; + +/** Cypress log messages sometimes occur out of order so it is helpful to log to the console as well. */ +export const log = (message: string) => { + cy.log(message); + console.log(message); +}; + +export const databaseCachingSettingsPage = () => + cy.findByRole("main", { name: "Database caching settings" }); diff --git a/e2e/test/scenarios/admin/performance/helpers/e2e-strategy-form-helpers.ts b/e2e/test/scenarios/admin/performance/helpers/e2e-strategy-form-helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..34fbd3e6ad06d236c0041edd4a26e5554e217ace --- /dev/null +++ b/e2e/test/scenarios/admin/performance/helpers/e2e-strategy-form-helpers.ts @@ -0,0 +1,93 @@ +import { + type ScheduleComponentType, + getScheduleComponentLabel, +} from "metabase/components/Schedule/constants"; +import type { CacheStrategyType, CacheableModel } from "metabase-types/api"; + +import { databaseCachingSettingsPage } from "./e2e-performance-helpers"; + +/** Save the cache strategy form and wait for a response from the relevant endpoint */ +export const saveCacheStrategyForm = (options?: { + strategyType?: CacheStrategyType; + /** 'Model' as in 'type of object' */ + model?: CacheableModel; +}) => { + let expectedRoute: string; + if (options?.strategyType === "nocache" && options?.model === "root") { + // When setting the default policy to "Don't cache", we delete the policy in the BE + expectedRoute = "@deleteCacheConfig"; + } else if (options?.strategyType === "inherit") { + // When setting a database's policy to "Use default", we delete the policy in the BE + expectedRoute = "@deleteCacheConfig"; + } else { + // Otherwise we update the cache config + expectedRoute = "@putCacheConfig"; + } + cy.log("Save the cache strategy form"); + cacheStrategyForm().button(/Save/).click(); + return cy.wait(expectedRoute); +}; + +export const cacheStrategyForm = () => + cy.findByLabelText("Select the cache invalidation policy"); + +export const cacheStrategyRadioButton = (name: RegExp) => + cacheStrategyForm().findByRole("radio", { name }); + +export const durationRadioButton = () => cacheStrategyRadioButton(/Duration/); +export const adaptiveRadioButton = () => cacheStrategyRadioButton(/Adaptive/); +export const scheduleRadioButton = () => cacheStrategyRadioButton(/Schedule/); +export const dontCacheResultsRadioButton = () => + cacheStrategyRadioButton(/Don.t cache results/); +export const useDefaultRadioButton = () => + cacheStrategyRadioButton(/Use default/); + +export const formLauncher = ( + itemName: string, + preface: + | "currently" + | "currently inheriting" + | "currently inheriting the default policy", + strategyLabel = "", +) => { + databaseCachingSettingsPage().should("exist"); + const regExp = new RegExp(`Edit.*${itemName}.*${preface}.*${strategyLabel}`); + cy.log(`Finding strategy for launcher for regular expression: ${regExp}`); + const launcher = databaseCachingSettingsPage().findByLabelText(regExp); + launcher.should("exist"); + return launcher; +}; + +export const openStrategyFormForDatabaseOrDefaultPolicy = ( + /** To open the form for the default policy, set this parameter to "default policy" */ + databaseNameOrDefaultPolicy: string, + currentStrategyLabel?: string, +) => { + cy.visit("/admin/performance"); + cy.findByRole("tab", { name: "Database caching settings" }).click(); + cy.log(`Open strategy form for ${databaseNameOrDefaultPolicy}`); + formLauncher( + databaseNameOrDefaultPolicy, + "currently", + currentStrategyLabel, + ).click(); +}; + +export const getScheduleComponent = (componentType: ScheduleComponentType) => + cacheStrategyForm().findByLabelText(getScheduleComponentLabel(componentType)); + +export const openSidebar = () => { + cy.findByLabelText("info icon").click(); +}; +export const closeSidebar = () => { + cy.findByLabelText("info icon").click(); +}; + +/** Open the sidebar form that lets you set the caching strategy. + * This works on dashboards and questions */ +export const openSidebarCacheStrategyForm = () => { + cy.log("Open the cache strategy form in the sidebar"); + openSidebar(); + cy.wait("@getCacheConfig"); + cy.findByLabelText("Caching policy").click(); +}; diff --git a/e2e/test/scenarios/admin/performance/performance.cy.spec.ts b/e2e/test/scenarios/admin/performance/performance.cy.spec.ts index db63499714f260e0d1a3b581b08f391e4de44942..874064e860ca242b342f5c39c0dc628e561726f6 100644 --- a/e2e/test/scenarios/admin/performance/performance.cy.spec.ts +++ b/e2e/test/scenarios/admin/performance/performance.cy.spec.ts @@ -1,46 +1,17 @@ import { describeEE, restore, setTokenFeatures } from "e2e/support/helpers"; -const save = ({ expectedRoute = "@putCacheConfig" } = {}) => { - cy.log("Save the caching strategy form"); - cy.button(/Save/).click(); - cy.wait(expectedRoute); -}; - -const launchFormForItem = (itemName: string, currentStrategy: string) => { - cy.visit("/admin"); - cy.findByRole("link", { name: "Performance" }).click(); - cy.findByRole("tab", { name: "Database caching settings" }).click(); - cy.log(`Open strategy form for ${itemName}`); - formLauncher(itemName, "currently", currentStrategy).click(); -}; - -const main = () => cy.findByRole("main", { name: "Database caching settings" }); - -const form = () => cy.findByLabelText("Select the cache invalidation policy"); - -const radio = (name: RegExp) => form().findByRole("radio", { name }); - -const durationRadioButton = () => radio(/Duration/); -const adaptiveRadioButton = () => radio(/Adaptive/); -const scheduleRadioButton = () => radio(/Schedule/); -const dontCacheResultsRadioButton = () => radio(/Don.t cache results/); -const useDefaultRadioButton = () => radio(/Use default/); - -const formLauncher = ( - itemName: string, - preface: string, - strategyLabel: string, -) => { - main().should("exist"); - cy.log( - `Finding strategy for launcher for regex: Edit.*${itemName}.*${preface}.*${strategyLabel}`, - ); - const launcher = main().findByLabelText( - new RegExp(`Edit.*${itemName}.*${preface}.*${strategyLabel}`), - ); - launcher.should("exist"); - return launcher; -}; +import { + adaptiveRadioButton, + dontCacheResultsRadioButton, + formLauncher, + durationRadioButton, + useDefaultRadioButton, + scheduleRadioButton, + cacheStrategyForm, + openStrategyFormForDatabaseOrDefaultPolicy, + saveCacheStrategyForm, + cacheStrategyRadioButton, +} from "./helpers/e2e-strategy-form-helpers"; // NOTE: These tests just check that the form can be saved. They do not test // whether the cache is actually invalidated at the specified times. @@ -86,36 +57,35 @@ describe("scenarios > admin > performance", { tags: "@OSS" }, () => { }); it("there are two policy options for the default policy, Adaptive and Don't cache results", () => { - form().findAllByRole("radio").should("have.length", 2); + cacheStrategyForm().findAllByRole("radio").should("have.length", 2); adaptiveRadioButton().should("exist"); dontCacheResultsRadioButton().should("exist"); }); it("can set default policy to Don't cache results", () => { + const model = "root"; cy.log("Set default policy to Adaptive first"); adaptiveRadioButton().click(); - save(); + saveCacheStrategyForm({ strategyType: "ttl", model }); adaptiveRadioButton().should("be.checked"); cy.log("Then set default policy to Don't cache results"); dontCacheResultsRadioButton().click(); - save({ - expectedRoute: "@deleteCacheConfig", - }); + saveCacheStrategyForm({ strategyType: "nocache", model }); dontCacheResultsRadioButton().should("be.checked"); }); describe("adaptive strategy", () => { it("can set default policy to adaptive", () => { adaptiveRadioButton().click(); - save(); + saveCacheStrategyForm({ strategyType: "ttl", model: "root" }); adaptiveRadioButton().should("be.checked"); }); it("can configure a minimum query duration for the default adaptive policy", () => { adaptiveRadioButton().click(); cy.findByLabelText(/Minimum query duration/).type("1000"); - save(); + saveCacheStrategyForm({ strategyType: "ttl", model: "root" }); adaptiveRadioButton().should("be.checked"); cy.findByLabelText(/Minimum query duration/).should("have.value", "1000"); }); @@ -123,7 +93,7 @@ describe("scenarios > admin > performance", { tags: "@OSS" }, () => { it("can configure a multiplier for the default adaptive policy", () => { adaptiveRadioButton().click(); cy.findByLabelText(/Multiplier/).type("3"); - save(); + saveCacheStrategyForm({ strategyType: "ttl", model: "root" }); adaptiveRadioButton().should("be.checked"); cy.findByLabelText(/Multiplier/).should("have.value", "3"); }); @@ -132,7 +102,7 @@ describe("scenarios > admin > performance", { tags: "@OSS" }, () => { adaptiveRadioButton().click(); cy.findByLabelText(/Minimum query duration/).type("1234"); cy.findByLabelText(/Multiplier/).type("4"); - save(); + saveCacheStrategyForm({ strategyType: "ttl", model: "root" }); adaptiveRadioButton().should("be.checked"); cy.findByLabelText(/Minimum query duration/).should("have.value", "1234"); cy.findByLabelText(/Multiplier/).should("have.value", "4"); @@ -169,11 +139,11 @@ describeEE("EE", () => { }; it("can call cache invalidation endpoint for Sample Database", () => { - launchFormForItem("default policy", "No caching"); + openStrategyFormForDatabaseOrDefaultPolicy("default policy", "No caching"); cy.log('A "Clear cache" button is not present for the default policy'); cy.button(/Clear cache/).should("not.exist"); - launchFormForItem("Sample Database", "No caching"); + openStrategyFormForDatabaseOrDefaultPolicy("Sample Database", "No caching"); cy.log( 'A "Clear cache" button is not yet present because the database does not use a cache', ); @@ -183,7 +153,7 @@ describeEE("EE", () => { durationRadioButton().click(); cy.log("Save the caching strategy form"); - save(); + saveCacheStrategyForm({ strategyType: "duration", model: "database" }); cy.log("Now there's a 'Clear cache' button. Click it"); cy.button(/Clear cache/).click(); @@ -203,62 +173,60 @@ describeEE("EE", () => { const strategyAsString = strategy.toString().replace(/\//g, ""); it(`can configure Sample Database to use a default policy of ${strategyAsString}`, () => { cy.log(`Set default policy to ${strategy}`); - launchFormForItem("default policy", "No caching"); - radio(strategy).click(); - save(); + openStrategyFormForDatabaseOrDefaultPolicy( + "default policy", + "No caching", + ); + cacheStrategyRadioButton(strategy).click(); + saveCacheStrategyForm(); cy.log("Open strategy form for Sample Database"); - launchFormForItem("Sample Database", strategyAsString); + openStrategyFormForDatabaseOrDefaultPolicy( + "Sample Database", + strategyAsString, + ); cy.log("Set Sample Database to Duration first"); durationRadioButton().click(); - save(); + saveCacheStrategyForm({ strategyType: "duration", model: "database" }); formLauncher("Sample Database", "currently", "Duration"); cy.log("Then configure Sample Database to use the default policy"); useDefaultRadioButton().click(); - save({ expectedRoute: "@deleteCacheConfig" }); + saveCacheStrategyForm({ strategyType: "inherit", model: "database" }); formLauncher("Sample Database", "currently inheriting", strategyAsString); }); }); ["default policy", "Sample Database"].forEach(itemName => { + const model = itemName === "default policy" ? "root" : "database"; const expectedNumberOfOptions = itemName === "default policy" ? 4 : 5; it(`there are ${expectedNumberOfOptions} policy options for ${itemName}`, () => { - launchFormForItem(itemName, "No caching"); - form() + openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching"); + cacheStrategyForm() .findAllByRole("radio") .should("have.length", expectedNumberOfOptions); }); it(`can set ${itemName} to Don't cache results`, () => { - launchFormForItem(itemName, "No caching"); + openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching"); cy.log(`Set ${itemName} to Duration first`); durationRadioButton().click(); - save(); + saveCacheStrategyForm({ strategyType: "duration", model }); formLauncher(itemName, "currently", "Duration"); cy.log(`Then set ${itemName} to Don't cache results`); dontCacheResultsRadioButton().click(); - // When the default policy is set to "Don't cache results", we delete the - // policy, but when a database's policy is set to "Don't cache results", - // we set its policy to "nocache". When a database's policy is set to - // "Use default", then we delete its policy. - save({ - expectedRoute: - itemName === "default policy" - ? "@deleteCacheConfig" - : "@putCacheConfig", - }); + saveCacheStrategyForm({ strategyType: "nocache", model }); formLauncher(itemName, "currently", "No caching"); checkInheritanceIfNeeded(itemName, "No caching"); }); it(`can set ${itemName} to a duration-based cache invalidation policy`, () => { - launchFormForItem(itemName, "No caching"); + openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching"); cy.log(`Set ${itemName} to Duration`); durationRadioButton().click(); - save(); + saveCacheStrategyForm({ strategyType: "duration", model }); cy.log(`${itemName} is now set to Duration`); formLauncher(itemName, "currently", "Duration"); checkInheritanceIfNeeded(itemName, "Duration"); @@ -266,19 +234,19 @@ describeEE("EE", () => { describe("adaptive strategy", () => { beforeEach(() => { - launchFormForItem(itemName, "No caching"); + openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching"); adaptiveRadioButton().click(); }); it(`can set ${itemName} to adaptive`, () => { - save(); + saveCacheStrategyForm({ strategyType: "ttl", model }); formLauncher(itemName, "currently", "Adaptive"); checkInheritanceIfNeeded(itemName, "Adaptive"); }); it(`can configure a minimum query duration for ${itemName}'s adaptive policy`, () => { cy.findByLabelText(/Minimum query duration/).type("1000"); - save(); + saveCacheStrategyForm({ strategyType: "ttl", model }); formLauncher(itemName, "currently", "Adaptive"); cy.findByLabelText(/Minimum query duration/).should( "have.value", @@ -288,7 +256,7 @@ describeEE("EE", () => { it(`can configure a multiplier for ${itemName}'s adaptive policy`, () => { cy.findByLabelText(/Multiplier/).type("3"); - save(); + saveCacheStrategyForm({ strategyType: "ttl", model }); formLauncher(itemName, "currently", "Adaptive"); cy.findByLabelText(/Multiplier/).should("have.value", "3"); }); @@ -296,7 +264,7 @@ describeEE("EE", () => { it(`can configure both a minimum query duration and a multiplier for ${itemName}'s adaptive policy`, () => { cy.findByLabelText(/Minimum query duration/).type("1234"); cy.findByLabelText(/Multiplier/).type("4"); - save(); + saveCacheStrategyForm({ strategyType: "ttl", model }); formLauncher(itemName, "currently", "Adaptive"); cy.findByLabelText(/Minimum query duration/).should( "have.value", @@ -330,7 +298,7 @@ describeEE("EE", () => { it(`can save a new hourly schedule policy for ${itemName}`, () => { selectScheduleType("hourly"); - save(); + saveCacheStrategyForm({ strategyType: "schedule", model }); formLauncher(itemName, "currently", "Scheduled: hourly"); }); @@ -342,17 +310,12 @@ describeEE("EE", () => { cy.findAllByRole("searchbox").eq(1).click(); cy.findByRole("listbox").findByText(`${time}:00`).click(); cy.findByLabelText(amPm).next().click(); - save(); + saveCacheStrategyForm({ strategyType: "schedule", model }); formLauncher("Sample Database", "currently", "Scheduled: daily"); // reset for next iteration of loop dontCacheResultsRadioButton().click(); - save({ - expectedRoute: - itemName === "default policy" - ? "@deleteCacheConfig" - : "@putCacheConfig", - }); + saveCacheStrategyForm({ strategyType: "nocache", model }); scheduleRadioButton().click(); }); }); @@ -376,7 +339,7 @@ describeEE("EE", () => { const [hour, amPm] = time.split(" "); cy.findByRole("listbox").findByText(hour).click(); cy.findByLabelText(amPm).next().click(); - save(); + saveCacheStrategyForm({ strategyType: "schedule", model }); formLauncher(itemName, "currently", "Scheduled: weekly"); cy.findAllByRole("searchbox").then(searchBoxes => { const values = Cypress._.map( @@ -389,12 +352,7 @@ describeEE("EE", () => { // reset for next iteration of loop dontCacheResultsRadioButton().click(); - save({ - expectedRoute: - itemName === "default policy" - ? "@deleteCacheConfig" - : "@putCacheConfig", - }); + saveCacheStrategyForm({ strategyType: "nocache", model }); scheduleRadioButton().click(); }); }); diff --git a/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts b/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..93d35645efd037c442a9bfdea9ce8e37e5b85d6f --- /dev/null +++ b/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts @@ -0,0 +1,163 @@ +import _ from "underscore"; + +import { + describeEE, + popover, + restore, + setTokenFeatures, +} from "e2e/support/helpers"; +import type { ScheduleComponentType } from "metabase/components/Schedule/constants"; +import type { CacheableModel } from "metabase-types/api"; + +import { interceptRoutes } from "./helpers/e2e-performance-helpers"; +import { + cacheStrategyForm, + getScheduleComponent, + openStrategyFormForDatabaseOrDefaultPolicy, + saveCacheStrategyForm, + scheduleRadioButton, +} from "./helpers/e2e-strategy-form-helpers"; + +/** These tests check that the schedule strategy form fields (in Admin / + * Performance) send the correct cron expressions to the API. They do not check + * that configuring the schedule strategy causes the cache to be invalidated at + * the appointed time. Nor do they check that the cron expression retrieved + * from the API is displayed in the UI. */ +describeEE("scenarios > admin > performance > schedule strategy", () => { + beforeEach(() => { + restore(); + interceptRoutes(); + cy.signInAsAdmin(); + setTokenFeatures("all"); + }); + + /** An object describing the values to enter in the schedule strategy configuration form. */ + type FormValues = Partial<Record<ScheduleComponentType, string>>; + + type ScheduleTestOptions = [ + FormValues, + /** The cron expression we expect to be sent to the API. */ + string, + ]; + + // prettier-ignore + const schedules: ScheduleTestOptions[] = [ + [{}, "0 0 * * * ?"], // The default schedule is 'every hour, on the hour' + + [{ frequency: "daily" }, "0 0 8 * * ?"], // 8 am is the default time for a daily schedule + + [{ frequency: "hourly" }, "0 0 * * * ?"], // Check that switching back to hourly works + + [{ frequency: "daily", time: "9:00" }, "0 0 9 * * ?"], // AM is the default choice between AM and PM + [{ frequency: "daily", time: "9:00", amPm: "PM" }, "0 0 21 * * ?"], + [{ frequency: "daily", time: "12:00", amPm: "AM" }, "0 0 0 * * ?"], + + [ { frequency: "weekly", weekday: "Monday", time: "12:00", amPm: "AM" }, "0 0 0 ? * 2" ], + [ { frequency: "weekly", weekday: "Monday", time: "1:00", amPm: "AM" }, "0 0 1 ? * 2" ], + + [ { frequency: "weekly", weekday: "Tuesday", time: "2:00", amPm: "AM" }, "0 0 2 ? * 3" ], + [ { frequency: "weekly", weekday: "Tuesday", time: "3:00", amPm: "AM" }, "0 0 3 ? * 3" ], + + [ { frequency: "weekly", weekday: "Wednesday", time: "4:00", amPm: "AM" }, "0 0 4 ? * 4" ], + [ { frequency: "weekly", weekday: "Wednesday", time: "5:00", amPm: "AM" }, "0 0 5 ? * 4" ], + + [ { frequency: "weekly", weekday: "Thursday", time: "6:00", amPm: "AM" }, "0 0 6 ? * 5" ], + [ { frequency: "weekly", weekday: "Thursday", time: "7:00", amPm: "AM" }, "0 0 7 ? * 5" ], + + [ { frequency: "weekly", weekday: "Friday", time: "8:00", amPm: "AM" }, "0 0 8 ? * 6" ], + [ { frequency: "weekly", weekday: "Friday", time: "9:00", amPm: "AM" }, "0 0 9 ? * 6" ], + + [ { frequency: "weekly", weekday: "Saturday", time: "10:00", amPm: "AM" }, "0 0 10 ? * 7" ], + [ { frequency: "weekly", weekday: "Saturday", time: "11:00", amPm: "AM" }, "0 0 11 ? * 7" ], + + [ { frequency: "weekly", weekday: "Sunday", time: "12:00", amPm: "PM" }, "0 0 12 ? * 1" ], + [ { frequency: "weekly", weekday: "Sunday", time: "1:00", amPm: "PM" }, "0 0 13 ? * 1" ], + + [ { frequency: "monthly", frame: "first", weekdayOfMonth: "Sunday", time: "12:00", amPm: "AM" }, "0 0 0 ? * 1#1" ], + [ { frequency: "monthly", frame: "first", weekdayOfMonth: "Monday", time: "2:00", amPm: "AM" }, "0 0 2 ? * 2#1" ], + + [ { frequency: "monthly", frame: "last", weekdayOfMonth: "Tuesday", time: "12:00", amPm: "AM" }, "0 0 0 ? * 3L" ], + [ { frequency: "monthly", frame: "last", weekdayOfMonth: "Wednesday", time: "12:00", amPm: "AM" }, "0 0 0 ? * 4L" ], + + [ { frequency: "monthly", frame: "15th", time: "12:00", amPm: "AM" }, "0 0 0 15 * ?" ], + [ { frequency: "monthly", frame: "15th", time: "11:00", amPm: "PM" }, "0 0 23 15 * ?" ], + + ]; + + (["root", "database"] as CacheableModel[]).forEach(model => { + schedules.forEach(([schedule, cronExpression]) => { + it(`can set on ${model}: ${ + Object.values(schedule).join(" ") || "default values" + }, yielding a cron of ${cronExpression}`, () => { + openStrategyFormForDatabaseOrDefaultPolicy( + model === "root" ? "default policy" : "Sample Database", + "No caching", + ); + scheduleRadioButton().click(); + _.pairs(schedule).forEach(([componentType, optionToClick]) => { + if (componentType === "amPm") { + // AM/PM is a segmented control, not a select + cacheStrategyForm() + .findByLabelText("AM/PM") + .findByText(optionToClick) + .click(); + } else { + getScheduleComponent(componentType).click(); + + popover().within(() => { + cy.findByText(optionToClick).click(); + }); + } + }); + saveCacheStrategyForm().then(xhr => { + const { body } = xhr.request; + expect(body.model).to.eq(model); + expect(body.strategy.schedule).to.eq(cronExpression); + }); + cy.log( + "Ensure there are no unexpected components among the schedule strategy form fields", + ); + const expectedFieldLabels: string[] = []; + expectedFieldLabels.push("Frequency"); + switch (schedule.frequency) { + case undefined: + // This means the frequency was not changed; the default frequency (hourly) is selected + break; + case "hourly": + break; + case "daily": + expectedFieldLabels.push("Time", "AM/PM", "Your Metabase timezone"); + break; + case "weekly": + expectedFieldLabels.push( + "Day of the week", + "Time", + "AM/PM", + "Your Metabase timezone", + ); + break; + case "monthly": + expectedFieldLabels.push("First, 15th, or last of the month"); + if (schedule.frame !== "15th") { + expectedFieldLabels.push("Day of the month"); + } + expectedFieldLabels.push("Time", "AM/PM", "Your Metabase timezone"); + break; + default: + throw new Error( + `Unexpected schedule frequency: ${schedule.frequency}`, + ); + } + cacheStrategyForm() + .findByLabelText("Describe how often the cache should be invalidated") + .find("[aria-label]") + .then($labels => { + const labels = $labels + .get() + .map($el => $el.getAttribute("aria-label")); + expect(labels).to.deep.equal(expectedFieldLabels); + }); + }); + }); + }); +}); diff --git a/e2e/test/scenarios/dashboard/dashboard.cy.spec.js b/e2e/test/scenarios/dashboard/dashboard.cy.spec.js index d69b1db8ea865017ca99287c0dc75070bce692dd..b9bbdcf134cf48d5fea29129d50d79bc01f78407 100644 --- a/e2e/test/scenarios/dashboard/dashboard.cy.spec.js +++ b/e2e/test/scenarios/dashboard/dashboard.cy.spec.js @@ -58,6 +58,13 @@ import { createMockVirtualDashCard, } from "metabase-types/api/mocks"; +import { interceptRoutes as interceptPerformanceRoutes } from "../admin/performance/helpers/e2e-performance-helpers"; +import { + adaptiveRadioButton, + durationRadioButton, + openSidebarCacheStrategyForm, +} from "../admin/performance/helpers/e2e-strategy-form-helpers"; + const { ORDERS, ORDERS_ID, PRODUCTS, PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; describe("scenarios > dashboard", () => { @@ -1254,40 +1261,29 @@ describeEE("scenarios > dashboard > caching", () => { * It's in the Cypress describe block labeled "scenarios > question > caching" */ it("can configure cache for a dashboard, on an enterprise instance", () => { - cy.intercept("PUT", "/api/cache").as("putCacheConfig"); + interceptPerformanceRoutes(); visitDashboard(ORDERS_DASHBOARD_ID); - toggleDashboardInfoSidebar(); + openSidebarCacheStrategyForm(); rightSidebar().within(() => { - cy.findByText(/Caching policy/).within(() => { - cy.findByText(/Use default/).click(); - }); - cy.findByRole("heading", { name: /Caching settings/ }).click(); - cy.findByRole("radio", { name: /Duration/ }).click(); + cy.findByRole("heading", { name: /Caching settings/ }).should( + "be.visible", + ); + durationRadioButton().click(); cy.findByLabelText("Cache results for this many hours").type("48"); cy.findByRole("button", { name: /Save/ }).click(); cy.wait("@putCacheConfig"); - cy.findByText(/Caching policy/).within(() => { - cy.log( - "Check that the newly chosen cache invalidation policy - Duration - is now visible in the sidebar", - ); - const durationButton = cy.findByRole("button", { name: /Duration/ }); - durationButton.should("be.visible"); - cy.log("Open the cache invalidation policy configuration form again"); - durationButton.click(); - }); - cy.findByRole("radio", { name: /Adaptive/ }).click(); + cy.log( + "Check that the newly chosen cache invalidation policy - Duration - is now visible in the sidebar", + ); + cy.findByLabelText(/Caching policy/).should("contain", "Duration"); + cy.findByLabelText(/Caching policy/).click(); + adaptiveRadioButton().click(); cy.findByLabelText(/Minimum query duration/).type("999"); cy.findByRole("button", { name: /Save/ }).click(); cy.wait("@putCacheConfig"); - cy.findByText(/Caching policy/).within(() => { - cy.log( - "Check that the newly chosen cache invalidation policy - Adaptive - is now visible in the sidebar", - ); - const policyToken = cy.findByRole("button", { name: /Adaptive/ }); - policyToken.should("be.visible"); - }); + cy.findByLabelText(/Caching policy/).should("contain", "Adaptive"); }); }); }); diff --git a/e2e/test/scenarios/question/caching.cy.spec.js b/e2e/test/scenarios/question/caching.cy.spec.js index a6c6e5e7c2261aa65ae16ea46ca77316bb1361d0..0d37c4f4e776e6ef5acabb1d05a2e39332855d33 100644 --- a/e2e/test/scenarios/question/caching.cy.spec.js +++ b/e2e/test/scenarios/question/caching.cy.spec.js @@ -1,13 +1,19 @@ import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data"; import { describeEE, - questionInfoButton, restore, rightSidebar, setTokenFeatures, visitQuestion, } from "e2e/support/helpers"; +import { interceptRoutes as interceptPerformanceRoutes } from "../admin/performance/helpers/e2e-performance-helpers"; +import { + adaptiveRadioButton, + durationRadioButton, + openSidebarCacheStrategyForm, +} from "../admin/performance/helpers/e2e-strategy-form-helpers"; + describeEE("scenarios > question > caching", () => { beforeEach(() => { restore(); @@ -20,40 +26,32 @@ describeEE("scenarios > question > caching", () => { * It's in the Cypress describe block labeled "scenarios > dashboard > caching" */ it("can configure cache for a question, on an enterprise instance", () => { - cy.intercept("PUT", "/api/cache").as("putCacheConfig"); + interceptPerformanceRoutes(); visitQuestion(ORDERS_QUESTION_ID); - questionInfoButton().click(); + openSidebarCacheStrategyForm(); rightSidebar().within(() => { - cy.findByText(/Caching policy/).within(() => { - cy.findByRole("button", { name: /Use default/ }).click(); - }); - cy.findByRole("heading", { name: /Caching settings/ }).click(); - cy.findByRole("radio", { name: /Duration/ }).click(); + cy.findByRole("heading", { name: /Caching settings/ }).should( + "be.visible", + ); + durationRadioButton().click(); cy.findByLabelText("Cache results for this many hours").type("48"); cy.findByRole("button", { name: /Save/ }).click(); cy.wait("@putCacheConfig"); - cy.findByText(/Caching policy/).within(() => { - cy.log( - "Check that the newly chosen cache invalidation policy - Duration - is now visible in the sidebar", - ); - const durationButton = cy.findByRole("button", { name: /Duration/ }); - durationButton.should("be.visible"); - cy.log("Open the cache invalidation policy configuration form again"); - durationButton.click(); - }); - cy.findByRole("radio", { name: /Adaptive/ }).click(); + cy.log( + "Check that the newly chosen cache invalidation policy - Duration - is now visible in the sidebar", + ); + cy.findByLabelText(/Caching policy/).should("contain", "Duration"); + cy.findByLabelText(/Caching policy/).click(); + adaptiveRadioButton().click(); cy.findByLabelText(/Minimum query duration/).type("999"); cy.findByRole("button", { name: /Save/ }).click(); cy.wait("@putCacheConfig"); - cy.findByText(/Caching policy/).within(() => { - cy.log( - "Check that the newly chosen cache invalidation policy - Adaptive - is now visible in the sidebar", - ); - const policyToken = cy.findByRole("button", { name: /Adaptive/ }); - policyToken.should("be.visible"); - }); + cy.log( + "Check that the newly chosen cache invalidation policy - Adaptive - is now visible in the sidebar", + ); + cy.findByLabelText(/Caching policy/).should("contain", "Adaptive"); }); }); }); diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/SidebarCacheSection.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/SidebarCacheSection.tsx index d85e0343e22f34d0f93c5b607397f54a2c855b22..8620d94ef9d81ece12c8732da300c213a3e6a737 100644 --- a/enterprise/frontend/src/metabase-enterprise/caching/components/SidebarCacheSection.tsx +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/SidebarCacheSection.tsx @@ -33,12 +33,17 @@ export const SidebarCacheSection = ({ const shortStrategyLabel = getShortStrategyLabel(savedStrategy, model) || t`Use default`; + const labelId = "question-caching-policy-label"; return ( <DelayedLoadingAndErrorWrapper delay={0} loading={loading} error={error}> <Flex align="center" justify="space-between"> - {t`Caching policy`} - <FormLauncher role="button" onClick={() => setPage("caching")}> + <span id={labelId}>{t`Caching policy`}</span> + <FormLauncher + role="button" + onClick={() => setPage("caching")} + aria-labelledby={labelId} + > {shortStrategyLabel} </FormLauncher> </Flex> diff --git a/frontend/src/metabase/admin/performance/components/StrategyForm.tsx b/frontend/src/metabase/admin/performance/components/StrategyForm.tsx index faf9e01b16566db796d682e22dc3bdd8df0bbbfb..89a5616007f11fb425602ee2e24a66d2e8e429f5 100644 --- a/frontend/src/metabase/admin/performance/components/StrategyForm.tsx +++ b/frontend/src/metabase/admin/performance/components/StrategyForm.tsx @@ -344,6 +344,7 @@ const ScheduleStrategyFormFields = () => { onScheduleChange={onScheduleChange} verb={c("A verb in the imperative mood").t`Invalidate`} timezone={timezone} + aria-label={t`Describe how often the cache should be invalidated`} /> ); }; diff --git a/frontend/src/metabase/components/Schedule/Schedule.tsx b/frontend/src/metabase/components/Schedule/Schedule.tsx index f6ccd824791e8cbce6179e7db665d85d8ca812d9..fe48d0d1e4c2366af3e65dc15139749deff9a6b0 100644 --- a/frontend/src/metabase/components/Schedule/Schedule.tsx +++ b/frontend/src/metabase/components/Schedule/Schedule.tsx @@ -1,21 +1,24 @@ -import { type ReactNode, useCallback } from "react"; +import { type ReactNode, useCallback, type HTMLAttributes } from "react"; import { c } from "ttag"; -import { capitalize } from "metabase/lib/formatting/strings"; import { removeNullAndUndefinedValues } from "metabase/lib/types"; -import { Box } from "metabase/ui"; +import { Box, type BoxProps } from "metabase/ui"; import type { ScheduleSettings, ScheduleType } from "metabase-types/api"; import { - AutoWidthSelect, SelectFrame, SelectMinute, SelectTime, SelectWeekday, SelectWeekdayOfMonth, + SelectFrequency, } from "./components"; import { defaultDay, defaultHour, getScheduleStrings } from "./constants"; -import type { ScheduleChangeProp, UpdateSchedule } from "./types"; +import type { + ScheduleChangeProp, + UpdateSchedule, + ScheduleDefaults, +} from "./types"; import { fillScheduleTemplate, getLongestSelectLabel } from "./utils"; type ScheduleProperty = keyof ScheduleSettings; @@ -33,7 +36,7 @@ export interface ScheduleProps { minutesOnHourPicker?: boolean; } -const defaults: Record<string, Partial<ScheduleSettings>> = { +const defaults: ScheduleDefaults = { hourly: { schedule_day: null, schedule_frame: null, @@ -49,11 +52,13 @@ const defaults: Record<string, Partial<ScheduleSettings>> = { weekly: { schedule_day: defaultDay, schedule_frame: null, + schedule_hour: defaultHour, schedule_minute: 0, }, monthly: { - schedule_frame: "first", schedule_day: defaultDay, + schedule_frame: "first", + schedule_hour: defaultHour, schedule_minute: 0, }, }; @@ -65,6 +70,7 @@ export const Schedule = ({ verb, minutesOnHourPicker, onScheduleChange, + ...boxProps }: { schedule: ScheduleSettings; scheduleOptions: ScheduleType[]; @@ -75,7 +81,8 @@ export const Schedule = ({ nextSchedule: ScheduleSettings, change: ScheduleChangeProp, ) => void; -}) => { +} & BoxProps & + HTMLAttributes<HTMLDivElement>) => { const updateSchedule: UpdateSchedule = useCallback( (field: ScheduleProperty, value: ScheduleSettings[typeof field]) => { let newSchedule: ScheduleSettings = { @@ -117,6 +124,7 @@ export const Schedule = ({ gap: ".5rem", rowGap: ".35rem", }} + {...boxProps} > {renderSchedule({ fillScheduleTemplate, @@ -263,31 +271,3 @@ const renderSchedule = ({ return null; } }; - -/** A Select that changes the schedule frequency (e.g., daily, hourly, monthly, etc.), - * also known as the schedule 'type'. */ -const SelectFrequency = ({ - scheduleType, - updateSchedule, - scheduleOptions, -}: { - scheduleType?: ScheduleType | null; - updateSchedule: UpdateSchedule; - scheduleOptions: ScheduleType[]; -}) => { - const { scheduleOptionNames } = getScheduleStrings(); - - const scheduleTypeOptions = scheduleOptions.map(option => ({ - label: scheduleOptionNames[option] || capitalize(option), - value: option, - })); - - return ( - <AutoWidthSelect - display="flex" - value={scheduleType} - onChange={(value: ScheduleType) => updateSchedule("schedule_type", value)} - data={scheduleTypeOptions} - /> - ); -}; diff --git a/frontend/src/metabase/components/Schedule/components.tsx b/frontend/src/metabase/components/Schedule/components.tsx index 30f39f1f38ea434bc9b52e7cfb4cfa66076a77f3..bf115d4715f87e4cb1c4768997639583e93d3017 100644 --- a/frontend/src/metabase/components/Schedule/components.tsx +++ b/frontend/src/metabase/components/Schedule/components.tsx @@ -5,6 +5,7 @@ import { hourTo24HourFormat, hourToTwelveHourFormat, } from "metabase/admin/performance/utils"; +import { capitalize } from "metabase/lib/formatting/strings"; import { useSelector } from "metabase/lib/redux"; import { has24HourModeSetting } from "metabase/lib/time"; import { getSetting } from "metabase/selectors/settings"; @@ -16,6 +17,7 @@ import type { ScheduleDayType, ScheduleFrameType, ScheduleSettings, + ScheduleType, } from "metabase-types/api"; import { @@ -24,6 +26,7 @@ import { getHours, getScheduleStrings, minutes, + getScheduleComponentLabel, } from "./constants"; import type { UpdateSchedule } from "./types"; import { getLongestSelectLabel, measureTextWidthSafely } from "./utils"; @@ -34,11 +37,46 @@ export type SelectFrameProps = { frames?: { label: string; value: ScheduleFrameType }[]; }; +/** A Select that changes the schedule frequency (e.g., daily, hourly, monthly, etc.), + * also known as the schedule 'type'. */ +export const SelectFrequency = ({ + scheduleType, + updateSchedule, + scheduleOptions, +}: { + scheduleType?: ScheduleType | null; + updateSchedule: UpdateSchedule; + scheduleOptions: ScheduleType[]; +}) => { + const { scheduleOptionNames } = getScheduleStrings(); + + const scheduleTypeOptions = useMemo( + () => + scheduleOptions.map(option => ({ + label: scheduleOptionNames[option] || capitalize(option), + value: option, + })), + [scheduleOptions, scheduleOptionNames], + ); + + const label = useMemo(() => getScheduleComponentLabel("frequency"), []); + return ( + <AutoWidthSelect + display="flex" + value={scheduleType} + onChange={(value: ScheduleType) => updateSchedule("schedule_type", value)} + data={scheduleTypeOptions} + aria-label={label} + /> + ); +}; + export const SelectFrame = ({ schedule, updateSchedule, frames = getScheduleStrings().frames, }: SelectFrameProps) => { + const label = useMemo(() => getScheduleComponentLabel("frame"), []); return ( <AutoWidthSelect value={schedule.schedule_frame} @@ -46,6 +84,7 @@ export const SelectFrame = ({ updateSchedule("schedule_frame", value) } data={frames} + aria-label={label} /> ); }; @@ -60,7 +99,6 @@ export const SelectTime = ({ timezone?: string | null; }) => { const { amAndPM } = getScheduleStrings(); - const applicationName = useSelector(getApplicationName); const isClock12Hour = !has24HourModeSetting(); const hourIn24HourFormat = schedule.schedule_hour !== undefined && @@ -74,6 +112,10 @@ export const SelectTime = ({ const amPm = hourIn24HourFormat >= 12 ? 1 : 0; const hourIndex = isClock12Hour && hour === 12 ? 0 : hour; const value = hourIndex === 0 && isClock12Hour ? "12" : hourIndex.toString(); + const timeSelectLabel = useMemo(() => getScheduleComponentLabel("time"), []); + const amPmControlLabel = useMemo(() => getScheduleComponentLabel("amPm"), []); + const applicationName = useSelector(getApplicationName); + const timezoneTooltipText = t`Your ${applicationName} timezone`; return ( <Group spacing={isClock12Hour ? "xs" : "sm"} style={{ rowGap: ".5rem" }}> {/* Select the hour */} @@ -87,6 +129,7 @@ export const SelectTime = ({ isClock12Hour ? hourTo24HourFormat(num, amPm) : num, ); }} + aria-label={timeSelectLabel} /> {/* Choose between AM and PM */} <Group spacing="sm"> @@ -101,11 +144,17 @@ export const SelectTime = ({ ) } data={amAndPM} + aria-label={amPmControlLabel} /> )} {timezone && ( - <Tooltip label={t`Your ${applicationName} timezone`}> - <Box tabIndex={0}>{timezone}</Box> + <Tooltip label={timezoneTooltipText}> + <Box + aria-label={timezoneTooltipText} + tabIndex={0} // Ensure tooltip can be triggered by the keyboard + > + {timezone} + </Box> </Tooltip> )} </Group> @@ -123,6 +172,7 @@ export const SelectWeekday = ({ updateSchedule, }: SelectWeekdayProps) => { const { weekdays } = getScheduleStrings(); + const label = useMemo(() => getScheduleComponentLabel("weekday"), []); return ( <AutoWidthSelect value={schedule.schedule_day} @@ -130,6 +180,7 @@ export const SelectWeekday = ({ updateSchedule("schedule_day", value) } data={weekdays} + aria-label={label} /> ); }; @@ -143,13 +194,15 @@ export type SelectWeekdayOfMonthProps = { )[]; }; -/** Selects the weekday of the month, e.g. the first Monday of the month - "First" is selected via SelectFrame. This component provides the weekday */ +/** Selects the weekday of the month, such as the first Monday of the month or the last Tuesday of the month. + (The SelectFrame component offers a choice between 'first', '15th' and 'last'. + This component offers a choice of weekday.) */ export const SelectWeekdayOfMonth = ({ schedule, updateSchedule, weekdayOfMonthOptions = getScheduleStrings().weekdayOfMonthOptions, }: SelectWeekdayOfMonthProps) => { + const label = useMemo(() => getScheduleComponentLabel("weekdayOfMonth"), []); return ( <AutoWidthSelect value={schedule.schedule_day || "calendar-day"} @@ -157,6 +210,7 @@ export const SelectWeekdayOfMonth = ({ updateSchedule("schedule_day", value === "calendar-day" ? null : value) } data={weekdayOfMonthOptions} + aria-label={label} /> ); }; @@ -171,6 +225,7 @@ export const SelectMinute = ({ const minuteOfHour = isNaN(schedule.schedule_minute as number) ? 0 : schedule.schedule_minute; + const label = useMemo(() => getScheduleComponentLabel("minute"), []); return ( <AutoWidthSelect value={(minuteOfHour || 0).toString()} @@ -178,6 +233,7 @@ export const SelectMinute = ({ onChange={(value: string) => updateSchedule("schedule_minute", Number(value)) } + aria-label={label} /> ); }; diff --git a/frontend/src/metabase/components/Schedule/constants.ts b/frontend/src/metabase/components/Schedule/constants.ts index 01c1d11143e95353de8d697c6cd057c599bab9df..4c01d48a95b2ed6df2bfd9f42c75262cb7de6104 100644 --- a/frontend/src/metabase/components/Schedule/constants.ts +++ b/frontend/src/metabase/components/Schedule/constants.ts @@ -153,3 +153,27 @@ export enum Cron { NoSpecificValue = "?", NoSpecificValue_Escaped = "\\?", } + +export type ScheduleComponentType = + | "frequency" + | "frame" + | "weekdayOfMonth" + | "weekday" + | "time" + | "amPm" + | "minute"; + +export const getScheduleComponentLabel = ( + componentType: ScheduleComponentType, +) => { + const map: Record<ScheduleComponentType, string> = { + frequency: t`Frequency`, + frame: t`First, 15th, or last of the month`, + weekdayOfMonth: t`Day of the month`, + weekday: t`Day of the week`, + time: t`Time`, + amPm: t`AM/PM`, + minute: t`Minute`, + }; + return map[componentType]; +}; diff --git a/frontend/src/metabase/components/Schedule/types.ts b/frontend/src/metabase/components/Schedule/types.ts index 9196ae660a48ac6bc3ef6fddf691495747322872..02f1a7e8c35284be5dcb9521a981c22d4466766f 100644 --- a/frontend/src/metabase/components/Schedule/types.ts +++ b/frontend/src/metabase/components/Schedule/types.ts @@ -1,4 +1,4 @@ -import type { ScheduleSettings } from "metabase-types/api"; +import type { ScheduleSettings, ScheduleType } from "metabase-types/api"; type ScheduleProperty = keyof ScheduleSettings; export type ScheduleChangeProp = { name: ScheduleProperty; value: unknown }; @@ -7,3 +7,11 @@ export type UpdateSchedule = ( field: ScheduleProperty, value: ScheduleSettings[typeof field], ) => void; + +/** A default schedule should assign a value or null to + * each property of ScheduleSettings + * */ +export type ScheduleDefaults = Record< + ScheduleType, + Required<Omit<ScheduleSettings, "schedule_type">> +>;