Repository: comet-ml/opik
Pull request: [OPIK-374] Add bulk actions to all tables
## Details
![image](https://github.com/user-attachments/assets/c944df80-4697-4b42-b719-115faadd6662)
![image](https://github.com/user-attachments/assets/6641cdb3-cc67-4bbe-a398-247498f32247)
![image](https://github.com/user-attachments/assets/526aa59b-2b19-4e8d-8437-2d4b0bec0962)
![image](https://github.com/user-attachments/assets/bb7f1b84-8a4f-4a4f-9630-406d61c3bc7a)

## Issues

Resolves #494 

## Testing

## Documentation


Problem statement:
[FR]: Add multi-select options for Dataset view
### Willingness to contribute

No. I can't contribute this feature at this time.

### Proposal summary

It would be great if the experiment/dataset/projets pages UI is consistent. At the moment you can only delete one dataset at the time.

### Motivation

I have created 13 datasets which i don't want to keep. It's annoying to manually delete each

Hints from issue discussion:
@idoberko2 Can you take a look at this, I think we might have an endpoint missing for deleting multiple datasets
@jverre from what I see all resources lack multiple deletion endpoints
In addition to deleting multiple datasets it would be useful to be able to delete multiple DatasetItems within a Dataset

Commit message: [OPIK-374] Add bulk actions to all tables

Patch to review:
diff --git a/apps/opik-frontend/src/api/datasets/useDatasetBatchDeleteMutation.ts b/apps/opik-frontend/src/api/datasets/useDatasetBatchDeleteMutation.ts
new file mode 100644
index 0000000000..db5325a67d
--- /dev/null
+++ b/apps/opik-frontend/src/api/datasets/useDatasetBatchDeleteMutation.ts
@@ -0,0 +1,42 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import get from "lodash/get";
+import { useToast } from "@/components/ui/use-toast";
+import api, { DATASETS_REST_ENDPOINT } from "@/api/api";
+
+type UseDatasetBatchDeleteMutationParams = {
+  ids: string[];
+};
+
+const useDatasetBatchDeleteMutation = () => {
+  const queryClient = useQueryClient();
+  const { toast } = useToast();
+
+  return useMutation({
+    mutationFn: async ({ ids }: UseDatasetBatchDeleteMutationParams) => {
+      const { data } = await api.post(`${DATASETS_REST_ENDPOINT}delete-batch`, {
+        ids: ids,
+      });
+      return data;
+    },
+    onError: (error) => {
+      const message = get(
+        error,
+        ["response", "data", "message"],
+        error.message,
+      );
+
+      toast({
+        title: "Error",
+        description: message,
+        variant: "destructive",
+      });
+    },
+    onSettled: () => {
+      return queryClient.invalidateQueries({
+        queryKey: ["datasets"],
+      });
+    },
+  });
+};
+
+export default useDatasetBatchDeleteMutation;
diff --git a/apps/opik-frontend/src/api/datasets/useDatasetItemBatchDeleteMutation.ts b/apps/opik-frontend/src/api/datasets/useDatasetItemBatchDeleteMutation.ts
new file mode 100644
index 0000000000..13cc0de616
--- /dev/null
+++ b/apps/opik-frontend/src/api/datasets/useDatasetItemBatchDeleteMutation.ts
@@ -0,0 +1,42 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import get from "lodash/get";
+import { useToast } from "@/components/ui/use-toast";
+import api, { DATASETS_REST_ENDPOINT } from "@/api/api";
+
+type UseDatasetItemBatchDeleteMutationParams = {
+  ids: string[];
+};
+
+const useDatasetItemBatchDeleteMutation = () => {
+  const queryClient = useQueryClient();
+  const { toast } = useToast();
+
+  return useMutation({
+    mutationFn: async ({ ids }: UseDatasetItemBatchDeleteMutationParams) => {
+      const { data } = await api.post(`${DATASETS_REST_ENDPOINT}items/delete`, {
+        item_ids: ids,
+      });
+      return data;
+    },
+    onError: (error) => {
+      const message = get(
+        error,
+        ["response", "data", "message"],
+        error.message,
+      );
+
+      toast({
+        title: "Error",
+        description: message,
+        variant: "destructive",
+      });
+    },
+    onSettled: () => {
+      return queryClient.invalidateQueries({
+        queryKey: ["dataset-items"],
+      });
+    },
+  });
+};
+
+export default useDatasetItemBatchDeleteMutation;
diff --git a/apps/opik-frontend/src/api/feedback-definitions/useFeedbackDefinitionBatchDeleteMutation.ts b/apps/opik-frontend/src/api/feedback-definitions/useFeedbackDefinitionBatchDeleteMutation.ts
new file mode 100644
index 0000000000..cb6c115226
--- /dev/null
+++ b/apps/opik-frontend/src/api/feedback-definitions/useFeedbackDefinitionBatchDeleteMutation.ts
@@ -0,0 +1,47 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import get from "lodash/get";
+import { useToast } from "@/components/ui/use-toast";
+import api, { FEEDBACK_DEFINITIONS_REST_ENDPOINT } from "@/api/api";
+
+type UseFeedbackDefinitionBatchDeleteMutationParams = {
+  ids: string[];
+};
+
+const useFeedbackDefinitionBatchDeleteMutation = () => {
+  const queryClient = useQueryClient();
+  const { toast } = useToast();
+
+  return useMutation({
+    mutationFn: async ({
+      ids,
+    }: UseFeedbackDefinitionBatchDeleteMutationParams) => {
+      const { data } = await api.post(
+        `${FEEDBACK_DEFINITIONS_REST_ENDPOINT}delete`,
+        {
+          ids: ids,
+        },
+      );
+      return data;
+    },
+    onError: (error) => {
+      const message = get(
+        error,
+        ["response", "data", "message"],
+        error.message,
+      );
+
+      toast({
+        title: "Error",
+        description: message,
+        variant: "destructive",
+      });
+    },
+    onSettled: () => {
+      return queryClient.invalidateQueries({
+        queryKey: ["feedback-definitions"],
+      });
+    },
+  });
+};
+
+export default useFeedbackDefinitionBatchDeleteMutation;
diff --git a/apps/opik-frontend/src/api/projects/useProjectBatchDeleteMutation.ts b/apps/opik-frontend/src/api/projects/useProjectBatchDeleteMutation.ts
new file mode 100644
index 0000000000..fee9390eae
--- /dev/null
+++ b/apps/opik-frontend/src/api/projects/useProjectBatchDeleteMutation.ts
@@ -0,0 +1,42 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import get from "lodash/get";
+import { useToast } from "@/components/ui/use-toast";
+import api, { PROJECTS_REST_ENDPOINT } from "@/api/api";
+
+type UseProjectBatchDeleteMutationParams = {
+  ids: string[];
+};
+
+const useProjectBatchDeleteMutation = () => {
+  const queryClient = useQueryClient();
+  const { toast } = useToast();
+
+  return useMutation({
+    mutationFn: async ({ ids }: UseProjectBatchDeleteMutationParams) => {
+      const { data } = await api.post(`${PROJECTS_REST_ENDPOINT}delete`, {
+        ids: ids,
+      });
+      return data;
+    },
+    onError: (error) => {
+      const message = get(
+        error,
+        ["response", "data", "message"],
+        error.message,
+      );
+
+      toast({
+        title: "Error",
+        description: message,
+        variant: "destructive",
+      });
+    },
+    onSettled: () => {
+      return queryClient.invalidateQueries({
+        queryKey: ["projects"],
+      });
+    },
+  });
+};
+
+export default useProjectBatchDeleteMutation;
diff --git a/apps/opik-frontend/src/components/pages/DatasetItemsPage/DatasetItemsActionsPanel.tsx b/apps/opik-frontend/src/components/pages/DatasetItemsPage/DatasetItemsActionsPanel.tsx
new file mode 100644
index 0000000000..c39c73e9ff
--- /dev/null
+++ b/apps/opik-frontend/src/components/pages/DatasetItemsPage/DatasetItemsActionsPanel.tsx
@@ -0,0 +1,58 @@
+import React, { useCallback, useRef, useState } from "react";
+import { Trash } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { DatasetItem } from "@/types/datasets";
+import useDatasetItemBatchDeleteMutation from "@/api/datasets/useDatasetItemBatchDeleteMutation";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+
+type DatasetItemsActionsPanelsProps = {
+  datasetItems: DatasetItem[];
+};
+
+const DatasetItemsActionsPanel: React.FunctionComponent<
+  DatasetItemsActionsPanelsProps
+> = ({ datasetItems }) => {
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean>(false);
+  const disabled = !datasetItems?.length;
+
+  const { mutate } = useDatasetItemBatchDeleteMutation();
+
+  const deleteDatasetItemsHandler = useCallback(() => {
+    mutate({
+      ids: datasetItems.map((i) => i.id),
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [datasetItems]);
+
+  return (
+    <div className="flex items-center gap-2">
+      <ConfirmDialog
+        key={`delete-${resetKeyRef.current}`}
+        open={open}
+        setOpen={setOpen}
+        onConfirm={deleteDatasetItemsHandler}
+        title="Delete dataset items"
+        description="Are you sure you want to delete all selected dataset items?"
+        confirmText="Delete dataset items"
+      />
+      <TooltipWrapper content="Delete">
+        <Button
+          variant="outline"
+          size="icon"
+          onClick={() => {
+            setOpen(true);
+            resetKeyRef.current = resetKeyRef.current + 1;
+          }}
+          disabled={disabled}
+        >
+          <Trash className="size-4" />
+        </Button>
+      </TooltipWrapper>
+    </div>
+  );
+};
+
+export default DatasetItemsActionsPanel;
diff --git a/apps/opik-frontend/src/components/pages/DatasetItemsPage/DatasetItemsPage.tsx b/apps/opik-frontend/src/components/pages/DatasetItemsPage/DatasetItemsPage.tsx
index 5f7ced90fc..eb9619c4e5 100644
--- a/apps/opik-frontend/src/components/pages/DatasetItemsPage/DatasetItemsPage.tsx
+++ b/apps/opik-frontend/src/components/pages/DatasetItemsPage/DatasetItemsPage.tsx
@@ -1,5 +1,5 @@
 import React, { useCallback, useMemo, useRef, useState } from "react";
-import { ColumnPinningState } from "@tanstack/react-table";
+import { ColumnPinningState, RowSelectionState } from "@tanstack/react-table";
 import findIndex from "lodash/findIndex";
 import get from "lodash/get";
 import { NumberParam, StringParam, useQueryParam } from "use-query-params";
@@ -16,6 +16,7 @@ import useDatasetById from "@/api/datasets/useDatasetById";
 import { DatasetItem } from "@/types/datasets";
 import {
   COLUMN_ID_ID,
+  COLUMN_SELECT_ID,
   COLUMN_TYPE,
   ColumnData,
   DynamicColumn,
@@ -23,10 +24,12 @@ import {
 } from "@/types/shared";
 import ResizableSidePanel from "@/components/shared/ResizableSidePanel/ResizableSidePanel";
 import DatasetItemPanelContent from "@/components/pages/DatasetItemsPage/DatasetItemPanelContent";
+import DatasetItemsActionsPanel from "@/components/pages/DatasetItemsPage/DatasetItemsActionsPanel";
 import { DatasetItemRowActionsCell } from "@/components/pages/DatasetItemsPage/DatasetItemRowActionsCell";
 import DataTableRowHeightSelector from "@/components/shared/DataTableRowHeightSelector/DataTableRowHeightSelector";
 import AddEditDatasetItemDialog from "@/components/pages/DatasetItemsPage/AddEditDatasetItemDialog";
 import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
 import { convertColumnDataToColumn, mapColumnDataFields } from "@/lib/table";
 import { buildDocsUrl } from "@/lib/utils";
 import DataTableNoData from "@/components/shared/DataTableNoData/DataTableNoData";
@@ -34,13 +37,16 @@ import AutodetectCell from "@/components/shared/DataTableCells/AutodetectCell";
 import LinkCell from "@/components/shared/DataTableCells/LinkCell";
 import { formatDate } from "@/lib/date";
 import { mapDynamicColumnTypesToColumnType } from "@/lib/filters";
-import { generateActionsColumDef } from "@/components/shared/DataTable/utils";
+import {
+  generateActionsColumDef,
+  generateSelectColumDef,
+} from "@/components/shared/DataTable/utils";
 import { useDynamicColumnsCache } from "@/hooks/useDynamicColumnsCache";
 
 const getRowId = (d: DatasetItem) => d.id;
 
 export const DEFAULT_COLUMN_PINNING: ColumnPinningState = {
-  left: [COLUMN_ID_ID],
+  left: [COLUMN_SELECT_ID, COLUMN_ID_ID],
   right: [],
 };
 
@@ -66,6 +72,8 @@ const DatasetItemsPage = () => {
     updateType: "replaceIn",
   });
 
+  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
+
   const [height = ROW_HEIGHT.small, setHeight] = useQueryParam(
     "height",
     StringParam,
@@ -116,6 +124,10 @@ const DatasetItemsPage = () => {
   const rows: Array<DatasetItem> = useMemo(() => data?.content ?? [], [data]);
   const noDataText = "There are no dataset items yet";
 
+  const selectedRows: DatasetItem[] = useMemo(() => {
+    return rows.filter((row) => rowSelection[row.id]);
+  }, [rowSelection, rows]);
+
   const dynamicDatasetColumns = useMemo(() => {
     return (data?.columns ?? []).map<DynamicColumn>((c) => ({
       id: c.name,
@@ -179,6 +191,7 @@ const DatasetItemsPage = () => {
 
   const columns = useMemo(() => {
     return [
+      generateSelectColumDef<DatasetItem>(),
       mapColumnDataFields<DatasetItem, DatasetItem>({
         id: COLUMN_ID_ID,
         label: "Item ID",
@@ -246,6 +259,8 @@ const DatasetItemsPage = () => {
       <div className="mb-4 flex items-center justify-between gap-8">
         <div className="flex items-center gap-2"></div>
         <div className="flex items-center gap-2">
+          <DatasetItemsActionsPanel datasetItems={selectedRows} />
+          <Separator orientation="vertical" className="ml-2 mr-2.5 h-6" />
           <DataTableRowHeightSelector
             type={height as ROW_HEIGHT}
             setType={setHeight}
@@ -267,6 +282,10 @@ const DatasetItemsPage = () => {
         data={rows}
         activeRowId={activeRowId ?? ""}
         resizeConfig={resizeConfig}
+        selectionConfig={{
+          rowSelection,
+          setRowSelection,
+        }}
         getRowId={getRowId}
         rowHeight={height as ROW_HEIGHT}
         columnPinning={DEFAULT_COLUMN_PINNING}
diff --git a/apps/opik-frontend/src/components/pages/DatasetsPage/DatasetsActionsPanel.tsx b/apps/opik-frontend/src/components/pages/DatasetsPage/DatasetsActionsPanel.tsx
new file mode 100644
index 0000000000..ad90608df0
--- /dev/null
+++ b/apps/opik-frontend/src/components/pages/DatasetsPage/DatasetsActionsPanel.tsx
@@ -0,0 +1,58 @@
+import React, { useCallback, useRef, useState } from "react";
+import { Trash } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Dataset } from "@/types/datasets";
+import useDatasetBatchDeleteMutation from "@/api/datasets/useDatasetBatchDeleteMutation";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+
+type DatasetsActionsPanelsProps = {
+  datasets: Dataset[];
+};
+
+const DatasetsActionsPanel: React.FunctionComponent<
+  DatasetsActionsPanelsProps
+> = ({ datasets }) => {
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean>(false);
+  const disabled = !datasets?.length;
+
+  const { mutate } = useDatasetBatchDeleteMutation();
+
+  const deleteDatasetsHandler = useCallback(() => {
+    mutate({
+      ids: datasets.map((d) => d.id),
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [datasets]);
+
+  return (
+    <div className="flex items-center gap-2">
+      <ConfirmDialog
+        key={`delete-${resetKeyRef.current}`}
+        open={open}
+        setOpen={setOpen}
+        onConfirm={deleteDatasetsHandler}
+        title="Delete datasets"
+        description="Are you sure you want to delete all selected datasets?"
+        confirmText="Delete datasets"
+      />
+      <TooltipWrapper content="Delete">
+        <Button
+          variant="outline"
+          size="icon"
+          onClick={() => {
+            setOpen(true);
+            resetKeyRef.current = resetKeyRef.current + 1;
+          }}
+          disabled={disabled}
+        >
+          <Trash className="size-4" />
+        </Button>
+      </TooltipWrapper>
+    </div>
+  );
+};
+
+export default DatasetsActionsPanel;
diff --git a/apps/opik-frontend/src/components/pages/DatasetsPage/DatasetsPage.tsx b/apps/opik-frontend/src/components/pages/DatasetsPage/DatasetsPage.tsx
index d38f03c10b..f9a75e0f25 100644
--- a/apps/opik-frontend/src/components/pages/DatasetsPage/DatasetsPage.tsx
+++ b/apps/opik-frontend/src/components/pages/DatasetsPage/DatasetsPage.tsx
@@ -8,20 +8,32 @@ import useDatasetsList from "@/api/datasets/useDatasetsList";
 import { Dataset } from "@/types/datasets";
 import Loader from "@/components/shared/Loader/Loader";
 import AddDatasetDialog from "@/components/pages/DatasetsPage/AddDatasetDialog";
+import DatasetsActionsPanel from "@/components/pages/DatasetsPage/DatasetsActionsPanel";
 import { DatasetRowActionsCell } from "@/components/pages/DatasetsPage/DatasetRowActionsCell";
 import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
 import useAppStore from "@/store/AppStore";
 import SearchInput from "@/components/shared/SearchInput/SearchInput";
 import { formatDate } from "@/lib/date";
-import { COLUMN_NAME_ID, COLUMN_TYPE, ColumnData } from "@/types/shared";
+import {
+  COLUMN_NAME_ID,
+  COLUMN_SELECT_ID,
+  COLUMN_TYPE,
+  ColumnData,
+} from "@/types/shared";
 import useLocalStorageState from "use-local-storage-state";
 import { convertColumnDataToColumn, mapColumnDataFields } from "@/lib/table";
 import ColumnsButton from "@/components/shared/ColumnsButton/ColumnsButton";
-import { ColumnPinningState } from "@tanstack/react-table";
-import { generateActionsColumDef } from "@/components/shared/DataTable/utils";
+import { ColumnPinningState, RowSelectionState } from "@tanstack/react-table";
+import {
+  generateActionsColumDef,
+  generateSelectColumDef,
+} from "@/components/shared/DataTable/utils";
 import ResourceCell from "@/components/shared/DataTableCells/ResourceCell";
 import { RESOURCE_TYPE } from "@/components/shared/ResourceLink/ResourceLink";
 
+export const getRowId = (d: Dataset) => d.id;
+
 const SELECTED_COLUMNS_KEY = "datasets-selected-columns";
 const COLUMNS_WIDTH_KEY = "datasets-columns-width";
 const COLUMNS_ORDER_KEY = "datasets-columns-order";
@@ -63,7 +75,7 @@ export const DEFAULT_COLUMNS: ColumnData<Dataset>[] = [
 ];
 
 export const DEFAULT_COLUMN_PINNING: ColumnPinningState = {
-  left: [COLUMN_NAME_ID],
+  left: [COLUMN_SELECT_ID, COLUMN_NAME_ID],
   right: [],
 };
 
@@ -83,6 +95,9 @@ const DatasetsPage: React.FunctionComponent = () => {
   const [search, setSearch] = useState("");
   const [page, setPage] = useState(1);
   const [size, setSize] = useState(10);
+
+  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
+
   const { data, isPending } = useDatasetsList(
     {
       workspaceName,
@@ -95,7 +110,7 @@ const DatasetsPage: React.FunctionComponent = () => {
     },
   );
 
-  const datasets = data?.content ?? [];
+  const datasets = useMemo(() => data?.content ?? [], [data?.content]);
   const total = data?.total ?? 0;
   const noData = !search;
   const noDataText = noData ? "There are no datasets yet" : "No search results";
@@ -120,8 +135,13 @@ const DatasetsPage: React.FunctionComponent = () => {
     defaultValue: {},
   });
 
+  const selectedRows: Dataset[] = useMemo(() => {
+    return datasets.filter((row) => rowSelection[row.id]);
+  }, [rowSelection, datasets]);
+
   const columns = useMemo(() => {
     return [
+      generateSelectColumDef<Dataset>(),
       mapColumnDataFields<Dataset, Dataset>({
         id: COLUMN_NAME_ID,
         label: "Name",
@@ -175,6 +195,8 @@ const DatasetsPage: React.FunctionComponent = () => {
           className="w-[320px]"
         ></SearchInput>
         <div className="flex items-center gap-2">
+          <DatasetsActionsPanel datasets={selectedRows} />
+          <Separator orientation="vertical" className="ml-2 mr-2.5 h-6" />
           <ColumnsButton
             columns={DEFAULT_COLUMNS}
             selectedColumns={selectedColumns}
@@ -191,6 +213,11 @@ const DatasetsPage: React.FunctionComponent = () => {
         columns={columns}
         data={datasets}
         resizeConfig={resizeConfig}
+        selectionConfig={{
+          rowSelection,
+          setRowSelection,
+        }}
+        getRowId={getRowId}
         columnPinning={DEFAULT_COLUMN_PINNING}
         noData={
           <DataTableNoData title={noDataText}>
diff --git a/apps/opik-frontend/src/components/pages/FeedbackDefinitionsPage/FeedbackDefinitionsActionsPanel.tsx b/apps/opik-frontend/src/components/pages/FeedbackDefinitionsPage/FeedbackDefinitionsActionsPanel.tsx
new file mode 100644
index 0000000000..a586e74cd1
--- /dev/null
+++ b/apps/opik-frontend/src/components/pages/FeedbackDefinitionsPage/FeedbackDefinitionsActionsPanel.tsx
@@ -0,0 +1,58 @@
+import React, { useCallback, useRef, useState } from "react";
+import { Trash } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { FeedbackDefinition } from "@/types/feedback-definitions";
+import useFeedbackDefinitionBatchDeleteMutation from "@/api/feedback-definitions/useFeedbackDefinitionBatchDeleteMutation";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+
+type FeedbackDefinitionsActionsPanelsProps = {
+  feedbackDefinitions: FeedbackDefinition[];
+};
+
+const FeedbackDefinitionsActionsPanel: React.FunctionComponent<
+  FeedbackDefinitionsActionsPanelsProps
+> = ({ feedbackDefinitions }) => {
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean>(false);
+  const disabled = !feedbackDefinitions?.length;
+
+  const { mutate } = useFeedbackDefinitionBatchDeleteMutation();
+
+  const deleteFeedbackDefinitionsHandler = useCallback(() => {
+    mutate({
+      ids: feedbackDefinitions.map((f) => f.id),
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [feedbackDefinitions]);
+
+  return (
+    <div className="flex items-center gap-2">
+      <ConfirmDialog
+        key={`delete-${resetKeyRef.current}`}
+        open={open}
+        setOpen={setOpen}
+        onConfirm={deleteFeedbackDefinitionsHandler}
+        title="Delete feedback definitions"
+        description="Are you sure you want to delete all selected feedback definitions?"
+        confirmText="Delete feedback definitions"
+      />
+      <TooltipWrapper content="Delete">
+        <Button
+          variant="outline"
+          size="icon"
+          onClick={() => {
+            setOpen(true);
+            resetKeyRef.current = resetKeyRef.current + 1;
+          }}
+          disabled={disabled}
+        >
+          <Trash className="size-4" />
+        </Button>
+      </TooltipWrapper>
+    </div>
+  );
+};
+
+export default FeedbackDefinitionsActionsPanel;
diff --git a/apps/opik-frontend/src/components/pages/FeedbackDefinitionsPage/FeedbackDefinitionsPage.tsx b/apps/opik-frontend/src/components/pages/FeedbackDefinitionsPage/FeedbackDefinitionsPage.tsx
index 8a2bc64a17..ac59472d65 100644
--- a/apps/opik-frontend/src/components/pages/FeedbackDefinitionsPage/FeedbackDefinitionsPage.tsx
+++ b/apps/opik-frontend/src/components/pages/FeedbackDefinitionsPage/FeedbackDefinitionsPage.tsx
@@ -17,12 +17,24 @@ import SearchInput from "@/components/shared/SearchInput/SearchInput";
 import { Button } from "@/components/ui/button";
 import useAppStore from "@/store/AppStore";
 import { FeedbackDefinition } from "@/types/feedback-definitions";
-import { COLUMN_NAME_ID, COLUMN_TYPE, ColumnData } from "@/types/shared";
+import {
+  COLUMN_NAME_ID,
+  COLUMN_SELECT_ID,
+  COLUMN_TYPE,
+  ColumnData,
+} from "@/types/shared";
 import { convertColumnDataToColumn, mapColumnDataFields } from "@/lib/table";
 import { formatDate } from "@/lib/date";
 import ColumnsButton from "@/components/shared/ColumnsButton/ColumnsButton";
-import { ColumnPinningState } from "@tanstack/react-table";
-import { generateActionsColumDef } from "@/components/shared/DataTable/utils";
+import { ColumnPinningState, RowSelectionState } from "@tanstack/react-table";
+import {
+  generateActionsColumDef,
+  generateSelectColumDef,
+} from "@/components/shared/DataTable/utils";
+import { Separator } from "@/components/ui/separator";
+import FeedbackDefinitionsActionsPanel from "@/components/pages/FeedbackDefinitionsPage/FeedbackDefinitionsActionsPanel";
+
+export const getRowId = (f: FeedbackDefinition) => f.id;
 
 const SELECTED_COLUMNS_KEY = "feedback-definitions-selected-columns";
 const COLUMNS_WIDTH_KEY = "feedback-definitions-columns-width";
@@ -62,7 +74,7 @@ export const DEFAULT_COLUMNS: ColumnData<FeedbackDefinition>[] = [
 ];
 
 export const DEFAULT_COLUMN_PINNING: ColumnPinningState = {
-  left: [COLUMN_NAME_ID],
+  left: [COLUMN_SELECT_ID, COLUMN_NAME_ID],
   right: [],
 };
 
@@ -77,6 +89,9 @@ const FeedbackDefinitionsPage: React.FunctionComponent = () => {
   const [search, setSearch] = useState("");
   const [page, setPage] = useState(1);
   const [size, setSize] = useState(10);
+
+  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
+
   const { data, isPending } = useFeedbackDefinitionsList(
     {
       workspaceName,
@@ -89,7 +104,10 @@ const FeedbackDefinitionsPage: React.FunctionComponent = () => {
     },
   );
 
-  const feedbackDefinitions = data?.content ?? [];
+  const feedbackDefinitions = useMemo(
+    () => data?.content ?? [],
+    [data?.content],
+  );
   const total = data?.total ?? 0;
   const noData = !search;
   const noDataText = noData
@@ -116,8 +134,13 @@ const FeedbackDefinitionsPage: React.FunctionComponent = () => {
     defaultValue: {},
   });
 
+  const selectedRows: FeedbackDefinition[] = useMemo(() => {
+    return feedbackDefinitions.filter((row) => rowSelection[row.id]);
+  }, [rowSelection, feedbackDefinitions]);
+
   const columns = useMemo(() => {
     return [
+      generateSelectColumDef<FeedbackDefinition>(),
       mapColumnDataFields<FeedbackDefinition, FeedbackDefinition>({
         id: COLUMN_NAME_ID,
         label: "Name",
@@ -173,6 +196,8 @@ const FeedbackDefinitionsPage: React.FunctionComponent = () => {
         ></SearchInput>
 
         <div className="flex items-center gap-2">
+          <FeedbackDefinitionsActionsPanel feedbackDefinitions={selectedRows} />
+          <Separator orientation="vertical" className="ml-2 mr-2.5 h-6" />
           <ColumnsButton
             columns={DEFAULT_COLUMNS}
             selectedColumns={selectedColumns}
@@ -189,6 +214,11 @@ const FeedbackDefinitionsPage: React.FunctionComponent = () => {
         columns={columns}
         data={feedbackDefinitions}
         resizeConfig={resizeConfig}
+        selectionConfig={{
+          rowSelection,
+          setRowSelection,
+        }}
+        getRowId={getRowId}
         columnPinning={DEFAULT_COLUMN_PINNING}
         noData={
           <DataTableNoData title={noDataText}>
diff --git a/apps/opik-frontend/src/components/pages/ProjectsPage/ProjectsActionsPanel.tsx b/apps/opik-frontend/src/components/pages/ProjectsPage/ProjectsActionsPanel.tsx
new file mode 100644
index 0000000000..63f0de02ad
--- /dev/null
+++ b/apps/opik-frontend/src/components/pages/ProjectsPage/ProjectsActionsPanel.tsx
@@ -0,0 +1,58 @@
+import React, { useCallback, useRef, useState } from "react";
+import { Trash } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Project } from "@/types/projects";
+import useProjectBatchDeleteMutation from "@/api/projects/useProjectBatchDeleteMutation";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+
+type ProjectsActionsPanelsProps = {
+  projects: Project[];
+};
+
+const ProjectsActionsPanel: React.FunctionComponent<
+  ProjectsActionsPanelsProps
+> = ({ projects }) => {
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean>(false);
+  const disabled = !projects?.length;
+
+  const { mutate } = useProjectBatchDeleteMutation();
+
+  const deleteProjectsHandler = useCallback(() => {
+    mutate({
+      ids: projects.map((p) => p.id),
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [projects]);
+
+  return (
+    <div className="flex items-center gap-2">
+      <ConfirmDialog
+        key={`delete-${resetKeyRef.current}`}
+        open={open}
+        setOpen={setOpen}
+        onConfirm={deleteProjectsHandler}
+        title="Delete projects"
+        description="Are you sure you want to delete all selected projects?"
+        confirmText="Delete projects"
+      />
+      <TooltipWrapper content="Delete">
+        <Button
+          variant="outline"
+          size="icon"
+          onClick={() => {
+            setOpen(true);
+            resetKeyRef.current = resetKeyRef.current + 1;
+          }}
+          disabled={disabled}
+        >
+          <Trash className="size-4" />
+        </Button>
+      </TooltipWrapper>
+    </div>
+  );
+};
+
+export default ProjectsActionsPanel;
diff --git a/apps/opik-frontend/src/components/pages/ProjectsPage/ProjectsPage.tsx b/apps/opik-frontend/src/components/pages/ProjectsPage/ProjectsPage.tsx
index 1c667dfb72..e3fb266044 100644
--- a/apps/opik-frontend/src/components/pages/ProjectsPage/ProjectsPage.tsx
+++ b/apps/opik-frontend/src/components/pages/ProjectsPage/ProjectsPage.tsx
@@ -9,19 +9,35 @@ import useProjectsList from "@/api/projects/useProjectsList";
 import { Project } from "@/types/projects";
 import Loader from "@/components/shared/Loader/Loader";
 import AddProjectDialog from "@/components/pages/ProjectsPage/AddProjectDialog";
+import ProjectsActionsPanel from "@/components/pages/ProjectsPage/ProjectsActionsPanel";
 import { ProjectRowActionsCell } from "@/components/pages/ProjectsPage/ProjectRowActionsCell";
 import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
 import useAppStore from "@/store/AppStore";
 import SearchInput from "@/components/shared/SearchInput/SearchInput";
 import { formatDate } from "@/lib/date";
 import ColumnsButton from "@/components/shared/ColumnsButton/ColumnsButton";
-import { COLUMN_NAME_ID, COLUMN_TYPE, ColumnData } from "@/types/shared";
+import {
+  COLUMN_NAME_ID,
+  COLUMN_SELECT_ID,
+  COLUMN_TYPE,
+  ColumnData,
+} from "@/types/shared";
 import { convertColumnDataToColumn, mapColumnDataFields } from "@/lib/table";
 import useLocalStorageState from "use-local-storage-state";
-import { ColumnPinningState, ColumnSort } from "@tanstack/react-table";
-import { generateActionsColumDef } from "@/components/shared/DataTable/utils";
+import {
+  ColumnPinningState,
+  ColumnSort,
+  RowSelectionState,
+} from "@tanstack/react-table";
+import {
+  generateActionsColumDef,
+  generateSelectColumDef,
+} from "@/components/shared/DataTable/utils";
 import { RESOURCE_TYPE } from "@/components/shared/ResourceLink/ResourceLink";
 
+export const getRowId = (p: Project) => p.id;
+
 const SELECTED_COLUMNS_KEY = "projects-selected-columns";
 const COLUMNS_WIDTH_KEY = "projects-columns-width";
 const COLUMNS_ORDER_KEY = "projects-columns-order";
@@ -58,7 +74,7 @@ export const DEFAULT_COLUMNS: ColumnData<Project>[] = [
 ];
 
 export const DEFAULT_COLUMN_PINNING: ColumnPinningState = {
-  left: [COLUMN_NAME_ID],
+  left: [COLUMN_SELECT_ID, COLUMN_NAME_ID],
   right: [],
 };
 
@@ -84,6 +100,8 @@ const ProjectsPage: React.FunctionComponent = () => {
   const [page, setPage] = useState(1);
   const [size, setSize] = useState(10);
 
+  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
+
   const [sortedColumns, setSortedColumns] = useLocalStorageState<ColumnSort[]>(
     COLUMNS_SORT_KEY,
     {
@@ -112,7 +130,7 @@ const ProjectsPage: React.FunctionComponent = () => {
     },
   );
 
-  const projects = data?.content ?? [];
+  const projects = useMemo(() => data?.content ?? [], [data?.content]);
   const total = data?.total ?? 0;
   const noData = !search;
   const noDataText = noData ? "There are no projects yet" : "No search results";
@@ -137,8 +155,13 @@ const ProjectsPage: React.FunctionComponent = () => {
     defaultValue: {},
   });
 
+  const selectedRows: Project[] = useMemo(() => {
+    return projects.filter((row) => rowSelection[row.id]);
+  }, [rowSelection, projects]);
+
   const columns = useMemo(() => {
     return [
+      generateSelectColumDef<Project>(),
       mapColumnDataFields<Project, Project>({
         id: COLUMN_NAME_ID,
         label: "Name",
@@ -193,6 +216,8 @@ const ProjectsPage: React.FunctionComponent = () => {
           className="w-[320px]"
         ></SearchInput>
         <div className="flex items-center gap-2">
+          <ProjectsActionsPanel projects={selectedRows} />
+          <Separator orientation="vertical" className="ml-2 mr-2.5 h-6" />
           <ColumnsButton
             columns={DEFAULT_COLUMNS}
             selectedColumns={selectedColumns}
@@ -214,6 +239,11 @@ const ProjectsPage: React.FunctionComponent = () => {
           setSorting: setSortedColumns,
         }}
         resizeConfig={resizeConfig}
+        selectionConfig={{
+          rowSelection,
+          setRowSelection,
+        }}
+        getRowId={getRowId}
         columnPinning={DEFAULT_COLUMN_PINNING}
         noData={
           <DataTableNoData title={noDataText}>


Code review comments:
diff_hunk: @@ -0,0 +1,58 @@
+import React, { useCallback, useRef, useState } from "react";
+import { Trash } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { FeedbackDefinition } from "@/types/feedback-definitions";
+import useFeedbackDefinitionBatchDeleteMutation from "@/api/feedback-definitions/useFeedbackDefinitionBatchDeleteMutation";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+
+type FeedbackDefinitionsActionsPanelsProps = {
+  feedbackDefinitions: FeedbackDefinition[];
+};
+
+const FeedbackDefinitionsActionsPanel: React.FunctionComponent<
+  FeedbackDefinitionsActionsPanelsProps
+> = ({ feedbackDefinitions }) => {
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean>(false);
+  const disabled = !feedbackDefinitions?.length;
+
+  const { mutate } = useFeedbackDefinitionBatchDeleteMutation();
+
+  const deleteFeedbackDefinitionsHandler = useCallback(() => {
+    mutate({
+      ids: feedbackDefinitions.map((f) => f.id),
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [feedbackDefinitions]);
path: apps/opik-frontend/src/components/pages/FeedbackDefinitionsPage/FeedbackDefinitionsActionsPanel.tsx
text: @user1:
i would add `mutate` to the dependencies

@author:
Fixed

diff_hunk: @@ -0,0 +1,58 @@
+import React, { useCallback, useRef, useState } from "react";
+import { Trash } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Project } from "@/types/projects";
+import useProjectBatchDeleteMutation from "@/api/projects/useProjectBatchDeleteMutation";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+
+type ProjectsActionsPanelsProps = {
+  projects: Project[];
+};
+
+const ProjectsActionsPanel: React.FunctionComponent<
+  ProjectsActionsPanelsProps
+> = ({ projects }) => {
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean>(false);
+  const disabled = !projects?.length;
+
+  const { mutate } = useProjectBatchDeleteMutation();
+
+  const deleteProjectsHandler = useCallback(() => {
+    mutate({
+      ids: projects.map((p) => p.id),
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [projects]);
path: apps/opik-frontend/src/components/pages/ProjectsPage/ProjectsActionsPanel.tsx
text: @user1:
you could just add `mutate` here. it's not as heavy as the entire `useProjectBatchDeleteMutation()`

@author:
Fixed

diff_hunk: @@ -0,0 +1,58 @@
+import React, { useCallback, useRef, useState } from "react";
+import { Trash } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Dataset } from "@/types/datasets";
+import useDatasetBatchDeleteMutation from "@/api/datasets/useDatasetBatchDeleteMutation";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+
+type DatasetsActionsPanelsProps = {
+  datasets: Dataset[];
+};
+
+const DatasetsActionsPanel: React.FunctionComponent<
+  DatasetsActionsPanelsProps
+> = ({ datasets }) => {
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean>(false);
+  const disabled = !datasets?.length;
+
+  const { mutate } = useDatasetBatchDeleteMutation();
+
+  const deleteDatasetsHandler = useCallback(() => {
+    mutate({
+      ids: datasets.map((d) => d.id),
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [datasets]);
path: apps/opik-frontend/src/components/pages/DatasetsPage/DatasetsActionsPanel.tsx
text: @user1:
i would add `mutate` to the list of dependencies 

@author:
Fixed

diff_hunk: @@ -0,0 +1,58 @@
+import React, { useCallback, useRef, useState } from "react";
+import { Trash } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { DatasetItem } from "@/types/datasets";
+import useDatasetItemBatchDeleteMutation from "@/api/datasets/useDatasetItemBatchDeleteMutation";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+
+type DatasetItemsActionsPanelsProps = {
+  datasetItems: DatasetItem[];
+};
+
+const DatasetItemsActionsPanel: React.FunctionComponent<
+  DatasetItemsActionsPanelsProps
+> = ({ datasetItems }) => {
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean>(false);
+  const disabled = !datasetItems?.length;
+
+  const { mutate } = useDatasetItemBatchDeleteMutation();
+
+  const deleteDatasetItemsHandler = useCallback(() => {
+    mutate({
+      ids: datasetItems.map((i) => i.id),
+    });
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [datasetItems]);
path: apps/opik-frontend/src/components/pages/DatasetItemsPage/DatasetItemsActionsPanel.tsx
text: @user1:
i would add `mutate` to the dependencies

@author:
Fixed