From bd9c63c55db350c2f7e6153b694d1df2a9105ef0 Mon Sep 17 00:00:00 2001
From: Alexander Polyankin <alexander.polyankin@metabase.com>
Date: Thu, 11 Apr 2024 15:49:18 +0300
Subject: [PATCH] Migrate alerts to RTK (#41190)

* Replace AlertApi

* Replace AlertApi

* Replace AlertApi

* Fix bugs

* Fix bugs

* Fix typo

---------

Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>
---
 frontend/src/metabase-types/api/alert.ts      | 48 +++++++++--
 .../src/metabase-types/api/mocks/alert.ts     | 14 +++-
 frontend/src/metabase/alert/alert.js          | 29 +++++--
 frontend/src/metabase/api/alert.ts            | 83 +++++++++++++++++++
 frontend/src/metabase/api/index.ts            |  1 +
 frontend/src/metabase/api/tags/constants.ts   |  1 +
 frontend/src/metabase/api/tags/utils.ts       | 14 ++++
 frontend/src/metabase/entities/alerts.js      | 44 +++++++++-
 frontend/src/metabase/lib/request.js          |  2 +-
 frontend/src/metabase/services.js             |  9 --
 10 files changed, 215 insertions(+), 30 deletions(-)
 create mode 100644 frontend/src/metabase/api/alert.ts

diff --git a/frontend/src/metabase-types/api/alert.ts b/frontend/src/metabase-types/api/alert.ts
index 108d3a704e4..9e100bb2dcd 100644
--- a/frontend/src/metabase-types/api/alert.ts
+++ b/frontend/src/metabase-types/api/alert.ts
@@ -1,20 +1,23 @@
-import type { Card } from "./card";
+import type { CardId } from "./card";
 import type { CollectionId } from "./collection";
-import type { DashboardId } from "./dashboard";
+import type { DashboardId, DashCardId } from "./dashboard";
 import type { Channel } from "./notifications";
 import type { Parameter } from "./parameters";
 import type { UserId, UserInfo } from "./user";
 
+export type AlertId = number;
+export type AlertCondition = "goal" | "rows";
+
 export interface Alert {
-  id: number;
+  id: AlertId;
   name: string | null;
 
   alert_above_goal: boolean | null;
-  alert_condition: "goal" | "rows";
+  alert_condition: AlertCondition;
   alert_first_only: boolean;
   skip_if_empty: boolean;
 
-  card: Card;
+  card: AlertCard;
   parameters: Parameter[];
   channels: Channel[];
 
@@ -33,3 +36,38 @@ export interface Alert {
   created_at: string;
   updated_at: string;
 }
+
+export interface AlertCard {
+  id: CardId;
+  include_csv: boolean;
+  include_xls: boolean;
+  format_rows?: boolean;
+  dashboard_card_id?: DashCardId;
+}
+
+export interface ListAlertsRequest {
+  user_id?: UserId;
+  archived?: boolean;
+}
+
+export interface ListCardAlertsRequest {
+  id: CardId;
+  archived?: boolean;
+}
+
+export interface CreateAlertRequest {
+  card: AlertCard;
+  alert_condition: AlertCondition;
+  alert_first_only: boolean;
+  alert_above_goal: boolean;
+  channels: Channel[];
+}
+
+export interface UpdateAlertRequest {
+  id: AlertId;
+  card?: AlertCard;
+  alert_condition?: AlertCondition;
+  alert_first_only?: boolean;
+  alert_above_goal?: boolean;
+  channels?: Channel[];
+}
diff --git a/frontend/src/metabase-types/api/mocks/alert.ts b/frontend/src/metabase-types/api/mocks/alert.ts
index 7e0ad1a2f7e..0ffb13d6c5a 100644
--- a/frontend/src/metabase-types/api/mocks/alert.ts
+++ b/frontend/src/metabase-types/api/mocks/alert.ts
@@ -1,6 +1,5 @@
-import type { Alert } from "../alert";
+import type { Alert, AlertCard } from "../alert";
 
-import { createMockCard } from "./card";
 import { createMockUserInfo } from "./user";
 
 export const createMockAlert = (opts?: Partial<Alert>): Alert => ({
@@ -11,7 +10,7 @@ export const createMockAlert = (opts?: Partial<Alert>): Alert => ({
   alert_first_only: false,
   skip_if_empty: false,
 
-  card: createMockCard(),
+  card: createMockAlertCard(),
   parameters: [],
   channels: [],
 
@@ -32,3 +31,12 @@ export const createMockAlert = (opts?: Partial<Alert>): Alert => ({
 
   ...opts,
 });
+
+export function createMockAlertCard(opts?: Partial<AlertCard>): AlertCard {
+  return {
+    id: 1,
+    include_csv: false,
+    include_xls: false,
+    ...opts,
+  };
+}
diff --git a/frontend/src/metabase/alert/alert.js b/frontend/src/metabase/alert/alert.js
index d8f0e217b44..30d3a3b53c3 100644
--- a/frontend/src/metabase/alert/alert.js
+++ b/frontend/src/metabase/alert/alert.js
@@ -4,15 +4,17 @@ import { handleActions } from "redux-actions";
 import { t } from "ttag";
 import _ from "underscore";
 
+import { alertApi } from "metabase/api";
 import CS from "metabase/css/core/index.css";
+import { entityCompatibleQuery } from "metabase/lib/entities";
 import { RestfulRequest } from "metabase/lib/request";
 import { addUndo } from "metabase/redux/undo";
-import { AlertApi } from "metabase/services";
 import { Icon } from "metabase/ui";
 
 export const FETCH_ALL_ALERTS = "metabase/alerts/FETCH_ALL_ALERTS";
 const fetchAllAlertsRequest = new RestfulRequest({
-  endpoint: AlertApi.list,
+  endpoint: (params, dispatch) =>
+    entityCompatibleQuery(params, dispatch, alertApi.endpoints.listAlerts),
   actionPrefix: FETCH_ALL_ALERTS,
   storeAsDictionary: true,
 });
@@ -28,7 +30,8 @@ export const FETCH_ALERTS_FOR_QUESTION_CLEAR_OLD_ALERTS =
 export const FETCH_ALERTS_FOR_QUESTION =
   "metabase/alerts/FETCH_ALERTS_FOR_QUESTION";
 const fetchAlertsForQuestionRequest = new RestfulRequest({
-  endpoint: AlertApi.list_for_question,
+  endpoint: (params, dispatch) =>
+    entityCompatibleQuery(params, dispatch, alertApi.endpoints.listCardAlerts),
   actionPrefix: FETCH_ALERTS_FOR_QUESTION,
   storeAsDictionary: true,
 });
@@ -38,14 +41,15 @@ export const fetchAlertsForQuestion = questionId => {
       payload: questionId,
       type: FETCH_ALERTS_FOR_QUESTION_CLEAR_OLD_ALERTS,
     });
-    await dispatch(fetchAlertsForQuestionRequest.trigger({ questionId }));
+    await dispatch(fetchAlertsForQuestionRequest.trigger({ id: questionId }));
     dispatch({ type: FETCH_ALERTS_FOR_QUESTION });
   };
 };
 
 export const CREATE_ALERT = "metabase/alerts/CREATE_ALERT";
 const createAlertRequest = new RestfulRequest({
-  endpoint: AlertApi.create,
+  endpoint: (params, dispatch) =>
+    entityCompatibleQuery(params, dispatch, alertApi.endpoints.createAlert),
   actionPrefix: CREATE_ALERT,
   storeAsDictionary: true,
 });
@@ -92,7 +96,8 @@ function cleanAlert(alert) {
 
 export const UPDATE_ALERT = "metabase/alerts/UPDATE_ALERT";
 const updateAlertRequest = new RestfulRequest({
-  endpoint: AlertApi.update,
+  endpoint: (params, dispatch) =>
+    entityCompatibleQuery(params, dispatch, alertApi.endpoints.updateAlert),
   actionPrefix: UPDATE_ALERT,
   storeAsDictionary: true,
 });
@@ -123,13 +128,18 @@ export const UNSUBSCRIBE_FROM_ALERT = "metabase/alerts/UNSUBSCRIBE_FROM_ALERT";
 export const UNSUBSCRIBE_FROM_ALERT_CLEANUP =
   "metabase/alerts/UNSUBSCRIBE_FROM_ALERT_CLEANUP";
 const unsubscribeFromAlertRequest = new RestfulRequest({
-  endpoint: AlertApi.unsubscribe,
+  endpoint: (params, dispatch) =>
+    entityCompatibleQuery(
+      params,
+      dispatch,
+      alertApi.endpoints.deleteAlertSubscription,
+    ),
   actionPrefix: UNSUBSCRIBE_FROM_ALERT,
   storeAsDictionary: true,
 });
 export const unsubscribeFromAlert = alert => {
   return async (dispatch, getState) => {
-    await dispatch(unsubscribeFromAlertRequest.trigger(alert));
+    await dispatch(unsubscribeFromAlertRequest.trigger(alert.id));
     dispatch({ type: UNSUBSCRIBE_FROM_ALERT });
 
     // This delay lets us to show "You're unsubscribed" text in place of an
@@ -144,7 +154,8 @@ export const unsubscribeFromAlert = alert => {
 
 export const DELETE_ALERT = "metabase/alerts/DELETE_ALERT";
 const deleteAlertRequest = new RestfulRequest({
-  endpoint: AlertApi.update,
+  endpoint: (params, dispatch) =>
+    entityCompatibleQuery(params, dispatch, alertApi.endpoints.updateAlert),
   actionPrefix: DELETE_ALERT,
   storeAsDictionary: true,
 });
diff --git a/frontend/src/metabase/api/alert.ts b/frontend/src/metabase/api/alert.ts
new file mode 100644
index 00000000000..104bfc0514e
--- /dev/null
+++ b/frontend/src/metabase/api/alert.ts
@@ -0,0 +1,83 @@
+import type {
+  Alert,
+  AlertId,
+  CreateAlertRequest,
+  ListAlertsRequest,
+  ListCardAlertsRequest,
+  UpdateAlertRequest,
+} from "metabase-types/api";
+
+import { Api } from "./api";
+import {
+  idTag,
+  invalidateTags,
+  listTag,
+  provideAlertListTags,
+  provideAlertTags,
+} from "./tags";
+
+export const alertApi = Api.injectEndpoints({
+  endpoints: builder => ({
+    listAlerts: builder.query<Alert[], ListAlertsRequest | void>({
+      query: body => ({
+        method: "GET",
+        url: "/api/alert",
+        body,
+      }),
+      providesTags: (alerts = []) => provideAlertListTags(alerts),
+    }),
+    listCardAlerts: builder.query<Alert[], ListCardAlertsRequest>({
+      query: ({ id, ...body }) => ({
+        method: "GET",
+        url: `/api/alert/question/${id}`,
+        body,
+      }),
+      providesTags: (alerts = []) => provideAlertListTags(alerts),
+    }),
+    getAlert: builder.query<Alert, AlertId>({
+      query: id => ({
+        method: "GET",
+        url: `/api/alert/${id}`,
+      }),
+      providesTags: alert => (alert ? provideAlertTags(alert) : []),
+    }),
+    createAlert: builder.mutation<Alert, CreateAlertRequest>({
+      query: body => ({
+        method: "POST",
+        url: "/api/alert",
+        body,
+      }),
+      invalidatesTags: (alert, error) =>
+        invalidateTags(error, [listTag("alert")]),
+    }),
+    updateAlert: builder.mutation<Alert, UpdateAlertRequest>({
+      query: ({ id, ...body }) => ({
+        method: "PUT",
+        url: `/api/alert/${id}`,
+        body,
+      }),
+      invalidatesTags: (alert, error) =>
+        invalidateTags(error, [
+          listTag("alert"),
+          ...(alert ? [idTag("alert", alert.id)] : []),
+        ]),
+    }),
+    deleteAlertSubscription: builder.mutation<void, AlertId>({
+      query: id => ({
+        method: "DELETE",
+        url: `/api/alert/${id}/subscription`,
+      }),
+      invalidatesTags: (_, error, id) =>
+        invalidateTags(error, [listTag("alert"), idTag("alert", id)]),
+    }),
+  }),
+});
+
+export const {
+  useListAlertsQuery,
+  useListCardAlertsQuery,
+  useGetAlertQuery,
+  useCreateAlertMutation,
+  useUpdateAlertMutation,
+  useDeleteAlertSubscriptionMutation,
+} = alertApi;
diff --git a/frontend/src/metabase/api/index.ts b/frontend/src/metabase/api/index.ts
index 6fc6f187011..d92d7695886 100644
--- a/frontend/src/metabase/api/index.ts
+++ b/frontend/src/metabase/api/index.ts
@@ -1,4 +1,5 @@
 export * from "./activity";
+export * from "./alert";
 export * from "./api";
 export * from "./api-key";
 export * from "./automagic-dashboards";
diff --git a/frontend/src/metabase/api/tags/constants.ts b/frontend/src/metabase/api/tags/constants.ts
index 3b7809c979c..5eaf8c7d9a2 100644
--- a/frontend/src/metabase/api/tags/constants.ts
+++ b/frontend/src/metabase/api/tags/constants.ts
@@ -2,6 +2,7 @@ export type TagType = typeof TAG_TYPES[number];
 
 export const TAG_TYPES = [
   "action",
+  "alert",
   "api-key",
   "bookmark",
   "card",
diff --git a/frontend/src/metabase/api/tags/utils.ts b/frontend/src/metabase/api/tags/utils.ts
index 65ca033cadb..5257e44ffe4 100644
--- a/frontend/src/metabase/api/tags/utils.ts
+++ b/frontend/src/metabase/api/tags/utils.ts
@@ -1,6 +1,7 @@
 import type { TagDescription } from "@reduxjs/toolkit/query";
 
 import type {
+  Alert,
   ApiKey,
   Bookmark,
   Card,
@@ -70,6 +71,19 @@ export function provideActivityItemTags(
   return [idTag(TAG_TYPE_MAPPING[item.model], item.model_id)];
 }
 
+export function provideAlertListTags(
+  alerts: Alert[],
+): TagDescription<TagType>[] {
+  return [listTag("alert"), ...alerts.flatMap(provideAlertTags)];
+}
+
+export function provideAlertTags(alert: Alert): TagDescription<TagType>[] {
+  return [
+    idTag("alert", alert.id),
+    ...(alert.creator ? provideUserTags(alert.creator) : []),
+  ];
+}
+
 export function provideApiKeyListTags(
   apiKeys: ApiKey[],
 ): TagDescription<TagType>[] {
diff --git a/frontend/src/metabase/entities/alerts.js b/frontend/src/metabase/entities/alerts.js
index 379f301458f..084b9e51aee 100644
--- a/frontend/src/metabase/entities/alerts.js
+++ b/frontend/src/metabase/entities/alerts.js
@@ -1,8 +1,12 @@
 import { t } from "ttag";
 
-import { createEntity, undo } from "metabase/lib/entities";
+import { alertApi } from "metabase/api";
+import {
+  createEntity,
+  entityCompatibleQuery,
+  undo,
+} from "metabase/lib/entities";
 import { addUndo } from "metabase/redux/undo";
-import { AlertApi } from "metabase/services";
 
 export const UNSUBSCRIBE = "metabase/entities/alerts/unsubscribe";
 
@@ -14,6 +18,36 @@ const Alerts = createEntity({
   nameOne: "alert",
   path: "/api/alert",
 
+  api: {
+    list: (entityQuery, dispatch) =>
+      entityCompatibleQuery(
+        entityQuery,
+        dispatch,
+        alertApi.endpoints.listAlerts,
+      ),
+    get: (entityQuery, options, dispatch) =>
+      entityCompatibleQuery(
+        entityQuery.id,
+        dispatch,
+        alertApi.endpoints.listAlerts,
+      ),
+    create: (entityQuery, dispatch) =>
+      entityCompatibleQuery(
+        entityQuery,
+        dispatch,
+        alertApi.endpoints.createAlert,
+      ),
+    update: (entityQuery, dispatch) =>
+      entityCompatibleQuery(
+        entityQuery,
+        dispatch,
+        alertApi.endpoints.updateAlert,
+      ),
+    delete: () => {
+      throw new TypeError("Alerts.api.delete is not supported");
+    },
+  },
+
   actionTypes: {
     UNSUBSCRIBE,
   },
@@ -30,7 +64,11 @@ const Alerts = createEntity({
     unsubscribe:
       ({ id }) =>
       async dispatch => {
-        await AlertApi.unsubscribe({ id });
+        await entityCompatibleQuery(
+          id,
+          dispatch,
+          alertApi.endpoints.deleteAlertSubscription,
+        );
         dispatch(addUndo({ message: t`Successfully unsubscribed` }));
         dispatch({ type: UNSUBSCRIBE, payload: { id } });
         dispatch({ type: Alerts.actionTypes.INVALIDATE_LISTS_ACTION });
diff --git a/frontend/src/metabase/lib/request.js b/frontend/src/metabase/lib/request.js
index cca68f7d49a..22fd1505c44 100644
--- a/frontend/src/metabase/lib/request.js
+++ b/frontend/src/metabase/lib/request.js
@@ -40,7 +40,7 @@ export class RestfulRequest {
   trigger = params => async dispatch => {
     dispatch({ type: this.actions.requestStarted });
     try {
-      const result = await this.endpoint(params);
+      const result = await this.endpoint(params, dispatch);
       dispatch({ type: this.actions.requestSuccessful, payload: { result } });
     } catch (error) {
       dispatch({ type: this.actions.requestFailed, payload: { error } });
diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js
index 6a125d9e94d..8037f6c9598 100644
--- a/frontend/src/metabase/services.js
+++ b/frontend/src/metabase/services.js
@@ -367,15 +367,6 @@ export const PulseApi = {
   unsubscribe: DELETE("/api/pulse/:id/subscription"),
 };
 
-export const AlertApi = {
-  list: GET("/api/alert"),
-  list_for_question: GET("/api/alert/question/:questionId"),
-  get: GET("/api/alert/:id"),
-  create: POST("/api/alert"),
-  update: PUT("/api/alert/:id"),
-  unsubscribe: DELETE("/api/alert/:id/subscription"),
-};
-
 export const SegmentApi = {
   list: GET("/api/segment"),
   create: POST("/api/segment"),
-- 
GitLab