From af24dd91105f3f025aa547c6ccfadc7e9bddc24e Mon Sep 17 00:00:00 2001 From: Raphael Krut-Landau <raphael.kl@gmail.com> Date: Fri, 23 Aug 2024 14:57:54 -0400 Subject: [PATCH] feat(admin/performance): In Admin / Performance, add a tab where you can manage the caching policies of dashboards and questions (#42990) Closes #42567 --- docs/configuring-metabase/caching.md | 4 +- .../helpers/e2e-performance-helpers.ts | 15 +- .../helpers/e2e-strategy-form-helpers.ts | 18 +- .../admin/performance/performance.cy.spec.ts | 705 +++++++++++------- .../admin/performance/schedule.cy.spec.ts | 4 +- .../scenarios/dashboard/dashboard.cy.spec.js | 2 +- .../scenarios/question/caching.cy.spec.js | 2 +- .../DashboardAndQuestionCachingTab.tsx | 16 + ...EditorForQuestionsAndDashboards.module.css | 60 ++ ...trategyEditorForQuestionsAndDashboards.tsx | 374 ++++++++++ .../TableRowForCacheableItem.styled.tsx | 7 + .../TableRowForCacheableItem.tsx | 101 +++ .../constants.tsx | 15 + .../utils.tsx | 30 + .../utils.unit.spec.tsx | 174 +++++ .../caching/components/types.tsx | 26 + .../caching/components/utils.tsx | 11 + .../metabase-enterprise/caching/constants.ts | 27 +- .../src/metabase-enterprise/caching/index.tsx | 12 +- .../UploadManagementTable.tsx | 3 + frontend/src/metabase-types/api/search.ts | 1 + frontend/src/metabase-types/store/admin.ts | 5 +- .../ModelPersistenceConfiguration.module.css | 4 + .../ModelPersistenceConfiguration.tsx | 12 +- ...p.styled.tsx => PerformanceApp.module.css} | 38 +- .../performance/components/PerformanceApp.tsx | 62 +- .../components/StrategyEditorForDatabases.tsx | 2 +- .../admin/performance/constants/complex.ts | 22 +- .../performance/hooks/useSaveStrategy.tsx | 4 +- .../src/metabase/admin/performance/types.ts | 1 + .../src/metabase/admin/performance/utils.tsx | 7 +- frontend/src/metabase/admin/routes.jsx | 23 +- .../components/ApiKeys/ManageApiKeys.tsx | 3 + .../browse/components/ModelsTable.tsx | 16 +- .../metabase/browse/components/constants.tsx | 1 - .../src/metabase/browse/components/utils.tsx | 18 +- .../browse/components/utils.unit.spec.tsx | 32 +- frontend/src/metabase/browse/types.tsx | 6 - frontend/src/metabase/collections/utils.ts | 17 + .../metabase/collections/utils.unit.spec.ts | 27 + .../EllipsifiedCollectionPath.tsx | 21 + .../components/Table/ClientSortableTable.tsx | 69 +- .../common/components/Table/Table.tsx | 61 +- .../components/Table/Table.unit.spec.tsx | 13 +- .../metabase/common/components/Table/types.ts | 7 + .../components/Table/useTableSorting.tsx | 65 ++ .../src/metabase/common/components/types.ts | 5 + .../ResponsiveContainer.tsx | 4 +- .../metabase/components/Schedule/constants.ts | 4 +- .../palette/hooks/useCommandPalette.tsx | 10 +- frontend/src/metabase/plugins/index.ts | 8 +- 51 files changed, 1676 insertions(+), 498 deletions(-) create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/DashboardAndQuestionCachingTab.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.module.css create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.styled.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/constants.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.unit.spec.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/types.tsx create mode 100644 frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.module.css rename frontend/src/metabase/admin/performance/components/{PerformanceApp.styled.tsx => PerformanceApp.module.css} (57%) delete mode 100644 frontend/src/metabase/browse/components/constants.tsx create mode 100644 frontend/src/metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath.tsx create mode 100644 frontend/src/metabase/common/components/Table/types.ts create mode 100644 frontend/src/metabase/common/components/Table/useTableSorting.tsx create mode 100644 frontend/src/metabase/common/components/types.ts diff --git a/docs/configuring-metabase/caching.md b/docs/configuring-metabase/caching.md index 36edaa0a1a5..d9e24f00f20 100644 --- a/docs/configuring-metabase/caching.md +++ b/docs/configuring-metabase/caching.md @@ -83,7 +83,7 @@ _* Denotes [Pro](https://www.metabase.com/product/pro) and [Enterprise](https:// ### Default caching policy -To set a default caching policy for your Metabase: Hit Cmd/Ctrl + k to bring up the command palette and search for **Performance**. Or, click through **Gear** settings icon > **Admin settings** > **Performance** > **Database caching settings**. +To set a default caching policy for your Metabase: Hit Cmd/Ctrl + k to bring up the command palette and search for **Performance**. Or, click through **Gear** settings icon > **Admin settings** > **Performance** > **Database caching**. Click on the button next to **Default policy**, and select a [cache invalidation policy](#cache-invalidation-policies). @@ -137,7 +137,7 @@ A question policy overrides a dashboard policy, which overrides a database polic To clear the cache and refresh the results: - **Questions and dashboards**: Visit the item and click through the **Info > Caching policy > Clear cache** (the "Clear cache" button is at the bottom of the sidebar). -- **Database**: Click the **Gear** icon and click through **Admin settings** > **Performance** > **Database caching settings**. Select your database and click the **Clear cache** button (at the bottom of the page). +- **Database**: Click the **Gear** icon and click through **Admin settings** > **Performance** > **Database caching**. Select your database and click the **Clear cache** button (at the bottom of the page). ## Caching location diff --git a/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts b/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts index aedde7d6368..210f2b8bb9b 100644 --- a/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts +++ b/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts @@ -6,7 +6,7 @@ dayjs.extend(duration); dayjs.extend(relativeTime); /** Intercept routes for caching tests */ -export const interceptRoutes = () => { +export const interceptPerformanceRoutes = () => { cy.intercept("POST", "/api/dataset").as("dataset"); cy.intercept("POST", "/api/card/*/query").as("cardQuery"); cy.intercept("PUT", "/api/cache").as("putCacheConfig"); @@ -28,5 +28,14 @@ export const log = (message: string) => { console.log(message); }; -export const databaseCachingSettingsPage = () => - cy.findByRole("main", { name: "Database caching settings" }); +export const databaseCachingPage = () => + cy.findByRole("tabpanel", { name: "Database caching" }); + +export const visitDashboardAndQuestionCachingTab = () => { + cy.visit("/admin/performance"); + cy.findByRole("tablist") + .get("[aria-selected]") + .contains("Database caching") + .should("be.visible"); + cy.findByRole("tab", { name: "Dashboard and question caching" }).click(); +}; 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 index 34fbd3e6ad0..f85d672c5a3 100644 --- a/e2e/test/scenarios/admin/performance/helpers/e2e-strategy-form-helpers.ts +++ b/e2e/test/scenarios/admin/performance/helpers/e2e-strategy-form-helpers.ts @@ -1,10 +1,11 @@ +import { modal } from "e2e/support/helpers"; import { type ScheduleComponentType, getScheduleComponentLabel, } from "metabase/components/Schedule/constants"; import type { CacheStrategyType, CacheableModel } from "metabase-types/api"; -import { databaseCachingSettingsPage } from "./e2e-performance-helpers"; +import { databaseCachingPage } from "./e2e-performance-helpers"; /** Save the cache strategy form and wait for a response from the relevant endpoint */ export const saveCacheStrategyForm = (options?: { @@ -29,7 +30,7 @@ export const saveCacheStrategyForm = (options?: { }; export const cacheStrategyForm = () => - cy.findByLabelText("Select the cache invalidation policy"); + cy.findByRole("form", { name: "Select the cache invalidation policy" }); export const cacheStrategyRadioButton = (name: RegExp) => cacheStrategyForm().findByRole("radio", { name }); @@ -50,21 +51,21 @@ export const formLauncher = ( | "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); + const launcher = databaseCachingPage().findByLabelText(regExp); launcher.should("exist"); return launcher; }; +/** Opens the strategy form on 'Database caching' tab */ 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.findByRole("tablist").get("[aria-selected]").contains("Database caching"); cy.log(`Open strategy form for ${databaseNameOrDefaultPolicy}`); formLauncher( databaseNameOrDefaultPolicy, @@ -91,3 +92,10 @@ export const openSidebarCacheStrategyForm = () => { cy.wait("@getCacheConfig"); cy.findByLabelText("Caching policy").click(); }; + +export const cancelConfirmationModal = () => { + modal().within(() => { + cy.findByText("Discard your changes?"); + cy.button("Cancel").click(); + }); +}; diff --git a/e2e/test/scenarios/admin/performance/performance.cy.spec.ts b/e2e/test/scenarios/admin/performance/performance.cy.spec.ts index 6a25467e6d2..5a4c231da48 100644 --- a/e2e/test/scenarios/admin/performance/performance.cy.spec.ts +++ b/e2e/test/scenarios/admin/performance/performance.cy.spec.ts @@ -1,317 +1,406 @@ -import { describeEE, restore, setTokenFeatures } from "e2e/support/helpers"; +import { + ORDERS_COUNT_QUESTION_ID, + ORDERS_QUESTION_ID, +} from "e2e/support/cypress_sample_instance_data"; +import { + describeEE, + restore, + setTokenFeatures, + visitQuestion, +} from "e2e/support/helpers"; +import { + interceptPerformanceRoutes, + visitDashboardAndQuestionCachingTab, +} from "./helpers/e2e-performance-helpers"; import { adaptiveRadioButton, cacheStrategyForm, cacheStrategyRadioButton, + cancelConfirmationModal, dontCacheResultsRadioButton, durationRadioButton, formLauncher, + openSidebarCacheStrategyForm, openStrategyFormForDatabaseOrDefaultPolicy, saveCacheStrategyForm, scheduleRadioButton, useDefaultRadioButton, } 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. - -describe("scenarios > admin > performance", { tags: "@OSS" }, () => { - beforeEach(() => { - restore(); - cy.intercept("PUT", "/api/cache").as("putCacheConfig"); - cy.intercept("DELETE", "/api/cache").as("deleteCacheConfig"); - cy.intercept("POST", "/api/persist/enable").as("enablePersistence"); - cy.intercept("POST", "/api/persist/disable").as("disablePersistence"); - cy.signInAsAdmin(); - - cy.visit("/admin"); - cy.findByRole("link", { name: "Performance" }).click(); - }); - - it("can enable and disable model persistence", () => { - cy.findByRole("tab", { name: "Model persistence" }).click(); - cy.findByRole("checkbox", { name: "Disabled" }).next("label").click(); - cy.wait("@enablePersistence"); - cy.findByTestId("toast-undo").contains("Saved"); - cy.findByTestId("toast-undo") - .findByRole("img", { name: /close icon/ }) - .click(); - - cy.findByRole("checkbox", { name: "Enabled" }).next("label").click(); - cy.wait("@disablePersistence"); - cy.findByTestId("toast-undo").contains("Saved"); - }); - - it("can change when models are refreshed", () => { - cy.findByRole("tab", { name: "Model persistence" }).click(); - cy.findByRole("checkbox", { name: "Disabled" }).next("label").click(); - cy.wait("@enablePersistence"); - cy.findByTestId("toast-undo").contains("Saved"); - cy.findByTestId("toast-undo") - .findByRole("img", { name: /close icon/ }) - .click(); - cy.findByRole("combobox").click(); - cy.findByRole("listbox").findByText("2 hours").click(); - cy.findByTestId("toast-undo").contains("Saved"); - }); - - it("there are two policy options for the default policy, Adaptive and Don't cache results", () => { - 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(); - saveCacheStrategyForm({ strategyType: "ttl", model }); - adaptiveRadioButton().should("be.checked"); - - cy.log("Then set default policy to Don't cache results"); - dontCacheResultsRadioButton().click(); - saveCacheStrategyForm({ strategyType: "nocache", model }); - dontCacheResultsRadioButton().should("be.checked"); - }); +/** NOTE: These do not test whether caches are actually invalidated at the specified times. */ +describe("scenarios > admin > performance", () => { + describe("oss", { tags: "@OSS" }, () => { + beforeEach(() => { + restore(); + interceptPerformanceRoutes(); + cy.signInAsAdmin(); - describe("adaptive strategy", () => { - it("can set default policy to adaptive", () => { - adaptiveRadioButton().click(); - saveCacheStrategyForm({ strategyType: "ttl", model: "root" }); - adaptiveRadioButton().should("be.checked"); + cy.visit("/admin"); + cy.findByRole("link", { name: "Performance" }).click(); }); - - it("can configure a minimum query duration for the default adaptive policy", () => { - adaptiveRadioButton().click(); - cy.findByLabelText(/Minimum query duration/).type("1000"); - saveCacheStrategyForm({ strategyType: "ttl", model: "root" }); - adaptiveRadioButton().should("be.checked"); - cy.findByLabelText(/Minimum query duration/).should("have.value", "1000"); + it("has the right tabs", () => { + cy.findByRole("main") + .findByRole("tablist") + .findAllByRole("tab") + .should("have.length", 2); + cy.findByRole("tab", { name: "Database caching" }).should("be.visible"); + cy.findByRole("tab", { name: "Model persistence" }).should("be.visible"); + cy.findByRole("tab", { name: "Dashboard and question caching" }).should( + "not.exist", + ); }); - it("can configure a multiplier for the default adaptive policy", () => { - adaptiveRadioButton().click(); - cy.findByLabelText(/Multiplier/).type("3"); - saveCacheStrategyForm({ strategyType: "ttl", model: "root" }); - adaptiveRadioButton().should("be.checked"); - cy.findByLabelText(/Multiplier/).should("have.value", "3"); - }); + describe("model persistence tab", () => { + it("can enable and disable model persistence", () => { + cy.findByRole("tab", { name: "Model persistence" }).click(); + cy.findByRole("checkbox", { name: "Disabled" }).next("label").click(); + cy.wait("@enablePersistence"); + cy.findByTestId("toast-undo").contains("Saved"); + cy.findByTestId("toast-undo") + .findByRole("img", { name: /close icon/ }) + .click(); + + cy.findByRole("checkbox", { name: "Enabled" }).next("label").click(); + cy.wait("@disablePersistence"); + cy.findByTestId("toast-undo").contains("Saved"); + }); - it("can configure both a minimum query duration and a multiplier for the default adaptive policy", () => { - adaptiveRadioButton().click(); - cy.findByLabelText(/Minimum query duration/).type("1234"); - cy.findByLabelText(/Multiplier/).type("4"); - 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"); + it("can change when models are refreshed", () => { + cy.findByRole("tab", { name: "Model persistence" }).click(); + cy.findByRole("checkbox", { name: "Disabled" }).next("label").click(); + cy.wait("@enablePersistence"); + cy.findByTestId("toast-undo").contains("Saved"); + cy.findByTestId("toast-undo") + .findByRole("img", { name: /close icon/ }) + .click(); + cy.findByRole("combobox").click(); + cy.findByRole("listbox").findByText("2 hours").click(); + cy.findByTestId("toast-undo").contains("Saved"); + }); }); - }); -}); -describeEE("EE", () => { - beforeEach(() => { - restore(); - cy.intercept("PUT", "/api/cache").as("putCacheConfig"); - cy.intercept("DELETE", "/api/cache").as("deleteCacheConfig"); - cy.intercept( - "POST", - "/api/cache/invalidate?include=overrides&database=1", - ).as("invalidateCacheForSampleDatabase"); - cy.intercept("POST", "/api/persist/enable").as("enablePersistence"); - cy.intercept("POST", "/api/persist/disable").as("disablePersistence"); - cy.signInAsAdmin(); - setTokenFeatures("all"); - }); - - const checkInheritanceIfNeeded = (itemName: string, strategyName: string) => { - if (itemName === "default policy") { - cy.log( - `Sample Database is now inheriting a default policy of ${strategyName}`, - ); - formLauncher( - "Sample Database", - "currently inheriting the default policy", - strategyName, - ); - } - }; + describe("database caching tab", () => { + it("there are two policy options for the default policy, Adaptive and Don't cache results", () => { + cacheStrategyForm().findAllByRole("radio").should("have.length", 2); + adaptiveRadioButton().should("exist"); + dontCacheResultsRadioButton().should("exist"); + }); - it("can call cache invalidation endpoint for Sample Database", () => { - 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"); + it("can set default policy to Don't cache results", () => { + const model = "root"; + cy.log("Set default policy to Adaptive first"); + adaptiveRadioButton().click(); + saveCacheStrategyForm({ strategyType: "ttl", model }); + adaptiveRadioButton().should("be.checked"); - openStrategyFormForDatabaseOrDefaultPolicy("Sample Database", "No caching"); - cy.log( - 'A "Clear cache" button is not yet present because the database does not use a cache', - ); - cy.button(/Clear cache/).should("not.exist"); + cy.log("Then set default policy to Don't cache results"); + dontCacheResultsRadioButton().click(); + saveCacheStrategyForm({ strategyType: "nocache", model }); + dontCacheResultsRadioButton().should("be.checked"); + }); - cy.log("Set Sample Database's caching policy to Duration"); - durationRadioButton().click(); + describe("adaptive strategy", () => { + it("can set default policy to adaptive", () => { + adaptiveRadioButton().click(); + saveCacheStrategyForm({ strategyType: "ttl", model: "root" }); + adaptiveRadioButton().should("be.checked"); + }); - cy.log("Save the caching strategy form"); - saveCacheStrategyForm({ strategyType: "duration", model: "database" }); + it("can configure a minimum query duration for the default adaptive policy", () => { + adaptiveRadioButton().click(); + cy.findByLabelText(/Minimum query duration/).type("1000"); + saveCacheStrategyForm({ strategyType: "ttl", model: "root" }); + adaptiveRadioButton().should("be.checked"); + cy.findByLabelText(/Minimum query duration/).should( + "have.value", + "1000", + ); + }); - cy.log("Now there's a 'Clear cache' button. Click it"); - cy.button(/Clear cache/).click(); + it("can configure a multiplier for the default adaptive policy", () => { + adaptiveRadioButton().click(); + cy.findByLabelText(/Multiplier/).type("3"); + saveCacheStrategyForm({ strategyType: "ttl", model: "root" }); + adaptiveRadioButton().should("be.checked"); + cy.findByLabelText(/Multiplier/).should("have.value", "3"); + }); - cy.log('Confirm via the "Clear cache" dialog'); - cy.findByRole("dialog") - .button(/Clear cache/) - .click(); + it("can configure both a minimum query duration and a multiplier for the default adaptive policy", () => { + adaptiveRadioButton().click(); + cy.findByLabelText(/Minimum query duration/).type("1234"); + cy.findByLabelText(/Multiplier/).type("4"); + 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"); + }); + }); + }); + }); - cy.wait("@invalidateCacheForSampleDatabase"); + describeEE("ee", () => { + beforeEach(() => { + restore(); + interceptPerformanceRoutes(); + cy.signInAsAdmin(); + setTokenFeatures("all"); + }); - cy.log("The cache has been cleared"); - cy.button(/Cache cleared/); - }); + const checkInheritanceIfNeeded = ( + itemName: string, + strategyName: string, + ) => { + if (itemName === "default policy") { + cy.log( + `Sample Database is now inheriting a default policy of ${strategyName}`, + ); + formLauncher( + "Sample Database", + "currently inheriting the default policy", + strategyName, + ); + } + }; + + it("has the right tabs", () => { + cy.visit("/admin"); + cy.findByRole("link", { name: "Performance" }).click(); + cy.findByRole("main") + .findByRole("tablist") + .findAllByRole("tab") + .should("have.length", 3); + cy.findByRole("tab", { name: "Database caching" }).should("be.visible"); + cy.findByRole("tab", { name: "Dashboard and question caching" }).should( + "be.visible", + ); + cy.findByRole("tab", { name: "Model persistence" }).should("be.visible"); + }); - [/Duration/, /Schedule/, /Adaptive/].forEach(strategy => { - 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}`); + it("can call cache invalidation endpoint for Sample Database", () => { openStrategyFormForDatabaseOrDefaultPolicy( "default policy", "No caching", ); - cacheStrategyRadioButton(strategy).click(); - saveCacheStrategyForm(); + cy.log('A "Clear cache" button is not present for the default policy'); + cy.button(/Clear cache/).should("not.exist"); - cy.log("Open strategy form for Sample Database"); openStrategyFormForDatabaseOrDefaultPolicy( "Sample Database", - strategyAsString, + "No caching", ); + cy.log( + 'A "Clear cache" button is not yet present because the database does not use a cache', + ); + cy.button(/Clear cache/).should("not.exist"); - cy.log("Set Sample Database to Duration first"); + cy.log("Set Sample Database's caching policy to Duration"); durationRadioButton().click(); + + cy.log("Save the caching strategy form"); saveCacheStrategyForm({ strategyType: "duration", model: "database" }); - formLauncher("Sample Database", "currently", "Duration"); - cy.log("Then configure Sample Database to use the default policy"); - useDefaultRadioButton().click(); - saveCacheStrategyForm({ strategyType: "inherit", model: "database" }); - formLauncher("Sample Database", "currently inheriting", strategyAsString); - }); - }); + cy.log("Now there's a 'Clear cache' button. Click it"); + cy.button(/Clear cache/).click(); - ["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}`, () => { - openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching"); - cacheStrategyForm() - .findAllByRole("radio") - .should("have.length", expectedNumberOfOptions); - }); + cy.log('Confirm via the "Clear cache" dialog'); + cy.findByRole("dialog") + .button(/Clear cache/) + .click(); - it(`can set ${itemName} to Don't cache results`, () => { - openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching"); - cy.log(`Set ${itemName} to Duration first`); - durationRadioButton().click(); - saveCacheStrategyForm({ strategyType: "duration", model }); - formLauncher(itemName, "currently", "Duration"); - - cy.log(`Then set ${itemName} to Don't cache results`); - dontCacheResultsRadioButton().click(); - saveCacheStrategyForm({ strategyType: "nocache", model }); - formLauncher(itemName, "currently", "No caching"); - checkInheritanceIfNeeded(itemName, "No caching"); - }); + cy.wait("@invalidateCacheForSampleDatabase"); - it(`can set ${itemName} to a duration-based cache invalidation policy`, () => { - openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching"); - cy.log(`Set ${itemName} to Duration`); - durationRadioButton().click(); - saveCacheStrategyForm({ strategyType: "duration", model }); - cy.log(`${itemName} is now set to Duration`); - formLauncher(itemName, "currently", "Duration"); - checkInheritanceIfNeeded(itemName, "Duration"); + cy.log("The cache has been cleared"); + cy.button(/Cache cleared/); }); - describe("adaptive strategy", () => { - beforeEach(() => { - openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching"); - adaptiveRadioButton().click(); - }); + [/Duration/, /Schedule/, /Adaptive/].forEach(strategy => { + 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}`); + openStrategyFormForDatabaseOrDefaultPolicy( + "default policy", + "No caching", + ); + cacheStrategyRadioButton(strategy).click(); + saveCacheStrategyForm(); - it(`can set ${itemName} to adaptive`, () => { - saveCacheStrategyForm({ strategyType: "ttl", model }); - formLauncher(itemName, "currently", "Adaptive"); - checkInheritanceIfNeeded(itemName, "Adaptive"); - }); + cy.log("Open strategy form for Sample Database"); + openStrategyFormForDatabaseOrDefaultPolicy( + "Sample Database", + strategyAsString, + ); - it(`can configure a minimum query duration for ${itemName}'s adaptive policy`, () => { - cy.findByLabelText(/Minimum query duration/).type("1000"); - saveCacheStrategyForm({ strategyType: "ttl", model }); - formLauncher(itemName, "currently", "Adaptive"); - cy.findByLabelText(/Minimum query duration/).should( - "have.value", - "1000", + cy.log("Set Sample Database to Duration first"); + durationRadioButton().click(); + saveCacheStrategyForm({ strategyType: "duration", model: "database" }); + formLauncher("Sample Database", "currently", "Duration"); + + cy.log("Then configure Sample Database to use the default policy"); + useDefaultRadioButton().click(); + saveCacheStrategyForm({ strategyType: "inherit", model: "database" }); + formLauncher( + "Sample Database", + "currently inheriting", + strategyAsString, ); }); + }); - it(`can configure a multiplier for ${itemName}'s adaptive policy`, () => { - cy.findByLabelText(/Multiplier/).type("3"); - saveCacheStrategyForm({ strategyType: "ttl", model }); - formLauncher(itemName, "currently", "Adaptive"); - cy.findByLabelText(/Multiplier/).should("have.value", "3"); + ["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}`, () => { + openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching"); + cacheStrategyForm() + .findAllByRole("radio") + .should("have.length", expectedNumberOfOptions); }); - 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"); - saveCacheStrategyForm({ strategyType: "ttl", model }); - formLauncher(itemName, "currently", "Adaptive"); - cy.findByLabelText(/Minimum query duration/).should( - "have.value", - "1234", - ); - cy.findByLabelText(/Multiplier/).should("have.value", "4"); + it(`can set ${itemName} to Don't cache results`, () => { + openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching"); + cy.log(`Set ${itemName} to Duration first`); + durationRadioButton().click(); + saveCacheStrategyForm({ strategyType: "duration", model }); + formLauncher(itemName, "currently", "Duration"); + + cy.log(`Then set ${itemName} to Don't cache results`); + dontCacheResultsRadioButton().click(); + saveCacheStrategyForm({ strategyType: "nocache", model }); + formLauncher(itemName, "currently", "No caching"); + checkInheritanceIfNeeded(itemName, "No caching"); }); - }); - describe(`can set ${itemName} to a schedule-based cache invalidation policy`, () => { - beforeEach(() => { - cy.visit("/admin"); - cy.findByRole("link", { name: "Performance" }).click(); - cy.log(`Open caching strategy form for ${itemName}`); - formLauncher( - itemName, - itemName === "default policy" - ? "currently" - : "currently inheriting the default policy", - "No caching", - ).click(); - cy.log("View schedule options"); - scheduleRadioButton().click(); + it(`can set ${itemName} to a duration-based cache invalidation policy`, () => { + openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching"); + cy.log(`Set ${itemName} to Duration`); + durationRadioButton().click(); + saveCacheStrategyForm({ strategyType: "duration", model }); + cy.log(`${itemName} is now set to Duration`); + formLauncher(itemName, "currently", "Duration"); + checkInheritanceIfNeeded(itemName, "Duration"); }); - const selectScheduleType = (type: string) => { - cy.log(`Set schedule to "${type}"`); - cy.findByRole("searchbox").click(); - cy.findByRole("listbox").findByText(type).click(); - }; + describe("adaptive strategy", () => { + beforeEach(() => { + openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching"); + adaptiveRadioButton().click(); + }); + + it(`can set ${itemName} to adaptive`, () => { + 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"); + saveCacheStrategyForm({ strategyType: "ttl", model }); + formLauncher(itemName, "currently", "Adaptive"); + cy.findByLabelText(/Minimum query duration/).should( + "have.value", + "1000", + ); + }); + + it(`can configure a multiplier for ${itemName}'s adaptive policy`, () => { + cy.findByLabelText(/Multiplier/).type("3"); + saveCacheStrategyForm({ strategyType: "ttl", model }); + formLauncher(itemName, "currently", "Adaptive"); + cy.findByLabelText(/Multiplier/).should("have.value", "3"); + }); - it(`can save a new hourly schedule policy for ${itemName}`, () => { - selectScheduleType("hourly"); - saveCacheStrategyForm({ strategyType: "schedule", model }); - formLauncher(itemName, "currently", "Scheduled: hourly"); + 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"); + saveCacheStrategyForm({ strategyType: "ttl", model }); + formLauncher(itemName, "currently", "Adaptive"); + cy.findByLabelText(/Minimum query duration/).should( + "have.value", + "1234", + ); + cy.findByLabelText(/Multiplier/).should("have.value", "4"); + }); }); - it(`can save a new daily schedule policy - for ${itemName}`, () => { - [12, 1, 11].forEach(time => { - ["AM", "PM"].forEach(amPm => { - cy.log(`Test daily at ${time} ${amPm}`); - selectScheduleType("daily"); + describe(`can set ${itemName} to a schedule-based cache invalidation policy`, () => { + beforeEach(() => { + cy.visit("/admin"); + cy.findByRole("link", { name: "Performance" }).click(); + cy.log(`Open caching strategy form for ${itemName}`); + formLauncher( + itemName, + itemName === "default policy" + ? "currently" + : "currently inheriting the default policy", + "No caching", + ).click(); + cy.log("View schedule options"); + scheduleRadioButton().click(); + }); + + const selectScheduleType = (type: string) => { + cy.log(`Set schedule to "${type}"`); + cy.findByRole("searchbox").click(); + cy.findByRole("listbox").findByText(type).click(); + }; + + it(`can save a new hourly schedule policy for ${itemName}`, () => { + selectScheduleType("hourly"); + saveCacheStrategyForm({ strategyType: "schedule", model }); + formLauncher(itemName, "currently", "Scheduled: hourly"); + }); + + it(`can save a new daily schedule policy - for ${itemName}`, () => { + [12, 1, 11].forEach(time => { + ["AM", "PM"].forEach(amPm => { + cy.log(`Test daily at ${time} ${amPm}`); + selectScheduleType("daily"); + cy.findAllByRole("searchbox").eq(1).click(); + cy.findByRole("listbox").findByText(`${time}:00`).click(); + cy.findByLabelText(amPm).next().click(); + saveCacheStrategyForm({ strategyType: "schedule", model }); + formLauncher("Sample Database", "currently", "Scheduled: daily"); + + // reset for next iteration of loop + dontCacheResultsRadioButton().click(); + saveCacheStrategyForm({ strategyType: "nocache", model }); + scheduleRadioButton().click(); + }); + }); + }); + + it(`can save a new weekly schedule policy - for ${itemName}`, () => { + [ + ["Sunday", "12:00 AM"], + ["Monday", "1:00 AM"], + ["Tuesday", "11:00 AM"], + ["Wednesday", "12:00 PM"], + ["Thursday", "1:00 PM"], + ["Friday", "7:00 PM"], + ["Saturday", "11:00 PM"], + ].forEach(([day, time]) => { + cy.log(`testing on ${day} at ${time}`); + selectScheduleType("weekly"); cy.findAllByRole("searchbox").eq(1).click(); - cy.findByRole("listbox").findByText(`${time}:00`).click(); + cy.findByRole("listbox").findByText(day).click(); + cy.findAllByRole("searchbox").eq(2).click(); + const [hour, amPm] = time.split(" "); + cy.findByRole("listbox").findByText(hour).click(); cy.findByLabelText(amPm).next().click(); saveCacheStrategyForm({ strategyType: "schedule", model }); - formLauncher("Sample Database", "currently", "Scheduled: daily"); + formLauncher(itemName, "currently", "Scheduled: weekly"); + cy.findAllByRole("searchbox").then(searchBoxes => { + const values = Cypress._.map( + searchBoxes, + box => (box as HTMLInputElement).value, + ); + expect(values).to.deep.equal(["weekly", day, hour]); + }); + cy.findByRole("radio", { name: amPm }).should("be.checked"); // reset for next iteration of loop dontCacheResultsRadioButton().click(); @@ -320,41 +409,95 @@ describeEE("EE", () => { }); }); }); + }); - it(`can save a new weekly schedule policy - for ${itemName}`, () => { - [ - ["Sunday", "12:00 AM"], - ["Monday", "1:00 AM"], - ["Tuesday", "11:00 AM"], - ["Wednesday", "12:00 PM"], - ["Thursday", "1:00 PM"], - ["Friday", "7:00 PM"], - ["Saturday", "11:00 PM"], - ].forEach(([day, time]) => { - cy.log(`testing on ${day} at ${time}`); - selectScheduleType("weekly"); - cy.findAllByRole("searchbox").eq(1).click(); - cy.findByRole("listbox").findByText(day).click(); - cy.findAllByRole("searchbox").eq(2).click(); - const [hour, amPm] = time.split(" "); - cy.findByRole("listbox").findByText(hour).click(); - cy.findByLabelText(amPm).next().click(); - saveCacheStrategyForm({ strategyType: "schedule", model }); - formLauncher(itemName, "currently", "Scheduled: weekly"); - cy.findAllByRole("searchbox").then(searchBoxes => { - const values = Cypress._.map( - searchBoxes, - box => (box as HTMLInputElement).value, - ); - expect(values).to.deep.equal(["weekly", day, hour]); + describe("Dashboard and question caching tab", () => { + it("can configure Sample Database on the 'Dashboard and question caching' tab", () => { + interceptPerformanceRoutes(); + visitDashboardAndQuestionCachingTab(); + const table = () => + cy.findByRole("table", { + name: /Here are the dashboards and questions/, + }); + table() + .should("be.visible") + .contains( + "No dashboards or questions have their own caching policies yet.", + ); + visitQuestion(ORDERS_QUESTION_ID); + openSidebarCacheStrategyForm(); + durationRadioButton().click(); + cy.findByLabelText(/Cache results for this many hours/).type("99"); + saveCacheStrategyForm({ strategyType: "duration", model: "database" }); + visitDashboardAndQuestionCachingTab(); + table() + .contains("Duration: 99h") + .within(() => { + cy.findByText("Duration: 99h").click(); }); - cy.findByRole("radio", { name: amPm }).should("be.checked"); + adaptiveRadioButton().click(); + saveCacheStrategyForm({ strategyType: "ttl", model: "database" }); + }); - // reset for next iteration of loop - dontCacheResultsRadioButton().click(); - saveCacheStrategyForm({ strategyType: "nocache", model }); - scheduleRadioButton().click(); + it("confirmation modal appears before dirty form is abandoned", () => { + interceptPerformanceRoutes(); + visitDashboardAndQuestionCachingTab(); + const table = () => + cy.findByRole("table", { + name: /Here are the dashboards and questions/, + }); + table() + .should("be.visible") + .contains( + "No dashboards or questions have their own caching policies yet.", + ); + visitQuestion(ORDERS_QUESTION_ID); + openSidebarCacheStrategyForm(); + durationRadioButton().click(); + cy.findByLabelText(/Cache results for this many hours/).type("99"); + saveCacheStrategyForm({ strategyType: "duration", model: "database" }); + visitDashboardAndQuestionCachingTab(); + table() + .contains("Duration: 99h") + .within(() => { + cy.findByText("Duration: 99h").click(); + }); + adaptiveRadioButton().click(); + saveCacheStrategyForm({ strategyType: "ttl", model: "database" }); + + visitQuestion(ORDERS_COUNT_QUESTION_ID); + openSidebarCacheStrategyForm(); + durationRadioButton().click(); + cy.findByLabelText(/Cache results for this many hours/).type("24"); + saveCacheStrategyForm({ strategyType: "duration", model: "database" }); + visitDashboardAndQuestionCachingTab(); + table().contains("Adaptive"); + table() + .contains("Duration: 24h") + .within(() => { + cy.findByText("Duration: 24h").click(); + }); + + cy.log("Make form dirty"); + scheduleRadioButton().click(); + + cy.log("Modal appears when another row's form launcher is clicked"); + table() + .contains("Adaptive") + .within(() => { + cy.findByText("Adaptive").click(); + }); + cancelConfirmationModal(); + + cy.log("Modal appears when another admin nav item is clicked"); + cy.findByLabelText("Navigation bar").within(() => { + cy.findByText("Settings").click(); }); + cancelConfirmationModal(); + + cy.log("Modal appears when another Performance tab is clicked"); + cy.findByRole("tab", { name: "Database caching" }).click(); + cancelConfirmationModal(); }); }); }); diff --git a/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts b/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts index 93d35645efd..88492b13899 100644 --- a/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts +++ b/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts @@ -9,7 +9,7 @@ import { import type { ScheduleComponentType } from "metabase/components/Schedule/constants"; import type { CacheableModel } from "metabase-types/api"; -import { interceptRoutes } from "./helpers/e2e-performance-helpers"; +import { interceptPerformanceRoutes } from "./helpers/e2e-performance-helpers"; import { cacheStrategyForm, getScheduleComponent, @@ -26,7 +26,7 @@ import { describeEE("scenarios > admin > performance > schedule strategy", () => { beforeEach(() => { restore(); - interceptRoutes(); + interceptPerformanceRoutes(); cy.signInAsAdmin(); setTokenFeatures("all"); }); diff --git a/e2e/test/scenarios/dashboard/dashboard.cy.spec.js b/e2e/test/scenarios/dashboard/dashboard.cy.spec.js index 5592e038b5a..332c592a7b8 100644 --- a/e2e/test/scenarios/dashboard/dashboard.cy.spec.js +++ b/e2e/test/scenarios/dashboard/dashboard.cy.spec.js @@ -58,7 +58,7 @@ import { createMockVirtualDashCard, } from "metabase-types/api/mocks"; -import { interceptRoutes as interceptPerformanceRoutes } from "../admin/performance/helpers/e2e-performance-helpers"; +import { interceptPerformanceRoutes } from "../admin/performance/helpers/e2e-performance-helpers"; import { adaptiveRadioButton, durationRadioButton, diff --git a/e2e/test/scenarios/question/caching.cy.spec.js b/e2e/test/scenarios/question/caching.cy.spec.js index 0d37c4f4e77..21615cb3673 100644 --- a/e2e/test/scenarios/question/caching.cy.spec.js +++ b/e2e/test/scenarios/question/caching.cy.spec.js @@ -7,7 +7,7 @@ import { visitQuestion, } from "e2e/support/helpers"; -import { interceptRoutes as interceptPerformanceRoutes } from "../admin/performance/helpers/e2e-performance-helpers"; +import { interceptPerformanceRoutes } from "../admin/performance/helpers/e2e-performance-helpers"; import { adaptiveRadioButton, durationRadioButton, diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardAndQuestionCachingTab.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardAndQuestionCachingTab.tsx new file mode 100644 index 00000000000..3a44c5b0810 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardAndQuestionCachingTab.tsx @@ -0,0 +1,16 @@ +import P from "metabase/admin/performance/components/PerformanceApp.module.css"; +import { PerformanceTabId } from "metabase/admin/performance/types"; +import { getPerformanceTabName } from "metabase/admin/performance/utils"; +import { Tabs } from "metabase/ui"; + +export const DashboardAndQuestionCachingTab = () => { + return ( + <Tabs.Tab + className={P.Tab} + key="DashboardAndQuestionCaching" + value={PerformanceTabId.DashboardsAndQuestions} + > + {getPerformanceTabName(PerformanceTabId.DashboardsAndQuestions)} + </Tabs.Tab> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.module.css b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.module.css new file mode 100644 index 00000000000..aecea51ef6a --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.module.css @@ -0,0 +1,60 @@ +.NoResultsTableRow, +.SkeletonTableRow { + &:hover { + background-color: transparent !important; + } +} + +.SkeletonTableRow { + height: 3rem; + + td { + width: 20rem; + } +} + +.CacheableItemTable { + table-layout: fixed; + background-color: var(--mb-color-text-white); + + col { + width: 30%; + } + + td { + padding: 0.75rem !important; + } + + tbody > tr:hover { + background-color: var(--mb-color-brand-alpha-04) !important; + } + + tbody > tr.currentTarget { + background-color: var(--mb-color-brand-lighter) !important; + } +} + +.StrategyFormPanel { + border-left: 1px solid var(--mb-color-border); + position: relative; + max-width: 30rem; + + @media screen and (--breakpoint-max-lg) { + max-width: 25rem; + } +} + +.ItemLink { + font-size: inherit !important; + + &:hover { + color: var(--mb-color-brand); + } +} + +.ItemLink, +.CollectionLink { + :hover { + color: var(--mb-color-brand); + } +} diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.tsx new file mode 100644 index 00000000000..b0117d264ae --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.tsx @@ -0,0 +1,374 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { InjectedRouter, Route } from "react-router"; +import { withRouter } from "react-router"; +import { t } from "ttag"; +import _ from "underscore"; + +import { Panel } from "metabase/admin/performance/components/StrategyEditorForDatabases.styled"; +import { StrategyForm } from "metabase/admin/performance/components/StrategyForm"; +import { rootId } from "metabase/admin/performance/constants/simple"; +import { useCacheConfigs } from "metabase/admin/performance/hooks/useCacheConfigs"; +import { useConfirmIfFormIsDirty } from "metabase/admin/performance/hooks/useConfirmIfFormIsDirty"; +import { useSaveStrategy } from "metabase/admin/performance/hooks/useSaveStrategy"; +import { skipToken, useSearchQuery } from "metabase/api"; +import { ClientSortableTable } from "metabase/common/components/Table/ClientSortableTable"; +import type { ColumnItem } from "metabase/common/components/Table/types"; +import { useLocale } from "metabase/common/hooks/use-locale/use-locale"; +import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper"; +import { + Box, + Button, + Center, + Flex, + Icon, + Skeleton, + Stack, + Text, +} from "metabase/ui"; +import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat"; +import type { CacheableModel } from "metabase-types/api"; +import { CacheDurationUnit } from "metabase-types/api"; +import { SortDirection } from "metabase-types/api/sorting"; + +import type { + CacheableItem, + DashboardResult, + QuestionResult, + UpdateTarget, +} from "../types"; + +import Styles from "./StrategyEditorForQuestionsAndDashboards.module.css"; +import { TableRowForCacheableItem } from "./TableRowForCacheableItem"; +import { getConstants } from "./constants"; +import { formatValueForSorting } from "./utils"; + +type CacheableItemResult = DashboardResult | QuestionResult; + +const _StrategyEditorForQuestionsAndDashboards = ({ + router, + route, +}: { + router: InjectedRouter; + route?: Route; +}) => { + const [ + // The targetId is the id of the object that is currently being edited + targetId, + setTargetId, + ] = useState<number | null>(null); + + const { tableColumns } = useMemo(() => getConstants(), []); + + const [targetModel, setTargetModel] = useState<CacheableModel | null>(null); + + const configurableModels: CacheableModel[] = useMemo( + () => ["dashboard", "question"], + [], + ); + + const { + configs, + setConfigs, + error: configsError, + loading: configsAreLoading, + } = useCacheConfigs({ configurableModels }); + + const dashboardIds = useMemo( + () => + configs + .filter(config => config.model === "dashboard") + .map(c => c.model_id), + [configs], + ); + + const questionIds = useMemo( + () => + configs + .filter(config => config.model === "question") + .map(c => c.model_id), + [configs], + ); + + const dashboardsResult = useSearchQuery( + dashboardIds.length + ? { + models: ["dashboard"], + ids: dashboardIds, + //FIXME: Add `ancestors: true` once jds/ancestors-for-all-the-things is merged + } + : skipToken, + ); + const questionsResult = useSearchQuery( + questionIds.length + ? { + models: ["card"], + ids: questionIds, + //FIXME: Add `ancestors: true` once jds/ancestors-for-all-the-things is merged + } + : skipToken, + ); + + const dashboardsAndQuestions = useMemo( + () => + (dashboardsResult.data?.data || []).concat( + questionsResult.data?.data || [], + ) as CacheableItemResult[], + [dashboardsResult.data, questionsResult.data], + ); + + const cacheableItems = useMemo(() => { + const items = new Map<string, CacheableItem>(); + for (const config of configs) { + items.set(`${config.model}${config.model_id}`, { + ..._.omit(config, "model_id"), + id: config.model_id, + }); + } + + // Hydrate data from the search results into the cacheable items + for (const result of dashboardsAndQuestions ?? []) { + const normalizedModel = + result.model === "card" ? "question" : result.model; + const item = items.get(`${normalizedModel}${result.id}`); + if (item) { + item.name = result.name; + item.collection = result.collection; + item.iconModel = result.model; + } + } + // Filter out items that have no match in the dashboard and question list + const hydratedCacheableItems: CacheableItem[] = [...items.values()].filter( + item => item.name !== undefined, + ); + + return hydratedCacheableItems; + }, [configs, dashboardsAndQuestions]); + + useEffect( + /** When the user configures an item to 'Use default' and that item + * disappears from the table, it should no longer be the target */ + function removeTargetIfNoLongerInTable() { + const isTargetIdInTable = cacheableItems.some( + item => item.id === targetId, + ); + if (targetId !== null && !isTargetIdInTable) { + setTargetId(null); + setTargetModel(null); + } + }, + [targetId, cacheableItems], + ); + + /** The config for the object currently being edited */ + const targetConfig = targetModel + ? _.findWhere(configs, { + model_id: targetId ?? undefined, + model: targetModel, + }) + : undefined; + const savedStrategy = targetConfig?.strategy; + + const targetName = useMemo(() => { + if (targetId === null || targetModel === null) { + return; + } + const item = _.findWhere(cacheableItems, { + id: targetId, + model: targetModel, + }); + return item?.name; + }, [targetId, targetModel, cacheableItems]); + + if (savedStrategy?.type === "duration") { + savedStrategy.unit = CacheDurationUnit.Hours; + } + + const { + askBeforeDiscardingChanges, + confirmationModal, + isStrategyFormDirty, + setIsStrategyFormDirty, + } = useConfirmIfFormIsDirty(router, route); + + /** Change the target, but first confirm if the form is unsaved */ + const updateTarget: UpdateTarget = useCallback( + ({ id: newTargetId, model: newTargetModel }, isFormDirty) => { + if (targetId !== newTargetId || targetModel !== newTargetModel) { + const update = () => { + setTargetId(newTargetId); + setTargetModel(newTargetModel); + setIsStrategyFormDirty(false); + }; + isFormDirty ? askBeforeDiscardingChanges(update) : update(); + } + }, + [ + targetId, + targetModel, + setTargetId, + setTargetModel, + setIsStrategyFormDirty, + askBeforeDiscardingChanges, + ], + ); + + const saveStrategy = useSaveStrategy( + targetId, + configs, + setConfigs, + targetModel, + ); + + const cacheableItemsAreLoading = configs.length > 0 && !cacheableItems.length; + + const error = configsError || dashboardsResult.error || questionsResult.error; + const loading = + configsAreLoading || + dashboardsResult.isLoading || + questionsResult.isLoading || + cacheableItemsAreLoading; + + const rowRenderer = useCallback( + (item: CacheableItem) => ( + <TableRowForCacheableItem + updateTarget={updateTarget} + currentTargetId={targetId} + currentTargetModel={targetModel} + forId={item.id} + item={item} + isFormDirty={isStrategyFormDirty} + /> + ), + [updateTarget, targetId, targetModel, isStrategyFormDirty], + ); + + const explanatoryAsideId = "mb-explanatory-aside"; + + const closeForm = useCallback(() => { + updateTarget({ id: null, model: null }, isStrategyFormDirty); + }, [updateTarget, isStrategyFormDirty]); + + const locale = useLocale(); + + return ( + <Flex + role="region" + aria-label={t`Dashboard and question caching`} + w="100%" + direction="row" + justify="space-between" + onKeyDown={e => { + if (e.key === "Escape" && !e.ctrlKey && !e.metaKey) { + closeForm(); + } + }} + > + <Stack + spacing="sm" + lh="1.5rem" + pt="md" + pb="md" + px="2.5rem" + style={{ + flex: 1, + overflowY: "auto", + }} + > + <Box component="aside" maw="32rem" id={explanatoryAsideId}> + {t`Here are the dashboards and questions that have their own caching policies, which override any default or database policies you’ve set.`} + </Box> + {confirmationModal} + <Flex maw="60rem"> + <DelayedLoadingAndErrorWrapper + error={error} + loading={loading} + loader={<TableSkeleton columns={tableColumns} />} + > + <Flex align="flex-start"> + <ClientSortableTable<CacheableItem> + className={Styles.CacheableItemTable} + columns={tableColumns} + rows={cacheableItems} + rowRenderer={rowRenderer} + defaultSortColumn="name" + defaultSortDirection={SortDirection.Asc} + locale={locale} + formatValueForSorting={formatValueForSorting} + emptyBody={<NoResultsTableRow />} + aria-labelledby={explanatoryAsideId} + cols={ + <> + <col /> + <col /> + <col /> + </> + } + /> + </Flex> + </DelayedLoadingAndErrorWrapper> + </Flex> + </Stack> + + {targetId !== null && targetModel !== null && ( + <Panel className={Styles.StrategyFormPanel}> + <Button + variant="subtle" + pos="absolute" + p="1rem" + top="1rem" + style={{ insetInlineEnd: "1rem" }} + onClick={() => { + closeForm(); + }} + > + <Text color="var(--mb-color-text-dark)"> + <Icon name="close" /> + </Text> + </Button> + <StrategyForm + targetId={targetId} + targetModel={targetModel} + targetName={targetName ?? `Untitled ${targetModel}`} + setIsDirty={setIsStrategyFormDirty} + saveStrategy={saveStrategy} + savedStrategy={savedStrategy} + shouldAllowInvalidation={true} + shouldShowName={targetId !== rootId} + /> + </Panel> + )} + </Flex> + ); +}; + +export const StrategyEditorForQuestionsAndDashboards = withRouter( + _StrategyEditorForQuestionsAndDashboards, +); + +const TableSkeleton = ({ columns }: { columns: ColumnItem[] }) => ( + <ClientSortableTable<{ id: number }> + columns={columns} + rows={[{ id: 0 }, { id: 1 }, { id: 2 }]} + rowRenderer={() => ( + <tr className={Styles.SkeletonTableRow}> + <Repeat times={3}> + <td> + <Skeleton h="1rem" natural /> + </td> + </Repeat> + </tr> + )} + className={Styles.CacheableItemTable} + locale="en-US" + /> +); + +const NoResultsTableRow = () => ( + <tr className={Styles.NoResultsTableRow}> + <td colSpan={3}> + <Center fw="bold" c="text-light"> + {t`No dashboards or questions have their own caching policies yet.`} + </Center> + </td> + </tr> +); diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.styled.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.styled.tsx new file mode 100644 index 00000000000..a1b56da776a --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.styled.tsx @@ -0,0 +1,7 @@ +import styled from "@emotion/styled"; + +import { Ellipsified } from "metabase/core/components/Ellipsified"; + +export const CacheableItemName = styled(Ellipsified)` + font-weight: bold; +`; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.tsx new file mode 100644 index 00000000000..dc9d0f3808a --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.tsx @@ -0,0 +1,101 @@ +import { useMemo } from "react"; + +import { getShortStrategyLabel } from "metabase/admin/performance/utils"; +import { EllipsifiedCollectionPath } from "metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath"; +import { MaybeLink } from "metabase/components/Badge/Badge.styled"; +import { Ellipsified } from "metabase/core/components/Ellipsified"; +import Link from "metabase/core/components/Link"; +import { getIcon } from "metabase/lib/icon"; +import * as Urls from "metabase/lib/urls"; +import { Box, Button, FixedSizeIcon, Flex } from "metabase/ui"; +import type { CacheableModel } from "metabase-types/api"; + +import type { CacheableItem, UpdateTarget } from "../types"; +import { getItemUrl } from "../utils"; + +import StrategyEditorForQuestionsAndDashboardsS from "./StrategyEditorForQuestionsAndDashboards.module.css"; + +export const TableRowForCacheableItem = ({ + item, + forId, + currentTargetId, + currentTargetModel, + updateTarget, + isFormDirty, +}: { + item: CacheableItem; + forId: number; + currentTargetId: number | null; + currentTargetModel: CacheableModel | null; + updateTarget: UpdateTarget; + isFormDirty: boolean; +}) => { + const { name, id, collection, model, strategy, iconModel } = item; + + const iconName = iconModel + ? getIcon({ model: iconModel || "card" }).name + : null; + + const url = useMemo( + () => getItemUrl(model, item as { id: number; name: string }) || undefined, + [model, item], + ); + + const launchForm = () => { + if (currentTargetId !== item.id || currentTargetModel !== item.model) { + updateTarget({ id, model }, isFormDirty); + } + }; + + return ( + <tr + className={ + currentTargetId !== null && currentTargetId === forId + ? StrategyEditorForQuestionsAndDashboardsS.currentTarget + : undefined + } + > + <td> + <MaybeLink + className={StrategyEditorForQuestionsAndDashboardsS.ItemLink} + to={url} + > + <Flex + align="center" + wrap="nowrap" + gap="sm" + style={{ overflow: "hidden" }} + > + {iconName ? ( + <FixedSizeIcon name={iconName} /> + ) : ( + <Box h="sm" w="md" /> + )} + <Ellipsified style={{ fontWeight: "bold" }}>{name}</Ellipsified> + </Flex> + </MaybeLink> + </td> + <td> + {collection && ( + <Link + className={StrategyEditorForQuestionsAndDashboardsS.CollectionLink} + to={Urls.collection(collection)} + > + <EllipsifiedCollectionPath collection={collection} /> + </Link> + )} + </td> + <td> + <Button + variant="subtle" + onClick={() => launchForm()} + p={0} + fw="bold" + c="var(--mb-color-brand)" + > + {getShortStrategyLabel(strategy)} + </Button> + </td> + </tr> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/constants.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/constants.tsx new file mode 100644 index 00000000000..9a0d3be9144 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/constants.tsx @@ -0,0 +1,15 @@ +import { t } from "ttag"; + +import type { ColumnItem } from "metabase/common/components/Table/types"; + +/** Retrieve constants for the dashboard and question caching table + * + * Some constants need to be defined within the component's scope so that ttag.t knows the current locale */ +export const getConstants = () => { + const tableColumns: ColumnItem[] = [ + { key: "name", name: t`Name`, sortable: true }, + { key: "collection", name: t`Collection`, sortable: true }, + { key: "policy", name: t`Policy`, sortable: true }, + ]; + return { tableColumns }; +}; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.tsx new file mode 100644 index 00000000000..8e0cc213cf9 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.tsx @@ -0,0 +1,30 @@ +import { t } from "ttag"; +import _ from "underscore"; + +import { getShortStrategyLabel } from "metabase/admin/performance/utils"; +import { getCollectionPathAsString } from "metabase/collections/utils"; + +import type { CacheableItem } from "../types"; + +export const formatValueForSorting = ( + row: CacheableItem, + columnName: string, +) => { + if (columnName === "policy") { + const label = getShortStrategyLabel(row.strategy, row.model); + if (row.strategy.type === "duration") { + // Sort durations in ascending order of length + return label?.replace(/(\d+)h/, (_, num) => { + const paddedNumber = num.padStart(5, "0"); + return `${t`Duration`} ${paddedNumber}`; + }); + } else { + return label; + } + } + if (columnName === "collection") { + return row.collection ? getCollectionPathAsString(row.collection) : ""; + } else { + return _.get(row, columnName); + } +}; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.unit.spec.tsx new file mode 100644 index 00000000000..c2504c7e65a --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.unit.spec.tsx @@ -0,0 +1,174 @@ +import { strategies } from "metabase/admin/performance/constants/complex"; +import { getShortStrategyLabel } from "metabase/admin/performance/utils"; +import { getCollectionPathAsString } from "metabase/collections/utils"; +import { PLUGIN_CACHING } from "metabase/plugins"; +import { enterpriseOnlyCachingStrategies } from "metabase-enterprise/caching/constants"; +import { + type AdaptiveStrategy, + CacheDurationUnit, + type ScheduleStrategy, +} from "metabase-types/api"; +import { createMockCollection } from "metabase-types/api/mocks"; + +import type { CacheableItem } from "../types"; + +import { formatValueForSorting } from "./utils"; + +const hourlyScheduleStrategy: ScheduleStrategy = { + type: "schedule", + schedule: "0 0 * * * ?", +}; +const dailyScheduleStrategy: ScheduleStrategy = { + type: "schedule", + schedule: "0 0 8 * * ?", +}; +const weeklyScheduleStrategy: ScheduleStrategy = { + type: "schedule", + schedule: "0 0 8 ? * 2", +}; +const monthlyScheduleStrategy: ScheduleStrategy = { + type: "schedule", + schedule: "0 0 8 ? * 2#1", +}; +const adaptiveStrategy: AdaptiveStrategy = { + type: "ttl", + multiplier: 10, + min_duration_ms: 1000, +}; + +const a = createMockCollection({ id: 2, name: "A" }); +const b = createMockCollection({ id: 3, name: "B" }); +const c = createMockCollection({ id: 4, name: "C" }); +const d = createMockCollection({ id: 5, name: "D" }); +const e = createMockCollection({ id: 6, name: "E" }); +const f = createMockCollection({ id: 7, name: "F" }); +const g = createMockCollection({ id: 8, name: "G" }); +const h = createMockCollection({ id: 9, name: "H" }); + +const unsortedRows: CacheableItem[] = [ + { + model: "question", + id: 0, + strategy: monthlyScheduleStrategy, + collection: createMockCollection({ + ...c, + effective_ancestors: [a, b], + }), + }, + { + model: "question", + id: 1, + strategy: { + type: "duration", + duration: 100, + unit: CacheDurationUnit.Hours, + }, + collection: createMockCollection({ + ...b, + effective_ancestors: [h, a], + }), + }, + { + model: "question", + id: 2, + strategy: hourlyScheduleStrategy, + collection: createMockCollection({ + ...a, + effective_ancestors: [g, h], + }), + }, + { + model: "question", + id: 3, + strategy: { type: "duration", duration: 10, unit: CacheDurationUnit.Hours }, + collection: createMockCollection({ + ...d, + effective_ancestors: [b, c], + }), + }, + { + model: "question", + id: 4, + strategy: weeklyScheduleStrategy, + collection: createMockCollection({ + ...h, + effective_ancestors: [f, g], + }), + }, + { + model: "question", + id: 5, + strategy: { type: "duration", duration: 1, unit: CacheDurationUnit.Hours }, + collection: createMockCollection({ + ...f, + effective_ancestors: [d, e], + }), + }, + { + model: "question", + id: 6, + strategy: dailyScheduleStrategy, + collection: createMockCollection({ + ...e, + effective_ancestors: [c, d], + }), + }, + { + model: "question", + id: 7, + strategy: adaptiveStrategy, + collection: createMockCollection({ + ...g, + effective_ancestors: [e, f], + }), + }, +]; + +describe("StrategyEditorForQuestionsAndDashboards utilities", () => { + describe("formatValueForSorting", () => { + beforeAll(() => { + PLUGIN_CACHING.strategies = { + ...strategies, + ...enterpriseOnlyCachingStrategies, + }; + }); + it("sorts by policy correctly", () => { + const sorted = unsortedRows.sort((rowA, rowB) => { + const a = formatValueForSorting(rowA, "policy") as string; + const b = formatValueForSorting(rowB, "policy") as string; + return a.localeCompare(b); + }); + const strategies = sorted.map(row => getShortStrategyLabel(row.strategy)); + expect(strategies).toEqual([ + "Adaptive", + "Duration: 1h", + "Duration: 10h", + "Duration: 100h", + "Scheduled: daily", + "Scheduled: hourly", + "Scheduled: monthly", + "Scheduled: weekly", + ]); + }); + it("sorts by collection correctly", () => { + const sorted = unsortedRows.sort((rowA, rowB) => { + const a = formatValueForSorting(rowA, "collection") as string; + const b = formatValueForSorting(rowB, "collection") as string; + return a.localeCompare(b); + }); + const collections = sorted.map(row => + row.collection ? getCollectionPathAsString(row.collection) : null, + ); + expect(collections).toEqual([ + "A / B / C", + "B / C / D", + "C / D / E", + "D / E / F", + "E / F / G", + "F / G / H", + "G / H / A", + "H / A / B", + ]); + }); + }); +}); diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/types.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/types.tsx new file mode 100644 index 00000000000..6778c5c3ccb --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/types.tsx @@ -0,0 +1,26 @@ +import type { + CacheConfig, + CacheStrategy, + CacheableModel, + CollectionEssentials, + SearchModel, + SearchResult, +} from "metabase-types/api"; + +/** Something with a configurable cache strategy */ +export type CacheableItem = Omit<CacheConfig, "model_id"> & { + id: number; + name?: string; + collection?: CollectionEssentials; + strategy?: CacheStrategy; + /** In the sense of 'type of object' */ + iconModel?: SearchModel; +}; + +export type DashboardResult = SearchResult<number, "dashboard">; +export type QuestionResult = SearchResult<number, "card">; + +export type UpdateTarget = ( + newValues: { id: number | null; model: CacheableModel | null }, + isFormDirty: boolean, +) => void; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/utils.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/utils.tsx index 35bdb3b8103..743d6d4c3a2 100644 --- a/enterprise/frontend/src/metabase-enterprise/caching/components/utils.tsx +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/utils.tsx @@ -1,5 +1,7 @@ +import { match } from "ts-pattern"; import { t } from "ttag"; +import * as Urls from "metabase/lib/urls"; import type Question from "metabase-lib/v1/Question"; import type { CacheableDashboard, CacheableModel } from "metabase-types/api"; @@ -18,3 +20,12 @@ export const getItemName = ( model === "dashboard" ? (item as CacheableDashboard).name : (item as Question).displayName() ?? t`Untitled question`; + +export const getItemUrl = ( + model: CacheableModel, + item: { id: number; name: string }, +) => + match(model) + .with("dashboard", () => Urls.dashboard(item)) + .with("question", () => Urls.question(item)) + .otherwise(() => null); diff --git a/enterprise/frontend/src/metabase-enterprise/caching/constants.ts b/enterprise/frontend/src/metabase-enterprise/caching/constants.ts index 53878dbcb63..ada84ede168 100644 --- a/enterprise/frontend/src/metabase-enterprise/caching/constants.ts +++ b/enterprise/frontend/src/metabase-enterprise/caching/constants.ts @@ -1,10 +1,17 @@ import { t } from "ttag"; import * as Yup from "yup"; -import { getPositiveIntegerSchema } from "metabase/admin/performance/constants/complex"; -import type { StrategyData } from "metabase/admin/performance/types"; +import { + getPerformanceTabMetadata, + getPositiveIntegerSchema, +} from "metabase/admin/performance/constants/complex"; +import { + PerformanceTabId, + type StrategyData, +} from "metabase/admin/performance/types"; import { defaultCron } from "metabase/admin/performance/utils"; import { CacheDurationUnit } from "metabase-types/api"; +import type { AdminPath } from "metabase-types/store"; export const durationUnits = new Set( Object.values(CacheDurationUnit).map(String), @@ -40,3 +47,19 @@ export const enterpriseOnlyCachingStrategies: Record<string, StrategyData> = { shortLabel: t`Duration`, }, }; + +export const getEnterprisePerformanceTabMetadata = () => { + const metadata = getPerformanceTabMetadata(); + // On EE there is an additional tab in between the "Database caching" and + // "Model persistence" tabs + return [ + metadata.find(({ key }) => key === "performance-databases"), + { + name: t`Dashboard and question caching`, + path: "/admin/performance/dashboards-and-questions", + key: "performance-dashboards-and-questions", + tabId: PerformanceTabId.DashboardsAndQuestions, + }, + metadata.find(({ key }) => key === "performance-models"), + ] as (AdminPath & { tabId: string })[]; +}; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/index.tsx b/enterprise/frontend/src/metabase-enterprise/caching/index.tsx index f1b8fc016a8..30de2d38fd1 100644 --- a/enterprise/frontend/src/metabase-enterprise/caching/index.tsx +++ b/enterprise/frontend/src/metabase-enterprise/caching/index.tsx @@ -1,13 +1,18 @@ import { PLUGIN_CACHING } from "metabase/plugins"; import { hasPremiumFeature } from "metabase-enterprise/settings"; +import { DashboardAndQuestionCachingTab } from "./components/DashboardAndQuestionCachingTab"; import { DashboardStrategySidebar } from "./components/DashboardStrategySidebar"; import { GranularControlsExplanation } from "./components/GranularControlsExplanation"; import { InvalidateNowButton } from "./components/InvalidateNowButton"; import { SidebarCacheForm } from "./components/SidebarCacheForm"; import { SidebarCacheSection } from "./components/SidebarCacheSection"; +import { StrategyEditorForQuestionsAndDashboards } from "./components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards"; import { StrategyFormLauncherPanel } from "./components/StrategyFormLauncherPanel"; -import { enterpriseOnlyCachingStrategies } from "./constants"; +import { + enterpriseOnlyCachingStrategies, + getEnterprisePerformanceTabMetadata, +} from "./constants"; import { hasQuestionCacheSection } from "./utils"; if (hasPremiumFeature("cache_granular_controls")) { @@ -27,4 +32,9 @@ if (hasPremiumFeature("cache_granular_controls")) { ttl: PLUGIN_CACHING.strategies.ttl, nocache: PLUGIN_CACHING.strategies.nocache, }; + PLUGIN_CACHING.DashboardAndQuestionCachingTab = + DashboardAndQuestionCachingTab; + PLUGIN_CACHING.StrategyEditorForQuestionsAndDashboards = + StrategyEditorForQuestionsAndDashboards; + PLUGIN_CACHING.getTabMetadata = getEnterprisePerformanceTabMetadata; } diff --git a/enterprise/frontend/src/metabase-enterprise/upload_management/UploadManagementTable.tsx b/enterprise/frontend/src/metabase-enterprise/upload_management/UploadManagementTable.tsx index eb8057636a7..8f4feea32ea 100644 --- a/enterprise/frontend/src/metabase-enterprise/upload_management/UploadManagementTable.tsx +++ b/enterprise/frontend/src/metabase-enterprise/upload_management/UploadManagementTable.tsx @@ -3,6 +3,7 @@ import { msgid, ngettext, t } from "ttag"; import SettingHeader from "metabase/admin/settings/components/SettingHeader"; import { ClientSortableTable } from "metabase/common/components/Table"; +import { useLocale } from "metabase/common/hooks"; import { BulkActionBar, BulkActionButton, @@ -35,6 +36,7 @@ export function UploadManagementTable() { const [deleteTableRequest] = useDeleteUploadTableMutation(); const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); const dispatch = useDispatch(); + const locale = useLocale(); // TODO: once we have uploads running through RTK Query, we can remove the force update // because we can properly invalidate the tables tag @@ -154,6 +156,7 @@ export function UploadManagementTable() { columns={columns} rows={uploadTables} rowRenderer={row => renderRow(row)} + locale={locale} /> </Box> ); diff --git a/frontend/src/metabase-types/api/search.ts b/frontend/src/metabase-types/api/search.ts index f787f07fc94..c7ed408f7cd 100644 --- a/frontend/src/metabase-types/api/search.ts +++ b/frontend/src/metabase-types/api/search.ts @@ -128,6 +128,7 @@ export type SearchRequest = { archived?: boolean; table_db_id?: DatabaseId; models?: SearchModel[]; + ids?: SearchResultId[]; filter_items_in_personal_collection?: "only" | "exclude"; context?: SearchContext; created_at?: string | null; diff --git a/frontend/src/metabase-types/store/admin.ts b/frontend/src/metabase-types/store/admin.ts index f18f5ddc6e0..9cd856c8e46 100644 --- a/frontend/src/metabase-types/store/admin.ts +++ b/frontend/src/metabase-types/store/admin.ts @@ -14,7 +14,10 @@ export type AdminPathKey = | "troubleshooting" | "audit" | "tools" - | "performance"; + | "performance" + | "performance-models" + | "performance-dashboards-and-questions" + | "performance-databases"; export type AdminPath = { key: AdminPathKey; diff --git a/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.module.css b/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.module.css new file mode 100644 index 00000000000..a0bc5e7e618 --- /dev/null +++ b/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.module.css @@ -0,0 +1,4 @@ +.Explanation p { + margin-top: 0; + margin-bottom: 1rem; +} diff --git a/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.tsx b/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.tsx index 5d1176278be..be6a71857f3 100644 --- a/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.tsx +++ b/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.tsx @@ -15,7 +15,9 @@ import { getShowMetabaseLinks, } from "metabase/selectors/whitelabel"; import { PersistedModelsApi } from "metabase/services"; -import { Stack, Switch, Text } from "metabase/ui"; +import { Box, Stack, Switch, Text } from "metabase/ui"; + +import ModelPersistenceConfigurationS from "./ModelPersistenceConfiguration.module.css"; const modelCachingOptions = [ { @@ -134,7 +136,11 @@ export const ModelPersistenceConfiguration = () => { return ( <Stack spacing="xl" maw="40rem"> - <div> + <Box + mb="sm" + lh="1.5rem" + className={ModelPersistenceConfigurationS.Explanation} + > <p> {t`Enable model persistence to make your models (and the queries that use them) load faster.`} </p> @@ -170,7 +176,7 @@ export const ModelPersistenceConfiguration = () => { checked={modelPersistenceEnabled} /> </DelayedLoadingAndErrorWrapper> - </div> + </Box> {modelPersistenceEnabled && ( <div> <ModelCachingScheduleWidget diff --git a/frontend/src/metabase/admin/performance/components/PerformanceApp.styled.tsx b/frontend/src/metabase/admin/performance/components/PerformanceApp.module.css similarity index 57% rename from frontend/src/metabase/admin/performance/components/PerformanceApp.styled.tsx rename to frontend/src/metabase/admin/performance/components/PerformanceApp.module.css index 129927ab1fc..762ce7c8253 100644 --- a/frontend/src/metabase/admin/performance/components/PerformanceApp.styled.tsx +++ b/frontend/src/metabase/admin/performance/components/PerformanceApp.module.css @@ -1,17 +1,12 @@ -import styled from "@emotion/styled"; - -import { Tabs } from "metabase/ui"; - -import { PerformanceTabId } from "../types"; - -export const TabsList = styled(Tabs.List)` +.TabsList { padding: 0 2.5rem; background-color: var(--mb-color-bg-light); border-bottom-width: 1px; margin-top: 1.5rem; -`; + border-bottom-color: var(--mb-color-border); +} -export const Tab = styled(Tabs.Tab)` +.Tab { top: 1px; margin-bottom: 1px; border-bottom-width: 3px !important; @@ -23,13 +18,30 @@ export const Tab = styled(Tabs.Tab)` background-color: inherit; border-color: transparent; } -`; +} -export const TabsPanel = styled(Tabs.Panel)<{ value: string }>` +.TabsPanel { display: flex; flex-flow: column nowrap; flex: 1; justify-content: stretch; +} + +.hideOverflow { + overflow: hidden; +} + +.TabBody { + flex: 1; + background-color: var(--mb-color-bg-light); + height: 100%; +} + +.DatabasesTabBody { + padding: 1rem 2.5rem; + overflow: hidden; +} + +.ModelPersistenceConfigurationTabBody { padding: 1rem 2.5rem; - ${props => props.value === PerformanceTabId.Databases && `overflow: hidden;`} -`; +} diff --git a/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx b/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx index ccaab07f227..e6e98335895 100644 --- a/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx +++ b/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx @@ -1,16 +1,18 @@ +import classNames from "classnames"; import { useLayoutEffect, useRef, useState } from "react"; import type { Route } from "react-router"; import { push } from "react-router-redux"; -import { t } from "ttag"; import { useDispatch } from "metabase/lib/redux"; +import { PLUGIN_CACHING } from "metabase/plugins"; import type { TabsValue } from "metabase/ui"; import { Flex, Tabs } from "metabase/ui"; import { PerformanceTabId } from "../types"; +import { getPerformanceTabName } from "../utils"; import { ModelPersistenceConfiguration } from "./ModelPersistenceConfiguration"; -import { Tab, TabsList, TabsPanel } from "./PerformanceApp.styled"; +import P from "./PerformanceApp.module.css"; import { StrategyEditorForDatabases } from "./StrategyEditorForDatabases"; const validTabIds = new Set(Object.values(PerformanceTabId).map(String)); @@ -62,34 +64,60 @@ export const PerformanceApp = ({ console.error("Invalid tab value", value); } }} - style={{ display: "flex", flexDirection: "column" }} + style={{ height: tabsHeight, display: "flex", flexDirection: "column" }} ref={tabsRef} - bg="bg-light" - h={tabsHeight} + bg="var(--mb-color-bg-light)" > - <TabsList> - <Tab + <Tabs.List className={P.TabsList}> + <Tabs.Tab + className={P.Tab} key={PerformanceTabId.Databases} value={PerformanceTabId.Databases} > - {t`Database caching settings`} - </Tab> - <Tab key={PerformanceTabId.Models} value={PerformanceTabId.Models}> - {t`Model persistence`} - </Tab> - </TabsList> - <TabsPanel key={tabId} value={tabId}> + {getPerformanceTabName(PerformanceTabId.Databases)} + </Tabs.Tab> + <PLUGIN_CACHING.DashboardAndQuestionCachingTab /> + <Tabs.Tab + className={P.Tab} + key={PerformanceTabId.Models} + value={PerformanceTabId.Models} + > + {getPerformanceTabName(PerformanceTabId.Models)} + </Tabs.Tab> + </Tabs.List> + <Tabs.Panel + key={tabId} + value={tabId} + className={classNames(P.TabsPanel, { + [P.hideOverflow]: [ + PerformanceTabId.Databases, + PerformanceTabId.DashboardsAndQuestions, + ].includes(tabId), + })} + > {tabId === PerformanceTabId.Databases && ( - <Flex style={{ flex: 1, overflow: "hidden" }} bg="bg-light" h="100%"> + <Flex className={classNames(P.TabBody, P.DatabasesTabBody)}> <StrategyEditorForDatabases route={route} /> </Flex> )} + {tabId === PerformanceTabId.DashboardsAndQuestions && ( + <Flex className={classNames(P.TabBody, P.hideOverflow)}> + <PLUGIN_CACHING.StrategyEditorForQuestionsAndDashboards + route={route} + /> + </Flex> + )} {tabId === PerformanceTabId.Models && ( - <Flex style={{ flex: 1 }} bg="bg-light" h="100%"> + <Flex + className={classNames( + P.TabBody, + P.ModelPersistenceConfigurationTabBody, + )} + > <ModelPersistenceConfiguration /> </Flex> )} - </TabsPanel> + </Tabs.Panel> </Tabs> ); }; diff --git a/frontend/src/metabase/admin/performance/components/StrategyEditorForDatabases.tsx b/frontend/src/metabase/admin/performance/components/StrategyEditorForDatabases.tsx index 4d27b40517d..46f28d1ba72 100644 --- a/frontend/src/metabase/admin/performance/components/StrategyEditorForDatabases.tsx +++ b/frontend/src/metabase/admin/performance/components/StrategyEditorForDatabases.tsx @@ -127,7 +127,7 @@ const StrategyEditorForDatabases_Base = ({ } return ( - <TabWrapper aria-label={t`Database caching settings`}> + <TabWrapper role="region" aria-label={t`Data caching settings`}> <Stack spacing="xl" lh="1.5rem" maw="32rem" mb="1.5rem"> <aside> {t`Speed up queries by caching their results.`} diff --git a/frontend/src/metabase/admin/performance/constants/complex.ts b/frontend/src/metabase/admin/performance/constants/complex.ts index 8ccfacae27a..e1434f21faa 100644 --- a/frontend/src/metabase/admin/performance/constants/complex.ts +++ b/frontend/src/metabase/admin/performance/constants/complex.ts @@ -2,8 +2,9 @@ import { t } from "ttag"; import * as Yup from "yup"; import type { CacheableModel } from "metabase-types/api"; +import type { AdminPath } from "metabase-types/store"; -import type { StrategyData } from "../types"; +import { PerformanceTabId, type StrategyData } from "../types"; import { getStrategyValidationSchema, isValidStrategyName } from "../utils"; import { defaultMinDurationMs } from "./simple"; @@ -98,3 +99,22 @@ export const strategies = { validationSchema: doNotCacheStrategyValidationSchema, }, } as Record<string, StrategyData>; + +export const getPerformanceTabMetadata = () => + [ + { + name: t`Database caching`, + path: "/admin/performance/databases", + key: "performance-databases", + tabId: PerformanceTabId.Databases, + }, + { + name: t`Model persistence`, + path: "/admin/performance/models", + key: "performance-models", + tabId: PerformanceTabId.Models, + }, + ] as (AdminPath & { tabId: string })[]; + +export const getPerformanceAdminPaths = (metadata: AdminPath[]) => + metadata.map(tab => ({ ...tab, name: `${t`Performance`} - ${tab.name}` })); diff --git a/frontend/src/metabase/admin/performance/hooks/useSaveStrategy.tsx b/frontend/src/metabase/admin/performance/hooks/useSaveStrategy.tsx index 6feab91b94f..16b7e1629de 100644 --- a/frontend/src/metabase/admin/performance/hooks/useSaveStrategy.tsx +++ b/frontend/src/metabase/admin/performance/hooks/useSaveStrategy.tsx @@ -22,11 +22,11 @@ export const useSaveStrategy = ( targetId: number | null, configs: CacheConfig[], setConfigs: Dispatch<SetStateAction<CacheConfig[]>>, - model: CacheableModel, + model: CacheableModel | null, ) => { const saveStrategy = useCallback( async (values: CacheStrategy) => { - if (targetId === null) { + if (targetId === null || model === null) { return; } const { strategies } = PLUGIN_CACHING; diff --git a/frontend/src/metabase/admin/performance/types.ts b/frontend/src/metabase/admin/performance/types.ts index 310c4b3bb18..d8209db03d4 100644 --- a/frontend/src/metabase/admin/performance/types.ts +++ b/frontend/src/metabase/admin/performance/types.ts @@ -21,4 +21,5 @@ export type StrategyData = { export enum PerformanceTabId { Databases = "databases", Models = "models", + DashboardsAndQuestions = "dashboards-and-questions", } diff --git a/frontend/src/metabase/admin/performance/utils.tsx b/frontend/src/metabase/admin/performance/utils.tsx index 3e087472c7e..5936ebb597e 100644 --- a/frontend/src/metabase/admin/performance/utils.tsx +++ b/frontend/src/metabase/admin/performance/utils.tsx @@ -22,7 +22,7 @@ import type { } from "metabase-types/api"; import { defaultMinDurationMs, rootId } from "./constants/simple"; -import type { StrategyData, StrategyLabel } from "./types"; +import type { PerformanceTabId, StrategyData, StrategyLabel } from "./types"; const AM = 0; const PM = 1; @@ -315,3 +315,8 @@ export const translateConfigFromAPI = (config: CacheConfig): CacheConfig => /** Translate a config from the frontend's format into the API's preferred format */ export const translateConfigToAPI = (config: CacheConfig): CacheConfig => translateConfig(config, "toAPI"); + +export const getPerformanceTabName = (tabId: PerformanceTabId) => + PLUGIN_CACHING.getTabMetadata().find( + ({ key }) => key === `performance-${tabId}`, + )?.name; diff --git a/frontend/src/metabase/admin/routes.jsx b/frontend/src/metabase/admin/routes.jsx index 5810fbd910c..73c08649326 100644 --- a/frontend/src/metabase/admin/routes.jsx +++ b/frontend/src/metabase/admin/routes.jsx @@ -48,6 +48,7 @@ import { PLUGIN_ADMIN_ROUTES, PLUGIN_ADMIN_TOOLS, PLUGIN_ADMIN_USER_MENU_ROUTES, + PLUGIN_CACHING, } from "metabase/plugins"; import { getSetting } from "metabase/selectors/settings"; @@ -164,18 +165,16 @@ const getRoutes = (store, CanAccessSettings, IsAdmin) => ( > <Route title={t`Performance`}> <IndexRedirect to={PerformanceTabId.Databases} /> - <Route - title={t`Database caching`} - path={PerformanceTabId.Databases} - component={() => ( - <PerformanceApp tabId={PerformanceTabId.Databases} /> - )} - /> - <Route - title={t`Model persistence`} - path={PerformanceTabId.Models} - component={() => <PerformanceApp tabId={PerformanceTabId.Models} />} - /> + {PLUGIN_CACHING.getTabMetadata().map(({ name, key, tabId }) => ( + <Route + component={routeProps => ( + <PerformanceApp {...routeProps} tabId={tabId} /> + )} + title={name} + path={tabId} + key={key} + /> + ))} </Route> </Route> <Route diff --git a/frontend/src/metabase/admin/settings/components/ApiKeys/ManageApiKeys.tsx b/frontend/src/metabase/admin/settings/components/ApiKeys/ManageApiKeys.tsx index eabc596d18d..debae137f30 100644 --- a/frontend/src/metabase/admin/settings/components/ApiKeys/ManageApiKeys.tsx +++ b/frontend/src/metabase/admin/settings/components/ApiKeys/ManageApiKeys.tsx @@ -3,6 +3,7 @@ import { t } from "ttag"; import { useListApiKeysQuery } from "metabase/api"; import { ClientSortableTable } from "metabase/common/components/Table"; +import { useLocale } from "metabase/common/hooks/use-locale/use-locale"; import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper"; import { Ellipsified } from "metabase/core/components/Ellipsified"; import CS from "metabase/css/core/index.css"; @@ -66,6 +67,7 @@ function ApiKeysTable({ error?: unknown; }) { const flatApiKeys = useMemo(() => apiKeys?.map(flattenApiKey), [apiKeys]); + const locale = useLocale(); if (loading || error) { return <DelayedLoadingAndErrorWrapper loading={loading} error={error} />; @@ -80,6 +82,7 @@ function ApiKeysTable({ data-testid="api-keys-table" columns={columns} rows={flatApiKeys} + locale={locale} rowRenderer={row => ( <ApiKeyRow apiKey={row} diff --git a/frontend/src/metabase/browse/components/ModelsTable.tsx b/frontend/src/metabase/browse/components/ModelsTable.tsx index ad6054a95de..5ce8f905ca1 100644 --- a/frontend/src/metabase/browse/components/ModelsTable.tsx +++ b/frontend/src/metabase/browse/components/ModelsTable.tsx @@ -3,7 +3,7 @@ import { push } from "react-router-redux"; import { t } from "ttag"; import { getCollectionName } from "metabase/collections/utils"; -import { EllipsifiedPath } from "metabase/common/components/EllipsifiedPath"; +import { EllipsifiedCollectionPath } from "metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath"; import { useLocale } from "metabase/common/hooks/use-locale/use-locale"; import EntityItem from "metabase/components/EntityItem"; import { SortableColumnHeader } from "metabase/components/ItemsTable/BaseItemsTable"; @@ -43,11 +43,7 @@ import { ModelNameColumn, ModelTableRow, } from "./ModelsTable.styled"; -import { - getCollectionPathString, - getModelDescription, - sortModels, -} from "./utils"; +import { getModelDescription, sortModels } from "./utils"; export interface ModelsTableProps { models?: ModelResult[]; @@ -220,13 +216,7 @@ const TBodyRow = ({ <Flex gap="sm"> <FixedSizeIcon name="folder" /> <Box w="calc(100% - 1.5rem)"> - <EllipsifiedPath - tooltip={getCollectionPathString(model.collection)} - items={[ - ...(model.collection?.effective_ancestors || []), - model.collection, - ].map(c => getCollectionName(c))} - /> + <EllipsifiedCollectionPath collection={model.collection} /> </Box> </Flex> </Link> diff --git a/frontend/src/metabase/browse/components/constants.tsx b/frontend/src/metabase/browse/components/constants.tsx deleted file mode 100644 index 7146a49bfee..00000000000 --- a/frontend/src/metabase/browse/components/constants.tsx +++ /dev/null @@ -1 +0,0 @@ -export const pathSeparatorChar = "/"; diff --git a/frontend/src/metabase/browse/components/utils.tsx b/frontend/src/metabase/browse/components/utils.tsx index 3f83dae39a9..2aa67029d5b 100644 --- a/frontend/src/metabase/browse/components/utils.tsx +++ b/frontend/src/metabase/browse/components/utils.tsx @@ -1,12 +1,10 @@ import { t } from "ttag"; -import type { CollectionEssentials, SearchResult } from "metabase-types/api"; +import { getCollectionPathAsString } from "metabase/collections/utils"; +import type { SearchResult } from "metabase-types/api"; import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; import type { ModelResult } from "../types"; -import { getCollectionName } from "../utils"; - -import { pathSeparatorChar } from "./constants"; export const isModel = (item: SearchResult) => item.model === "dataset"; @@ -18,22 +16,12 @@ export const getModelDescription = (item: SearchResult) => { } }; -export const getCollectionPathString = (collection: CollectionEssentials) => { - const ancestors: CollectionEssentials[] = - collection.effective_ancestors || []; - const collections = ancestors.concat(collection); - const pathString = collections - .map(coll => getCollectionName(coll)) - .join(` ${pathSeparatorChar} `); - return pathString; -}; - const getValueForSorting = ( model: ModelResult, sort_column: keyof ModelResult, ): string => { if (sort_column === "collection") { - return getCollectionPathString(model.collection); + return getCollectionPathAsString(model.collection); } else { return model[sort_column]; } diff --git a/frontend/src/metabase/browse/components/utils.unit.spec.tsx b/frontend/src/metabase/browse/components/utils.unit.spec.tsx index e7244066ef1..80cd31e2972 100644 --- a/frontend/src/metabase/browse/components/utils.unit.spec.tsx +++ b/frontend/src/metabase/browse/components/utils.unit.spec.tsx @@ -4,37 +4,7 @@ import { SortDirection } from "metabase-types/api/sorting"; import { createMockModelResult } from "../test-utils"; import type { ModelResult } from "../types"; -import { - getCollectionPathString, - getMaxRecentModelCount, - sortModels, -} from "./utils"; - -describe("getCollectionPathString", () => { - it("should return path for collection without ancestors", () => { - const collection = createMockCollection({ - id: 0, - name: "Documents", - effective_ancestors: [], - }); - const pathString = getCollectionPathString(collection); - expect(pathString).toBe("Documents"); - }); - - it("should return path for collection with multiple ancestors", () => { - const ancestors = [ - createMockCollection({ name: "Home" }), - createMockCollection({ name: "User" }), - createMockCollection({ name: "Files" }), - ]; - const collection = createMockCollection({ - name: "Documents", - effective_ancestors: ancestors, - }); - const pathString = getCollectionPathString(collection); - expect(pathString).toBe("Home / User / Files / Documents"); - }); -}); +import { getMaxRecentModelCount, sortModels } from "./utils"; describe("sortModels", () => { let id = 0; diff --git a/frontend/src/metabase/browse/types.tsx b/frontend/src/metabase/browse/types.tsx index 88efd19b541..d689a551985 100644 --- a/frontend/src/metabase/browse/types.tsx +++ b/frontend/src/metabase/browse/types.tsx @@ -1,15 +1,9 @@ -import type { MutableRefObject } from "react"; - import type { RecentCollectionItem, RecentItem, SearchResult, } from "metabase-types/api"; -export type RefProp<RefValue> = { - ref: MutableRefObject<RefValue> | ((value: RefValue) => void); -}; - /** Model retrieved through the search endpoint */ export type ModelResult = SearchResult<number, "dataset">; diff --git a/frontend/src/metabase/collections/utils.ts b/frontend/src/metabase/collections/utils.ts index 0d30d91855b..cb0693964c6 100644 --- a/frontend/src/metabase/collections/utils.ts +++ b/frontend/src/metabase/collections/utils.ts @@ -3,6 +3,7 @@ import { t } from "ttag"; import { PLUGIN_COLLECTIONS } from "metabase/plugins"; import type { Collection, + CollectionEssentials, CollectionId, CollectionItem, } from "metabase-types/api"; @@ -246,3 +247,19 @@ export const getCollectionName = ( } return collection?.name || t`Untitled collection`; }; + +export const getCollectionPath = (collection: CollectionEssentials) => { + const ancestors: CollectionEssentials[] = + collection.effective_ancestors || []; + const collections = ancestors.concat(collection); + return collections; +}; + +export const getCollectionPathAsString = (collection: CollectionEssentials) => { + const collections = getCollectionPath(collection); + return collections + .map(coll => getCollectionName(coll)) + .join(` ${collectionPathSeparator} `); +}; + +export const collectionPathSeparator = "/"; diff --git a/frontend/src/metabase/collections/utils.unit.spec.ts b/frontend/src/metabase/collections/utils.unit.spec.ts index fa43c0b963f..65abf83a39d 100644 --- a/frontend/src/metabase/collections/utils.unit.spec.ts +++ b/frontend/src/metabase/collections/utils.unit.spec.ts @@ -1,5 +1,6 @@ import { canonicalCollectionId, + getCollectionPathAsString, isItemCollection, isReadOnlyCollection, isRootCollection, @@ -125,4 +126,30 @@ describe("Collections > utils", () => { ).toBe(false); }); }); + + describe("getCollectionPathAsString", () => { + it("should return path for collection without ancestors", () => { + const collection = createMockCollection({ + id: 0, + name: "Documents", + effective_ancestors: [], + }); + const pathString = getCollectionPathAsString(collection); + expect(pathString).toBe("Documents"); + }); + + it("should return path for collection with multiple ancestors", () => { + const ancestors = [ + createMockCollection({ name: "Home" }), + createMockCollection({ name: "User" }), + createMockCollection({ name: "Files" }), + ]; + const collection = createMockCollection({ + name: "Documents", + effective_ancestors: ancestors, + }); + const pathString = getCollectionPathAsString(collection); + expect(pathString).toBe("Home / User / Files / Documents"); + }); + }); }); diff --git a/frontend/src/metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath.tsx b/frontend/src/metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath.tsx new file mode 100644 index 00000000000..6b152d0f23f --- /dev/null +++ b/frontend/src/metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath.tsx @@ -0,0 +1,21 @@ +import { + getCollectionName, + getCollectionPath, + getCollectionPathAsString, +} from "metabase/collections/utils"; +import type { CollectionEssentials } from "metabase-types/api"; + +import { EllipsifiedPath } from "./EllipsifiedPath"; + +export const EllipsifiedCollectionPath = ({ + collection, +}: { + collection: CollectionEssentials; +}) => { + return ( + <EllipsifiedPath + tooltip={getCollectionPathAsString(collection)} + items={getCollectionPath(collection).map(c => getCollectionName(c))} + /> + ); +}; diff --git a/frontend/src/metabase/common/components/Table/ClientSortableTable.tsx b/frontend/src/metabase/common/components/Table/ClientSortableTable.tsx index 2ffe7ba30fa..092a4e80530 100644 --- a/frontend/src/metabase/common/components/Table/ClientSortableTable.tsx +++ b/frontend/src/metabase/common/components/Table/ClientSortableTable.tsx @@ -1,62 +1,49 @@ -import React, { useMemo } from "react"; +import cx from "classnames"; -import { SortDirection } from "metabase-types/api/sorting"; +import type { SortDirection } from "metabase-types/api/sorting"; import { type BaseRow, Table, type TableProps } from "./Table"; - -const compareNumbers = (a: number, b: number) => a - b; +import TableS from "./Table.module.css"; +import { useTableSorting } from "./useTableSorting"; export type ClientSortableTableProps<T extends BaseRow> = TableProps<T> & { - locale?: string; + locale: string; formatValueForSorting?: (row: T, columnName: string) => any; + defaultSortColumn?: string; + defaultSortDirection?: SortDirection; + className?: string; }; /** * A basic reusable table component that supports client-side sorting by a column - * - * @param props.columns - an array of objects with name and key properties - * @param props.rows - an array of objects with keys that match the column keys - * @param props.rowRenderer - a function that takes a row object and returns a <tr> element - * @param props.formatValueForSorting - a function that is passed the row and column and returns a value to be used for sorting. Defaults to row[column] - * @param props.locale - a locale used for string comparisons - * @param props.emptyBody - content to be displayed when the row count is 0 - * @param props.cols - a ReactNode that is inserted in the table element before <thead>. Useful for defining <colgroups> and <cols> */ export function ClientSortableTable<Row extends BaseRow>({ + className, columns, rows, rowRenderer, formatValueForSorting = (row: Row, columnName: string) => row[columnName], + defaultSortColumn, + defaultSortDirection, locale, ...rest }: ClientSortableTableProps<Row>) { - const [sortColumn, setSortColumn] = React.useState<string | null>(null); - const [sortDirection, setSortDirection] = React.useState<SortDirection>( - SortDirection.Asc, - ); - - const sortedRows = useMemo(() => { - if (sortColumn) { - return [...rows].sort((rowA, rowB) => { - const a = formatValueForSorting(rowA, sortColumn); - const b = formatValueForSorting(rowB, sortColumn); - - if (!isSortableValue(a) || !isSortableValue(b)) { - return 0; - } - - const result = - typeof a === "string" - ? compareStrings(a, b as string, locale) - : compareNumbers(a, b as number); - return sortDirection === SortDirection.Asc ? result : -result; - }); - } - return rows; - }, [rows, sortColumn, sortDirection, locale, formatValueForSorting]); + const { + sortColumn, + sortDirection, + setSortColumn, + setSortDirection, + sortedRows, + } = useTableSorting({ + rows, + defaultSortColumn, + formatValueForSorting, + locale, + }); return ( <Table + className={cx(className, TableS.Table)} rows={sortedRows} columns={columns} rowRenderer={rowRenderer} @@ -70,11 +57,3 @@ export function ClientSortableTable<Row extends BaseRow>({ /> ); } - -function isSortableValue(value: unknown): value is string | number { - return typeof value === "string" || typeof value === "number"; -} - -function compareStrings(a: string, b: string, locale?: string) { - return a.localeCompare(b, locale, { sensitivity: "base" }); -} diff --git a/frontend/src/metabase/common/components/Table/Table.tsx b/frontend/src/metabase/common/components/Table/Table.tsx index 245477bf9b7..2d339363c95 100644 --- a/frontend/src/metabase/common/components/Table/Table.tsx +++ b/frontend/src/metabase/common/components/Table/Table.tsx @@ -1,22 +1,18 @@ +import cx from "classnames"; import React from "react"; import { PaginationControls, type PaginationControlsProps, } from "metabase/components/PaginationControls"; -import { Box, Flex, Icon } from "metabase/ui"; +import { Box, Flex, type FlexProps, Icon } from "metabase/ui"; import { SortDirection } from "metabase-types/api/sorting"; import CS from "./Table.module.css"; +import type { ColumnItem } from "./types"; export type BaseRow = Record<string, unknown> & { id: number | string }; -type ColumnItem = { - name: string; - key: string; - sortable?: boolean; -}; - export type TableProps<Row extends BaseRow> = { columns: ColumnItem[]; rows: Row[]; @@ -30,7 +26,7 @@ export type TableProps<Row extends BaseRow> = { > & { onPageChange: (page: number) => void }; emptyBody?: React.ReactNode; cols?: React.ReactNode; -}; +} & Omit<React.HTMLProps<HTMLTableElement>, "rows" | "cols">; /** * A basic reusable table component @@ -38,14 +34,15 @@ export type TableProps<Row extends BaseRow> = { * @param props.columns - an array of objects with name and key properties * @param props.rows - an array of objects with keys that match the column keys * @param props.rowRenderer - a function that takes a row object and returns a <tr> element - * @param props.emptyBody - content to be displayed when the row count is 0 - * @param props.cols - a ReactNode that is inserted in the table element before <thead>. Useful for defining <colgroups> and <cols> * @param props.sortColumnName - ID of the column currently used in row sorting * @param props.sortDirection - The direction of the sort. Can be "asc" or "desc" * @param props.onSort - a callback containing updated sort info for when a header is clicked * @param props.paginationProps - a map of information used to render pagination controls. + * @param props.emptyBody - content to be displayed when the row count is 0 + * @param props.cols - a ReactNode that is inserted in the table element before <thead>. Useful for defining <colgroups> and <cols> + * @param props.className - this will be added to the <table> element along with the default classname + * @note All other props are passed to the <table> element */ - export function Table<Row extends BaseRow>({ columns, rows, @@ -56,30 +53,34 @@ export function Table<Row extends BaseRow>({ paginationProps, emptyBody, cols, + className, ...rest }: TableProps<Row>) { return ( <> - <table {...rest} className={CS.Table}> + <table className={cx(CS.Table, className)} {...rest}> {cols && <colgroup>{cols}</colgroup>} <thead> <tr> - {columns.map(column => ( - <th key={String(column.key)}> - {onSort && column.sortable !== false ? ( - <ColumnHeader - column={column} - sortColumn={sortColumnName} - sortDirection={sortDirection} - onSort={(columnKey: string, direction: SortDirection) => { - onSort(columnKey, direction); - }} - /> - ) : ( - column.name - )} - </th> - ))} + {columns.map(column => { + const { sortable = true } = column; + return ( + <th key={String(column.key)}> + {onSort && sortable ? ( + <ColumnHeader + column={column} + sortColumn={sortColumnName} + sortDirection={sortDirection} + onSort={(columnKey: string, direction: SortDirection) => { + onSort(columnKey, direction); + }} + /> + ) : ( + column.name + )} + </th> + ); + })} </tr> </thead> <tbody> @@ -118,12 +119,13 @@ function ColumnHeader({ sortColumn, sortDirection, onSort, + ...rest }: { column: ColumnItem; sortColumn?: string | null; sortDirection?: SortDirection; onSort: (column: string, direction: SortDirection) => void; -}) { +} & FlexProps) { return ( <Flex gap="sm" @@ -137,6 +139,7 @@ function ColumnHeader({ : SortDirection.Asc, ) } + {...rest} > {column.name} { diff --git a/frontend/src/metabase/common/components/Table/Table.unit.spec.tsx b/frontend/src/metabase/common/components/Table/Table.unit.spec.tsx index 5f28e726d86..bacad083a13 100644 --- a/frontend/src/metabase/common/components/Table/Table.unit.spec.tsx +++ b/frontend/src/metabase/common/components/Table/Table.unit.spec.tsx @@ -1,7 +1,6 @@ -import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { getIcon, queryIcon } from "__support__/ui"; +import { getIcon, queryIcon, render, screen, within } from "__support__/ui"; import { ClientSortableTable } from "./ClientSortableTable"; import { Table } from "./Table"; @@ -94,6 +93,7 @@ describe("common > components > ClientSortableTable", () => { columns={sampleColumns} rows={sampleData} rowRenderer={renderRow} + locale="en-US" />, ); expect(screen.getByText("Name")).toBeInTheDocument(); @@ -108,6 +108,7 @@ describe("common > components > ClientSortableTable", () => { columns={sampleColumns} rows={sampleData} rowRenderer={renderRow} + locale="en-US" />, ); expect(screen.getByText("Bulbasaur")).toBeInTheDocument(); @@ -125,6 +126,7 @@ describe("common > components > ClientSortableTable", () => { columns={sampleColumns} rows={sampleData} rowRenderer={renderRow} + locale="en-US" />, ); const sortButton = screen.getByText("Name"); @@ -188,6 +190,7 @@ describe("common > components > ClientSortableTable", () => { columns={sampleColumns} rows={sampleData} rowRenderer={renderRow} + locale="en-US" />, ); const sortNameButton = screen.getByText("Name"); @@ -221,6 +224,7 @@ describe("common > components > ClientSortableTable", () => { <td colSpan={3}>No Results</td> </tr> } + locale="en-US" />, ); expect(screen.getByText("Name")).toBeInTheDocument(); @@ -229,12 +233,13 @@ describe("common > components > ClientSortableTable", () => { expect(screen.getByText("No Results")).toBeInTheDocument(); }); - it("should allow you provide a format values when sorting", async () => { + it("should let you provide format values when sorting", async () => { render( <ClientSortableTable columns={sampleColumns} rows={sampleData} rowRenderer={renderRow} + locale="en-US" formatValueForSorting={(row, colName) => { if (colName === "type") { if (row.type === "Water") { @@ -280,7 +285,7 @@ describe("common > components > Table", () => { expect(onSort).toHaveBeenCalledWith("type", "asc"); }); - it("if pageination props are passed, it should render the pagination controller.", async () => { + it("should render the pagination controller if pagination props are passed", async () => { const onPageChange = jest.fn(); render( diff --git a/frontend/src/metabase/common/components/Table/types.ts b/frontend/src/metabase/common/components/Table/types.ts new file mode 100644 index 00000000000..87d4d5822bf --- /dev/null +++ b/frontend/src/metabase/common/components/Table/types.ts @@ -0,0 +1,7 @@ +export type BaseRow = Record<string, any> & { id: number | string }; + +export type ColumnItem = { + name: string; + key: string; + sortable?: boolean; +}; diff --git a/frontend/src/metabase/common/components/Table/useTableSorting.tsx b/frontend/src/metabase/common/components/Table/useTableSorting.tsx new file mode 100644 index 00000000000..ac607d486a9 --- /dev/null +++ b/frontend/src/metabase/common/components/Table/useTableSorting.tsx @@ -0,0 +1,65 @@ +import { useCallback, useMemo, useState } from "react"; + +import { SortDirection } from "metabase-types/api/sorting"; + +import type { BaseRow } from "./types"; + +const compareNumbers = (a: number, b: number) => a - b; + +export const useTableSorting = <Row extends BaseRow>({ + rows, + defaultSortColumn, + defaultSortDirection = SortDirection.Asc, + formatValueForSorting, + locale, +}: { + rows: Row[]; + defaultSortColumn?: string; + defaultSortDirection?: SortDirection; + formatValueForSorting: (row: Row, columnName: string) => any; + locale: string; +}) => { + const [sortColumn, setSortColumn] = useState<string | undefined>( + defaultSortColumn, + ); + const [sortDirection, setSortDirection] = + useState<SortDirection>(defaultSortDirection); + + const compareStrings = useCallback( + (a: string, b: string) => + a.localeCompare(b, locale, { sensitivity: "base" }), + [locale], + ); + + const sortedRows = useMemo(() => { + if (sortColumn) { + return [...rows].sort((rowA, rowB) => { + const a = formatValueForSorting(rowA, sortColumn); + const b = formatValueForSorting(rowB, sortColumn); + + if (!isSortableValue(a) || !isSortableValue(b)) { + return 0; + } + + const result = + typeof a === "string" + ? compareStrings(a, b as string) + : compareNumbers(a, b as number); + return sortDirection === SortDirection.Asc ? result : -result; + }); + } + return rows; + }, [rows, sortColumn, sortDirection, formatValueForSorting, compareStrings]); + + return { + sortColumn, + sortDirection, + setSortColumn, + setSortDirection, + sortedRows, + }; +}; + +function isSortableValue(value: unknown): value is string | number { + return typeof value === "string" || typeof value === "number"; +} diff --git a/frontend/src/metabase/common/components/types.ts b/frontend/src/metabase/common/components/types.ts new file mode 100644 index 00000000000..2f21f864662 --- /dev/null +++ b/frontend/src/metabase/common/components/types.ts @@ -0,0 +1,5 @@ +import type { Ref } from "react"; + +export type RefProp<RefValue> = { + ref: Ref<RefValue>; +}; diff --git a/frontend/src/metabase/components/ResponsiveContainer/ResponsiveContainer.tsx b/frontend/src/metabase/components/ResponsiveContainer/ResponsiveContainer.tsx index 48f141a13de..d386f50af4f 100644 --- a/frontend/src/metabase/components/ResponsiveContainer/ResponsiveContainer.tsx +++ b/frontend/src/metabase/components/ResponsiveContainer/ResponsiveContainer.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; +import type { Ref } from "react"; -import type { RefProp } from "metabase/browse/types"; import { doNotForwardProps } from "metabase/common/utils/doNotForwardProps"; import { Box, type BoxProps } from "metabase/ui"; @@ -22,5 +22,5 @@ ResponsiveContainer.defaultProps = { type: "inline-size" }; export const ResponsiveChild = styled(Box, doNotForwardProps("containerName"))< BoxProps & { containerName: string; - } & Partial<RefProp<HTMLDivElement | null>> + } & { ref?: Ref<HTMLDivElement | null> } >``; diff --git a/frontend/src/metabase/components/Schedule/constants.ts b/frontend/src/metabase/components/Schedule/constants.ts index 4c01d48a95b..7c4346cb8d2 100644 --- a/frontend/src/metabase/components/Schedule/constants.ts +++ b/frontend/src/metabase/components/Schedule/constants.ts @@ -54,7 +54,9 @@ export type Weekday = { /** These strings are created in a function, rather than in module scope, so that ttag is not called until the locale is set */ export const getScheduleStrings = () => { const scheduleOptionNames = { - // The context is needed because 'hourly' can be an adjective ('hourly rate') or adverb ('update hourly'). Same with 'daily', 'weekly', and 'monthly'. + // The context is needed because 'hourly' can be an adjective ('hourly + // rate') or adverb ('update hourly'). Same with 'daily', 'weekly', and + // 'monthly'. hourly: c("adverb").t`hourly`, daily: c("adverb").t`daily`, weekly: c("adverb").t`weekly`, diff --git a/frontend/src/metabase/palette/hooks/useCommandPalette.tsx b/frontend/src/metabase/palette/hooks/useCommandPalette.tsx index 0cfff14237d..20c6ee67997 100644 --- a/frontend/src/metabase/palette/hooks/useCommandPalette.tsx +++ b/frontend/src/metabase/palette/hooks/useCommandPalette.tsx @@ -6,6 +6,7 @@ import { useDebounce } from "react-use"; import { jt, t } from "ttag"; import { getAdminPaths } from "metabase/admin/app/selectors"; +import { getPerformanceAdminPaths } from "metabase/admin/performance/constants/complex"; import { getSectionsWithPlugins } from "metabase/admin/settings/selectors"; import { useListRecentsQuery, useSearchQuery } from "metabase/api"; import { useSetting } from "metabase/common/hooks"; @@ -16,6 +17,7 @@ import { getIcon } from "metabase/lib/icon"; import { getName } from "metabase/lib/name"; import { useDispatch, useSelector } from "metabase/lib/redux"; import * as Urls from "metabase/lib/urls"; +import { PLUGIN_CACHING } from "metabase/plugins"; import { trackSearchClick } from "metabase/search/analytics"; import { getDocsSearchUrl, @@ -263,7 +265,13 @@ export const useCommandPalette = ({ ]); const adminActions = useMemo<PaletteAction[]>(() => { - return adminPaths.map(adminPath => ({ + // Subpaths - i.e. paths to items within the main Admin tabs - are needed + // in the command palette but are not part of the main list of admin paths + const adminSubpaths = getPerformanceAdminPaths( + PLUGIN_CACHING.getTabMetadata(), + ); + const paths = [...adminPaths, ...adminSubpaths]; + return paths.map(adminPath => ({ id: `admin-page-${adminPath.key}`, name: `${adminPath.name}`, icon: "gear", diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index e1e770448da..bb2a981a2cc 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -10,7 +10,10 @@ import _ from "underscore"; import type { AnySchema } from "yup"; import noResultsSource from "assets/img/no_results.svg"; -import { strategies } from "metabase/admin/performance/constants/complex"; +import { + getPerformanceTabMetadata, + strategies, +} from "metabase/admin/performance/constants/complex"; import { UNABLE_TO_CHANGE_ADMIN_PERMISSIONS } from "metabase/admin/permissions/constants/messages"; import { type DataPermission, @@ -403,6 +406,9 @@ export const PLUGIN_CACHING = { canOverrideRootStrategy: false, /** Metadata describing the different kinds of strategies */ strategies: strategies, + DashboardAndQuestionCachingTab: PluginPlaceholder as any, + StrategyEditorForQuestionsAndDashboards: PluginPlaceholder as any, + getTabMetadata: getPerformanceTabMetadata, }; export const PLUGIN_REDUCERS: { -- GitLab