diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 057ea7c8c031..1eb290ef5ae3 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -1504,6 +1504,10 @@ export interface paths { */ get: operations["version_api_version_get"]; }; + "/api/visualizations": { + /** Returns visualizations for the current user. */ + get: operations["index_api_visualizations_get"]; + }; "/api/visualizations/{id}/disable_link_access": { /** * Makes this item inaccessible by a URL link. @@ -1534,7 +1538,7 @@ export interface paths { }; "/api/visualizations/{id}/sharing": { /** - * Get the current sharing status of the given Page. + * Get the current sharing status of the given Visualization. * @description Return the sharing status of the item. */ get: operations["sharing_api_visualizations__id__sharing_get"]; @@ -9446,6 +9450,81 @@ export interface components { * @description Base model definition with common configuration used by all derived models. */ Visualization: Record; + /** + * VisualizationSummary + * @description Base model definition with common configuration used by all derived models. + */ + VisualizationSummary: { + /** + * Annotation + * @description The annotation of this Visualization. + */ + annotation?: string; + /** + * Create Time + * Format: date-time + * @description The time and date this item was created. + */ + create_time?: string; + /** + * DbKey + * @description The database key of the visualization. + */ + dbkey?: string; + /** + * Deleted + * @description Whether this Visualization has been deleted. + */ + deleted: boolean; + /** + * ID + * @description Encoded ID of the Visualization. + * @example 0123456789ABCDEF + */ + id: string; + /** + * Importable + * @description Whether this Visualization can be imported. + */ + importable: boolean; + /** + * Published + * @description Whether this Visualization has been published. + */ + published: boolean; + /** + * Tags + * @description A list of tags to add to this item. + */ + tags: components["schemas"]["TagCollection"]; + /** + * Title + * @description The name of the visualization. + */ + title: string; + /** + * Type + * @description The type of the visualization. + */ + type: string; + /** + * Update Time + * Format: date-time + * @description The last time and date this item was updated. + */ + update_time?: string; + /** + * Username + * @description The name of the user owning this Visualization. + */ + username: string; + }; + /** + * VisualizationSummaryList + * @description Base model definition with common configuration used by all derived models. + * @default [] + */ + VisualizationSummaryList: components["schemas"]["VisualizationSummary"][]; /** * WorkflowInvocationStateSummary * @description Base model definition with common configuration used by all derived models. @@ -17858,6 +17937,82 @@ export interface operations { }; }; }; + index_api_visualizations_get: { + /** Returns visualizations for the current user. */ + parameters?: { + /** @description Whether to include deleted visualizations in the result. */ + /** @description The maximum number of items to return. */ + /** @description Starts at the beginning skip the first ( offset - 1 ) items and begin returning at the Nth item */ + /** @description Sort visualization index by this specified attribute on the visualization model */ + /** @description Sort in descending order? */ + /** + * @description A mix of free text and GitHub-style tags used to filter the index operation. + * + * ## Query Structure + * + * GitHub-style filter tags (not be confused with Galaxy tags) are tags of the form + * `:` or `:''`. The tag name + * *generally* (but not exclusively) corresponds to the name of an attribute on the model + * being indexed (i.e. a column in the database). + * + * If the tag is quoted, the attribute will be filtered exactly. If the tag is unquoted, + * generally a partial match will be used to filter the query (i.e. in terms of the implementation + * this means the database operation `ILIKE` will typically be used). + * + * Once the tagged filters are extracted from the search query, the remaining text is just + * used to search various documented attributes of the object. + * + * ## GitHub-style Tags Available + * + * `title` + * : The visualization's title. + * + * `slug` + * : The visualization's slug. (The tag `s` can be used a short hand alias for this tag to filter on this attribute.) + * + * `tag` + * : The visualization's tags. (The tag `t` can be used a short hand alias for this tag to filter on this attribute.) + * + * `user` + * : The visualization's owner's username. (The tag `u` can be used a short hand alias for this tag to filter on this attribute.) + * + * ## Free Text + * + * Free text search terms will be searched against the following attributes of the + * Visualizations: `title`, `slug`, `tag`, `type`. + */ + query?: { + deleted?: boolean; + limit?: number; + offset?: number; + user_id?: string; + show_own?: boolean; + show_published?: boolean; + show_shared?: boolean; + sort_by?: "create_time" | "title" | "update_time" | "username"; + sort_desc?: boolean; + search?: string; + }; + /** @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; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["VisualizationSummaryList"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; disable_link_access_api_visualizations__id__disable_link_access_put: { /** * Makes this item inaccessible by a URL link. @@ -17985,7 +18140,7 @@ export interface operations { }; sharing_api_visualizations__id__sharing_get: { /** - * Get the current sharing status of the given Page. + * Get the current sharing status of the given Visualization. * @description Return the sharing status of the item. */ parameters: { diff --git a/client/src/components/Grid/GridElements/GridLink.vue b/client/src/components/Grid/GridElements/GridLink.vue new file mode 100644 index 000000000000..8556fc3bf3b4 --- /dev/null +++ b/client/src/components/Grid/GridElements/GridLink.vue @@ -0,0 +1,13 @@ + + + diff --git a/client/src/components/Grid/GridElements/GridOperations.vue b/client/src/components/Grid/GridElements/GridOperations.vue new file mode 100644 index 000000000000..d135109286fc --- /dev/null +++ b/client/src/components/Grid/GridElements/GridOperations.vue @@ -0,0 +1,52 @@ + + + diff --git a/client/src/components/Grid/GridElements/GridText.vue b/client/src/components/Grid/GridElements/GridText.vue new file mode 100644 index 000000000000..7128471a6ccc --- /dev/null +++ b/client/src/components/Grid/GridElements/GridText.vue @@ -0,0 +1,10 @@ + + + diff --git a/client/src/components/Grid/GridList.test.js b/client/src/components/Grid/GridList.test.js new file mode 100644 index 000000000000..44a94a0e5178 --- /dev/null +++ b/client/src/components/Grid/GridList.test.js @@ -0,0 +1,174 @@ +import { mount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; +import { useRouter } from "vue-router/composables"; + +import Filtering from "@/utils/filtering"; + +import MountTarget from "./GridList.vue"; + +const localVue = getLocalVue(); + +jest.mock("vue-router/composables"); + +useRouter.mockImplementation(() => "router"); + +const testGrid = { + actions: [ + { + title: "test", + icon: "test-icon", + handler: jest.fn(), + }, + ], + fields: [ + { + key: "id", + title: "id", + type: "text", + }, + { + key: "link", + title: "link", + type: "link", + }, + { + key: "operation", + title: "operation", + type: "operations", + condition: jest.fn(), + operations: [ + { + title: "operation-title-1", + icon: "operation-icon-1", + condition: () => true, + handler: jest.fn(), + }, + { + title: "operation-title-2", + icon: "operation-icon-2", + condition: () => false, + handler: jest.fn(), + }, + { + title: "operation-title-3", + icon: "operation-icon-3", + condition: () => true, + handler: jest.fn(), + }, + ], + }, + ], + filtering: new Filtering({}, undefined, false, false), + getData: jest.fn((offset, limit) => { + const data = []; + for (let i = offset; i < offset + limit; i++) { + data.push({ + id: `id-${i + 1}`, + link: `link-${i + 1}`, + operation: `operation-${i + 1}`, + }); + } + return [data, 100]; + }), + plural: "Tests", + sortBy: "id", + sortDesc: true, + sortKeys: ["id"], + title: "Test", +}; + +function createTarget(propsData) { + return mount(MountTarget, { + localVue, + propsData, + stubs: { + Icon: true, + }, + }); +} + +describe("GridList", () => { + it("basic rendering", async () => { + const wrapper = createTarget({ + config: testGrid, + }); + const findInput = wrapper.find("[data-description='filter text input']"); + expect(findInput.attributes().placeholder).toBe("search tests"); + expect(wrapper.find(".loading-message").text()).toBe("Loading..."); + const findAction = wrapper.find("[data-description='grid action test']"); + expect(findAction.text()).toBe("test"); + await findAction.trigger("click"); + expect(testGrid.actions[0].handler).toHaveBeenCalledTimes(1); + expect(testGrid.getData).toHaveBeenCalledTimes(1); + expect(testGrid.getData.mock.calls[0]).toEqual([0, 25, "", "id", true]); + expect(findAction.find("[icon='test-icon']").exists()).toBeTruthy(); + await wrapper.vm.$nextTick(); + expect(wrapper.find("[data-description='grid title']").text()).toBe("Test"); + expect(wrapper.find("[data-description='grid cell 0-0']").text()).toBe("id-1"); + expect(wrapper.find("[data-description='grid cell 1-0']").text()).toBe("id-2"); + expect(wrapper.find("[data-description='grid cell 0-1'] > button").text()).toBe("link-1"); + expect(wrapper.find("[data-description='grid cell 1-1'] > button").text()).toBe("link-2"); + const firstHeader = wrapper.find("[data-description='grid header 0']"); + expect(firstHeader.find("a").text()).toBe("id"); + await firstHeader.find("[data-description='grid sort asc']").trigger("click"); + expect(testGrid.getData).toHaveBeenCalledTimes(2); + expect(testGrid.getData.mock.calls[1]).toEqual([0, 25, "", "id", false]); + expect(firstHeader.find("[data-description='grid sort asc']").exists()).toBeFalsy(); + expect(firstHeader.find("[data-description='grid sort desc']").exists()).toBeTruthy(); + const secondHeader = wrapper.find("[data-description='grid header 1']"); + expect(secondHeader.find("[data-description='grid sort asc']").exists()).toBeFalsy(); + expect(secondHeader.find("[data-description='grid sort desc']").exists()).toBeFalsy(); + }); + + it("header rendering", async () => { + const wrapper = createTarget({ + config: testGrid, + }); + await wrapper.vm.$nextTick(); + for (const [fieldIndex, field] of Object.entries(testGrid.fields)) { + expect(wrapper.find(`[data-description='grid header ${fieldIndex}']`).text()).toBe(field.title); + } + }); + + it("operation handling", async () => { + const wrapper = createTarget({ + config: testGrid, + }); + await wrapper.vm.$nextTick(); + const dropdown = wrapper.find("[data-description='grid cell 0-2']"); + const dropdownItems = dropdown.findAll(".dropdown-item"); + expect(dropdownItems.at(0).text()).toBe("operation-title-1"); + expect(dropdownItems.at(1).text()).toBe("operation-title-3"); + await dropdownItems.at(0).trigger("click"); + const clickHandler = testGrid.fields[2].operations[0].handler; + expect(clickHandler).toHaveBeenCalledTimes(1); + expect(clickHandler.mock.calls[0]).toEqual([ + { id: "id-1", link: "link-1", operation: "operation-1" }, + "router", + ]); + }); + + it("filter handling", async () => { + const wrapper = createTarget({ + config: testGrid, + }); + await wrapper.vm.$nextTick(); + const filterInput = wrapper.find("[data-description='filter text input']"); + await filterInput.setValue("filter query"); + await new Promise((r) => setTimeout(r, 500)); + expect(testGrid.getData).toHaveBeenCalledTimes(2); + expect(testGrid.getData.mock.calls[1]).toEqual([0, 25, "filter query", "id", true]); + }); + + it("pagination", async () => { + const wrapper = createTarget({ + config: testGrid, + limit: 2, + }); + await wrapper.vm.$nextTick(); + const pageLinks = wrapper.findAll(".page-link"); + await pageLinks.at(4).trigger("click"); + expect(wrapper.find("[data-description='grid cell 0-0']").text()).toBe("id-5"); + expect(wrapper.find("[data-description='grid cell 1-0']").text()).toBe("id-6"); + }); +}); diff --git a/client/src/components/Grid/GridList.vue b/client/src/components/Grid/GridList.vue new file mode 100644 index 000000000000..d43c9716f2d9 --- /dev/null +++ b/client/src/components/Grid/GridList.vue @@ -0,0 +1,292 @@ + + + + + diff --git a/client/src/components/Grid/GridShared.vue b/client/src/components/Grid/GridShared.vue deleted file mode 100644 index 017beab7eaef..000000000000 --- a/client/src/components/Grid/GridShared.vue +++ /dev/null @@ -1,33 +0,0 @@ - - - diff --git a/client/src/components/Grid/configs/types.ts b/client/src/components/Grid/configs/types.ts new file mode 100644 index 000000000000..80e357fa1b5e --- /dev/null +++ b/client/src/components/Grid/configs/types.ts @@ -0,0 +1,55 @@ +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import type Router from "vue-router"; + +import Filtering from "@/utils/filtering"; + +export interface Action { + title: string; + icon?: IconDefinition; + handler: (router: Router) => void; +} + +export type ActionArray = Array; + +export interface Config { + actions?: ActionArray; + fields: FieldArray; + filtering: Filtering; + getData: (offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) => Promise; + plural: string; + sortBy: string; + sortKeys: Array; + sortDesc: boolean; + title: string; +} + +export type FieldArray = Array; + +export interface FieldEntry { + key: string; + title: string; + condition?: (data: RowData) => boolean; + disabled?: boolean; + type: string; + operations?: Array; + handler?: FieldHandler; + width?: number; +} + +export type FieldHandler = (data: RowData, router?: Router) => void; + +export interface Operation { + title: string; + icon: IconDefinition; + condition?: (data: RowData) => boolean; + handler: (data: RowData, router: Router) => OperationHandlerReturn; +} + +interface OperationHandlerMessage { + message: string; + status: string; +} + +type OperationHandlerReturn = Promise | void; + +export type RowData = Record; diff --git a/client/src/components/Grid/configs/visualizations.ts b/client/src/components/Grid/configs/visualizations.ts new file mode 100644 index 000000000000..784aab8a4a68 --- /dev/null +++ b/client/src/components/Grid/configs/visualizations.ts @@ -0,0 +1,249 @@ +import { faCopy, faEdit, faEye, faPlus, faShareAlt, faTrash, faTrashRestore } from "@fortawesome/free-solid-svg-icons"; +import axios from "axios"; +import type Router from "vue-router"; + +import { fetcher } from "@/api/schema"; +import { getGalaxyInstance } from "@/app"; +import Filtering, { contains, equals, expandNameTag, toBool, type ValidFilter } from "@/utils/filtering"; +import { withPrefix } from "@/utils/redirect"; +import { errorMessageAsString, rethrowSimple } from "@/utils/simple-error"; + +import type { ActionArray, Config, FieldArray } from "./types"; + +/** + * Api endpoint handlers + */ +const getVisualizations = fetcher.path("/api/visualizations").method("get").create(); +const updateTags = fetcher.path("/api/tags").method("put").create(); + +/** + * Local types + */ +type SortKeyLiteral = "create_time" | "title" | "update_time" | "username" | undefined; +type VisualizationEntry = Record; + +/** + * Request and return data from server + */ +async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) { + // TODO: Avoid using Galaxy instance to identify current user + const Galaxy = getGalaxyInstance(); + const userId = !Galaxy.isAnonymous && Galaxy.user.id; + if (!userId) { + rethrowSimple("Please login to access this page."); + } + const { data, headers } = await getVisualizations({ + limit, + offset, + search, + sort_by: sort_by as SortKeyLiteral, + sort_desc, + show_published: false, + user_id: userId, + }); + const totalMatches = parseInt(headers.get("total_matches") ?? "0"); + return [data, totalMatches]; +} + +/** + * Actions are grid-wide operations + */ +const actions: ActionArray = [ + { + title: "Create", + icon: faPlus, + handler: (router: Router) => { + router.push(`/visualizations`); + }, + }, +]; + +/** + * Declare columns to be displayed + */ +const fields: FieldArray = [ + { + title: "Title", + key: "title", + type: "operations", + width: 40, + condition: (data: VisualizationEntry) => !data.deleted, + operations: [ + { + title: "Open", + icon: faEye, + condition: (data: VisualizationEntry) => !data.deleted, + handler: (data: VisualizationEntry) => { + window.location.href = withPrefix(`/plugins/visualizations/${data.type}/saved?id=${data.id}`); + }, + }, + { + title: "Edit Attributes", + icon: faEdit, + condition: (data: VisualizationEntry) => !data.deleted, + handler: (data: VisualizationEntry, router: Router) => { + router.push(`/visualizations/edit?id=${data.id}`); + }, + }, + { + title: "Copy", + icon: faCopy, + condition: (data: VisualizationEntry) => !data.deleted, + handler: async (data: VisualizationEntry) => { + try { + const copyResponse = await axios.get(withPrefix(`/api/visualizations/${data.id}`)); + const copyViz = copyResponse.data; + const newViz = { + title: `Copy of '${copyViz.title}'`, + type: copyViz.type, + config: copyViz.latest_revision.config, + }; + await axios.post(withPrefix(`/api/visualizations`), newViz); + return { + status: "success", + message: `'${data.title}' has been copied.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to copy '${data.title}': ${errorMessageAsString(e)}.`, + }; + } + }, + }, + { + title: "Share and Publish", + icon: faShareAlt, + condition: (data: VisualizationEntry) => !data.deleted, + handler: (data: VisualizationEntry, router: Router) => { + router.push(`/visualizations/sharing?id=${data.id}`); + }, + }, + { + title: "Delete", + icon: faTrash, + condition: (data: VisualizationEntry) => !data.deleted, + handler: async (data: VisualizationEntry) => { + try { + await axios.put(withPrefix(`/api/visualizations/${data.id}`), { deleted: true }); + return { + status: "success", + message: `'${data.title}' has been deleted.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to delete '${data.title}': ${errorMessageAsString(e)}.`, + }; + } + }, + }, + { + title: "Restore", + icon: faTrashRestore, + condition: (data: VisualizationEntry) => !!data.deleted, + handler: async (data: VisualizationEntry) => { + try { + await axios.put(withPrefix(`/api/visualizations/${data.id}`), { deleted: false }); + return { + status: "success", + message: `'${data.title}' has been restored.`, + }; + } catch (e) { + return { + status: "danger", + message: `Failed to restore '${data.title}': ${errorMessageAsString(e)}.`, + }; + } + }, + }, + ], + }, + { + key: "type", + title: "Type", + type: "text", + }, + { + key: "tags", + title: "Tags", + type: "tags", + handler: async (data: VisualizationEntry) => { + try { + await updateTags({ + item_id: data.id as string, + item_class: "Visualization", + item_tags: data.tags as Array, + }); + } catch (e) { + rethrowSimple(e); + } + }, + }, + { + key: "create_time", + title: "Created", + type: "date", + }, + { + key: "update_time", + title: "Updated", + type: "date", + }, + { + key: "sharing", + title: "Shared", + type: "sharing", + }, +]; + +/** + * Declare filter options + */ +const validFilters: Record> = { + title: { placeholder: "title", type: String, handler: contains("title"), menuItem: true }, + slug: { handler: contains("slug"), menuItem: false }, + tag: { + placeholder: "tag(s)", + type: "MultiTags", + handler: contains("tag", "tag", expandNameTag), + menuItem: true, + }, + published: { + placeholder: "Filter on published visualizations", + type: Boolean, + boolType: "is", + handler: equals("published", "published", toBool), + menuItem: true, + }, + importable: { + placeholder: "Filter on importable visualizations", + type: Boolean, + boolType: "is", + handler: equals("importable", "importable", toBool), + menuItem: true, + }, + deleted: { + placeholder: "Filter on deleted visualizations", + type: Boolean, + boolType: "is", + handler: equals("deleted", "deleted", toBool), + menuItem: true, + }, +}; + +/** + * Grid configuration + */ +const config: Config = { + actions: actions, + fields: fields, + filtering: new Filtering(validFilters, undefined, false, false), + getData: getData, + plural: "Visualizations", + sortBy: "update_time", + sortDesc: true, + sortKeys: ["create_time", "title", "update_time"], + title: "Saved Visualizations", +}; +export default config; diff --git a/client/src/components/Grid/configs/visualizationsPublished.ts b/client/src/components/Grid/configs/visualizationsPublished.ts new file mode 100644 index 000000000000..0be07fb90dbe --- /dev/null +++ b/client/src/components/Grid/configs/visualizationsPublished.ts @@ -0,0 +1,96 @@ +import { fetcher } from "@/api/schema"; +import Filtering, { contains, expandNameTag, type ValidFilter } from "@/utils/filtering"; +import { withPrefix } from "@/utils/redirect"; + +/** + * Api endpoint handlers + */ +const getVisualizations = fetcher.path("/api/visualizations").method("get").create(); + +/** + * Local types + */ +type SortKeyLiteral = "create_time" | "title" | "update_time" | "username" | undefined; +type VisualizationEntry = Record; + +/** + * Request and return data from server + */ +async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) { + const { data, headers } = await getVisualizations({ + limit, + offset, + search, + sort_by: sort_by as SortKeyLiteral, + sort_desc, + show_own: false, + show_published: true, + show_shared: true, + }); + const totalMatches = parseInt(headers.get("total_matches") ?? "0"); + return [data, totalMatches]; +} + +/** + * Declare columns to be displayed + */ +const fields = [ + { + title: "Title", + key: "title", + type: "link", + width: "30%", + handler: (data: VisualizationEntry) => { + window.location.href = withPrefix(`/plugins/visualizations/${data.type}/saved?id=${data.id}`); + }, + }, + { + key: "annotation", + title: "Annotation", + type: "text", + }, + { + key: "username", + title: "Owner", + type: "text", + }, + { + key: "tags", + title: "Tags", + type: "tags", + disabled: true, + }, + { + key: "update_time", + title: "Updated", + type: "date", + }, +]; + +/** + * Declare filter options + */ +const validFilters: Record> = { + title: { placeholder: "title", type: String, handler: contains("title"), menuItem: true }, + slug: { handler: contains("slug"), menuItem: false }, + tag: { + placeholder: "tag(s)", + type: "MultiTags", + handler: contains("tag", "tag", expandNameTag), + menuItem: true, + }, +}; + +/** + * Grid configuration + */ +export default { + fields: fields, + filtering: new Filtering(validFilters, undefined, false, false), + getData: getData, + plural: "Visualizations", + sortBy: "update_time", + sortDesc: true, + sortKeys: ["create_time", "title", "update_time"], + title: "Published Visualizations", +}; diff --git a/client/src/components/Indices/SharingIndicators.vue b/client/src/components/Indices/SharingIndicators.vue index b2f1cd3fdae2..529a99dd3ed5 100644 --- a/client/src/components/Indices/SharingIndicators.vue +++ b/client/src/components/Indices/SharingIndicators.vue @@ -1,10 +1,10 @@