diff --git a/client/src/api/histories.ts b/client/src/api/histories.ts index cae87c145b8e..a4da4bb0627f 100644 --- a/client/src/api/histories.ts +++ b/client/src/api/histories.ts @@ -3,5 +3,7 @@ import { fetcher } from "@/api/schema"; export const historiesFetcher = fetcher.path("/api/histories").method("get").create(); export const archivedHistoriesFetcher = fetcher.path("/api/histories/archived").method("get").create(); export const deleteHistory = fetcher.path("/api/histories/{history_id}").method("delete").create(); +export const deleteHistories = fetcher.path("/api/histories/batch/delete").method("put").create(); export const undeleteHistory = fetcher.path("/api/histories/deleted/{history_id}/undelete").method("post").create(); +export const undeleteHistories = fetcher.path("/api/histories/batch/undelete").method("put").create(); export const historiesQuery = fetcher.path("/api/histories/query").method("get").create(); diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 826aa9070b13..a6558ada944a 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -493,6 +493,14 @@ export interface paths { */ get: operations["get_archived_histories_api_histories_archived_get"]; }; + "/api/histories/batch/delete": { + /** Marks several histories with the given IDs as deleted. */ + put: operations["batch_delete_api_histories_batch_delete_put"]; + }; + "/api/histories/batch/undelete": { + /** Marks several histories with the given IDs as undeleted. */ + put: operations["batch_undelete_api_histories_batch_undelete_put"]; + }; "/api/histories/count": { /** Returns number of histories for the current user. */ get: operations["count_api_histories_count_get"]; @@ -3985,6 +3993,20 @@ export interface components { */ success_count: number; }; + /** DeleteHistoriesPayload */ + DeleteHistoriesPayload: { + /** + * IDs + * @description List of history IDs to be deleted. + */ + ids: string[]; + /** + * Purge + * @description Whether to definitely remove this history from disk. + * @default false + */ + purge?: boolean; + }; /** DeleteHistoryContentPayload */ DeleteHistoryContentPayload: { /** @@ -10354,6 +10376,14 @@ export interface components { */ title?: string | null; }; + /** UndeleteHistoriesPayload */ + UndeleteHistoriesPayload: { + /** + * IDs + * @description List of history IDs to be undeleted. + */ + ids: string[]; + }; /** * UpdateCollectionAttributePayload * @description Contains attributes that can be updated for all elements in a dataset collection. @@ -13832,6 +13862,83 @@ export interface operations { }; }; }; + batch_delete_api_histories_batch_delete_put: { + /** Marks several histories with the given IDs as deleted. */ + parameters?: { + /** @description View to be passed to the serializer */ + /** @description Comma-separated list of keys to be passed to the serializer */ + query?: { + purge?: boolean; + view?: string | null; + keys?: string | null; + }; + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DeleteHistoriesPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": ( + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["HistorySummary"] + | components["schemas"]["HistoryMinimal"] + )[]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + batch_undelete_api_histories_batch_undelete_put: { + /** Marks several histories with the given IDs as undeleted. */ + parameters?: { + /** @description View to be passed to the serializer */ + /** @description Comma-separated list of keys to be passed to the serializer */ + query?: { + view?: string | null; + keys?: string | null; + }; + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UndeleteHistoriesPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": ( + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["HistorySummary"] + | components["schemas"]["HistoryMinimal"] + )[]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; count_api_histories_count_get: { /** Returns number of histories for the current user. */ parameters?: { diff --git a/client/src/components/Grid/GridList.vue b/client/src/components/Grid/GridList.vue index 3db89944ecb0..d5eb85074aa1 100644 --- a/client/src/components/Grid/GridList.vue +++ b/client/src/components/Grid/GridList.vue @@ -3,11 +3,11 @@ import { library } from "@fortawesome/fontawesome-svg-core"; import { faCaretDown, faCaretUp, faShieldAlt } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { useDebounceFn, useEventBus } from "@vueuse/core"; -import { BAlert, BButton, BPagination } from "bootstrap-vue"; +import { BAlert, BButton, BFormCheckbox, BPagination } from "bootstrap-vue"; import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import { useRouter } from "vue-router/composables"; -import { FieldHandler, GridConfig, Operation, RowData } from "./configs/types"; +import { BatchOperation, FieldHandler, GridConfig, Operation, RowData } from "./configs/types"; import GridBoolean from "./GridElements/GridBoolean.vue"; import GridDatasets from "./GridElements/GridDatasets.vue"; @@ -49,6 +49,11 @@ const errorMessage = ref(""); const operationMessage = ref(""); const operationStatus = ref(""); +// selection references +const selected = ref(new Set()); +const selectedAll = computed(() => gridData.value.length === selected.value.size); +const selectedIndeterminate = computed(() => ![0, gridData.value.length].includes(selected.value.size)); + // page references const currentPage = ref(1); const totalRows = ref(0); @@ -98,6 +103,7 @@ function displayInitialMessage() { * Request grid data */ async function getGridData() { + selected.value = new Set(); if (props.gridConfig) { try { const offset = props.limit * (currentPage.value - 1); @@ -122,6 +128,15 @@ async function getGridData() { /** * Execute grid operation and display message if available */ +async function onBatchOperation(operation: BatchOperation, rowDataArray: Array) { + const response = await operation.handler(rowDataArray); + if (response) { + await getGridData(); + operationMessage.value = response.message; + operationStatus.value = response.status || "success"; + } +} + async function onOperation(operation: Operation, rowData: RowData) { const response = await operation.handler(rowData); if (response) { @@ -172,6 +187,24 @@ function onFilter(filter?: string) { } } +// Select multiple rows +function onSelect(rowData: RowData) { + if (selected.value.has(rowData)) { + selected.value.delete(rowData); + } else { + selected.value.add(rowData); + } + selected.value = new Set(selected.value); +} + +function onSelectAll(current: boolean): void { + if (current) { + selected.value = new Set(gridData.value); + } else { + selected.value = new Set(); + } +} + /** * Initialize grid data */ @@ -242,6 +275,13 @@ watch(operationMessage, () => { + +
+ + {
+ + {
- @@ -322,7 +391,7 @@ watch(operationMessage, () => { top: 0; } .grid-sticky { - z-index: 1; + z-index: 2; background: $white; opacity: 0.95; position: sticky; diff --git a/client/src/components/Grid/configs/histories.ts b/client/src/components/Grid/configs/histories.ts index a5713964bcba..3042aa515983 100644 --- a/client/src/components/Grid/configs/histories.ts +++ b/client/src/components/Grid/configs/histories.ts @@ -10,14 +10,14 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { useEventBus } from "@vueuse/core"; -import { deleteHistory, historiesQuery, undeleteHistory } from "@/api/histories"; +import { deleteHistories, deleteHistory, historiesQuery, undeleteHistories, undeleteHistory } from "@/api/histories"; import { updateTags } from "@/api/tags"; import { useHistoryStore } from "@/stores/historyStore"; import Filtering, { contains, equals, expandNameTag, toBool, type ValidFilter } from "@/utils/filtering"; import _l from "@/utils/localization"; import { errorMessageAsString, rethrowSimple } from "@/utils/simple-error"; -import type { ActionArray, FieldArray, GridConfig } from "./types"; +import type { ActionArray, BatchOperationArray, FieldArray, GridConfig } from "./types"; const { emit } = useEventBus("grid-router-push"); @@ -57,6 +57,76 @@ const actions: ActionArray = [ }, ]; +// Batch operation +const batch: BatchOperationArray = [ + { + title: "Delete", + icon: faTrash, + condition: (data: Array) => !data.some((x) => x.deleted), + handler: async (data: Array) => { + if (confirm(_l(`Are you sure that you want to delete the selected histories?`))) { + try { + const historyIds = data.map((x) => String(x.id)); + await deleteHistories({ ids: historyIds }); + return { + status: "success", + message: `Deleted ${data.length} histories.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to delete histories: ${errorMessageAsString(e)}`, + }; + } + } + }, + }, + { + title: "Restore", + icon: faTrashRestore, + condition: (data: Array) => !data.some((x) => !x.deleted || x.purged), + handler: async (data: Array) => { + if (confirm(_l(`Are you sure that you want to restore the selected histories?`))) { + try { + const historyIds = data.map((x) => String(x.id)); + await undeleteHistories({ ids: historyIds }); + return { + status: "success", + message: `Restored ${data.length} histories.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to restore histories: ${errorMessageAsString(e)}`, + }; + } + } + }, + }, + { + title: "Purge", + icon: faTrash, + condition: (data: Array) => !data.some((x) => x.purged), + handler: async (data: Array) => { + if (confirm(_l(`Are you sure that you want to permanently delete the selected histories?`))) { + try { + const historyIds = data.map((x) => String(x.id)); + await deleteHistories({ ids: historyIds, purge: true }); + return { + status: "success", + message: `Purged ${data.length} histories.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to delete histories: ${errorMessageAsString(e)}`, + }; + } + } + }, + }, +]; + /** * Declare columns to be displayed */ @@ -258,6 +328,7 @@ const gridConfig: GridConfig = { fields: fields, filtering: new Filtering(validFilters, undefined, false, false), getData: getData, + batch: batch, plural: "Histories", sortBy: "update_time", sortDesc: true, diff --git a/client/src/components/Grid/configs/types.ts b/client/src/components/Grid/configs/types.ts index fcc27b69bd53..e008e967cac6 100644 --- a/client/src/components/Grid/configs/types.ts +++ b/client/src/components/Grid/configs/types.ts @@ -17,6 +17,7 @@ export interface GridConfig { fields: FieldArray; filtering: Filtering; getData: (offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) => Promise; + batch?: BatchOperationArray; plural: string; sortBy: string; sortKeys: Array; @@ -39,6 +40,15 @@ export interface FieldEntry { export type FieldHandler = (data: RowData) => void; +export interface BatchOperation { + title: string; + icon: IconDefinition; + condition?: (data: Array) => boolean; + handler: (data: Array) => OperationHandlerReturn; +} + +export type BatchOperationArray = Array; + export interface Operation { title: string; icon: IconDefinition; @@ -51,7 +61,7 @@ interface OperationHandlerMessage { status: string; } -type OperationHandlerReturn = Promise | void; +type OperationHandlerReturn = Promise | void; export type RowData = Record; diff --git a/lib/galaxy/managers/histories.py b/lib/galaxy/managers/histories.py index 8264a6795a2b..69715c06955e 100644 --- a/lib/galaxy/managers/histories.py +++ b/lib/galaxy/managers/histories.py @@ -202,7 +202,9 @@ def p_tag_filter(term_text: str, quoted: bool): if show_purged: stmt = stmt.where(self.model_class.purged == true()) else: - stmt = stmt.where(self.model_class.deleted == (true() if show_deleted else false())) + stmt = stmt.where(self.model_class.purged == false()).where( + self.model_class.deleted == (true() if show_deleted else false()) + ) if include_total_count: total_matches = get_count(trans.sa_session, stmt) diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index 65486cf3a140..9377cee49e55 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -544,9 +544,12 @@ def select_grid_operation(self, item_name, option_label): target_item = None grid = self.components.grids.body.wait_for_visible() for row in grid.find_elements(By.TAG_NAME, "tr"): - name_cell = row.find_elements(By.TAG_NAME, "td")[0] - if item_name in name_cell.text: - target_item = name_cell + for name_column in range(2): + name_cell = row.find_elements(By.TAG_NAME, "td")[name_column] + if item_name in name_cell.text: + target_item = name_cell + break + if target_item is not None: break if target_item is None: @@ -564,7 +567,7 @@ def select_grid_cell(self, grid_name, item_name, column_index=3): grid = self.wait_for_selector(grid_name) for row in grid.find_elements(By.TAG_NAME, "tr"): td = row.find_elements(By.TAG_NAME, "td") - if td[0].text == item_name: + if item_name in [td[0].text, td[1].text]: cell = td[column_index] break @@ -573,6 +576,16 @@ def select_grid_cell(self, grid_name, item_name, column_index=3): return cell + def check_grid_rows(self, grid_name, item_names): + grid = self.wait_for_selector(grid_name) + for row in grid.find_elements(By.TAG_NAME, "tr"): + td = row.find_elements(By.TAG_NAME, "td") + item_name = td[1].text + if item_name in item_names: + checkbox = td[0].find_element(self.by.TAG_NAME, "input") + # bootstrap vue checkbox seems to be hidden by label, but the label is not interactable + self.driver.execute_script("$(arguments[0]).click();", checkbox) + def published_grid_search_for(self, search_term=None): return self._inline_search_for( self.navigation.grids.free_text_search, diff --git a/lib/galaxy/webapps/galaxy/api/histories.py b/lib/galaxy/webapps/galaxy/api/histories.py index 0a5a15396eb5..600d1a303d5e 100644 --- a/lib/galaxy/webapps/galaxy/api/histories.py +++ b/lib/galaxy/webapps/galaxy/api/histories.py @@ -22,6 +22,7 @@ ) from pydantic.fields import Field from pydantic.main import BaseModel +from typing_extensions import Annotated from galaxy.managers.context import ( ProvidesHistoryContext, @@ -139,6 +140,17 @@ class DeleteHistoryPayload(BaseModel): ) +class DeleteHistoriesPayload(BaseModel): + ids: Annotated[List[DecodedDatabaseIdField], Field(title="IDs", description="List of history IDs to be deleted.")] + purge: Annotated[ + bool, Field(default=False, title="Purge", description="Whether to definitely remove this history from disk.") + ] + + +class UndeleteHistoriesPayload(BaseModel): + ids: Annotated[List[DecodedDatabaseIdField], Field(title="IDs", description="List of history IDs to be undeleted.")] + + @as_form class CreateHistoryFormData(CreateHistoryPayload): """Uses Form data instead of JSON""" @@ -375,6 +387,25 @@ def delete( purge = payload.purge return self.service.delete(trans, history_id, serialization_params, purge) + @router.put( + "/api/histories/batch/delete", + summary="Marks several histories with the given IDs as deleted.", + ) + def batch_delete( + self, + trans: ProvidesHistoryContext = DependsOnTrans, + serialization_params: SerializationParams = Depends(query_serialization_params), + purge: bool = Query(default=False), + payload: DeleteHistoriesPayload = Body(...), + ) -> List[AnyHistoryView]: + if payload: + purge = payload.purge + results = [] + for history_id in payload.ids: + result = self.service.delete(trans, history_id, serialization_params, purge) + results.append(result) + return results + @router.post( "/api/histories/deleted/{history_id}/undelete", summary="Restores a deleted history with the given ID (that hasn't been purged).", @@ -387,6 +418,22 @@ def undelete( ) -> AnyHistoryView: return self.service.undelete(trans, history_id, serialization_params) + @router.put( + "/api/histories/batch/undelete", + summary="Marks several histories with the given IDs as undeleted.", + ) + def batch_undelete( + self, + trans: ProvidesHistoryContext = DependsOnTrans, + serialization_params: SerializationParams = Depends(query_serialization_params), + payload: UndeleteHistoriesPayload = Body(...), + ) -> List[AnyHistoryView]: + results = [] + for history_id in payload.ids: + result = self.service.undelete(trans, history_id, serialization_params) + results.append(result) + return results + @router.put( "/api/histories/{history_id}", summary="Updates the values for the history with the given ID.", diff --git a/lib/galaxy_test/selenium/test_histories_list.py b/lib/galaxy_test/selenium/test_histories_list.py index 91b0606d57b8..d89cb0b94983 100644 --- a/lib/galaxy_test/selenium/test_histories_list.py +++ b/lib/galaxy_test/selenium/test_histories_list.py @@ -113,12 +113,46 @@ def test_permanently_delete_history(self): self.assert_histories_in_grid([self.history4_name], False) self.components.histories.advanced_search_toggle.wait_for_and_click() - self.components.histories.advanced_search_filter(filter="deleted").wait_for_and_click() + self.components.histories.advanced_search_filter(filter="purged").wait_for_and_click() self.components.histories.advanced_search_submit.wait_for_and_click() self.sleep_for(self.wait_types.UX_RENDER) self.assert_histories_in_grid([self.history4_name]) + @selenium_test + def test_delete_and_undelete_multiple_histories(self): + self._login() + self.navigate_to_histories_page() + + delete_button_selector = 'button[data-description="grid batch delete"]' + undelete_button_selector = 'button[data-description="grid batch restore"]' + + # Select multiple histories + self.check_grid_rows("#histories-grid", [self.history2_name, self.history3_name]) + + # Delete multiple histories + self.wait_for_and_click_selector(delete_button_selector) + alert = self.driver.switch_to.alert + alert.accept() + + # Display deleted histories + self.components.histories.advanced_search_toggle.wait_for_and_click() + self.components.histories.advanced_search_filter(filter="deleted").wait_for_and_click() + self.components.histories.advanced_search_submit.wait_for_and_click() + + # Select multiple histories + self.sleep_for(self.wait_types.UX_RENDER) + self.check_grid_rows("#histories-grid", [self.history2_name, self.history3_name]) + + # Undelete multiple histories + self.wait_for_and_click_selector(undelete_button_selector) + alert = self.driver.switch_to.alert + alert.accept() + + # Verify deleted histories have been undeleted + self.components.histories.reset_input.wait_for_and_click() + self.assert_histories_in_grid([self.history2_name, self.history3_name]) + @selenium_test def test_sort_by_name(self): self._login() @@ -141,7 +175,6 @@ def test_sort_by_name(self): def test_standard_search(self): self._login() self.navigate_to_histories_page() - self.sleep_for(self.wait_types.UX_RENDER) self.components.histories.search_input.wait_for_and_send_keys(self.history2_name) self.assert_grid_histories_are([self.history2_name]) self.components.histories.reset_input.wait_for_and_click() @@ -152,7 +185,6 @@ def test_standard_search(self): def test_advanced_search(self): self._login() self.navigate_to_histories_page() - self.sleep_for(self.wait_types.UX_RENDER) # search by name self.components.histories.advanced_search_toggle.wait_for_and_click() self.components.histories.advanced_search_name_input.wait_for_and_send_keys(self.history3_name) @@ -189,8 +221,9 @@ def test_advanced_search(self): def assert_histories_present(self, expected_histories, sort_by_matters=False): present_histories = self.get_present_histories() assert len(present_histories) == len(expected_histories) + name_column = 1 for index, row in enumerate(present_histories): - cell = row.find_elements(By.TAG_NAME, "td")[0] + cell = row.find_elements(By.TAG_NAME, "td")[name_column] if not sort_by_matters: assert cell.text in expected_histories else: @@ -206,7 +239,7 @@ def test_tags(self): self.navigate_to_histories_page() # Insert a tag - tag_column = 3 + tag_column = 4 tags_cell = self.select_grid_cell("#histories-grid", self.history2_name, tag_column) self.add_tag(tags_cell, self.history2_tags[0])