From 505d43496ba7697925a54bf47771f702e88f004a Mon Sep 17 00:00:00 2001
From: Raphael Krut-Landau <raphael.kl@gmail.com>
Date: Tue, 28 May 2024 23:15:24 -0400
Subject: [PATCH] Move model caching form to Admin / Performance (#43143)

---
 .../components/InvalidateNowButton.tsx        |   2 +-
 .../ModelPersistenceConfiguration.tsx         | 178 ++++++++++++++++++
 .../components/ModelPersistenceTab.tsx        |  12 ++
 .../src/metabase-enterprise/caching/index.tsx |   4 +
 .../src/metabase-types/api/mocks/settings.ts  |   1 +
 frontend/src/metabase-types/api/settings.ts   |   1 +
 .../performance/components/PerformanceApp.tsx |  16 +-
 .../src/metabase/admin/performance/utils.tsx  |   4 +-
 .../ModelCachingScheduleWidget.jsx            |  10 +-
 .../ModelCachingScheduleWidget/index.ts       |   2 -
 .../src/metabase/admin/settings/selectors.js  |  70 +------
 frontend/src/metabase/plugins/index.ts        |   2 +
 12 files changed, 217 insertions(+), 85 deletions(-)
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceConfiguration.tsx
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceTab.tsx
 delete mode 100644 frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/index.ts

diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/InvalidateNowButton.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/InvalidateNowButton.tsx
index ce6eca58669..3e5c0955e94 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/InvalidateNowButton.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/InvalidateNowButton.tsx
@@ -32,7 +32,7 @@ export const InvalidateNowButton = ({
         { include: "overrides", [targetModel]: targetId },
         { hasBody: false },
       );
-      await resolveSmoothly(invalidate);
+      await resolveSmoothly([invalidate]);
     } catch (e) {
       if (isErrorWithMessage(e)) {
         dispatch(
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceConfiguration.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceConfiguration.tsx
new file mode 100644
index 00000000000..aff4e64276b
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceConfiguration.tsx
@@ -0,0 +1,178 @@
+import type { ChangeEventHandler } from "react";
+import { useCallback, useEffect, useState } from "react";
+import { c, t } from "ttag";
+
+import { ModelCachingScheduleWidget } from "metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/ModelCachingScheduleWidget";
+import { useSetting } from "metabase/common/hooks";
+import ExternalLink from "metabase/core/components/ExternalLink";
+import { useDispatch, useSelector } from "metabase/lib/redux";
+import MetabaseSettings from "metabase/lib/settings";
+import { refreshSiteSettings } from "metabase/redux/settings";
+import { addUndo, dismissUndo } from "metabase/redux/undo";
+import {
+  getApplicationName,
+  getShowMetabaseLinks,
+} from "metabase/selectors/whitelabel";
+import { PersistedModelsApi } from "metabase/services";
+import { Stack, Switch, Text } from "metabase/ui";
+
+export const ModelPersistenceConfiguration = () => {
+  const showMetabaseLinks = useSelector(getShowMetabaseLinks);
+  const persistenceEnabledInAPI = useSetting("persisted-models-enabled");
+
+  const [persistenceEnabled, setPersistenceEnabled] = useState(false);
+  useEffect(() => {
+    setPersistenceEnabled(persistenceEnabledInAPI);
+  }, [persistenceEnabledInAPI]);
+
+  const modelCachingSchedule = useSetting(
+    "persisted-model-refresh-cron-schedule",
+  );
+
+  const modelCachingSetting = {
+    value: modelCachingSchedule,
+    options: [
+      {
+        value: "0 0 0/1 * * ? *",
+        name: t`Hour`,
+      },
+      {
+        value: "0 0 0/2 * * ? *",
+        name: t`2 hours`,
+      },
+      {
+        value: "0 0 0/3 * * ? *",
+        name: t`3 hours`,
+      },
+      {
+        value: "0 0 0/6 * * ? *",
+        name: t`6 hours`,
+      },
+      {
+        value: "0 0 0/12 * * ? *",
+        name: t`12 hours`,
+      },
+      {
+        value: "0 0 0 ? * * *",
+        name: t`24 hours`,
+      },
+      {
+        value: "custom",
+        name: t`Custom…`,
+      },
+    ],
+  };
+  const dispatch = useDispatch();
+
+  const showLoadingToast = useCallback(async () => {
+    const result = await dispatch(
+      addUndo({
+        icon: "info",
+        message: t`Loading...`,
+      }),
+    );
+    return result?.payload?.id as number;
+  }, [dispatch]);
+
+  const dismissLoadingToast = useCallback(
+    (toastId: number) => {
+      dispatch(dismissUndo(toastId));
+    },
+    [dispatch],
+  );
+
+  const showErrorToast = useCallback(() => {
+    dispatch(
+      addUndo({
+        icon: "warning",
+        toastColor: "error",
+        message: t`An error occurred`,
+      }),
+    );
+  }, [dispatch]);
+
+  const showSuccessToast = useCallback(() => {
+    dispatch(addUndo({ message: "Saved" }));
+  }, [dispatch]);
+
+  const resolveWithToasts = useCallback(
+    async (promises: Promise<any>[]) => {
+      let loadingToastId;
+      try {
+        loadingToastId = await showLoadingToast();
+        await Promise.all(promises);
+        showSuccessToast();
+      } catch (e) {
+        showErrorToast();
+      } finally {
+        if (loadingToastId !== undefined) {
+          dismissLoadingToast(loadingToastId);
+        }
+      }
+    },
+    [showLoadingToast, showSuccessToast, showErrorToast, dismissLoadingToast],
+  );
+
+  const applicationName = useSelector(getApplicationName);
+
+  const onSwitchChanged = useCallback<ChangeEventHandler<HTMLInputElement>>(
+    async e => {
+      const shouldEnable = e.target.checked;
+      setPersistenceEnabled(shouldEnable);
+      const promise = shouldEnable
+        ? PersistedModelsApi.enablePersistence()
+        : PersistedModelsApi.disablePersistence();
+      await resolveWithToasts([promise]);
+    },
+    [resolveWithToasts, setPersistenceEnabled],
+  );
+
+  return (
+    <Stack spacing="xl" maw="40rem">
+      <div>
+        <p>
+          {t`Enable model persistence to make your models (and the queries that use them) load faster.`}
+        </p>
+        <p>
+          {c(
+            // eslint-disable-next-line no-literal-metabase-strings -- This string provides context for translators
+            '{0} is either "Metabase" or the customized name of the application.',
+          )
+            .t`This will create a table for each of your models in a dedicated schema. ${applicationName} will refresh them on a schedule. Questions and queries that use your models will query these tables.`}
+          {showMetabaseLinks && (
+            <>
+              {" "}
+              <ExternalLink
+                key="model-caching-link"
+                href={MetabaseSettings.docsUrl("data-modeling/models")}
+              >{t`Learn more`}</ExternalLink>
+            </>
+          )}
+        </p>
+        <Switch
+          mt="sm"
+          label={
+            <Text fw="bold">
+              {persistenceEnabled ? t`Enabled` : t`Disabled`}
+            </Text>
+          }
+          onChange={onSwitchChanged}
+          checked={persistenceEnabled}
+        />
+      </div>
+      {persistenceEnabled && (
+        <div>
+          <ModelCachingScheduleWidget
+            setting={modelCachingSetting}
+            onChange={async value => {
+              await resolveWithToasts([
+                PersistedModelsApi.setRefreshSchedule({ cron: value }),
+                dispatch(refreshSiteSettings({})),
+              ]);
+            }}
+          />
+        </div>
+      )}
+    </Stack>
+  );
+};
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceTab.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceTab.tsx
new file mode 100644
index 00000000000..43995ff54f7
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceTab.tsx
@@ -0,0 +1,12 @@
+import { t } from "ttag";
+
+import { TabId } from "metabase/admin/performance/components/PerformanceApp";
+import { Tab } from "metabase/admin/performance/components/PerformanceApp.styled";
+
+export const ModelPersistenceTab = () => {
+  return (
+    <Tab key="ModelPersistence" value={TabId.ModelPersistence}>
+      {t`Model persistence`}
+    </Tab>
+  );
+};
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/index.tsx b/enterprise/frontend/src/metabase-enterprise/caching/index.tsx
index 4d1e2fa30cc..90fba28b7ab 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/index.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/index.tsx
@@ -5,6 +5,8 @@ import CacheTTLField from "./components/CacheTTLField";
 import { DashboardStrategySidebar } from "./components/DashboardStrategySidebar";
 import { GranularControlsExplanation } from "./components/GranularControlsExplanation";
 import { InvalidateNowButton } from "./components/InvalidateNowButton";
+import { ModelPersistenceConfiguration } from "./components/ModelPersistenceConfiguration";
+import { ModelPersistenceTab } from "./components/ModelPersistenceTab";
 import QuestionCacheTTLField from "./components/QuestionCacheTTLField";
 import { SidebarCacheForm } from "./components/SidebarCacheForm";
 import { SidebarCacheSection } from "./components/SidebarCacheSection";
@@ -44,4 +46,6 @@ if (hasPremiumFeature("cache_granular_controls")) {
     ttl: PLUGIN_CACHING.strategies.ttl,
     nocache: PLUGIN_CACHING.strategies.nocache,
   };
+  PLUGIN_CACHING.ModelPersistenceTab = ModelPersistenceTab;
+  PLUGIN_CACHING.ModelPersistenceConfiguration = ModelPersistenceConfiguration;
 }
diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts
index 198b72434ad..a145d3bb31d 100644
--- a/frontend/src/metabase-types/api/mocks/settings.ts
+++ b/frontend/src/metabase-types/api/mocks/settings.ts
@@ -199,6 +199,7 @@ export const createMockSettings = (
   "other-sso-enabled?": null,
   "password-complexity": { total: 6, digit: 1 },
   "persisted-models-enabled": false,
+  "persisted-model-refresh-cron-schedule": "0 0 0/6 * * ? *",
   "premium-embedding-token": null,
   "read-only-mode": false,
   "report-timezone-short": "UTC",
diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts
index d597a4f52ea..3f02857c885 100644
--- a/frontend/src/metabase-types/api/settings.ts
+++ b/frontend/src/metabase-types/api/settings.ts
@@ -302,6 +302,7 @@ interface PublicSettings {
   "other-sso-enabled?": boolean | null; // TODO: FIXME! This is an enterprise-only setting!
   "password-complexity": PasswordComplexity;
   "persisted-models-enabled": boolean;
+  "persisted-model-refresh-cron-schedule": string;
   "report-timezone-long": string;
   "report-timezone-short": string;
   "session-cookies": boolean | null;
diff --git a/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx b/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx
index 1b6c43ccb5d..ff73a08727b 100644
--- a/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx
+++ b/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx
@@ -2,6 +2,7 @@ import { useLayoutEffect, useRef, useState } from "react";
 import type { Route } from "react-router";
 import { t } from "ttag";
 
+import { PLUGIN_CACHING } from "metabase/plugins";
 import type { TabsValue } from "metabase/ui";
 import { Flex, Tabs } from "metabase/ui";
 
@@ -10,6 +11,7 @@ import { StrategyEditorForDatabases } from "./StrategyEditorForDatabases";
 
 export enum TabId {
   DataCachingSettings = "dataCachingSettings",
+  ModelPersistence = "modelPersistence",
 }
 const validTabIds = new Set(Object.values(TabId).map(String));
 const isValidTabId = (tab: TabsValue): tab is TabId =>
@@ -56,11 +58,19 @@ export const PerformanceApp = ({ route }: { route: Route }) => {
         <Tab key="DataCachingSettings" value={TabId.DataCachingSettings}>
           {t`Data caching settings`}
         </Tab>
+        <PLUGIN_CACHING.ModelPersistenceTab />
       </TabsList>
       <TabsPanel key={tabId} value={tabId} p="1rem 2.5rem">
-        <Flex style={{ flex: 1 }} bg="bg-light" h="100%">
-          <StrategyEditorForDatabases route={route} />
-        </Flex>
+        {tabId === TabId.DataCachingSettings && (
+          <Flex style={{ flex: 1 }} bg="bg-light" h="100%">
+            <StrategyEditorForDatabases route={route} />
+          </Flex>
+        )}
+        {tabId === TabId.ModelPersistence && (
+          <Flex style={{ flex: 1 }} bg="bg-light" h="100%">
+            <PLUGIN_CACHING.ModelPersistenceConfiguration />
+          </Flex>
+        )}
       </TabsPanel>
     </Tabs>
   );
diff --git a/frontend/src/metabase/admin/performance/utils.tsx b/frontend/src/metabase/admin/performance/utils.tsx
index 54286b46bbe..c1dc6ac7a18 100644
--- a/frontend/src/metabase/admin/performance/utils.tsx
+++ b/frontend/src/metabase/admin/performance/utils.tsx
@@ -173,10 +173,10 @@ const delay = (milliseconds: number) =>
  * An example of jumpiness: clicking a save button results in
  * displaying a loading spinner for 10 ms and then a success message */
 export const resolveSmoothly = async (
-  promise: Promise<any>,
+  promises: Promise<any>[],
   timeout: number = 300,
 ) => {
-  return await Promise.all([delay(timeout), promise]);
+  return await Promise.all([delay(timeout), ...promises]);
 };
 
 export const getFrequencyFromCron = (cron: string) => {
diff --git a/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/ModelCachingScheduleWidget.jsx b/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/ModelCachingScheduleWidget.jsx
index b7d666c4b20..277156e0c9d 100644
--- a/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/ModelCachingScheduleWidget.jsx
+++ b/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/ModelCachingScheduleWidget.jsx
@@ -32,11 +32,7 @@ function formatCronExpression(cronExpression) {
   return partsWithoutSecondsAndYear.join(" ");
 }
 
-const PersistedModelRefreshIntervalWidget = ({
-  setting,
-  disabled,
-  onChange,
-}) => {
+export const ModelCachingScheduleWidget = ({ setting, disabled, onChange }) => {
   const [isCustom, setCustom] = useState(isCustomSchedule(setting));
   const [customCronSchedule, setCustomCronSchedule] = useState(
     // We don't allow to specify the "year" component, but it's present in the value
@@ -93,6 +89,4 @@ const PersistedModelRefreshIntervalWidget = ({
   );
 };
 
-PersistedModelRefreshIntervalWidget.propTypes = propTypes;
-
-export default PersistedModelRefreshIntervalWidget;
+ModelCachingScheduleWidget.propTypes = propTypes;
diff --git a/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/index.ts b/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/index.ts
deleted file mode 100644
index 23e74e3577d..00000000000
--- a/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// eslint-disable-next-line import/no-default-export -- deprecated usage
-export { default } from "./ModelCachingScheduleWidget";
diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js
index 49a68c836d2..516e78d3f72 100644
--- a/frontend/src/metabase/admin/settings/selectors.js
+++ b/frontend/src/metabase/admin/settings/selectors.js
@@ -1,11 +1,10 @@
 import { createSelector } from "@reduxjs/toolkit";
-import { jt, t } from "ttag";
+import { t } from "ttag";
 import _ from "underscore";
 
 import { SMTPConnectionForm } from "metabase/admin/settings/components/Email/SMTPConnectionForm";
 import Breadcrumbs from "metabase/components/Breadcrumbs";
 import { DashboardSelector } from "metabase/components/DashboardSelector";
-import ExternalLink from "metabase/core/components/ExternalLink";
 import MetabaseSettings from "metabase/lib/settings";
 import {
   PLUGIN_ADMIN_SETTINGS_UPDATES,
@@ -14,7 +13,6 @@ import {
 } from "metabase/plugins";
 import { refreshCurrentUser } from "metabase/redux/user";
 import { getUserIsAdmin } from "metabase/selectors/user";
-import { PersistedModelsApi } from "metabase/services";
 
 import {
   trackCustomHomepageDashboardEnabled,
@@ -34,7 +32,6 @@ import {
 import { EmbeddingSwitchWidget } from "./components/widgets/EmbeddingSwitchWidget";
 import FormattingWidget from "./components/widgets/FormattingWidget";
 import HttpsOnlyWidget from "./components/widgets/HttpsOnlyWidget";
-import ModelCachingScheduleWidget from "./components/widgets/ModelCachingScheduleWidget";
 import {
   EmbeddedResources,
   PublicLinksActionListing,
@@ -515,71 +512,6 @@ export const ADMIN_SETTINGS_SECTIONS = {
     component: SettingsLicense,
     settings: [],
   },
-  caching: {
-    name: t`Caching`,
-    order: 120,
-    settings: [
-      {
-        key: "persisted-models-enabled",
-        display_name: t`Models`,
-        description: jt`Enabling caching will create tables for your models in a dedicated schema and Metabase will refresh them on a schedule. Questions based on your models will query these tables. ${(
-          <ExternalLink
-            key="model-caching-link"
-            href={MetabaseSettings.docsUrl("data-modeling/models")}
-          >{t`Learn more`}</ExternalLink>
-        )}.`,
-        type: "boolean",
-        disableDefaultUpdate: true,
-        onChanged: async (wasEnabled, isEnabled) => {
-          if (isEnabled) {
-            await PersistedModelsApi.enablePersistence();
-          } else {
-            await PersistedModelsApi.disablePersistence();
-          }
-        },
-      },
-      {
-        key: "persisted-model-refresh-cron-schedule",
-        noHeader: true,
-        type: "select",
-        options: [
-          {
-            value: "0 0 0/1 * * ? *",
-            name: t`Hour`,
-          },
-          {
-            value: "0 0 0/2 * * ? *",
-            name: t`2 hours`,
-          },
-          {
-            value: "0 0 0/3 * * ? *",
-            name: t`3 hours`,
-          },
-          {
-            value: "0 0 0/6 * * ? *",
-            name: t`6 hours`,
-          },
-          {
-            value: "0 0 0/12 * * ? *",
-            name: t`12 hours`,
-          },
-          {
-            value: "0 0 0 ? * * *",
-            name: t`24 hours`,
-          },
-          {
-            value: "custom",
-            name: t`Custom…`,
-          },
-        ],
-        widget: ModelCachingScheduleWidget,
-        disableDefaultUpdate: true,
-        getHidden: settings => !settings["persisted-models-enabled"],
-        onChanged: (previousValue, value) =>
-          PersistedModelsApi.setRefreshSchedule({ cron: value }),
-      },
-    ],
-  },
   metabot: {
     name: t`Metabot`,
     order: 130,
diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts
index ffc749423db..429e49e3c02 100644
--- a/frontend/src/metabase/plugins/index.ts
+++ b/frontend/src/metabase/plugins/index.ts
@@ -364,6 +364,8 @@ export const PLUGIN_CACHING = {
   hasQuestionCacheSection: (_question: Question) => false,
   canOverrideRootStrategy: false,
   strategies: strategies,
+  ModelPersistenceTab: PluginPlaceholder as any,
+  ModelPersistenceConfiguration: PluginPlaceholder as any,
 };
 
 export const PLUGIN_REDUCERS: {
-- 
GitLab