From c9bfb047eaf847a4267cb9c18b16091d55b39127 Mon Sep 17 00:00:00 2001
From: Nick Fitzpatrick <nick@metabase.com>
Date: Wed, 23 Oct 2024 18:03:21 -0300
Subject: [PATCH] Alert isValid check only looks at configured channels
 (#48485)

* converting UpdateAlertModalContent and CreateAlertModalContent to functional

* lint stuff

* e2e adjustments:

* type and e2e adjustments
---
 e2e/test/scenarios/question/saved.cy.spec.js  |   7 +-
 .../scenarios/sharing/alert/alert.cy.spec.js  |  19 ++-
 .../sharing/alert/email-alert.cy.spec.js      |   1 +
 .../sharing/sharing-reproductions.cy.spec.js  |   2 +
 frontend/src/metabase-lib/v1/Alert/Alert.ts   |   8 +-
 .../components/ModalContent/ModalContent.tsx  |   2 +-
 frontend/src/metabase/lib/alert.js            |  12 +-
 frontend/src/metabase/lib/pulse.ts            |  18 ++
 frontend/src/metabase/pulse/selectors.js      |  23 ---
 .../AlertListPopoverContent/AlertPopover.tsx  |  12 +-
 .../AlertModals/CreateAlertModalContent.jsx   | 159 ------------------
 .../AlertModals/CreateAlertModalContent.tsx   | 135 +++++++++++++++
 .../AlertModals/UpdateAlertModalContent.jsx   | 101 -----------
 .../AlertModals/UpdateAlertModalContent.tsx   |  93 ++++++++++
 14 files changed, 290 insertions(+), 302 deletions(-)
 delete mode 100644 frontend/src/metabase/query_builder/components/AlertModals/CreateAlertModalContent.jsx
 create mode 100644 frontend/src/metabase/query_builder/components/AlertModals/CreateAlertModalContent.tsx
 delete mode 100644 frontend/src/metabase/query_builder/components/AlertModals/UpdateAlertModalContent.jsx
 create mode 100644 frontend/src/metabase/query_builder/components/AlertModals/UpdateAlertModalContent.tsx

diff --git a/e2e/test/scenarios/question/saved.cy.spec.js b/e2e/test/scenarios/question/saved.cy.spec.js
index 304cbac69b1..3b4811187b8 100644
--- a/e2e/test/scenarios/question/saved.cy.spec.js
+++ b/e2e/test/scenarios/question/saved.cy.spec.js
@@ -31,6 +31,7 @@ import {
   sidesheet,
   summarize,
   tableHeaderClick,
+  toggleAlertChannel,
   visitQuestion,
 } from "e2e/support/helpers";
 
@@ -497,10 +498,8 @@ describe(
       popover().findByText("Create alert").click();
       modal().button("Set up an alert").click();
       modal().within(() => {
-        getAlertChannel(secondWebhookName).scrollIntoView();
-        getAlertChannel(secondWebhookName)
-          .findByRole("checkbox")
-          .click({ force: true });
+        toggleAlertChannel("Email");
+        toggleAlertChannel(secondWebhookName);
         cy.button("Done").click();
       });
       cy.findByTestId("sharing-menu-button").click();
diff --git a/e2e/test/scenarios/sharing/alert/alert.cy.spec.js b/e2e/test/scenarios/sharing/alert/alert.cy.spec.js
index 7e19af0a82c..2deb179e6c6 100644
--- a/e2e/test/scenarios/sharing/alert/alert.cy.spec.js
+++ b/e2e/test/scenarios/sharing/alert/alert.cy.spec.js
@@ -17,7 +17,18 @@ import {
   visitQuestion,
 } from "e2e/support/helpers";
 
-const channels = { slack: mockSlackConfigured, email: setupSMTP };
+const channels = {
+  slack: {
+    setup: mockSlackConfigured,
+    createAlert: () => {
+      toggleAlertChannel("Email");
+      toggleAlertChannel("Slack");
+      cy.findByPlaceholderText(/Pick a user or channel/).click();
+      popover().findByText("#work").click();
+    },
+  },
+  email: { setup: setupSMTP, createAlert: () => {} },
+};
 
 describe("scenarios > alert", () => {
   beforeEach(() => {
@@ -66,9 +77,9 @@ describe("scenarios > alert", () => {
     });
   });
 
-  Object.entries(channels).forEach(([channel, setup]) => {
+  Object.entries(channels).forEach(([channel, config]) => {
     describe(`with ${channel} set up`, { tags: "@external" }, () => {
-      beforeEach(setup);
+      beforeEach(config.setup);
 
       it("educational screen should show for the first alert, but not for the second", () => {
         cy.intercept("POST", "/api/alert").as("savedAlert");
@@ -94,6 +105,8 @@ describe("scenarios > alert", () => {
 
         // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
         cy.findByText("Set up an alert").click();
+
+        config.createAlert();
         // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
         cy.findByText("Done").click();
 
diff --git a/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js b/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js
index bf62504da7f..dd3b83d2680 100644
--- a/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js
+++ b/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js
@@ -58,6 +58,7 @@ describe("scenarios > alert > email_alert", { tags: "@external" }, () => {
 
   it("should respect email alerts toggled off (metabase#12349)", () => {
     updateSetting("report-timezone", "America/New_York");
+    mockSlackConfigured();
 
     //For this test, we need to pretend that slack is set up
     mockSlackConfigured();
diff --git a/e2e/test/scenarios/sharing/sharing-reproductions.cy.spec.js b/e2e/test/scenarios/sharing/sharing-reproductions.cy.spec.js
index 44954979489..1a64a3346c8 100644
--- a/e2e/test/scenarios/sharing/sharing-reproductions.cy.spec.js
+++ b/e2e/test/scenarios/sharing/sharing-reproductions.cy.spec.js
@@ -17,6 +17,7 @@ import {
   getDashboardCard,
   getFullName,
   getIframeBody,
+  mockSlackConfigured,
   modal,
   openAndAddEmailsToSubscriptions,
   openNewPublicLinkDropdown,
@@ -960,6 +961,7 @@ describe("issue 17547", () => {
   beforeEach(() => {
     restore();
     cy.signInAsAdmin();
+    mockSlackConfigured();
 
     cy.createQuestion(questionDetails).then(({ body: { id: questionId } }) => {
       setUpAlert(questionId);
diff --git a/frontend/src/metabase-lib/v1/Alert/Alert.ts b/frontend/src/metabase-lib/v1/Alert/Alert.ts
index a556fe2814d..b674f5d6f4f 100644
--- a/frontend/src/metabase-lib/v1/Alert/Alert.ts
+++ b/frontend/src/metabase-lib/v1/Alert/Alert.ts
@@ -5,11 +5,11 @@ import type { User } from "metabase-types/api/user";
 import { ALERT_TYPE_ROWS } from "./constants";
 
 export const getDefaultAlert = (
-  question: Question,
-  user: User,
+  question: Question | undefined,
+  user: User | null,
   visualizationSettings: VisualizationSettings,
 ) => {
-  const alertType = question.alertType(visualizationSettings);
+  const alertType = question?.alertType(visualizationSettings);
   const typeDependentAlertFields =
     alertType === ALERT_TYPE_ROWS
       ? {
@@ -24,7 +24,7 @@ export const getDefaultAlert = (
 
   return {
     card: {
-      id: question.id(),
+      id: question?.id(),
       include_csv: false,
       include_xls: false,
     },
diff --git a/frontend/src/metabase/components/ModalContent/ModalContent.tsx b/frontend/src/metabase/components/ModalContent/ModalContent.tsx
index a1b883d526f..cbb23d949e1 100644
--- a/frontend/src/metabase/components/ModalContent/ModalContent.tsx
+++ b/frontend/src/metabase/components/ModalContent/ModalContent.tsx
@@ -11,7 +11,7 @@ import type { CommonModalProps } from "./types";
 export interface ModalContentProps extends CommonModalProps {
   "data-testid"?: string;
   id?: string;
-  title: string;
+  title?: string;
   footer?: ReactNode;
   children: ReactNode;
 
diff --git a/frontend/src/metabase/lib/alert.js b/frontend/src/metabase/lib/alert.js
index 860ab019cda..b70bd2d947b 100644
--- a/frontend/src/metabase/lib/alert.js
+++ b/frontend/src/metabase/lib/alert.js
@@ -22,7 +22,15 @@ export function channelIsEnabled(channel) {
   return channel.enabled;
 }
 
-export function alertIsValid(alert) {
+export function alertIsValid(alert, channelSpec) {
   const enabledChannels = alert.channels.filter(channelIsEnabled);
-  return enabledChannels.length > 0 && enabledChannels.every(channelIsValid);
+
+  return (
+    channelSpec.channels &&
+    enabledChannels.length > 0 &&
+    enabledChannels.every(channel => channelIsValid(channel)) &&
+    enabledChannels
+      .filter(c => c.enabled)
+      .every(c => channelSpec.channels[c.channel_type]?.configured)
+  );
 }
diff --git a/frontend/src/metabase/lib/pulse.ts b/frontend/src/metabase/lib/pulse.ts
index 1db937155f2..86fc6981cbb 100644
--- a/frontend/src/metabase/lib/pulse.ts
+++ b/frontend/src/metabase/lib/pulse.ts
@@ -8,6 +8,7 @@ import {
 } from "metabase-lib/v1/parameters/utils/parameter-values";
 import type {
   Channel,
+  ChannelApiResponse,
   ChannelSpec,
   Pulse,
   PulseParameter,
@@ -214,3 +215,20 @@ export function getActivePulseParameters(
     (parameter: any) => parameter.value != null,
   );
 }
+
+export const getHasConfiguredAnyChannel = (
+  formInput: Partial<ChannelApiResponse>,
+) =>
+  (formInput.channels &&
+    _.some(Object.values(formInput.channels), c => c.configured)) ||
+  false;
+
+export const getHasConfiguredEmailChannel = (
+  formInput: Partial<ChannelApiResponse>,
+) =>
+  (formInput.channels &&
+    _.some(
+      Object.values(formInput.channels),
+      c => c.type === "email" && c.configured,
+    )) ||
+  false;
diff --git a/frontend/src/metabase/pulse/selectors.js b/frontend/src/metabase/pulse/selectors.js
index d31fb454fc4..87e24c422c3 100644
--- a/frontend/src/metabase/pulse/selectors.js
+++ b/frontend/src/metabase/pulse/selectors.js
@@ -1,32 +1,9 @@
-import { createSelector } from "@reduxjs/toolkit";
 import _ from "underscore";
 
 export const getEditingPulse = state => state.pulse.editingPulse;
 
 export const getPulseFormInput = state => state.pulse?.formInput;
 
-export const hasLoadedChannelInfoSelector = createSelector(
-  [getPulseFormInput],
-  formInput => !!formInput.channels,
-);
-export const hasConfiguredAnyChannelSelector = createSelector(
-  [getPulseFormInput],
-  formInput =>
-    (formInput.channels &&
-      _.some(Object.values(formInput.channels), c => c.configured)) ||
-    false,
-);
-export const hasConfiguredEmailChannelSelector = createSelector(
-  [getPulseFormInput],
-  formInput =>
-    (formInput.channels &&
-      _.some(
-        Object.values(formInput.channels),
-        c => c.type === "email" && c.configured,
-      )) ||
-    false,
-);
-
 export const getPulseCardPreviews = state => state.pulse.cardPreviews;
 
 export const getPulseId = (state, props) =>
diff --git a/frontend/src/metabase/query_builder/components/AlertListPopoverContent/AlertPopover.tsx b/frontend/src/metabase/query_builder/components/AlertListPopoverContent/AlertPopover.tsx
index e6f5e56117c..4f9c752d846 100644
--- a/frontend/src/metabase/query_builder/components/AlertListPopoverContent/AlertPopover.tsx
+++ b/frontend/src/metabase/query_builder/components/AlertListPopoverContent/AlertPopover.tsx
@@ -85,11 +85,13 @@ export const AlertPopover = forwardRef(function _AlertPopover(
         isOpen={showingElement === "update-modal"}
         onClose={onClose}
       >
-        <UpdateAlertModalContent
-          alert={editingAlert}
-          onCancel={onClose}
-          onAlertUpdated={onClose}
-        />
+        {editingAlert && (
+          <UpdateAlertModalContent
+            alert={editingAlert}
+            onCancel={onClose}
+            onAlertUpdated={onClose}
+          />
+        )}
       </Modal>
     </>
   );
diff --git a/frontend/src/metabase/query_builder/components/AlertModals/CreateAlertModalContent.jsx b/frontend/src/metabase/query_builder/components/AlertModals/CreateAlertModalContent.jsx
deleted file mode 100644
index 05e2b0178f1..00000000000
--- a/frontend/src/metabase/query_builder/components/AlertModals/CreateAlertModalContent.jsx
+++ /dev/null
@@ -1,159 +0,0 @@
-/* eslint-disable react/prop-types */
-import cx from "classnames";
-import { Component } from "react";
-import { connect } from "react-redux";
-import { t } from "ttag";
-
-import { createAlert } from "metabase/alert/alert";
-import ButtonWithStatus from "metabase/components/ButtonWithStatus";
-import ChannelSetupModal from "metabase/components/ChannelSetupModal";
-import ModalContent from "metabase/components/ModalContent";
-import Button from "metabase/core/components/Button";
-import CS from "metabase/css/core/index.css";
-import { alertIsValid } from "metabase/lib/alert";
-import MetabaseCookies from "metabase/lib/cookies";
-import { fetchPulseFormInput } from "metabase/pulse/actions";
-import {
-  hasConfiguredAnyChannelSelector,
-  hasConfiguredEmailChannelSelector,
-  hasLoadedChannelInfoSelector,
-} from "metabase/pulse/selectors";
-import { apiUpdateQuestion, updateUrl } from "metabase/query_builder/actions";
-import {
-  getQuestion,
-  getVisualizationSettings,
-} from "metabase/query_builder/selectors";
-import { getUser, getUserIsAdmin } from "metabase/selectors/user";
-import { getDefaultAlert } from "metabase-lib/v1/Alert";
-
-import { AlertEditForm } from "./AlertEditForm";
-import { AlertEducationalScreen } from "./AlertEducationalScreen";
-import { AlertModalTitle } from "./AlertModalTitle";
-import { AlertModalFooter } from "./AlertModals.styled";
-
-class CreateAlertModalContentInner extends Component {
-  constructor(props) {
-    super();
-
-    const { question, user, visualizationSettings } = props;
-
-    this.state = {
-      hasSeenEducationalScreen: MetabaseCookies.getHasSeenAlertSplash(),
-      alert: getDefaultAlert(question, user, visualizationSettings),
-    };
-  }
-
-  UNSAFE_componentWillReceiveProps(newProps) {
-    // NOTE Atte Keinänen 11/6/17: Don't fill in the card information yet
-    // Because `onCreate` and `onSave` of QueryHeader mix Redux action dispatches and `setState` calls,
-    // we don't have up-to-date card information in the constructor yet
-    // TODO: Refactor QueryHeader so that `onCreate` and `onSave` only call Redux actions and don't modify the local state
-    if (this.props.question !== newProps.question) {
-      this.setState({
-        alert: {
-          ...this.state.alert,
-          card: { ...this.state.alert.card, id: newProps.question.id() },
-        },
-      });
-    }
-  }
-
-  UNSAFE_componentWillMount() {
-    // loads the channel information
-    this.props.fetchPulseFormInput();
-  }
-
-  onAlertChange = alert => this.setState({ alert });
-
-  onCreateAlert = async () => {
-    const { question, createAlert, updateUrl, onAlertCreated } = this.props;
-    const { alert } = this.state;
-
-    await createAlert(alert);
-    await updateUrl(question, { dirty: false });
-
-    onAlertCreated();
-  };
-
-  proceedFromEducationalScreen = () => {
-    MetabaseCookies.setHasSeenAlertSplash(true);
-    this.setState({ hasSeenEducationalScreen: true });
-  };
-
-  render() {
-    const {
-      question,
-      visualizationSettings,
-      onCancel,
-      hasConfiguredAnyChannel,
-      hasConfiguredEmailChannel,
-      isAdmin,
-      user,
-      hasLoadedChannelInfo,
-    } = this.props;
-    const { alert, hasSeenEducationalScreen } = this.state;
-
-    const channelRequirementsMet = isAdmin
-      ? hasConfiguredAnyChannel
-      : hasConfiguredEmailChannel;
-    const isValid = alertIsValid(alert);
-
-    if (hasLoadedChannelInfo && !channelRequirementsMet) {
-      return (
-        <ChannelSetupModal
-          user={user}
-          onClose={onCancel}
-          entityNamePlural={t`alerts`}
-          channels={isAdmin ? ["email", "Slack", "Webhook"] : ["email"]}
-        />
-      );
-    }
-    if (!hasSeenEducationalScreen) {
-      return (
-        <ModalContent onClose={onCancel} data-testid="alert-education-screen">
-          <AlertEducationalScreen
-            onProceed={this.proceedFromEducationalScreen}
-          />
-        </ModalContent>
-      );
-    }
-
-    // TODO: Remove PulseEdit css hack
-    return (
-      <ModalContent data-testid="alert-create" onClose={onCancel}>
-        <div
-          className={cx(CS.mlAuto, CS.mrAuto, CS.mb4)}
-          style={{ maxWidth: "550px" }}
-        >
-          <AlertModalTitle text={t`Let's set up your alert`} />
-          <AlertEditForm
-            alertType={question.alertType(visualizationSettings)}
-            alert={alert}
-            onAlertChange={this.onAlertChange}
-          />
-          <AlertModalFooter>
-            <Button onClick={onCancel} className={CS.mr2}>{t`Cancel`}</Button>
-            <ButtonWithStatus
-              titleForState={{ default: t`Done` }}
-              disabled={!isValid}
-              onClickOperation={this.onCreateAlert}
-            />
-          </AlertModalFooter>
-        </div>
-      </ModalContent>
-    );
-  }
-}
-
-export const CreateAlertModalContent = connect(
-  state => ({
-    question: getQuestion(state),
-    visualizationSettings: getVisualizationSettings(state),
-    isAdmin: getUserIsAdmin(state),
-    user: getUser(state),
-    hasLoadedChannelInfo: hasLoadedChannelInfoSelector(state),
-    hasConfiguredAnyChannel: hasConfiguredAnyChannelSelector(state),
-    hasConfiguredEmailChannel: hasConfiguredEmailChannelSelector(state),
-  }),
-  { createAlert, fetchPulseFormInput, apiUpdateQuestion, updateUrl },
-)(CreateAlertModalContentInner);
diff --git a/frontend/src/metabase/query_builder/components/AlertModals/CreateAlertModalContent.tsx b/frontend/src/metabase/query_builder/components/AlertModals/CreateAlertModalContent.tsx
new file mode 100644
index 00000000000..17921384a26
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/AlertModals/CreateAlertModalContent.tsx
@@ -0,0 +1,135 @@
+import cx from "classnames";
+import { useEffect, useState } from "react";
+import { t } from "ttag";
+
+import { createAlert } from "metabase/alert/alert";
+import { useGetChannelInfoQuery } from "metabase/api";
+import ButtonWithStatus from "metabase/components/ButtonWithStatus";
+import ChannelSetupModal from "metabase/components/ChannelSetupModal";
+import ModalContent from "metabase/components/ModalContent";
+import Button from "metabase/core/components/Button";
+import CS from "metabase/css/core/index.css";
+import { alertIsValid } from "metabase/lib/alert";
+import MetabaseCookies from "metabase/lib/cookies";
+import {
+  getHasConfiguredAnyChannel,
+  getHasConfiguredEmailChannel,
+} from "metabase/lib/pulse";
+import { useDispatch, useSelector } from "metabase/lib/redux";
+import { updateUrl } from "metabase/query_builder/actions";
+import {
+  getQuestion,
+  getVisualizationSettings,
+} from "metabase/query_builder/selectors";
+import { getUser, getUserIsAdmin } from "metabase/selectors/user";
+import { getDefaultAlert } from "metabase-lib/v1/Alert";
+import type { Alert } from "metabase-types/api";
+
+import { AlertEditForm } from "./AlertEditForm";
+import { AlertEducationalScreen } from "./AlertEducationalScreen";
+import { AlertModalTitle } from "./AlertModalTitle";
+import { AlertModalFooter } from "./AlertModals.styled";
+
+interface CreateAlertModalContentProps {
+  onAlertCreated: () => void;
+  onCancel: () => void;
+}
+
+export const CreateAlertModalContent = ({
+  onAlertCreated,
+  onCancel,
+}: CreateAlertModalContentProps) => {
+  const dispatch = useDispatch();
+  const question = useSelector(getQuestion);
+  const visualizationSettings = useSelector(getVisualizationSettings);
+  const isAdmin = useSelector(getUserIsAdmin);
+  const user = useSelector(getUser);
+
+  const { data: channelSpec = {}, isLoading: isLoadingChannelInfo } =
+    useGetChannelInfoQuery();
+
+  const hasConfiguredAnyChannel = getHasConfiguredAnyChannel(channelSpec);
+  const hasConfiguredEmailChannel = getHasConfiguredEmailChannel(channelSpec);
+
+  const [alert, setAlert] = useState<any>(
+    getDefaultAlert(question, user, visualizationSettings),
+  );
+
+  const [hasSeenEducationalScreen, setHasSeenEducationalScreen] = useState(
+    MetabaseCookies.getHasSeenAlertSplash(),
+  );
+
+  useEffect(() => {
+    // NOTE Atte Keinänen 11/6/17: Don't fill in the card information yet
+    // Because `onCreate` and `onSave` of QueryHeader mix Redux action dispatches and `setState` calls,
+    // we don't have up-to-date card information in the constructor yet
+    // TODO: Refactor QueryHeader so that `onCreate` and `onSave` only call Redux actions and don't modify the local state
+    setAlert((currentAlert: any) => ({
+      ...currentAlert,
+      card: { ...currentAlert.card, id: question?.id() },
+    }));
+  }, [question]);
+
+  const onAlertChange = (newAlert: Alert) => setAlert(newAlert);
+
+  const onCreateAlert = async () => {
+    await dispatch(createAlert(alert));
+    await dispatch(updateUrl(question, { dirty: false }));
+
+    onAlertCreated();
+  };
+
+  const proceedFromEducationalScreen = () => {
+    MetabaseCookies.setHasSeenAlertSplash(true);
+    setHasSeenEducationalScreen(true);
+  };
+
+  const channelRequirementsMet = isAdmin
+    ? hasConfiguredAnyChannel
+    : hasConfiguredEmailChannel;
+
+  const isValid = alertIsValid(alert, channelSpec);
+
+  if (!isLoadingChannelInfo && !channelRequirementsMet) {
+    return (
+      <ChannelSetupModal
+        user={user}
+        onClose={onCancel}
+        entityNamePlural={t`alerts`}
+        channels={isAdmin ? ["email", "Slack", "Webhook"] : ["email"]}
+      />
+    );
+  }
+  if (!hasSeenEducationalScreen) {
+    return (
+      <ModalContent onClose={onCancel} data-testid="alert-education-screen">
+        <AlertEducationalScreen onProceed={proceedFromEducationalScreen} />
+      </ModalContent>
+    );
+  }
+
+  // TODO: Remove PulseEdit css hack
+  return (
+    <ModalContent data-testid="alert-create" onClose={onCancel}>
+      <div
+        className={cx(CS.mlAuto, CS.mrAuto, CS.mb4)}
+        style={{ maxWidth: "550px" }}
+      >
+        <AlertModalTitle text={t`Let's set up your alert`} />
+        <AlertEditForm
+          alertType={question?.alertType(visualizationSettings)}
+          alert={alert}
+          onAlertChange={onAlertChange}
+        />
+        <AlertModalFooter>
+          <Button onClick={onCancel} className={CS.mr2}>{t`Cancel`}</Button>
+          <ButtonWithStatus
+            titleForState={{ default: t`Done` }}
+            disabled={!isValid}
+            onClickOperation={onCreateAlert}
+          />
+        </AlertModalFooter>
+      </div>
+    </ModalContent>
+  );
+};
diff --git a/frontend/src/metabase/query_builder/components/AlertModals/UpdateAlertModalContent.jsx b/frontend/src/metabase/query_builder/components/AlertModals/UpdateAlertModalContent.jsx
deleted file mode 100644
index 2763dbb0d2d..00000000000
--- a/frontend/src/metabase/query_builder/components/AlertModals/UpdateAlertModalContent.jsx
+++ /dev/null
@@ -1,101 +0,0 @@
-/* eslint-disable react/prop-types */
-import cx from "classnames";
-import { Component } from "react";
-import { connect } from "react-redux";
-import { t } from "ttag";
-
-import { deleteAlert, updateAlert } from "metabase/alert/alert";
-import ButtonWithStatus from "metabase/components/ButtonWithStatus";
-import ModalContent from "metabase/components/ModalContent";
-import Button from "metabase/core/components/Button";
-import CS from "metabase/css/core/index.css";
-import { alertIsValid } from "metabase/lib/alert";
-import { updateUrl } from "metabase/query_builder/actions";
-import {
-  getQuestion,
-  getVisualizationSettings,
-} from "metabase/query_builder/selectors";
-import { getUser, getUserIsAdmin } from "metabase/selectors/user";
-
-import { AlertEditForm } from "./AlertEditForm";
-import { AlertModalTitle } from "./AlertModalTitle";
-import { AlertModalFooter } from "./AlertModals.styled";
-import { DeleteAlertSection } from "./DeleteAlertSection";
-
-class UpdateAlertModalContentInner extends Component {
-  constructor(props) {
-    super();
-    this.state = {
-      modifiedAlert: props.alert,
-    };
-  }
-
-  onAlertChange = modifiedAlert => this.setState({ modifiedAlert });
-
-  onUpdateAlert = async () => {
-    const { question, updateAlert, updateUrl, onAlertUpdated } = this.props;
-    const { modifiedAlert } = this.state;
-
-    await updateAlert(modifiedAlert);
-    await updateUrl(question, { dirty: false });
-    onAlertUpdated();
-  };
-
-  onDeleteAlert = async () => {
-    const { alert, deleteAlert, onAlertUpdated } = this.props;
-    await deleteAlert(alert.id);
-    onAlertUpdated();
-  };
-
-  render() {
-    const { onCancel, question, visualizationSettings, alert, user, isAdmin } =
-      this.props;
-    const { modifiedAlert } = this.state;
-
-    const isCurrentUser = alert.creator.id === user.id;
-    const title = isCurrentUser ? t`Edit your alert` : t`Edit alert`;
-    const isValid = alertIsValid(alert);
-
-    // TODO: Remove PulseEdit css hack
-    return (
-      <ModalContent onClose={onCancel} data-testid="alert-edit">
-        <div
-          className={cx(CS.mlAuto, CS.mrAuto, CS.mb4)}
-          style={{ maxWidth: "550px" }}
-        >
-          <AlertModalTitle text={title} />
-          <AlertEditForm
-            alertType={question.alertType(visualizationSettings)}
-            alert={modifiedAlert}
-            onAlertChange={this.onAlertChange}
-          />
-          {isAdmin && (
-            <DeleteAlertSection
-              alert={alert}
-              onDeleteAlert={this.onDeleteAlert}
-            />
-          )}
-
-          <AlertModalFooter>
-            <Button onClick={onCancel} className={CS.mr2}>{t`Cancel`}</Button>
-            <ButtonWithStatus
-              titleForState={{ default: t`Save changes` }}
-              disabled={!isValid}
-              onClickOperation={this.onUpdateAlert}
-            />
-          </AlertModalFooter>
-        </div>
-      </ModalContent>
-    );
-  }
-}
-
-export const UpdateAlertModalContent = connect(
-  state => ({
-    user: getUser(state),
-    isAdmin: getUserIsAdmin(state),
-    question: getQuestion(state),
-    visualizationSettings: getVisualizationSettings(state),
-  }),
-  { updateAlert, deleteAlert, updateUrl },
-)(UpdateAlertModalContentInner);
diff --git a/frontend/src/metabase/query_builder/components/AlertModals/UpdateAlertModalContent.tsx b/frontend/src/metabase/query_builder/components/AlertModals/UpdateAlertModalContent.tsx
new file mode 100644
index 00000000000..33442045340
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/AlertModals/UpdateAlertModalContent.tsx
@@ -0,0 +1,93 @@
+import cx from "classnames";
+import { useState } from "react";
+import { t } from "ttag";
+
+import { deleteAlert, updateAlert } from "metabase/alert/alert";
+import { useGetChannelInfoQuery } from "metabase/api";
+import ButtonWithStatus from "metabase/components/ButtonWithStatus";
+import ModalContent from "metabase/components/ModalContent";
+import Button from "metabase/core/components/Button";
+import CS from "metabase/css/core/index.css";
+import { alertIsValid } from "metabase/lib/alert";
+import { useDispatch, useSelector } from "metabase/lib/redux";
+import { updateUrl } from "metabase/query_builder/actions";
+import {
+  getQuestion,
+  getVisualizationSettings,
+} from "metabase/query_builder/selectors";
+import { getUser, getUserIsAdmin } from "metabase/selectors/user";
+import type { Alert } from "metabase-types/api";
+
+import { AlertEditForm } from "./AlertEditForm";
+import { AlertModalTitle } from "./AlertModalTitle";
+import { AlertModalFooter } from "./AlertModals.styled";
+import { DeleteAlertSection } from "./DeleteAlertSection";
+
+interface UpdateAlertModalContentProps {
+  alert: Alert;
+  onAlertUpdated: () => void;
+  onCancel: () => void;
+}
+
+export const UpdateAlertModalContent = ({
+  alert,
+  onAlertUpdated,
+  onCancel,
+}: UpdateAlertModalContentProps) => {
+  const dispatch = useDispatch();
+
+  const user = useSelector(getUser);
+  const isAdmin = useSelector(getUserIsAdmin);
+  const question = useSelector(getQuestion);
+  const visualizationSettings = useSelector(getVisualizationSettings);
+
+  const [modifiedAlert, setModifiedAlert] = useState(alert);
+  const onAlertChange = (newModifiedAlert: Alert) =>
+    setModifiedAlert(newModifiedAlert);
+
+  const { data: channelSpec = {} } = useGetChannelInfoQuery();
+
+  const onUpdateAlert = async () => {
+    await dispatch(updateAlert(modifiedAlert));
+    await dispatch(updateUrl(question, { dirty: false }));
+    onAlertUpdated();
+  };
+
+  const onDeleteAlert = async () => {
+    await dispatch(deleteAlert(alert?.id));
+    onAlertUpdated();
+  };
+
+  const isCurrentUser = alert?.creator.id === user?.id;
+  const title = isCurrentUser ? t`Edit your alert` : t`Edit alert`;
+  const isValid = alertIsValid(modifiedAlert, channelSpec);
+
+  // TODO: Remove PulseEdit css hack
+  return (
+    <ModalContent onClose={onCancel} data-testid="alert-edit">
+      <div
+        className={cx(CS.mlAuto, CS.mrAuto, CS.mb4)}
+        style={{ maxWidth: "550px" }}
+      >
+        <AlertModalTitle text={title} />
+        <AlertEditForm
+          alertType={question?.alertType(visualizationSettings)}
+          alert={modifiedAlert}
+          onAlertChange={onAlertChange}
+        />
+        {isAdmin && (
+          <DeleteAlertSection alert={alert} onDeleteAlert={onDeleteAlert} />
+        )}
+
+        <AlertModalFooter>
+          <Button onClick={onCancel} className={CS.mr2}>{t`Cancel`}</Button>
+          <ButtonWithStatus
+            titleForState={{ default: t`Save changes` }}
+            disabled={!isValid}
+            onClickOperation={onUpdateAlert}
+          />
+        </AlertModalFooter>
+      </div>
+    </ModalContent>
+  );
+};
-- 
GitLab