diff --git a/enterprise/frontend/src/metabase-enterprise/model_persistence/components/ModelCacheManagementSection/ModelCacheManagementSection.tsx b/enterprise/frontend/src/metabase-enterprise/model_persistence/components/ModelCacheManagementSection/ModelCacheManagementSection.tsx index 20da3839e6e2c49914361a3b99c37269bfc31ed8..224d0498696c41e6079dc99ea5a86048e6f92271 100644 --- a/enterprise/frontend/src/metabase-enterprise/model_persistence/components/ModelCacheManagementSection/ModelCacheManagementSection.tsx +++ b/enterprise/frontend/src/metabase-enterprise/model_persistence/components/ModelCacheManagementSection/ModelCacheManagementSection.tsx @@ -4,6 +4,7 @@ import moment from "moment"; import { connect } from "react-redux"; import PersistedModels from "metabase/entities/persisted-models"; +import { checkCanRefreshModelCache } from "metabase/lib/data-modeling/utils"; import Question from "metabase-lib/lib/Question"; import { ModelCacheRefreshStatus } from "metabase-types/api"; @@ -28,9 +29,15 @@ type LoaderRenderProps = { }; function getStatusMessage(job: ModelCacheRefreshStatus) { + if (job.state === "off") { + return `Caching is turned off`; + } if (job.state === "error") { return t`Failed to update model cache`; } + if (job.state === "creating") { + return t`Queued`; + } if (job.state === "refreshing") { return t`Refreshing model cache`; } @@ -52,7 +59,11 @@ function ModelCacheManagementSection({ model, onRefresh }: Props) { loadingAndErrorWrapper={false} > {({ persistedModel }: LoaderRenderProps) => { - if (!persistedModel) { + if ( + !persistedModel || + persistedModel.state === "off" || + persistedModel.state === "deletable" + ) { return null; } @@ -60,7 +71,7 @@ function ModelCacheManagementSection({ model, onRefresh }: Props) { const lastRefreshTime = moment(persistedModel.refresh_end).fromNow(); return ( - <Row> + <Row data-testid="model-cache-section"> <div> <StatusContainer> <StatusLabel>{getStatusMessage(persistedModel)}</StatusLabel> @@ -72,9 +83,15 @@ function ModelCacheManagementSection({ model, onRefresh }: Props) { </LastRefreshTimeLabel> )} </div> - <IconButton onClick={() => onRefresh(persistedModel)}> - <RefreshIcon name="refresh" tooltip={t`Refresh now`} size={14} /> - </IconButton> + {checkCanRefreshModelCache(persistedModel) && ( + <IconButton onClick={() => onRefresh(persistedModel)}> + <RefreshIcon + name="refresh" + tooltip={t`Refresh now`} + size={14} + /> + </IconButton> + )} </Row> ); }} diff --git a/enterprise/frontend/src/metabase-enterprise/model_persistence/components/ModelCacheManagementSection/ModelCacheManagementSection.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/model_persistence/components/ModelCacheManagementSection/ModelCacheManagementSection.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..65835613ecae39ba399f9d957041fc0199932305 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/model_persistence/components/ModelCacheManagementSection/ModelCacheManagementSection.unit.spec.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import moment from "moment"; +import xhrMock from "xhr-mock"; + +import PersistedModels from "metabase/entities/persisted-models"; +import { ModelCacheRefreshStatus } from "metabase-types/api"; +import { getMockModelCacheInfo } from "metabase-types/api/mocks/models"; + +import { + fireEvent, + renderWithProviders, + waitFor, + screen, +} from "__support__/ui"; +import { ORDERS } from "__support__/sample_database_fixture"; + +import ModelCacheManagementSection from "./ModelCacheManagementSection"; + +type SetupOpts = Partial<ModelCacheRefreshStatus> & { + waitForSectionAppearance?: boolean; +}; + +async function setup({ + waitForSectionAppearance = true, + ...cacheInfo +}: SetupOpts = {}) { + const question = ORDERS.question(); + const model = question.setCard({ + ...question.card(), + id: 1, + name: "Order model", + dataset: true, + }); + + const modelCacheInfo = getMockModelCacheInfo({ + ...cacheInfo, + card_id: model.id(), + card_name: model.displayName(), + }); + + const onRefreshMock = jest + .spyOn(PersistedModels.objectActions, "refreshCache") + .mockReturnValue({ type: "__MOCK__" }); + + xhrMock.get(`/api/persist/card/${model.id()}`, { + body: JSON.stringify(modelCacheInfo), + }); + + if (!waitForSectionAppearance) { + jest.spyOn(PersistedModels, "Loader").mockImplementation(props => { + const { children } = props as any; + return children({ persistedModel: cacheInfo }); + }); + } + + const utils = renderWithProviders( + <ModelCacheManagementSection model={model} />, + ); + + if (waitForSectionAppearance) { + await waitFor(() => utils.queryByTestId("model-cache-section")); + } + + return { + ...utils, + modelCacheInfo, + onRefreshMock, + }; +} + +describe("ModelCacheManagementSection", () => { + beforeEach(() => { + xhrMock.setup(); + }); + + afterEach(() => { + xhrMock.teardown(); + jest.resetAllMocks(); + }); + + it("doesn't show up in 'off' state", async () => { + await setup({ state: "off" }); + expect(screen.queryByTestId("model-cache-section")).not.toBeInTheDocument(); + }); + + it("doesn't show up in 'deletable' state", async () => { + await setup({ state: "deletable" }); + expect(screen.queryByTestId("model-cache-section")).not.toBeInTheDocument(); + }); + + it("displays 'creating' state correctly", async () => { + await setup({ state: "creating" }); + expect(screen.getByText("Queued")).toBeInTheDocument(); + expect(screen.queryByLabelText("refresh icon")).not.toBeInTheDocument(); + }); + + it("displays 'refreshing' state correctly", async () => { + await setup({ state: "refreshing" }); + expect(screen.getByText("Refreshing model cache")).toBeInTheDocument(); + expect(screen.queryByLabelText("refresh icon")).not.toBeInTheDocument(); + }); + + it("displays 'persisted' state correctly", async () => { + const { modelCacheInfo } = await setup({ state: "persisted" }); + const expectedTimestamp = moment(modelCacheInfo.refresh_end).fromNow(); + expect( + screen.getByText(`Model last cached ${expectedTimestamp}`), + ).toBeInTheDocument(); + expect(screen.getByLabelText("refresh icon")).toBeInTheDocument(); + }); + + it("triggers refresh from 'persisted' state", async () => { + const { modelCacheInfo, onRefreshMock } = await setup({ + state: "persisted", + }); + fireEvent.click(screen.getByLabelText("refresh icon")); + expect(onRefreshMock).toHaveBeenCalledWith(modelCacheInfo); + }); + + it("displays 'error' state correctly", async () => { + const { modelCacheInfo } = await setup({ state: "error" }); + const expectedTimestamp = moment(modelCacheInfo.refresh_end).fromNow(); + + expect( + screen.getByText("Failed to update model cache"), + ).toBeInTheDocument(); + expect( + screen.getByText(`Last attempt ${expectedTimestamp}`), + ).toBeInTheDocument(); + expect(screen.getByLabelText("refresh icon")).toBeInTheDocument(); + }); + + it("triggers refresh from 'error' state", async () => { + const { modelCacheInfo, onRefreshMock } = await setup({ state: "error" }); + fireEvent.click(screen.getByLabelText("refresh icon")); + expect(onRefreshMock).toHaveBeenCalledWith(modelCacheInfo); + }); +}); diff --git a/frontend/src/metabase-types/api/mocks/index.ts b/frontend/src/metabase-types/api/mocks/index.ts index e26e0e465d8751da547af1e13406b95ad72c6906..deac792384d31edcf8e7d37b8af47a8004257db9 100644 --- a/frontend/src/metabase-types/api/mocks/index.ts +++ b/frontend/src/metabase-types/api/mocks/index.ts @@ -4,6 +4,7 @@ export * from "./collection"; export * from "./dashboard"; export * from "./database"; export * from "./dataset"; +export * from "./models"; export * from "./timeline"; export * from "./settings"; export * from "./user"; diff --git a/frontend/src/metabase-types/api/mocks/models.ts b/frontend/src/metabase-types/api/mocks/models.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7702114c635880022e44c2e153caf967f8ce4d7 --- /dev/null +++ b/frontend/src/metabase-types/api/mocks/models.ts @@ -0,0 +1,39 @@ +import { ModelCacheRefreshStatus } from "metabase-types/api"; + +export const getMockModelCacheInfo = ( + opts?: Partial<ModelCacheRefreshStatus>, +): ModelCacheRefreshStatus => { + const now = new Date(); + + const past = new Date(); + past.setMinutes(now.getMinutes() - 30); + + const future = new Date(); + future.setHours(now.getHours() + 1); + + return { + id: 1, + state: "persisted", + error: null, + active: true, + + card_id: 1, + card_name: "Test Model", + + collection_id: "root", + collection_name: "Our analytics", + collection_authority_level: null, + + columns: [], + database_id: 1, + database_name: "Sample Database", + schema_name: "PUBLIC", + table_name: "Orders", + + refresh_begin: past.toISOString(), + refresh_end: now.toISOString(), + "next-fire-time": future.toISOString(), + + ...opts, + }; +}; diff --git a/frontend/src/metabase-types/api/models.ts b/frontend/src/metabase-types/api/models.ts index c75a2256a5d401c802295eeb54eebf029cf7aedb..24bc6970dd55012e820b49240b61a6f3275e963d 100644 --- a/frontend/src/metabase-types/api/models.ts +++ b/frontend/src/metabase-types/api/models.ts @@ -7,9 +7,17 @@ import { UserId, } from "metabase-types/api"; +export type ModelCacheState = + | "creating" + | "refreshing" + | "persisted" + | "error" + | "deletable" + | "off"; + export interface ModelCacheRefreshStatus { id: number; - state: "refreshing" | "persisted" | "error"; + state: ModelCacheState; error: string | null; active: boolean; diff --git a/frontend/src/metabase/admin/tasks/containers/ModelCacheRefreshJobs/ModelCacheRefreshJobs.tsx b/frontend/src/metabase/admin/tasks/containers/ModelCacheRefreshJobs/ModelCacheRefreshJobs.tsx index e8cbd4bdf950886f1a8630f907963b019ff4f3cc..f9315c26a84f78cf5fb81b8809b61baccd058ca2 100644 --- a/frontend/src/metabase/admin/tasks/containers/ModelCacheRefreshJobs/ModelCacheRefreshJobs.tsx +++ b/frontend/src/metabase/admin/tasks/containers/ModelCacheRefreshJobs/ModelCacheRefreshJobs.tsx @@ -5,11 +5,13 @@ import { connect } from "react-redux"; import Link from "metabase/core/components/Link"; import DateTime from "metabase/components/DateTime"; +import EmptyState from "metabase/components/EmptyState"; import Icon from "metabase/components/Icon"; import Tooltip from "metabase/components/Tooltip"; import PaginationControls from "metabase/components/PaginationControls"; import PersistedModels from "metabase/entities/persisted-models"; +import { checkCanRefreshModelCache } from "metabase/lib/data-modeling/utils"; import { capitalize } from "metabase/lib/formatting"; import * as Urls from "metabase/lib/urls"; @@ -17,6 +19,8 @@ import { usePagination } from "metabase/hooks/use-pagination"; import { ModelCacheRefreshStatus } from "metabase-types/api"; +import NoResults from "assets/img/no_results.svg"; + import { ErrorBox, IconButtonContainer, @@ -39,6 +43,12 @@ function JobTableItem({ job, onRefresh }: JobTableItemProps) { const lastRunAtLabel = capitalize(moment(job.refresh_begin).fromNow()); const renderStatus = useCallback(() => { + if (job.state === "off") { + return t`Off`; + } + if (job.state === "creating") { + return t`Queued`; + } if (job.state === "refreshing") { return t`Refreshing`; } @@ -73,11 +83,13 @@ function JobTableItem({ job, onRefresh }: JobTableItemProps) { </th> <th>{job.creator?.common_name || t`Automatic`}</th> <th> - <Tooltip tooltip={t`Refresh`}> - <IconButtonContainer onClick={onRefresh}> - <Icon name="refresh" /> - </IconButtonContainer> - </Tooltip> + {checkCanRefreshModelCache(job) && ( + <Tooltip tooltip={t`Refresh`}> + <IconButtonContainer onClick={onRefresh}> + <Icon name="refresh" /> + </IconButtonContainer> + </Tooltip> + )} </th> </tr> ); @@ -118,8 +130,23 @@ function ModelCacheRefreshJobs({ children, onRefresh }: Props) { {({ persistedModels, metadata }: PersistedModelsListLoaderProps) => { const hasPagination = metadata.total > PAGE_SIZE; + const modelCacheInfo = persistedModels.filter( + cacheInfo => cacheInfo.state !== "deletable", + ); + + if (modelCacheInfo.length === 0) { + return ( + <div data-testid="model-cache-logs"> + <EmptyState + title={t`No results`} + illustrationElement={<img src={NoResults} />} + /> + </div> + ); + } + return ( - <> + <div data-testid="model-cache-logs"> <table className="ContentTable border-bottom"> <colgroup> <col style={{ width: "30%" }} /> @@ -138,7 +165,7 @@ function ModelCacheRefreshJobs({ children, onRefresh }: Props) { </tr> </thead> <tbody> - {persistedModels.map(job => ( + {modelCacheInfo.map(job => ( <JobTableItem key={job.id} job={job} @@ -160,7 +187,7 @@ function ModelCacheRefreshJobs({ children, onRefresh }: Props) { /> </PaginationControlsContainer> )} - </> + </div> ); }} </PersistedModels.ListLoader> diff --git a/frontend/src/metabase/admin/tasks/containers/ModelCacheRefreshJobs/ModelCacheRefreshJobs.unit.spec.tsx b/frontend/src/metabase/admin/tasks/containers/ModelCacheRefreshJobs/ModelCacheRefreshJobs.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f35820627cad84dfaa24afd5e57280ed6f3816f2 --- /dev/null +++ b/frontend/src/metabase/admin/tasks/containers/ModelCacheRefreshJobs/ModelCacheRefreshJobs.unit.spec.tsx @@ -0,0 +1,131 @@ +import React from "react"; + +import PersistedModels from "metabase/entities/persisted-models"; +import { ModelCacheRefreshStatus } from "metabase-types/api"; +import { getMockModelCacheInfo } from "metabase-types/api/mocks/models"; + +import { renderWithProviders, waitFor, screen } from "__support__/ui"; + +import ModelCacheRefreshJobs from "./ModelCacheRefreshJobs"; + +async function setup({ logs = [] }: { logs?: ModelCacheRefreshStatus[] } = {}) { + const onRefreshMock = jest + .spyOn(PersistedModels.objectActions, "refreshCache") + .mockReturnValue({ type: "__MOCK__" }); + + jest.spyOn(PersistedModels, "ListLoader").mockImplementation(props => { + const { children } = props as any; + return children({ + persistedModels: logs, + metadata: { + limit: 20, + offset: 0, + total: logs.length, + }, + }); + }); + + const utils = renderWithProviders( + <ModelCacheRefreshJobs> + <></> + </ModelCacheRefreshJobs>, + ); + + await waitFor(() => utils.queryByTestId("model-cache-logs")); + + return { + ...utils, + onRefreshMock, + }; +} + +describe("ModelCacheRefreshJobs", () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it("shows empty state when there are no cache logs", async () => { + await setup({ logs: [] }); + expect(screen.getByText("No results")).toBeInTheDocument(); + expect(document.querySelector("table")).not.toBeInTheDocument(); + }); + + it("shows empty state when all logs are in 'deletable' state", async () => { + await setup({ + logs: [ + getMockModelCacheInfo({ id: 1, card_id: 1, state: "deletable" }), + getMockModelCacheInfo({ id: 2, card_id: 2, state: "deletable" }), + ], + }); + expect(screen.getByText("No results")).toBeInTheDocument(); + expect(document.querySelector("table")).not.toBeInTheDocument(); + }); + + it("shows model and collection names", async () => { + const info = getMockModelCacheInfo({ + collection_name: "Growth", + card_name: "Customer", + }); + + await setup({ logs: [info] }); + + expect(screen.getByText("Customer")).toBeInTheDocument(); + expect(screen.getByText("Growth")).toBeInTheDocument(); + }); + + it("handles models in root collections", async () => { + const info = getMockModelCacheInfo({ + collection_id: "root", + collection_name: undefined, + }); + await setup({ logs: [info] }); + expect(screen.getByText("Our analytics")).toBeInTheDocument(); + }); + + it("doesn't show records in 'deletable' state", async () => { + await setup({ + logs: [ + getMockModelCacheInfo({ + id: 1, + card_id: 1, + card_name: "DELETABLE", + state: "deletable", + }), + getMockModelCacheInfo({ id: 2, card_id: 2, state: "persisted" }), + ], + }); + expect(screen.queryByText("DELETABLE")).not.toBeInTheDocument(); + }); + + it("displays 'off' state correctly", async () => { + await setup({ logs: [getMockModelCacheInfo({ state: "off" })] }); + expect(screen.getByText("Off")).toBeInTheDocument(); + expect(screen.queryByLabelText("refresh icon")).not.toBeInTheDocument(); + }); + + it("displays 'creating' state correctly", async () => { + await setup({ logs: [getMockModelCacheInfo({ state: "creating" })] }); + expect(screen.getByText("Queued")).toBeInTheDocument(); + expect(screen.queryByLabelText("refresh icon")).not.toBeInTheDocument(); + }); + + it("displays 'refreshing' state correctly", async () => { + await setup({ logs: [getMockModelCacheInfo({ state: "refreshing" })] }); + expect(screen.getByText("Refreshing")).toBeInTheDocument(); + expect(screen.queryByLabelText("refresh icon")).not.toBeInTheDocument(); + }); + + it("displays 'persisted' state correctly", async () => { + await setup({ logs: [getMockModelCacheInfo({ state: "persisted" })] }); + expect(screen.getByText("Completed")).toBeInTheDocument(); + expect(screen.getByLabelText("refresh icon")).toBeInTheDocument(); + }); + + it("displays 'error' state correctly", async () => { + await setup({ + logs: [getMockModelCacheInfo({ state: "error", error: "FOO BAR ERROR" })], + }); + expect(screen.getByText("FOO BAR ERROR")).toBeInTheDocument(); + expect(screen.getByLabelText("refresh icon")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/lib/data-modeling/utils.ts b/frontend/src/metabase/lib/data-modeling/utils.ts index 286f1639c5843d2ac6342c46409bae88c0a8f50f..5e07b7301d138741e40c83003dbce03e23266297 100644 --- a/frontend/src/metabase/lib/data-modeling/utils.ts +++ b/frontend/src/metabase/lib/data-modeling/utils.ts @@ -1,8 +1,11 @@ import Question from "metabase-lib/lib/Question"; import NativeQuery from "metabase-lib/lib/queries/NativeQuery"; import Database from "metabase-lib/lib/metadata/Database"; + import { isStructured } from "metabase/lib/query"; import { getQuestionVirtualTableId } from "metabase/lib/saved-questions"; + +import { ModelCacheRefreshStatus } from "metabase-types/api"; import { TemplateTag } from "metabase-types/types/Query"; import { Card as CardObject, @@ -66,3 +69,9 @@ export function isAdHocModelQuestion( } return isAdHocModelQuestionCard(question.card(), originalQuestion.card()); } + +export function checkCanRefreshModelCache( + refreshInfo: ModelCacheRefreshStatus, +) { + return refreshInfo.state === "persisted" || refreshInfo.state === "error"; +} diff --git a/frontend/src/metabase/lib/data-modeling/utils.unit.spec.ts b/frontend/src/metabase/lib/data-modeling/utils.unit.spec.ts index 71ebcc06ffbfede14b281ec9bf947c30d4b0f5c2..bc7229dfdb1d68a5a6147be827b8d7849b63c53d 100644 --- a/frontend/src/metabase/lib/data-modeling/utils.unit.spec.ts +++ b/frontend/src/metabase/lib/data-modeling/utils.unit.spec.ts @@ -1,5 +1,7 @@ import Question from "metabase-lib/lib/Question"; import Database from "metabase-lib/lib/metadata/Database"; + +import { ModelCacheState } from "metabase-types/api"; import { TemplateTag, TemplateTagType, @@ -7,12 +9,16 @@ import { SourceTableId, } from "metabase-types/types/Query"; import { CardId } from "metabase-types/types/Card"; + import { createMockDatabase } from "metabase-types/api/mocks/database"; +import { getMockModelCacheInfo } from "metabase-types/api/mocks/models"; import { ORDERS, metadata } from "__support__/sample_database_fixture"; + import { checkCanBeModel, isAdHocModelQuestion, isAdHocModelQuestionCard, + checkCanRefreshModelCache, } from "./utils"; type NativeQuestionFactoryOpts = { @@ -249,4 +255,24 @@ describe("data model utils", () => { ).toBe(false); }); }); + + describe("checkCanRefreshModelCache", () => { + const testCases: Record<ModelCacheState, boolean> = { + creating: false, + refreshing: false, + persisted: true, + error: true, + deletable: false, + off: false, + }; + const states = Object.keys(testCases) as ModelCacheState[]; + + states.forEach(state => { + const canRefresh = testCases[state]; + it(`returns '${canRefresh}' for '${state}' caching state`, () => { + const info = getMockModelCacheInfo({ state }); + expect(checkCanRefreshModelCache(info)).toBe(canRefresh); + }); + }); + }); }); diff --git a/frontend/src/types/global.d.ts b/frontend/src/types/global.d.ts index 8f9aee72e1f79504980f60c5106b0028bda17788..e92012c8de7c9c8cefee78e221deb0c2c4682352 100644 --- a/frontend/src/types/global.d.ts +++ b/frontend/src/types/global.d.ts @@ -1,3 +1,9 @@ interface Window { MetabaseBootstrap: any; } + +// This allows importing static SVGs from TypeScript files +declare module "*.svg" { + const content: any; + export default content; +}