Repository: keephq/keep
Pull request: feat(ui): Delete Multiple Alerts
<!-- 
Thanks for creating this pull request 🤗

Please make sure that the pull request is limited to one type (docs, feature, etc.) and keep it as small as possible. You can open multiple prs instead of opening a huge one.
-->

<!-- If this pull request closes an issue, please mention the issue number below -->
Closes #561 

## 📑 Description
<!-- Add a brief description of the pr -->
- add props to `AlertTable` for row selection. create optional checkbox for row selection
- create `AlertActions` for the panels that support row selection. replaces `ActionPresets` with action buttons
- move `AlertPresets` into `TabContent` so row selection logic can be shared between the two

https://github.com/keephq/keep/assets/58940073/0ae5c378-1b3a-4228-80e5-95b006aeceed

## ✅ Checks
<!-- Make sure your pr passes the CI checks and do check the following fields as needed - -->
- [x] My pull request adheres to the code style of this project
- [ ] My code requires changes to the documentation
- [ ] I have updated the documentation as required
- [ ] All the tests have passed

## ℹ Additional Information
<!-- Any additional information like breaking changes, dependencies added, screenshots, comparisons between new and old behavior, etc. -->


Problem statement:
Add an option to delete multiple alerts
Now that we support react-table (thanks to @asharonbaltazar!) we want to be able to delete multiple alerts at once.

Alert menu will be moved to the right as an ellipse.
When clicking an alert row, checkbox to the left will be marked and user will be able to check multiple alerts and hit a delete button.
Under the hood, multiple delete requests will be sent -- in the future, we will add a bulk delete operation on the backend side to make it more efficient.

Hints from issue discussion:
@talboren does this involves both frontend + backend changes? or is there existing api for multiple deletions?
> @talboren does this involves both frontend + backend changes? or is there existing api for multiple deletions?

There's an API for alert deletion that can easily be enhanced to support multiple alert deletion (it gets fingerprint today but can get fingerprints if needed). This is mostly UI.
@talboren can you assign this issue to me?
@asharonbaltazar FYI this seems to be relevant as well
Beep boop

Commit message: Merge branch 'main' into feat/delete-multiple-alerts

Patch to review:
diff --git a/keep-ui/app/alerts/alert-actions.tsx b/keep-ui/app/alerts/alert-actions.tsx
new file mode 100644
index 0000000000..4929938237
--- /dev/null
+++ b/keep-ui/app/alerts/alert-actions.tsx
@@ -0,0 +1,68 @@
+import { TrashIcon } from "@heroicons/react/24/outline";
+import { Button } from "@tremor/react";
+import { getSession } from "next-auth/react";
+import { getApiURL } from "utils/apiUrl";
+import { AlertDto } from "./models";
+
+interface Props {
+  amountOfRowSelections: number;
+  onDelete: (
+    fingerprint: string,
+    lastReceived: Date,
+    restore?: boolean
+  ) => void;
+  alerts: AlertDto[];
+}
+
+export default function AlertActions({
+  amountOfRowSelections,
+  onDelete: callDelete,
+  alerts,
+}: Props) {
+  const onDelete = async () => {
+    const confirmed = confirm(
+      `Are you sure you want to delete ${amountOfRowSelections} alert(s)?`
+    );
+
+    if (confirmed) {
+      const session = await getSession();
+      const apiUrl = getApiURL();
+
+      for await (const alert of alerts) {
+        const { fingerprint } = alert;
+
+        const body = {
+          fingerprint,
+          lastReceived: alert.lastReceived,
+          restore: false,
+        };
+
+        const res = await fetch(`${apiUrl}/alerts`, {
+          method: "DELETE",
+          headers: {
+            Authorization: `Bearer ${session!.accessToken}`,
+            "Content-Type": "application/json",
+          },
+          body: JSON.stringify(body),
+        });
+        if (res.ok) {
+          callDelete(fingerprint, alert.lastReceived);
+        }
+      }
+    }
+  };
+
+  return (
+    <div className="w-full flex justify-end items-center py-0.5">
+      <Button
+        icon={TrashIcon}
+        size="xs"
+        color="red"
+        title="Delete"
+        onClick={onDelete}
+      >
+        Delete {amountOfRowSelections} alert(s)
+      </Button>
+    </div>
+  );
+}
diff --git a/keep-ui/app/alerts/alert-menu.tsx b/keep-ui/app/alerts/alert-menu.tsx
index c95de81eb4..f6fbf1db08 100644
--- a/keep-ui/app/alerts/alert-menu.tsx
+++ b/keep-ui/app/alerts/alert-menu.tsx
@@ -1,9 +1,9 @@
 import { Menu, Transition } from "@headlessui/react";
 import { Fragment, useState } from "react";
-import { Bars3Icon } from "@heroicons/react/20/solid";
 import { Icon } from "@tremor/react";
 import {
   ArchiveBoxIcon,
+  EllipsisHorizontalIcon,
   PlusIcon,
   TrashIcon,
   UserPlusIcon,
@@ -137,17 +137,14 @@ export default function AlertMenu({
     setIsOpen(true);
   };
 
-  const assignee = alert.assignees
-    ? [alert.lastReceived.toISOString()]
-    : "";
+  const assignee = alert.assignees ? [alert.lastReceived.toISOString()] : "";
 
   return (
     <>
-      <Menu as="div" className="absolute inline-block text-left">
+      <Menu>
         <Menu.Button>
           <Icon
-            size="xs"
-            icon={Bars3Icon}
+            icon={EllipsisHorizontalIcon}
             className="hover:bg-gray-100"
             color="gray"
           />
@@ -161,7 +158,7 @@ export default function AlertMenu({
           leaveFrom="transform opacity-100 scale-100"
           leaveTo="transform opacity-0 scale-95"
         >
-          <Menu.Items className="z-50 relative mt-2 min-w-36 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
+          <Menu.Items className="z-50 fixed right-12 mt-2 divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
             <div className="px-1 py-1">
               <Menu.Item>
                 {({ active }) => (
diff --git a/keep-ui/app/alerts/alert-table-checkbox.tsx b/keep-ui/app/alerts/alert-table-checkbox.tsx
new file mode 100644
index 0000000000..30b7588f00
--- /dev/null
+++ b/keep-ui/app/alerts/alert-table-checkbox.tsx
@@ -0,0 +1,30 @@
+// copied from https://github.com/TanStack/table/blob/main/examples/react/row-selection/src/main.tsx#L338
+
+import { useEffect, useRef, HTMLProps } from "react";
+
+interface Props extends HTMLProps<HTMLInputElement> {
+  indeterminate?: boolean;
+}
+
+export default function AlertTableCheckbox({
+  indeterminate,
+  className = "",
+  ...rest
+}: Props) {
+  const ref = useRef<HTMLInputElement>(null!);
+
+  useEffect(() => {
+    if (typeof indeterminate === "boolean") {
+      ref.current.indeterminate = !rest.checked && indeterminate;
+    }
+  }, [indeterminate, rest.checked]);
+
+  return (
+    <input
+      type="checkbox"
+      ref={ref}
+      className={className + " cursor-pointer"}
+      {...rest}
+    />
+  );
+}
diff --git a/keep-ui/app/alerts/alert-table.tsx b/keep-ui/app/alerts/alert-table.tsx
index 28da1715e3..b1c6e357d9 100644
--- a/keep-ui/app/alerts/alert-table.tsx
+++ b/keep-ui/app/alerts/alert-table.tsx
@@ -17,6 +17,8 @@ import { Provider } from "app/providers/providers";
 import { User } from "app/settings/models";
 import { User as NextUser } from "next-auth";
 import {
+  OnChangeFn,
+  RowSelectionState,
   createColumnHelper,
   flexRender,
   getCoreRowModel,
@@ -32,6 +34,7 @@ import AlertAssignee from "./alert-assignee";
 import AlertMenu from "./alert-menu";
 import AlertSeverity from "./alert-severity";
 import AlertExtraPayload from "./alert-extra-payload";
+import AlertTableCheckbox from "./alert-table-checkbox";
 
 const getAlertLastReceieved = (lastRecievedFromAlert: Date) => {
   let lastReceived = "unknown";
@@ -67,6 +70,8 @@ interface Props {
   users?: User[];
   currentUser: NextUser;
   openModal?: (alert: AlertDto) => void;
+  rowSelection?: RowSelectionState;
+  setRowSelection?: OnChangeFn<RowSelectionState>;
 }
 
 export function AlertTable({
@@ -82,6 +87,8 @@ export function AlertTable({
   users = [],
   currentUser,
   openModal,
+  rowSelection,
+  setRowSelection,
 }: Props) {
   const router = useRouter();
 
@@ -93,28 +100,30 @@ export function AlertTable({
     }
   };
 
+  const checkboxColumn = rowSelection
+    ? [
+        columnHelper.display({
+          id: "checkbox",
+          header: (context) => (
+            <AlertTableCheckbox
+              checked={context.table.getIsAllRowsSelected()}
+              indeterminate={context.table.getIsSomeRowsSelected()}
+              onChange={context.table.getToggleAllRowsSelectedHandler()}
+            />
+          ),
+          cell: (context) => (
+            <AlertTableCheckbox
+              checked={context.row.getIsSelected()}
+              indeterminate={context.row.getIsSomeSelected()}
+              onChange={context.row.getToggleSelectedHandler()}
+            />
+          ),
+        }),
+      ]
+    : [];
+
   const columns = [
-    columnHelper.display({
-      id: "alertMenu",
-      cell: (context) => (
-        <div className="pb-6">
-          <AlertMenu
-            alert={context.row.original}
-            canOpenHistory={
-              !groupedByAlerts![(context.row.original as any)[groupBy!]]
-            }
-            openHistory={() => openModal!(context.row.original)}
-            provider={providers.find(
-              (p) => p.type === context.row.original.source![0]
-            )}
-            mutate={mutate}
-            callDelete={onDelete}
-            setAssignee={setAssignee}
-            currentUser={currentUser}
-          />
-        </div>
-      ),
-    }),
+    ...checkboxColumn,
     columnHelper.accessor("severity", {
       header: () => "Severity",
       cell: (context) => <AlertSeverity severity={context.getValue()} />,
@@ -215,12 +224,36 @@ export function AlertTable({
       id: "extraPayload",
       cell: (context) => <AlertExtraPayload alert={context.row.original} />,
     }),
+    columnHelper.display({
+      id: "alertMenu",
+      cell: (context) => (
+        <AlertMenu
+          alert={context.row.original}
+          canOpenHistory={
+            !groupedByAlerts![(context.row.original as any)[groupBy!]]
+          }
+          openHistory={() => openModal!(context.row.original)}
+          provider={providers.find(
+            (p) => p.type === context.row.original.source![0]
+          )}
+          mutate={mutate}
+          callDelete={onDelete}
+          setAssignee={setAssignee}
+          currentUser={currentUser}
+        />
+      ),
+    }),
   ];
 
   const table = useReactTable({
     data: alerts,
     columns,
     getCoreRowModel: getCoreRowModel(),
+    state: {
+      rowSelection,
+    },
+    enableRowSelection: true,
+    onRowSelectionChange: setRowSelection,
   });
 
   return (
diff --git a/keep-ui/app/alerts/alerts.tsx b/keep-ui/app/alerts/alerts.tsx
index 776e722ddd..ed4c9a6acb 100644
--- a/keep-ui/app/alerts/alerts.tsx
+++ b/keep-ui/app/alerts/alerts.tsx
@@ -23,6 +23,8 @@ import { User } from "app/settings/models";
 import AlertPagination from "./alert-pagination";
 import AlertPresets, { Option } from "./alert-presets";
 import { AlertHistory } from "./alert-history";
+import AlertActions from "./alert-actions";
+import { RowSelectionState } from "@tanstack/react-table";
 
 const defaultPresets: Preset[] = [
   { name: "Feed", options: [] },
@@ -237,7 +239,59 @@ export default function Alerts({
     );
   };
 
-  const setAssignee = (fingerprint: string, lastReceived: Date, unassign: boolean) => {
+  const AlertTableTabPanel = ({ preset }: { preset: Preset }) => {
+    const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
+
+    const amountOfRowSelections = Object.keys(rowSelection).length;
+
+    return (
+      <TabPanel className="mt-4">
+        {amountOfRowSelections ? (
+          <AlertActions
+            amountOfRowSelections={amountOfRowSelections}
+            onDelete={onDelete}
+            alerts={currentStateAlerts.slice(startIndex, endIndex)}
+          />
+        ) : (
+          <AlertPresets
+            preset={selectedPreset}
+            alerts={currentStateAlerts}
+            selectedOptions={selectedOptions}
+            setSelectedOptions={setSelectedOptions}
+            accessToken={accessToken}
+            presetsMutator={() => {
+              onIndexChange(0);
+              presetsMutate();
+            }}
+          />
+        )}
+        <AlertTable
+          alerts={currentStateAlerts.slice(startIndex, endIndex)}
+          groupedByAlerts={groupedByAlerts}
+          groupBy="fingerprint"
+          workflows={workflows}
+          providers={providers?.installed_providers}
+          mutate={() => mutate(undefined, { optimisticData: [] })}
+          isAsyncLoading={isAsyncLoading}
+          onDelete={onDelete}
+          setAssignee={setAssignee}
+          users={users}
+          currentUser={user}
+          openModal={openModal}
+          rowSelection={
+            preset.name === "Deleted" || isOpen ? undefined : rowSelection
+          }
+          setRowSelection={setRowSelection}
+        />
+      </TabPanel>
+    );
+  };
+
+  const setAssignee = (
+    fingerprint: string,
+    lastReceived: Date,
+    unassign: boolean
+  ) => {
     setAlerts((prevAlerts) =>
       prevAlerts.map((alert) => {
         if (alert.fingerprint === fingerprint) {
@@ -277,28 +331,6 @@ export default function Alerts({
     .filter((alert) => showDeletedAlert(alert) && filterAlerts(alert))
     .sort((a, b) => b.lastReceived.getTime() - a.lastReceived.getTime());
 
-  const TabContent = () => {
-    return (
-      <>
-        <div className="flex w-full"></div>
-        <AlertTable
-          alerts={currentStateAlerts.slice(startIndex, endIndex)}
-          groupedByAlerts={groupedByAlerts}
-          groupBy="fingerprint"
-          workflows={workflows}
-          providers={providers?.installed_providers}
-          mutate={() => mutate(undefined, { optimisticData: [] })}
-          isAsyncLoading={isAsyncLoading}
-          onDelete={onDelete}
-          setAssignee={setAssignee}
-          users={users}
-          currentUser={user}
-          openModal={openModal}
-        />
-      </>
-    );
-  };
-
   function onIndexChange(index: number) {
     setTabIndex(index);
     const preset = presets![index];
@@ -321,29 +353,16 @@ export default function Alerts({
     <>
       <Card className="mt-10 p-4 md:p-10 mx-auto">
         <TabGroup onIndexChange={onIndexChange} index={tabIndex}>
-          <TabList className="mb-4" variant="line" color="orange">
-            {presets!.map((preset, index) => (
+          <TabList variant="line" color="orange">
+            {presets.map((preset, index) => (
               <Tab key={preset.name} tabIndex={index}>
                 {preset.name}
               </Tab>
             ))}
           </TabList>
-          <AlertPresets
-            preset={selectedPreset}
-            alerts={currentStateAlerts}
-            selectedOptions={selectedOptions}
-            setSelectedOptions={setSelectedOptions}
-            accessToken={accessToken}
-            presetsMutator={() => {
-              onIndexChange(0);
-              presetsMutate();
-            }}
-          />
           <TabPanels>
-            {presets!.map((preset) => (
-              <TabPanel key={preset.name}>
-                <TabContent />
-              </TabPanel>
+            {presets.map((preset) => (
+              <AlertTableTabPanel key={preset.name} preset={preset} />
             ))}
           </TabPanels>
         </TabGroup>
diff --git a/keep-ui/app/alerts/models.tsx b/keep-ui/app/alerts/models.tsx
index 37e1cda27d..8aef819857 100644
--- a/keep-ui/app/alerts/models.tsx
+++ b/keep-ui/app/alerts/models.tsx
@@ -60,17 +60,3 @@ export const AlertKnownKeys = [
   "isDeleted", // TODO: leftover, should be removed in the future.
   "assignee",
 ];
-
-export const AlertTableKeys: { [key: string]: string } = {
-  Severity: "",
-  Name: "",
-  Description: "",
-  Type: "Whether the alert was pushed or pulled from the alert source",
-  Status: "",
-  When: "",
-  Source: "",
-  Assignee: "",
-  "Fatigue Meter": "Calculated based on various factors",
-  // "Automated workflow": "Workflows that defined to be executed automatically when this alert triggers",
-  Payload: "",
-};


Code review comments:
diff_hunk: @@ -161,7 +158,7 @@ export default function AlertMenu({
           leaveFrom="transform opacity-100 scale-100"
           leaveTo="transform opacity-0 scale-95"
         >
-          <Menu.Items className="z-50 relative mt-2 min-w-36 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
+          <Menu.Items className="z-50 fixed right-12 mt-2 divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
path: keep-ui/app/alerts/alert-menu.tsx
text: @user1:
This makes the menu scroll down with the user scrolling. We need to think this through.

@author:
Possibly resolved with https://github.com/keephq/keep/pull/657/commits/1fb3a84e30ff05642cb692a5f424627a34b75030. Lemme know what you think of the implementation. It's quite verbose, but the problem cannot (based on this [SO answer](https://stackoverflow.com/a/65535922/12627235)) addressed without dynamic positioning. 

Regarding the `@user2/react-dom` package, it's not installed as a dependency, but it's used in one of the other dependencies. I'm not sure – do we need to install it as a dependency?

diff_hunk: @@ -237,7 +239,59 @@ export default function Alerts({
     );
   };
 
-  const setAssignee = (fingerprint: string, lastReceived: Date, unassign: boolean) => {
+  const AlertTableTabPanel = ({ preset }: { preset: Preset }) => {
+    const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
+
+    const amountOfRowSelections = Object.keys(rowSelection).length;
+
+    return (
+      <TabPanel className="mt-4">
+        {amountOfRowSelections ? (
+          <AlertActions
+            amountOfRowSelections={amountOfRowSelections}
+            onDelete={onDelete}
+            alerts={currentStateAlerts.slice(startIndex, endIndex)}
path: keep-ui/app/alerts/alerts.tsx
text: @user1:
I think we're always passing all the alerts in the sliced view here so it deletes everything in the same view, although I only selected 1 alert.

@author:
Should be resolved with https://github.com/keephq/keep/pull/657/commits/2b6fbde2df1e14f23f30be9e05166a8902fc30b8. That was a brain fart on my end 🤯