diff --git a/client/src/api/datasets.ts b/client/src/api/datasets.ts index e25abfbfe89d..263fd6c8dedc 100644 --- a/client/src/api/datasets.ts +++ b/client/src/api/datasets.ts @@ -6,10 +6,54 @@ import { GalaxyApi, type GalaxyApiPaths, type HDADetailed, + type HDASummary, } from "@/api"; import { withPrefix } from "@/utils/redirect"; import { rethrowSimple } from "@/utils/simple-error"; +export interface LoadDatasetsOptions { + limit?: number; + offset?: number; + sortBy?: string; + sortDesc?: boolean; + search?: string; +} + +export interface LoadDatasetsResult { + data: HDASummary[]; + totalMatches: number; +} + +export async function loadDatasets(options: LoadDatasetsOptions): Promise { + const { limit = 24, offset = 0, sortBy = "update_time", sortDesc = true, search = "" } = options; + + const { + response, + data: datasets, + error, + } = await GalaxyApi().GET("/api/datasets", { + params: { + query: { + q: search ? ["name-contains"] : undefined, + qv: search ? [search] : undefined, + limit, + offset, + order: `${sortBy}${sortDesc ? "-dsc" : "-asc"}`, + view: "summary", + }, + }, + }); + + if (error) { + rethrowSimple(error); + } + + const totalMatches = parseInt(response.headers.get("total_matches") ?? "0", 10) || 0; + const data = datasets as unknown as HDASummary[]; + + return { data, totalMatches }; +} + export async function fetchDatasetTextContentDetails(params: { id: string }): Promise { const { data, error } = await GalaxyApi().GET("/api/datasets/{dataset_id}/get_content_as_text", { params: { @@ -56,11 +100,11 @@ export async function undeleteDataset(datasetId: string) { return data; } -export async function purgeDataset(datasetId: string) { +export async function deleteDataset(datasetId: string, purge: boolean = false) { const { data, error } = await GalaxyApi().DELETE("/api/datasets/{dataset_id}", { params: { path: { dataset_id: datasetId }, - query: { purge: true }, + query: { purge }, }, }); if (error) { @@ -69,6 +113,10 @@ export async function purgeDataset(datasetId: string) { return data; } +export async function purgeDataset(datasetId: string) { + return deleteDataset(datasetId, true); +} + type CopyDatasetParamsType = GalaxyApiPaths["/api/histories/{history_id}/contents/{type}s"]["post"]["parameters"]; type CopyDatasetBodyType = components["schemas"]["CreateHistoryContentPayload"]; diff --git a/client/src/components/Common/GTable.types.ts b/client/src/components/Common/GTable.types.ts new file mode 100644 index 000000000000..1ca5e6007bda --- /dev/null +++ b/client/src/components/Common/GTable.types.ts @@ -0,0 +1,119 @@ +import type { SizeProp } from "@fortawesome/fontawesome-svg-core"; +import type { IconDefinition } from "@fortawesome/free-solid-svg-icons"; + +import type { BootstrapVariant } from "@/components/Common"; + +/** Bootstrap component sizes */ +export type BootstrapSize = "xs" | "sm" | "md" | "lg" | "xl"; + +/** Table field sorting order */ +export type SortOrder = "asc" | "desc"; + +/** Table field alignment options */ +export type FieldAlignment = "left" | "center" | "right"; + +/** Table field definition */ +export interface TableField { + /** Unique key for the field (matches data property name) */ + key: string; + /** Display label for the column header */ + label?: string; + /** Whether the column is sortable */ + sortable?: boolean; + /** Custom CSS classes for the column */ + class?: string; + /** Custom CSS classes for the header cell */ + headerClass?: string; + /** Custom CSS classes for data cells */ + cellClass?: string; + /** Column alignment */ + align?: FieldAlignment; + /** Width of the column (CSS value) */ + width?: string; + /** Whether to hide the column on small screens */ + hideOnSmall?: boolean; + /** Custom formatter function for cell values */ + formatter?: (value: any, key: string, item: any) => string; +} + +/** Sort change event payload */ +export interface SortChangeEvent { + /** The field key being sorted */ + sortBy: string; + /** Whether sorting in descending order */ + sortDesc: boolean; +} + +/** Row click event payload */ +export interface RowClickEvent { + /** The row item data */ + item: T; + /** The row index */ + index: number; + /** The original mouse/keyboard event */ + event: MouseEvent | KeyboardEvent; +} + +/** Row selection event payload */ +export interface RowSelectEvent { + /** The selected row item */ + item: T; + /** The row index */ + index: number; + /** Whether the row is now selected */ + selected: boolean; +} + +/** Table action button configuration */ +export interface TableAction { + /** Unique identifier for the action */ + id: string; + /** Display label for the action */ + label: string; + /** Tooltip text */ + title: string; + /** FontAwesome icon */ + icon?: IconDefinition; + /** Bootstrap variant */ + variant?: BootstrapVariant; + /** Whether the action is disabled */ + disabled?: boolean; + /** Whether the action is visible */ + visible?: boolean; + /** Bootstrap component size */ + size?: BootstrapSize; + /** Vue Router route to navigate to */ + to?: string; + /** Hyperlink reference */ + href?: string; + /** Link target attribute */ + target?: string; + /** Whether link opens in new tab/window */ + externalLink?: boolean; + /** Click handler function */ + handler?: (item: any, index: number) => void; +} + +/** Empty state configuration */ +export interface TableEmptyState { + /** Message to display when no data */ + message: string; + /** Optional icon to display */ + icon?: IconDefinition; + /** Bootstrap variant for styling */ + variant?: BootstrapVariant; +} + +/** Row status icon configuration */ +export interface RowIcon { + /** FontAwesome icon (required) */ + icon: IconDefinition; + /** Additional CSS classes */ + class?: string; + /** Icon size (FontAwesome SizeProp) */ + size?: SizeProp; + /** Tooltip text */ + title?: string; + /** Whether icon should spin (loading state) */ + spin?: boolean; +} diff --git a/client/src/components/Common/GTable.vue b/client/src/components/Common/GTable.vue new file mode 100644 index 000000000000..c4dbfad1264c --- /dev/null +++ b/client/src/components/Common/GTable.vue @@ -0,0 +1,572 @@ + + + + + diff --git a/client/src/components/Common/ListHeader.vue b/client/src/components/Common/ListHeader.vue index 1514d2038ff9..f65eae24ad16 100644 --- a/client/src/components/Common/ListHeader.vue +++ b/client/src/components/Common/ListHeader.vue @@ -1,7 +1,7 @@ + + diff --git a/client/src/components/Dataset/DatasetName.test.ts b/client/src/components/Dataset/DatasetName.test.ts deleted file mode 100644 index 1e83aaec2c6b..000000000000 --- a/client/src/components/Dataset/DatasetName.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { getLocalVue } from "@tests/vitest/helpers"; -import { shallowMount } from "@vue/test-utils"; -import { describe, expect, it } from "vitest"; - -import DatasetName from "./DatasetName.vue"; - -const localVue = getLocalVue(); - -async function mountComponent(propsData: { item: { name: string; state: string } }) { - return shallowMount(DatasetName as object, { - propsData, - localVue, - }); -} - -describe("Dataset Name", () => { - it("test dataset default", async () => { - const wrapper = await mountComponent({ item: { name: "name", state: "success" } }); - - const state = wrapper.findAll(".name"); - expect(state.length).toBe(1); - expect(state.at(0).text()).toBe("name"); - const $linkCopy = wrapper.find(".dropdown-item:first-child"); - $linkCopy.trigger("click"); - - expect(Array.isArray(wrapper.emitted().copyDataset)).toBe(true); - }); - - it("test dataset error", async () => { - const wrapper = await mountComponent({ item: { name: "name", state: "error" } }); - - const state = wrapper.findAll(".name"); - expect(state.length).toBe(1); - expect(state.at(0).text()).toBe("name"); - - const errorstate = wrapper.findAll(".error"); - expect(errorstate.length).toBe(1); - expect(errorstate.at(0).classes()).toEqual(expect.arrayContaining(["text-danger"])); - }); - - it("test dataset paused", async () => { - const wrapper = await mountComponent({ item: { name: "name", state: "paused" } }); - - const state = wrapper.findAll(".name"); - expect(state.length).toBe(1); - expect(state.at(0).text()).toBe("name"); - - const pausestate = wrapper.findAll(".pause"); - expect(pausestate.length).toBe(1); - expect(pausestate.at(0).classes()).toEqual(expect.arrayContaining(["text-info"])); - }); -}); diff --git a/client/src/components/Dataset/DatasetName.vue b/client/src/components/Dataset/DatasetName.vue deleted file mode 100644 index 6fcfbbca3951..000000000000 --- a/client/src/components/Dataset/DatasetName.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/client/src/components/Dataset/useDatasetTableActions.ts b/client/src/components/Dataset/useDatasetTableActions.ts new file mode 100644 index 000000000000..40395d151b77 --- /dev/null +++ b/client/src/components/Dataset/useDatasetTableActions.ts @@ -0,0 +1,115 @@ +import { faCopy, faEye, faFire, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { storeToRefs } from "pinia"; + +import type { HDASummary } from "@/api"; +import { copyDataset, deleteDataset } from "@/api/datasets"; +import type { TableAction } from "@/components/Common/GTable.types"; +import { useConfirmDialog } from "@/composables/confirmDialog"; +import { Toast } from "@/composables/toast"; +import { useHistoryStore } from "@/stores/historyStore"; + +export function useDatasetTableActions(refreshList: () => Promise) { + const historyStore = useHistoryStore(); + const { currentHistoryId } = storeToRefs(historyStore); + + const { confirm } = useConfirmDialog(); + + async function onShowDataset(item: HDASummary) { + const { history_id } = item; + const filters = { + deleted: item.deleted, + visible: item.visible, + hid: item.hid, + }; + + try { + await historyStore.applyFilters(history_id, filters); + } catch (error) { + Toast.error("Failed to show dataset in history"); + } + } + + async function onCopyDataset(item: HDASummary) { + const dataset_id = item.id; + + try { + if (!currentHistoryId.value) { + throw new Error("No current history found."); + } + + await copyDataset(dataset_id, currentHistoryId.value); + + historyStore.loadCurrentHistory(); + await refreshList(); + Toast.success(`Dataset "${item.name}" copied to current history.`); + } catch (error) { + Toast.error("Failed to copy dataset"); + } + } + + async function confirmDeleteDataset(item: HDASummary, purge: boolean) { + const confirmed = await confirm( + `Are you sure you want to ${purge ? "purge" : "delete"} the dataset "${item.name}"?`, + { + title: purge ? "Purge Dataset" : "Delete Dataset", + okTitle: purge ? "Purge" : "Delete", + okVariant: "danger", + }, + ); + + if (confirmed) { + try { + await deleteDataset(item.id, purge); + + Toast.success(`Dataset "${item.name}" ${purge ? "purged" : "deleted"}.`); + historyStore.loadCurrentHistory(); + await refreshList(); + } catch (error) { + Toast.error(`Failed to ${purge ? "purge" : "delete"} dataset.`); + } + } + } + + async function onDeleteDataset(item: HDASummary) { + confirmDeleteDataset(item, false); + } + + async function onPurgeDataset(item: HDASummary) { + confirmDeleteDataset(item, true); + } + + const datasetTableActions: TableAction[] = [ + { + id: "copy-dataset", + label: "Copy to current history", + title: "Copy Dataset to current history", + icon: faCopy, + handler: onCopyDataset, + }, + { + id: "show-dataset", + label: "Show in history", + title: "Show dataset in history panel", + icon: faEye, + handler: onShowDataset, + }, + { + id: "delete-dataset", + label: "Delete", + title: "Delete dataset", + icon: faTrash, + handler: onDeleteDataset, + }, + { + id: "purge-dataset", + label: "Purge", + title: "Purge dataset", + icon: faFire, + handler: onPurgeDataset, + }, + ]; + + return { + datasetTableActions, + }; +} diff --git a/client/src/components/History/SwitchToHistoryLink.test.ts b/client/src/components/History/SwitchToHistoryLink.test.ts index e7b16cbaefa6..ea39c97cdea8 100644 --- a/client/src/components/History/SwitchToHistoryLink.test.ts +++ b/client/src/components/History/SwitchToHistoryLink.test.ts @@ -127,7 +127,11 @@ function mountSwitchToHistoryLinkForHistory(history: HistorySummaryExtended, has * @param setsFilters Whether filters are applied to the current history on click */ async function expectActionForHistory( - tooltip: "Switch to this history" | "This is your current history" | "View in new tab" | "Show in history", + tooltip: + | "Switch to this history" + | "This is your current history" + | "View in new tab" + | "Switch to history and view dataset", history: HistorySummaryExtended, opensInNewTab = false, hasFilters = false, @@ -192,7 +196,7 @@ describe("SwitchToHistoryLink", () => { await expectActionForHistory("Switch to this history", history, false, false, true, false); // Since history was not current, we switch to it AND apply filters - await expectActionForHistory("Show in history", history, false, true, true, true); + await expectActionForHistory("Switch to history and view dataset", history, false, true, true, true); }); it("only applies filters when the history is the Current history", async () => { @@ -209,7 +213,7 @@ describe("SwitchToHistoryLink", () => { await expectActionForHistory("This is your current history", history); // Since history is already current, we only apply filters - await expectActionForHistory("Show in history", history, false, true, false, true); + await expectActionForHistory("Switch to history and view dataset", history, false, true, false, true); }); it("opens purged history in new tab or applies filters", async () => { @@ -226,7 +230,7 @@ describe("SwitchToHistoryLink", () => { await expectActionForHistory("View in new tab", history, true); // We switch to the purged history and apply filters - await expectActionForHistory("Show in history", history, false, true, true, true); + await expectActionForHistory("Switch to history and view dataset", history, false, true, true, true); }); it("opens archived history in new tab or applies filters", async () => { @@ -243,7 +247,7 @@ describe("SwitchToHistoryLink", () => { await expectActionForHistory("View in new tab", history, true); // We switch to the archived history and apply filters - await expectActionForHistory("Show in history", history, false, true, true, true); + await expectActionForHistory("Switch to history and view dataset", history, false, true, true, true); }); it("only opens an accessible unowned history in new tab", async () => { diff --git a/client/src/components/History/SwitchToHistoryLink.vue b/client/src/components/History/SwitchToHistoryLink.vue index 223848e4226b..0c3f868e9d80 100644 --- a/client/src/components/History/SwitchToHistoryLink.vue +++ b/client/src/components/History/SwitchToHistoryLink.vue @@ -43,7 +43,7 @@ const canSwitch = computed( const linkTitle = computed(() => { if (props.filters && history.value && userOwnsHistory(userStore.currentUser, history.value)) { - return "Show in history"; + return "Switch to history and view dataset"; } if (historyStore.currentHistoryId === props.historyId) { diff --git a/lib/galaxy_test/selenium/test_histories_list.py b/lib/galaxy_test/selenium/test_histories_list.py index 338b5cc797c9..5839016deaf1 100644 --- a/lib/galaxy_test/selenium/test_histories_list.py +++ b/lib/galaxy_test/selenium/test_histories_list.py @@ -230,8 +230,8 @@ def test_sort_by_name(self): self._login() self.navigate_to_histories_page() - self.wait_for_and_click_selector('[data-title="Sort by name ascending"]') - self.wait_for_and_click_selector('[data-title="Sort by name ascending"]') + self.wait_for_and_click_selector('[data-title="Sort by Name ascending"]') + self.wait_for_and_click_selector('[data-title="Sort by Name ascending"]') self.sleep_for(self.wait_types.UX_RENDER) expected_histories = [self.history2_name, self.history3_name] diff --git a/lib/galaxy_test/selenium/test_histories_published.py b/lib/galaxy_test/selenium/test_histories_published.py index bf4915b9342d..ea486b1c180f 100644 --- a/lib/galaxy_test/selenium/test_histories_published.py +++ b/lib/galaxy_test/selenium/test_histories_published.py @@ -23,8 +23,8 @@ def test_published_histories_sort_by_name(self): self._login() self.navigate_to_published_histories() - self.wait_for_and_click_selector('[data-title="Sort by name ascending"]') - self.wait_for_and_click_selector('[data-title="Sort by name ascending"]') + self.wait_for_and_click_selector('[data-title="Sort by Name ascending"]') + self.wait_for_and_click_selector('[data-title="Sort by Name ascending"]') self.sleep_for(self.wait_types.UX_RENDER) sorted_histories = self.get_published_history_names_from_server(sort_by="name") @@ -36,7 +36,7 @@ def test_published_histories_sort_by_last_update(self): self._login() self.navigate_to_published_histories() - self.wait_for_and_click_selector('[data-title="Sort by update time ascending"]') + self.wait_for_and_click_selector('[data-title="Sort by Update time ascending"]') self.sleep_for(self.wait_types.UX_RENDER) expected_history_names = self.get_published_history_names_from_server(sort_by="update_time")