From 4a4557912b9bf1fdca2ce4d587c7a793113b33ee Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Fri, 2 Jun 2023 17:55:30 -0700 Subject: [PATCH 01/27] add page dropdown for debug sharing --- .../src/components/Page/PageDropdown.test.ts | 153 ++++++++++++++++++ client/src/components/Page/PageDropdown.vue | 80 +++++++++ 2 files changed, 233 insertions(+) create mode 100644 client/src/components/Page/PageDropdown.test.ts create mode 100644 client/src/components/Page/PageDropdown.vue diff --git a/client/src/components/Page/PageDropdown.test.ts b/client/src/components/Page/PageDropdown.test.ts new file mode 100644 index 000000000000..aeae6efce67b --- /dev/null +++ b/client/src/components/Page/PageDropdown.test.ts @@ -0,0 +1,153 @@ +import { expect, jest } from "@jest/globals"; + +import { shallowMount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; +import { PiniaVuePlugin, createPinia } from "pinia"; +import { createTestingPinia } from "@pinia/testing"; +import { mockFetcher } from "@/schema/__mocks__"; +import { useUserStore } from "@/stores/userStore"; +import flushPromises from "flush-promises"; + +import PageDropdown from "./PageDropdown.vue"; + +import "jest-location-mock"; + +jest.mock("@/schema"); + +const localVue = getLocalVue(true); +localVue.use(PiniaVuePlugin); + +const PAGE_DATA_OWNED = { + id: "page1235", + title: "My Page Title", + description: "A description derived from an annotation.", + shared: false, +}; + +const PAGE_DATA_SHARED = { + id: "page1235", + title: "My Page Title", + description: "A description derived from an annotation.", + shared: true, +}; + +describe("PageDropdown.vue", () => { + let wrapper: any; + + function pageOptions() { + return wrapper.findAll(".dropdown-menu .dropdown-item"); + } + + describe("navigation on owned pages", () => { + beforeEach(async () => { + const pinia = createPinia(); + const propsData = { + root: "/rootprefix/", + page: PAGE_DATA_OWNED, + }; + wrapper = shallowMount(PageDropdown, { + propsData, + localVue, + pinia: pinia, + }); + const userStore = useUserStore(); + userStore.currentUser = { email: "my@email", id: "1", tags_used: [] }; + }); + + it("should show page title", async () => { + const titleWrapper = await wrapper.find(".page-title"); + expect(titleWrapper.text()).toBe("My Page Title"); + }); + + it("should decorate dropdown with page ID for automation", async () => { + const linkWrapper = await wrapper.find("[data-page-dropdown='page1235']"); + expect(linkWrapper.exists()).toBeTruthy(); + }); + + it("should have a 'Share' option", async () => { + expect(wrapper.find(".dropdown-menu .dropdown-item-share").exists()).toBeTruthy(); + }); + + it("should provide 5 options", () => { + expect(pageOptions().length).toBe(5); + }); + }); + + describe("navigation on shared pages", () => { + beforeEach(async () => { + const propsData = { + root: "/rootprefixshared/", + page: PAGE_DATA_SHARED, + }; + wrapper = shallowMount(PageDropdown, { + propsData, + localVue, + pinia: createTestingPinia(), + }); + }); + + it("should have the 'View' option", async () => { + expect(wrapper.find(".dropdown-menu .dropdown-item-view").exists()).toBeTruthy(); + }); + + it("should have only single option", () => { + expect(pageOptions().length).toBe(1); + }); + }); + + describe("clicking page deletion", () => { + let confirmRequest: boolean; + + async function mountAndDelete() { + const propsData = { + root: "/rootprefixdelete/", + page: PAGE_DATA_OWNED, + }; + wrapper = shallowMount(PageDropdown, { + propsData, + localVue, + pinia: createTestingPinia(), + }); + wrapper.vm.onDelete(); + await flushPromises(); + } + + beforeEach(async () => { + confirmRequest = true; + global.confirm = jest.fn(() => confirmRequest); + }); + + afterEach(() => { + mockFetcher.clearMocks(); + }); + + it("should fire deletion API request upon confirmation", async () => { + mockFetcher.path("/api/pages/{id}").method("delete").mock({ status: 204 }); + console.log(wrapper.html()); + await mountAndDelete(); + const emitted = wrapper.emitted(); + expect(emitted["onRemove"][0][0]).toEqual("page1235"); + expect(emitted["onSuccess"]).toBeTruthy(); + }); + + it("should not fire deletion API request if not confirmed", async () => { + confirmRequest = false; + await mountAndDelete(); + const emitted = wrapper.emitted(); + expect(emitted["onRemove"]).toBeFalsy(); + expect(emitted["onSuccess"]).toBeFalsy(); + }); + + it("should emit an error on API fail", async () => { + mockFetcher + .path("/api/pages/{id}") + .method("delete") + .mock(() => { + throw Error("mock error"); + }); + await mountAndDelete(); + const emitted = wrapper.emitted(); + expect(emitted["onError"]).toBeTruthy(); + }); + }); +}); diff --git a/client/src/components/Page/PageDropdown.vue b/client/src/components/Page/PageDropdown.vue new file mode 100644 index 000000000000..5810b0ba590b --- /dev/null +++ b/client/src/components/Page/PageDropdown.vue @@ -0,0 +1,80 @@ + + From 625d87272253bda48c0098be1969a6ede587d06c Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Fri, 2 Jun 2023 18:21:54 -0700 Subject: [PATCH 02/27] change the test to not directly access comp function --- client/src/components/Page/PageDropdown.test.ts | 12 ++++++++---- client/src/components/Page/PageDropdown.vue | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/client/src/components/Page/PageDropdown.test.ts b/client/src/components/Page/PageDropdown.test.ts index aeae6efce67b..f39cc3bac81c 100644 --- a/client/src/components/Page/PageDropdown.test.ts +++ b/client/src/components/Page/PageDropdown.test.ts @@ -95,7 +95,8 @@ describe("PageDropdown.vue", () => { }); }); - describe("clicking page deletion", () => { + describe("clicking page deletion on owned page", () => { + const pinia = createPinia(); let confirmRequest: boolean; async function mountAndDelete() { @@ -106,9 +107,13 @@ describe("PageDropdown.vue", () => { wrapper = shallowMount(PageDropdown, { propsData, localVue, - pinia: createTestingPinia(), + pinia: pinia, }); - wrapper.vm.onDelete(); + const userStore = useUserStore(); + userStore.currentUser = { email: "my@email", id: "1", tags_used: [] }; + wrapper.find(".page-dropdown").trigger("click"); + await wrapper.vm.$nextTick(); + wrapper.find(".dropdown-item-delete").trigger("click"); await flushPromises(); } @@ -123,7 +128,6 @@ describe("PageDropdown.vue", () => { it("should fire deletion API request upon confirmation", async () => { mockFetcher.path("/api/pages/{id}").method("delete").mock({ status: 204 }); - console.log(wrapper.html()); await mountAndDelete(); const emitted = wrapper.emitted(); expect(emitted["onRemove"][0][0]).toEqual("page1235"); diff --git a/client/src/components/Page/PageDropdown.vue b/client/src/components/Page/PageDropdown.vue index 5810b0ba590b..f071153646ac 100644 --- a/client/src/components/Page/PageDropdown.vue +++ b/client/src/components/Page/PageDropdown.vue @@ -71,7 +71,7 @@ function onDelete() { Control sharing - + Delete From 68ca49c0efe869edd461086037e7f5c1d74069be Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Mon, 5 Jun 2023 12:40:46 -0700 Subject: [PATCH 03/27] standardize indexfilter icon handling --- client/src/components/Indices/IndexFilter.vue | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client/src/components/Indices/IndexFilter.vue b/client/src/components/Indices/IndexFilter.vue index f7eb2f8d6030..8aa2f51cbf68 100644 --- a/client/src/components/Indices/IndexFilter.vue +++ b/client/src/components/Indices/IndexFilter.vue @@ -21,7 +21,7 @@ title="Advanced Filtering Help" :size="size" @click="onHelp"> - + - + @@ -40,9 +40,14 @@ + From 90f8ad2d12a70084116300c90a7a47b5fef97979 Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Tue, 6 Jun 2023 15:40:06 -0700 Subject: [PATCH 05/27] standardize icon handling in pagedropdown --- client/src/components/Page/PageDropdown.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/src/components/Page/PageDropdown.vue b/client/src/components/Page/PageDropdown.vue index f071153646ac..b7fbfc6670ff 100644 --- a/client/src/components/Page/PageDropdown.vue +++ b/client/src/components/Page/PageDropdown.vue @@ -4,6 +4,11 @@ import { computed, unref } from "vue"; import { deletePage } from "./services"; import { storeToRefs } from "pinia"; import { useUserStore } from "@/stores/userStore"; +import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; +import { faCaretDown } from "@fortawesome/free-solid-svg-icons"; +import { library } from "@fortawesome/fontawesome-svg-core"; + +library.add(faCaretDown); interface Page { id: string; @@ -50,7 +55,7 @@ function onDelete() { aria-haspopup="true" :data-page-dropdown="props.page.id" aria-expanded="false"> - + {{ props.page.title }}

{{ props.page.description }}

From d189b21eeaf7abccc87fd871ec6e046cf52a3f12 Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Tue, 6 Jun 2023 15:41:59 -0700 Subject: [PATCH 06/27] finish client changes to the page grid components --- client/src/components/Page/PageList.test.js | 314 ++++++++++++++++++ client/src/components/Page/PageList.vue | 227 +++++++++++++ client/src/components/Page/services.ts | 27 ++ .../src/components/providers/PageProvider.js | 18 + .../components/providers/PageProvider.test.js | 43 +++ client/src/entry/analysis/router.js | 7 +- 6 files changed, 632 insertions(+), 4 deletions(-) create mode 100644 client/src/components/Page/PageList.test.js create mode 100644 client/src/components/Page/PageList.vue create mode 100644 client/src/components/Page/services.ts create mode 100644 client/src/components/providers/PageProvider.js create mode 100644 client/src/components/providers/PageProvider.test.js diff --git a/client/src/components/Page/PageList.test.js b/client/src/components/Page/PageList.test.js new file mode 100644 index 000000000000..6076b867dc83 --- /dev/null +++ b/client/src/components/Page/PageList.test.js @@ -0,0 +1,314 @@ +import PageList from "./PageList.vue"; +import { mount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; +import flushPromises from "flush-promises"; +import { PiniaVuePlugin } from "pinia"; +import { createTestingPinia } from "@pinia/testing"; +import { parseISO, formatDistanceToNow } from "date-fns"; + +jest.mock("app"); + +const localVue = getLocalVue(); +localVue.use(PiniaVuePlugin); + +describe("PgeList.vue", () => { + let axiosMock; + let wrapper; + + beforeEach(async () => { + axiosMock = new MockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + const personalGridApiParams = { + limit: 20, + offset: 0, + sort_by: "update_time", + sort_desc: true, + }; + const publishedGridApiParams = { + ...personalGridApiParams, + show_published: true, + search: "is:published", + show_shared: false, + }; + const publishedGridApiParamsSortTitleAsc = { + sort_by: "title", + limit: 20, + offset: 0, + search: "is:published", + show_published: true, + show_shared: false, + }; + const publishedGridApiParamsSortTitleDesc = { + ...publishedGridApiParamsSortTitleAsc, + sort_desc: true, + }; + + const propsDataPersonalGrid = { + inputDebounceDelay: 0, + published: false, + }; + const propsDataPublishedGrid = { + ...propsDataPersonalGrid, + published: true, + }; + + const privatePage = { + id: "5f1915bcf9f3561a", + update_time: "2023-05-23T17:51:51.069761", + create_time: "2023-01-04T17:40:58.407793", + deleted: false, + importable: false, + published: false, + slug: "raynor-here", + tags: ["tagThis", "tagIs", "tagJimmy"], + title: "jimmy's page", + username: "jimmyPage", + }; + const publishedPage = { + ...privatePage, + id: "5f1915bcf9f3561b", + published: true, + importable: true, + update_time: "2023-05-25T17:51:51.069761", + }; + const pageA = { + id: "5f1915bcf9f3561c", + update_time: "2023-05-21T17:51:51.069761", + create_time: "2023-01-04T17:40:58.407793", + deleted: false, + importable: true, + published: true, + slug: "a-page", + title: "a page title", + username: "APageUser", + }; + const mockPrivatePageData = [privatePage]; + const mockPublishedPageData = [publishedPage]; + const mockTwoPageData = [privatePage, pageA]; + + function mountPersonalGrid() { + wrapper = mount(PageList, { + propsData: propsDataPersonalGrid, + localVue, + }); + } + + describe(" with empty page list", () => { + beforeEach(async () => { + axiosMock.onAny().reply(200, [], { total_matches: "0" }); + mountPersonalGrid(); + }); + + it("title should be shown", async () => { + expect(wrapper.find("#pages-title").text()).toBe("Pages"); + }); + + it("no invocations message should be shown when not loading", async () => { + expect(wrapper.find("#no-pages").exists()).toBe(true); + }); + }); + describe("with server error", () => { + beforeEach(async () => { + axiosMock.onAny().reply(403, { err_msg: "this is a problem" }); + mountPersonalGrid(); + }); + + it("renders error message", async () => { + expect(wrapper.find(".index-grid-message").text()).toContain("this is a problem"); + }); + }); + + describe("with single private page", () => { + beforeEach(async () => { + axiosMock + .onGet("/api/pages", { params: { search: "", ...personalGridApiParams } }) + .reply(200, mockPrivatePageData, { total_matches: "1" }); + jest.spyOn(PageList.methods, "decorateData").mockImplementation((page) => { + page.shared = false; + }); + wrapper = mount(PageList, { + propsData: propsDataPersonalGrid, + localVue, + pinia: createTestingPinia(), + }); + await flushPromises(); + }); + + it("'no pages' message is gone", async () => { + expect(wrapper.find("#no-pages").exists()).toBe(false); + }); + + it("renders one row with correct sharing options", async () => { + const rows = wrapper.findAll("tbody > tr").wrappers; + expect(rows.length).toBe(1); + const row = rows[0]; + const columns = row.findAll("td"); + expect(columns.at(0).text()).toContain("jimmy's page"); + expect(columns.at(1).text()).toContain("tagThis"); + expect(columns.at(1).text()).toContain("tagIs"); + expect(columns.at(1).text()).toContain("tagJimmy"); + expect(columns.at(3).text()).toBe( + formatDistanceToNow(parseISO(`${mockPrivatePageData[0].update_time}Z`), { addSuffix: true }) + ); + expect(row.find(".share-this-page").exists()).toBe(true); + expect(row.find(".sharing-indicator-published").exists()).toBe(false); + expect(row.find(".sharing-indicator-importable").exists()).toBe(false); + expect(row.find(".sharing-indicator-shared").exists()).toBe(false); + }); + + it("starts with an empty filter", async () => { + expect(wrapper.find("#page-search").element.value).toBe(""); + }); + + it("fetches filtered results when search filter is used", async () => { + await wrapper.find("#page-search").setValue("mytext"); + await flushPromises(); + expect(wrapper.find("#page-search").element.value).toBe("mytext"); + expect(wrapper.vm.filter).toBe("mytext"); + }); + + it("updates filter when a tag is clicked", async () => { + const tags = wrapper.findAll("tbody > tr .tag").wrappers; + expect(tags.length).toBe(3); + tags[0].trigger("click"); + await flushPromises(); + expect(wrapper.vm.filter).toBe("tag:'tagThis'"); + }); + + it("updates filter when a tag is clicked only on the first click", async () => { + const tags = wrapper.findAll("tbody > tr .tag").wrappers; + expect(tags.length).toBe(3); + tags[0].trigger("click"); + tags[0].trigger("click"); + tags[0].trigger("click"); + await flushPromises(); + expect(wrapper.vm.filter).toBe("tag:'tagThis'"); + }); + }); + describe("with single published and importable page on personal grid", () => { + beforeEach(async () => { + axiosMock + .onGet("/api/pages", { params: { search: "", ...personalGridApiParams } }) + .reply(200, mockPublishedPageData, { total_matches: "1" }); + jest.spyOn(PageList.methods, "decorateData").mockImplementation((page) => { + page.shared = true; + }); + wrapper = mount(PageList, { + propsData: propsDataPersonalGrid, + localVue, + pinia: createTestingPinia(), + }); + await flushPromises(); + }); + it("updates filter when published icon is clicked", async () => { + const rows = wrapper.findAll("tbody > tr").wrappers; + const row = rows[0]; + row.find(".sharing-indicator-published").trigger("click"); + await flushPromises(); + expect(wrapper.vm.filter).toBe("is:published"); + }); + + it("updates filter when shared with me icon is clicked", async () => { + const rows = wrapper.findAll("tbody > tr").wrappers; + const row = rows[0]; + row.find(".sharing-indicator-shared").trigger("click"); + await flushPromises(); + expect(wrapper.vm.filter).toBe("is:shared_with_me"); + }); + + it("updates filter when importable icon is clicked", async () => { + const rows = wrapper.findAll("tbody > tr").wrappers; + const row = rows[0]; + row.find(".sharing-indicator-importable").trigger("click"); + await flushPromises(); + expect(wrapper.vm.filter).toBe("is:importable"); + }); + }); + describe("with single page on published grid", () => { + beforeEach(async () => { + axiosMock + .onGet("/api/pages", { params: publishedGridApiParams }) + .reply(200, mockPublishedPageData, { total_matches: "1" }); + jest.spyOn(PageList.methods, "decorateData").mockImplementation((page) => { + page.shared = false; + }); + wrapper = mount(PageList, { + propsData: propsDataPublishedGrid, + localVue, + pinia: createTestingPinia(), + }); + await flushPromises(); + }); + + it("renders one row with correct sharing options", async () => { + const rows = wrapper.findAll("tbody > tr").wrappers; + expect(rows.length).toBe(1); + const row = rows[0]; + const columns = row.findAll("td"); + expect(columns.at(0).text()).toContain("jimmy's page"); + expect(columns.at(1).text()).toContain("tagThis"); + expect(columns.at(1).text()).toContain("tagIs"); + expect(columns.at(1).text()).toContain("tagJimmy"); + expect(columns.at(2).text()).toBe("jimmyPage"); + expect(columns.at(3).text()).toBe( + formatDistanceToNow(parseISO(`${mockPublishedPageData[0].update_time}Z`), { addSuffix: true }) + ); + expect(row.find(".share-this-page").exists()).toBe(false); + expect(row.find(".sharing-indicator-published").exists()).toBe(false); + expect(row.find(".sharing-indicator-importable").exists()).toBe(false); + expect(row.find(".sharing-indicator-shared").exists()).toBe(false); + }); + }); + describe("with two pages on published grid", () => { + beforeEach(async () => { + axiosMock + .onGet("/api/pages", { params: publishedGridApiParams }) + .reply(200, mockTwoPageData, { total_matches: "2" }); + axiosMock + .onGet("/api/pages", { params: publishedGridApiParamsSortTitleAsc }) + .reply(200, [pageA, privatePage], { total_matches: "2" }); + axiosMock + .onGet("/api/pages", { params: publishedGridApiParamsSortTitleDesc }) + .reply(200, mockTwoPageData, { total_matches: "2" }); + jest.spyOn(PageList.methods, "decorateData").mockImplementation((page) => { + page.shared = false; + }); + wrapper = mount(PageList, { + propsData: propsDataPublishedGrid, + localVue, + pinia: createTestingPinia(), + }); + await flushPromises(); + }); + + it("should render both rows", async () => { + const rows = wrapper.findAll("tbody > tr").wrappers; + expect(rows.length).toBe(2); + }); + + it("should sort asc/desc when title column is clicked", async () => { + let firstRowColumns = wrapper.findAll("tbody > tr").wrappers[0].findAll("td"); + expect(firstRowColumns.at(0).text()).toContain("jimmy's page"); + const titleColumn = wrapper.findAll("th").wrappers[0]; + // default sort is by update_time + expect(titleColumn.attributes("aria-sort")).toBe("none"); + await titleColumn.trigger("click"); + await flushPromises(); + expect(titleColumn.attributes("aria-sort")).toBe("ascending"); + firstRowColumns = wrapper.findAll("tbody > tr").wrappers[0].findAll("td"); + expect(firstRowColumns.at(0).text()).toContain("a page title"); + await titleColumn.trigger("click"); + await flushPromises(); + expect(titleColumn.attributes("aria-sort")).toBe("descending"); + firstRowColumns = wrapper.findAll("tbody > tr").wrappers[0].findAll("td"); + expect(firstRowColumns.at(0).text()).toContain("jimmy's page"); + }); + }); +}); diff --git a/client/src/components/Page/PageList.vue b/client/src/components/Page/PageList.vue new file mode 100644 index 000000000000..ca674d1a6aff --- /dev/null +++ b/client/src/components/Page/PageList.vue @@ -0,0 +1,227 @@ + + diff --git a/client/src/components/Page/services.ts b/client/src/components/Page/services.ts new file mode 100644 index 000000000000..74cc0db8c752 --- /dev/null +++ b/client/src/components/Page/services.ts @@ -0,0 +1,27 @@ +import type { FetchArgType } from "openapi-typescript-fetch"; +import { fetcher } from "@/schema"; + +/** Page request helper **/ +const _deletePage = fetcher.path("/api/pages/{id}").method("delete").create(); +type PageDeleteArgs = FetchArgType; +export async function deletePage(itemId: PageDeleteArgs["id"]) { + const { data } = await _deletePage({ + id: itemId, + }); + return data; +} + +const _updateTags = fetcher.path("/api/tags").method("put").create(); +type UpdateTagsArgs = FetchArgType; +export async function updateTags( + itemId: UpdateTagsArgs["item_id"], + itemClass: UpdateTagsArgs["item_class"], + itemTags: UpdateTagsArgs["item_tags"] +) { + const { data } = await _updateTags({ + item_id: itemId, + item_class: itemClass, + item_tags: itemTags, + }); + return data; +} diff --git a/client/src/components/providers/PageProvider.js b/client/src/components/providers/PageProvider.js new file mode 100644 index 000000000000..71853a9ec5cf --- /dev/null +++ b/client/src/components/providers/PageProvider.js @@ -0,0 +1,18 @@ +import axios from "axios"; +import { cleanPaginationParameters } from "./utils"; + +export function pagesProvider(ctx, callback, extraParams = {}) { + const { root, ...requestParams } = ctx; + const apiUrl = `${root}api/pages`; + const cleanParams = cleanPaginationParameters(requestParams); + const promise = axios.get(apiUrl, { params: { ...cleanParams, ...extraParams } }); + + // Must return a promise that resolves to an array of items + return promise.then((data) => { + // Pluck the array of items off our axios response + const items = data.data; + callback && callback(data); + // Must return an array of items or an empty array if an error occurred + return items || []; + }); +} diff --git a/client/src/components/providers/PageProvider.test.js b/client/src/components/providers/PageProvider.test.js new file mode 100644 index 000000000000..29abd4618d15 --- /dev/null +++ b/client/src/components/providers/PageProvider.test.js @@ -0,0 +1,43 @@ +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; + +import { pagesProvider } from "./PageProvider"; + +describe("PageProvider", () => { + let axiosMock; + + beforeEach(async () => { + axiosMock = new MockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + describe("fetching pages without an error", () => { + it("should make an API call and fire callback", async () => { + axiosMock + .onGet("/prefix/api/pages", { + params: { limit: 50, offset: 0, search: "rna tutorial" }, + }) + .reply(200, [{ model_class: "Page" }], { total_matches: "1" }); + + const ctx = { + root: "/prefix/", + perPage: 50, + currentPage: 1, + }; + const extras = { + search: "rna tutorial", + }; + + let called = false; + const callback = function () { + called = true; + }; + const promise = pagesProvider(ctx, callback, extras); + await promise; + expect(called).toBeTruthy(); + }); + }); +}); diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js index bec9f46b1ce5..c04c10f2be56 100644 --- a/client/src/entry/analysis/router.js +++ b/client/src/entry/analysis/router.js @@ -31,6 +31,7 @@ import AvailableDatatypes from "components/AvailableDatatypes/AvailableDatatypes import FormGeneric from "components/Form/FormGeneric"; import GridShared from "components/Grid/GridShared"; import GridHistory from "components/Grid/GridHistory"; +import PageList from "components/Page/PageList"; import HistoryImport from "components/HistoryImport"; import HistoryView from "components/History/HistoryView"; import HistoryPublished from "components/History/HistoryPublished"; @@ -328,11 +329,9 @@ export function getRouter(Galaxy) { }, { path: "pages/:actionId", - component: GridShared, + component: PageList, props: (route) => ({ - actionId: route.params.actionId, - item: "page", - plural: "Pages", + published: route.params.actionId == "list_published" ? true : false, }), }, { From d2b1e451a40777a4c3480646bffc23ed5e1822d9 Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Wed, 7 Jun 2023 14:40:18 -0700 Subject: [PATCH 07/27] add backend changed for pages grids --- client/src/schema/schema.ts | 65 +++++++- lib/galaxy/managers/pages.py | 104 ++++++++++-- lib/galaxy/model/__init__.py | 1 + lib/galaxy/schema/schema.py | 23 ++- lib/galaxy/webapps/galaxy/api/pages.py | 24 ++- lib/galaxy/webapps/galaxy/services/pages.py | 18 ++- lib/galaxy_test/api/test_pages.py | 152 +++++++++++++----- lib/galaxy_test/base/populators.py | 7 +- lib/galaxy_test/selenium/test_pages_index.py | 33 ++++ .../selenium/test_published_pages.py | 31 ++++ 10 files changed, 392 insertions(+), 66 deletions(-) create mode 100644 lib/galaxy_test/selenium/test_pages_index.py create mode 100644 lib/galaxy_test/selenium/test_published_pages.py diff --git a/client/src/schema/schema.ts b/client/src/schema/schema.ts index 54d46d7ea3d3..eca110817f23 100644 --- a/client/src/schema/schema.ts +++ b/client/src/schema/schema.ts @@ -5975,6 +5975,12 @@ export interface components { * @default html */ content_format?: components["schemas"]["PageContentFormat"]; + /** + * Create Time + * Format: date-time + * @description The time and date this item was created. + */ + create_time?: string; /** * Deleted * @description Whether this Page has been deleted. @@ -6034,11 +6040,18 @@ export interface components { * @description The title slug for the page URL, must be unique. */ slug: string; + tags: components["schemas"]["TagCollection"]; /** * Title * @description The name of the page */ title: 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 Page. @@ -6050,12 +6063,18 @@ export interface components { * @description An enumeration. * @enum {string} */ - PageSortByEnum: "create_time" | "update_time"; + PageSortByEnum: "update_time" | "title" | "username"; /** * PageSummary * @description Base model definition with common configuration used by all derived models. */ PageSummary: { + /** + * Create Time + * Format: date-time + * @description The time and date this item was created. + */ + create_time?: string; /** * Deleted * @description Whether this Page has been deleted. @@ -6105,11 +6124,18 @@ export interface components { * @description The title slug for the page URL, must be unique. */ slug: string; + tags: components["schemas"]["TagCollection"]; /** * Title * @description The name of the page */ title: 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 Page. @@ -12977,6 +13003,42 @@ export interface operations { /** @description Whether to include deleted pages in the result. */ /** @description Sort page index by this specified attribute on the page 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 pages's title. + * + * `slug` + * : The pages's slug. (The tag `s` can be used a short hand alias for this tag to filter on this attribute.) + * + * `tag` + * : The pages's tags. (The tag `t` can be used a short hand alias for this tag to filter on this attribute.) + * + * `user` + * : The pages'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 + * Pages: `title`, `slug`, `tag`, `user`. + */ query?: { deleted?: boolean; user_id?: string; @@ -12986,6 +13048,7 @@ export interface operations { sort_desc?: boolean; limit?: number; offset?: number; + 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?: { diff --git a/lib/galaxy/managers/pages.py b/lib/galaxy/managers/pages.py index 446a1f678b24..9b4f4d8f4552 100644 --- a/lib/galaxy/managers/pages.py +++ b/lib/galaxy/managers/pages.py @@ -20,6 +20,7 @@ or_, true, ) +from sqlalchemy.orm import aliased from galaxy import ( exceptions, @@ -38,6 +39,12 @@ ready_galaxy_markdown_for_import, ) from galaxy.model.base import transaction +from galaxy.model.index_filter_util import ( + append_user_filter, + raw_text_column_filter, + tag_filter, + text_column_filter, +) from galaxy.model.item_attrs import UsesAnnotations from galaxy.schema.schema import ( CreatePagePayload, @@ -47,6 +54,11 @@ from galaxy.structured_app import MinimalManagerApp from galaxy.util import unicodify from galaxy.util.sanitize_html import sanitize_html +from galaxy.util.search import ( + FilteredTerm, + parse_filters_structured, + RawTextTerm, +) log = logging.getLogger(__name__) @@ -81,6 +93,17 @@ 159: "\u0178", # latin capital letter y with diaeresis } +INDEX_SEARCH_FILTERS = { + "title": "title", + "slug": "slug", + "tag": "tag", + "user": "user", + "u": "user", + "s": "slug", + "t": "tag", + "is": "is", +} + class PageManager(sharable.SharableModelManager, UsesAnnotations): """Provides operations for managing a Page.""" @@ -98,34 +121,93 @@ def __init__(self, app: MinimalManagerApp): super().__init__(app) self.workflow_manager = app.workflow_manager - def index_query(self, trans: ProvidesUserContext, payload: PageIndexQueryPayload) -> Tuple[List[model.Page], int]: - deleted: bool = payload.deleted - - query = trans.sa_session.query(model.Page) + def index_query( + self, trans: ProvidesUserContext, payload: PageIndexQueryPayload, include_total_count: bool = False + ) -> Tuple[List[model.Page], int]: + show_deleted = payload.deleted + show_shared = payload.show_shared is_admin = trans.user_is_admin user = trans.user + + if show_shared is None: + show_shared = not show_deleted + + if show_shared and show_deleted: + message = "show_shared and show_deleted cannot both be specified as true" + raise exceptions.RequestParameterInvalidException(message) + + query = trans.sa_session.query(model.Page) + if not is_admin: filters = [model.Page.user == trans.user] if payload.show_published: filters.append(model.Page.published == true()) - - if user and payload.show_shared: + if user and show_shared: filters.append(model.PageUserShareAssociation.user == user) query = query.outerjoin(model.Page.users_shared_with) - query = query.filter(or_(*filters)) - if not deleted: + if not show_deleted: query = query.filter(model.Page.deleted == false()) elif not is_admin: - # non-admin query that should include deleted pages... - # don't let non-admins see other user's deleted pages... + # don't let non-admins see other user's deleted pages query = query.filter(or_(model.Page.deleted == false(), model.Page.user == user)) if payload.user_id: query = query.filter(model.Page.user_id == payload.user_id) - total_matches = query.count() + if payload.search: + search_query = payload.search + parsed_search = parse_filters_structured(search_query, INDEX_SEARCH_FILTERS) + + def p_tag_filter(term_text: str, quoted: bool): + nonlocal query + alias = aliased(model.PageTagAssociation) + query = query.outerjoin(model.Page.tags.of_type(alias)) + return tag_filter(alias, term_text, quoted) + + for term in parsed_search.terms: + if isinstance(term, FilteredTerm): + key = term.filter + q = term.text + if key == "tag": + pg = p_tag_filter(term.text, term.quoted) + query = query.filter(pg) + elif key == "title": + query = query.filter(text_column_filter(model.Page.title, term)) + elif key == "slug": + query = query.filter(text_column_filter(model.Page.slug, term)) + elif key == "user": + query = append_user_filter(query, model.Page, term) + elif key == "is": + if q == "published": + query = query.filter(model.Page.published == true()) + if q == "importable": + query = query.filter(model.Page.importable == true()) + elif q == "shared_with_me": + if not show_shared: + message = "Can only use tag is:shared_with_me if show_shared parameter also true." + raise exceptions.RequestParameterInvalidException(message) + query = query.filter(model.PageUserShareAssociation.user == user) + elif isinstance(term, RawTextTerm): + tf = p_tag_filter(term.text, False) + alias = aliased(model.User) + query = query.outerjoin(model.Page.user.of_type(alias)) + query = query.filter( + raw_text_column_filter( + [ + model.Page.title, + model.Page.slug, + tf, + alias.username, + ], + term, + ) + ) + if include_total_count: + total_matches = query.count() + else: + total_matches = None sort_column = getattr(model.Page, payload.sort_by) if payload.sort_desc: sort_column = sort_column.desc() diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index 87747618ca1f..85bcd81c76f7 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -9525,6 +9525,7 @@ class Page(Base, HasTags, Dictifiable, RepresentById): "deleted", "username", "email_hash", + "update_time", ] def to_dict(self, view="element"): diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index 9943adf93553..1f22b6844d50 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -1248,7 +1248,7 @@ class InvocationIndexQueryPayload(Model): sort_by: Optional[InvocationSortByEnum] = Field( title="Sort By", description="Sort Workflow Invocations by this attribute" ) - sort_desc: bool = Field(default=False, descritpion="Sort in descending order?") + sort_desc: bool = Field(default=False, description="Sort in descending order?") include_terminal: bool = Field(default=True, description="Set to false to only include terminal Invocations.") limit: Optional[int] = Field( default=100, @@ -1258,19 +1258,23 @@ class InvocationIndexQueryPayload(Model): class PageSortByEnum(str, Enum): - create_time = "create_time" update_time = "update_time" + title = "title" + username = "username" class PageIndexQueryPayload(Model): deleted: bool = False + show_published: Optional[bool] = None + show_shared: Optional[bool] = None user_id: Optional[DecodedDatabaseIdField] = None - sort_by: PageSortByEnum = PageSortByEnum.update_time - sort_desc: bool = Field(default=True, descritpion="Sort in descending order?") - show_published: bool = True - show_shared: bool = False - limit: int = 500 - offset: int = 0 + sort_by: PageSortByEnum = Field( + PageSortByEnum.update_time, title="Sort By", description="Sort pages by this attribute" + ) + sort_desc: Optional[bool] = Field(default=False, title="Sort descending", description="Sort in descending order.") + search: Optional[str] = Field(default=None, title="Filter text", description="Freetext to search.") + limit: Optional[int] = Field(default=100, lt=1000, title="Limit", description="Maximum number of pages to return.") + offset: Optional[int] = Field(default=0, title="Offset", description="Number of pages to skip") class CreateHistoryPayload(Model): @@ -3374,6 +3378,9 @@ class PageSummary(PageSummaryBase): title="List of revisions", description="The history with the encoded ID of each revision of the Page.", ) + create_time: Optional[datetime] = CreateTimeField + update_time: Optional[datetime] = UpdateTimeField + tags: TagCollection class PageDetails(PageSummary): diff --git a/lib/galaxy/webapps/galaxy/api/pages.py b/lib/galaxy/webapps/galaxy/api/pages.py index 7837b30a8489..01bc1df83f5e 100644 --- a/lib/galaxy/webapps/galaxy/api/pages.py +++ b/lib/galaxy/webapps/galaxy/api/pages.py @@ -32,7 +32,9 @@ from galaxy.webapps.galaxy.api import ( depends, DependsOnTrans, + IndexQueryTag, Router, + search_query_param, ) from galaxy.webapps.galaxy.services.pages import PagesService @@ -66,7 +68,7 @@ SortDescQueryParam: bool = Query( - default=True, + default=False, title="Sort Descending", description="Sort in descending order?", ) @@ -78,6 +80,19 @@ title="Number of pages to skip in sorted query (to enable pagination).", ) +query_tags = [ + IndexQueryTag("title", "The pages's title."), + IndexQueryTag("slug", "The pages's slug.", "s"), + IndexQueryTag("tag", "The pages's tags.", "t"), + IndexQueryTag("user", "The pages's owner's username.", "u"), +] + +SearchQueryParam: Optional[str] = search_query_param( + model_name="Page", + tags=query_tags, + free_text_fields=["title", "slug", "tag", "user"], +) + @router.cbv class FastAPIPages: @@ -90,6 +105,7 @@ class FastAPIPages: ) async def index( self, + response: Response, trans: ProvidesUserContext = DependsOnTrans, deleted: bool = DeletedQueryParam, user_id: Optional[DecodedDatabaseIdField] = UserIdQueryParam, @@ -99,6 +115,7 @@ async def index( sort_desc: bool = SortDescQueryParam, limit: int = LimitQueryParam, offset: int = OffsetQueryParam, + search: Optional[str] = SearchQueryParam, ) -> PageSummaryList: """Get a list with summary information of all Pages available to the user.""" payload = PageIndexQueryPayload.construct( @@ -110,8 +127,11 @@ async def index( sort_desc=sort_desc, limit=limit, offset=offset, + search=search, ) - return self.service.index(trans, payload) + pages, total_matches = self.service.index(trans, payload, include_total_count=True) + response.headers["total_matches"] = str(total_matches) + return pages @router.post( "/api/pages", diff --git a/lib/galaxy/webapps/galaxy/services/pages.py b/lib/galaxy/webapps/galaxy/services/pages.py index 9c9f41668282..3514df795dc3 100644 --- a/lib/galaxy/webapps/galaxy/services/pages.py +++ b/lib/galaxy/webapps/galaxy/services/pages.py @@ -56,7 +56,7 @@ def __init__( self.shareable_service = ShareableService(self.manager, self.serializer) self.short_term_storage_allocator = short_term_storage_allocator - def index(self, trans, payload: PageIndexQueryPayload) -> PageSummaryList: + def index(self, trans, payload: PageIndexQueryPayload, include_total_count: bool = False) -> PageSummaryList: """Return a list of Pages viewable by the user :param deleted: Display deleted pages @@ -65,13 +65,16 @@ def index(self, trans, payload: PageIndexQueryPayload) -> PageSummaryList: :returns: dictionaries containing summary or detailed Page information """ if not trans.user_is_admin: - user_id = trans.user.id + user_id = trans.user and trans.user.id if payload.user_id and payload.user_id != user_id: raise exceptions.AdminRequiredException("Only admins can index the pages of others") - pages, _ = self.manager.index_query(trans, payload) - return PageSummaryList.construct( - __root__=[trans.security.encode_all_ids(p.to_dict(), recursive=True) for p in pages] + pages, total_matches = self.manager.index_query(trans, payload, include_total_count) + return ( + PageSummaryList.construct( + __root__=[trans.security.encode_all_ids(p.to_dict(), recursive=True) for p in pages] + ), + total_matches, ) def create(self, trans, payload: CreatePagePayload) -> PageSummary: @@ -86,11 +89,12 @@ def create(self, trans, payload: CreatePagePayload) -> PageSummary: def delete(self, trans, id: DecodedDatabaseIdField): """ - Deletes a page (or marks it as deleted) + Mark page as deleted + + :param id: ID of the page to be deleted """ page = base.get_object(trans, id, "Page", check_ownership=True) - # Mark a page as deleted page.deleted = True with transaction(trans.sa_session): trans.sa_session.commit() diff --git a/lib/galaxy_test/api/test_pages.py b/lib/galaxy_test/api/test_pages.py index fa1597500b91..a4b41ac1d007 100644 --- a/lib/galaxy_test/api/test_pages.py +++ b/lib/galaxy_test/api/test_pages.py @@ -6,6 +6,7 @@ Union, ) from unittest import SkipTest +from uuid import uuid4 from requests import delete from requests.models import Response @@ -29,8 +30,8 @@ def setUp(self): self.dataset_populator = DatasetPopulator(self.galaxy_interactor) self.workflow_populator = WorkflowPopulator(self.galaxy_interactor) - def _create_valid_page_with_slug(self, slug): - return self.dataset_populator.new_page(slug=slug) + def _create_valid_page_with_slug(self, slug, **kwd) -> Dict[str, Any]: + return self.dataset_populator.new_page(slug=slug, **kwd) def _create_valid_page_as(self, other_email, slug): run_as_user = self._setup_user(other_email) @@ -111,6 +112,10 @@ def test_index(self): create_response_json = self._create_valid_page_with_slug("indexpage") assert self._users_index_has_page_with_id(create_response_json) + def test_index_published_shared(self): + response = self._index_raw(params=dict(show_shared=True, deleted=True)) + assert response.status_code == 400 + def test_index_deleted(self): response1 = self._create_valid_page_with_slug("indexdeletedpageundeleted") response2 = self._create_valid_page_with_slug("indexdeletedpagedeleted") @@ -136,9 +141,7 @@ def test_index_user_published(self): user_id = self.dataset_populator.user_id() response1 = self._create_valid_page_with_slug("indexuseridpublish1") with self._different_user(): - response2 = self._create_valid_page_with_slug("indexuseridpublish2") - self._make_public(response2["id"]) - + response2 = self._create_published_page_with_slug("indexuseridpublish2") assert self._users_index_has_page_with_id(response1) assert self._users_index_has_page_with_id(response2) assert self._users_index_has_page_with_id(response1, dict(user_id=user_id)) @@ -146,9 +149,7 @@ def test_index_user_published(self): def test_index_show_published(self): with self._different_user(): - response = self._create_valid_page_with_slug("indexshowpublish2") - self._make_public(response["id"]) - + response = self._create_published_page_with_slug("indexshowpublish2") assert self._users_index_has_page_with_id(response) assert self._users_index_has_page_with_id(response, dict(show_published=True)) assert not self._users_index_has_page_with_id(response, dict(show_published=False)) @@ -156,8 +157,7 @@ def test_index_show_published(self): def test_index_show_shared_with_me(self): user_id = self.dataset_populator.user_id() with self._different_user(): - response_published = self._create_valid_page_with_slug("indexshowsharedpublished") - self._make_public(response_published["id"]) + response_published = self._create_published_page_with_slug("indexshowsharedpublished") response_shared = self._create_valid_page_with_slug("indexshowsharedshared") self._share_with_user(response_shared["id"], user_id) @@ -170,8 +170,7 @@ def test_index_show_shared_with_me(self): def test_index_show_shared_with_me_deleted(self): user_id = self.dataset_populator.user_id() with self._different_user(): - response_published = self._create_valid_page_with_slug("indexshowsharedpublisheddeleted") - self._make_public(response_published["id"]) + response_published = self._create_published_page_with_slug("indexshowsharedpublisheddeleted") response_shared = self._create_valid_page_with_slug("indexshowsharedshareddeleted") self._share_with_user(response_shared["id"], user_id) self._delete(f"pages/{response_published['id']}").raise_for_status() @@ -179,46 +178,122 @@ def test_index_show_shared_with_me_deleted(self): assert not self._users_index_has_page_with_id(response_shared, dict(show_shared=True, show_published=True)) assert not self._users_index_has_page_with_id(response_published, dict(show_shared=True, show_published=True)) - assert not self._users_index_has_page_with_id( - response_shared, dict(show_shared=True, show_published=True, deleted=True) - ) - assert not self._users_index_has_page_with_id( - response_published, dict(show_shared=True, show_published=True, deleted=True) - ) + assert not self._users_index_has_page_with_id(response_shared, dict(show_published=True, deleted=True)) + assert not self._users_index_has_page_with_id(response_published, dict(show_published=True, deleted=True)) + + def test_index_owner(self): + my_workflow_id_1 = self._create_valid_page_with_slug("ownertags-m-1") + email_1 = f"{uuid4()}@test.com" + with self._different_user(email=email_1): + published_page_id_1 = self._create_published_page_with_slug("ownertags-p-1")["id"] + owner_1 = self._get("users").json()[0]["username"] + + email_2 = f"{uuid4()}@test.com" + with self._different_user(email=email_2): + published_page_id_2 = self._create_published_page_with_slug("ownertags-p-2")["id"] + + index_ids = self._index_ids(dict(search="is:published", show_published=True)) + assert published_page_id_1 in index_ids + assert published_page_id_2 in index_ids + assert my_workflow_id_1 not in index_ids + + index_ids = self._index_ids(dict(search=f"is:published u:{owner_1}", show_published=True)) + assert published_page_id_1 in index_ids + assert published_page_id_2 not in index_ids + assert my_workflow_id_1 not in index_ids + + index_ids = self._index_ids(dict(search=f"is:published u:'{owner_1}'", show_published=True)) + assert published_page_id_1 in index_ids + assert published_page_id_2 not in index_ids + assert my_workflow_id_1 not in index_ids + + index_ids = self._index_ids(dict(search=f"is:published {owner_1}", show_published=True)) + assert published_page_id_1 in index_ids + assert published_page_id_2 not in index_ids + assert my_workflow_id_1 not in index_ids def test_index_ordering(self): - older1 = self._create_valid_page_with_slug("indexorderingcreatedfirst")["id"] - newer1 = self._create_valid_page_with_slug("indexorderingcreatedsecond")["id"] - index_ids = self._index_ids() - assert index_ids.index(older1) > index_ids.index(newer1) - index_ids = self._index_ids(dict(sort_desc=True)) # the default but verify it works when explicit + older1 = self._create_valid_page_with_slug(slug="indexorderingcreatedfirst", title="A PAGE")["id"] + newer1 = self._create_valid_page_with_slug(slug="indexorderingcreatedsecond", title="B PAGE")["id"] + index_ids = self._index_ids(dict(sort_desc=True)) assert index_ids.index(older1) > index_ids.index(newer1) - - index_ids = self._index_ids(dict(sort_desc=False)) + index_ids = self._index_ids() + assert index_ids.index(older1) < index_ids.index(newer1) + index_ids = self._index_ids(dict(sort_desc=False)) # the default but verify it works when explicit assert index_ids.index(older1) < index_ids.index(newer1) # update older1 so the update time is newer... revision_data = dict(content="

NewContent!

") self._post(f"pages/{older1}/revisions", data=revision_data).raise_for_status() - index_ids = self._index_ids() + index_ids = self._index_ids(dict(sort_desc=True)) assert index_ids.index(older1) < index_ids.index(newer1) - # if we switch to create time instead of update time, older1 still appears later in - # in the list... - index_ids = self._index_ids(dict(sort_by="create_time")) - assert index_ids.index(older1) > index_ids.index(newer1) + index_ids = self._index_ids(dict(sort_by="title", sort_desc=False)) + assert index_ids.index(older1) < index_ids.index(newer1) - def test_limit_offset(self): + def test_index_limit_offset(self): older1 = self._create_valid_page_with_slug("indexlimitoffsetcreatedfirst")["id"] newer1 = self._create_valid_page_with_slug("indexlimitoffsetcreatedsecond")["id"] - index_ids = self._index_ids(dict(limit=1)) + index_ids = self._index_ids(dict(limit=1, sort_desc=True)) assert newer1 in index_ids assert older1 not in index_ids - index_ids = self._index_ids(dict(limit=1, offset=1)) + index_ids = self._index_ids(dict(limit=1, offset=1, sort_desc=True)) assert newer1 not in index_ids assert older1 in index_ids + def test_index_search_slug(self): + response = self._create_valid_page_with_slug("indexsearchstringfoo") + older1 = response["id"] + newer1 = self._create_valid_page_with_slug("indexsearchstringbar")["id"] + + index_ids = self._index_ids(dict(search="slug:indexsearchstringfoo")) + assert newer1 not in index_ids + assert older1 in index_ids + + index_ids = self._index_ids(dict(search="slug:'indexsearchstringfoo'")) + assert newer1 not in index_ids + assert older1 in index_ids + + index_ids = self._index_ids(dict(search="slug:foo")) + assert newer1 not in index_ids + assert older1 in index_ids + + index_ids = self._index_ids(dict(search="foo")) + assert newer1 not in index_ids + assert older1 in index_ids + + def test_index_search_title(self): + page_id = self._create_valid_page_with_slug("indexsearchbytitle", title="mycooltitle")["id"] + assert page_id in self._index_ids(dict(search="mycooltitle")) + assert page_id not in self._index_ids(dict(search="mycoolwrongtitle")) + assert page_id in self._index_ids(dict(search="title:mycoolti")) + assert page_id in self._index_ids(dict(search="title:'mycooltitle'")) + assert page_id not in self._index_ids(dict(search="title:'mycoolti'")) + + def test_index_search_sharing_tags(self): + user_id = self.dataset_populator.user_id() + with self._different_user(): + response_published = self._create_valid_page_with_slug("indexshowsharedpublishedtags")["id"] + self._make_public(response_published) + response_shared = self._create_valid_page_with_slug("indexshowsharedsharedtags")["id"] + self._share_with_user(response_shared, user_id) + + assert response_published in self._index_ids(dict(show_published=True, show_shared=True)) + assert response_shared in self._index_ids(dict(show_published=True, show_shared=True)) + + assert response_published in self._index_ids(dict(show_published=True, show_shared=True, search="is:published")) + assert response_shared not in self._index_ids( + dict(show_published=True, show_shared=True, search="is:published") + ) + + assert response_published not in self._index_ids( + dict(show_published=True, show_shared=True, search="is:shared_with_me") + ) + assert response_shared in self._index_ids( + dict(show_published=True, show_shared=True, search="is:shared_with_me") + ) + def test_index_does_not_show_unavailable_pages(self): create_response_json = self._create_valid_page_as("others_page_index@bx.psu.edu", "otherspageindex") assert not self._users_index_has_page_with_id(create_response_json) @@ -356,10 +431,13 @@ def test_400_on_download_pdf_when_unsupported_content_format(self): pdf_response = self._get(f"pages/{page_id}.pdf") self._assert_status_code_is(pdf_response, 400) + def _create_published_page_with_slug(self, slug, **kwd) -> Dict[str, Any]: + response = self.dataset_populator.new_page(slug=slug, **kwd) + response = self._make_public(response["id"]) + return response + def _make_public(self, page_id: str) -> dict: - sharing_response = self._put(f"pages/{page_id}/publish") - assert sharing_response.status_code == 200 - return sharing_response.json() + return self.dataset_populator.make_page_public(page_id) def _share_with_user(self, page_id: str, user_id_or_email: str): data = {"user_ids": [user_id_or_email]} @@ -373,6 +451,8 @@ def _index_raw(self, params: Optional[Dict[str, Any]] = None) -> Response: def _index(self, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: index_response = self._index_raw(params) self._assert_status_code_is(index_response, 200) + print(params) + print(index_response.json()) return index_response.json() def _index_ids(self, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: diff --git a/lib/galaxy_test/base/populators.py b/lib/galaxy_test/base/populators.py index 6d0fcd7103b4..5ae35c1c90b9 100644 --- a/lib/galaxy_test/base/populators.py +++ b/lib/galaxy_test/base/populators.py @@ -1417,7 +1417,7 @@ def new_page( self, slug: str = "mypage", title: str = "MY PAGE", content_format: str = "html", content: Optional[str] = None ) -> Dict[str, Any]: page_response = self.new_page_raw(slug=slug, title=title, content_format=content_format, content=content) - page_response.raise_for_status() + api_asserts.assert_status_code_is(page_response, 200) return page_response.json() def new_page_raw( @@ -1480,6 +1480,11 @@ def get_history_export_tasks(self, history_id: str): api_asserts.assert_status_code_is_ok(response) return response.json() + def make_page_public(self, page_id: str) -> Dict[str, Any]: + sharing_response = self._put(f"pages/{page_id}/publish") + assert sharing_response.status_code == 200 + return sharing_response.json() + class GalaxyInteractorHttpMixin: galaxy_interactor: ApiTestInteractor diff --git a/lib/galaxy_test/selenium/test_pages_index.py b/lib/galaxy_test/selenium/test_pages_index.py new file mode 100644 index 000000000000..e2512ec8926a --- /dev/null +++ b/lib/galaxy_test/selenium/test_pages_index.py @@ -0,0 +1,33 @@ +from .framework import ( + retry_assertion_during_transitions, + selenium_test, + SeleniumTestCase, +) + + +class TestPagesIndex(SeleniumTestCase): + ensure_registered = True + + @selenium_test + def test_page_deletion(self): + page_response = self.new_page() + page_id = page_response["id"] + self.navigate_to_pages() + self._assert_showing_n_pages(1) + self.components.pages.dropdown(id=page_id).wait_for_visible() + self.page_index_click_option("Delete", page_id) + self.accept_alert() + self.components.pages.dropdown(id=page_id).wait_for_absent_or_hidden() + self._assert_showing_n_pages(0) + + def new_page(self): + slug = self._get_random_name() + response = self.dataset_populator.new_page(slug=slug) + return response + + @retry_assertion_during_transitions + def _assert_showing_n_pages(self, n): + actual_count = len(self.pages_index_table_elements()) + if actual_count != n: + message = f"Expected {n} pages to be displayed, based on DOM found {actual_count} page index rows." + raise AssertionError(message) diff --git a/lib/galaxy_test/selenium/test_published_pages.py b/lib/galaxy_test/selenium/test_published_pages.py new file mode 100644 index 000000000000..7d367ae7da17 --- /dev/null +++ b/lib/galaxy_test/selenium/test_published_pages.py @@ -0,0 +1,31 @@ +from .framework import ( + selenium_test, + SharedStateSeleniumTestCase, +) + + +class TestPublishedPagesGrid(SharedStateSeleniumTestCase): + @selenium_test + def test_index(self): + self.navigate_to_published_pages() + self.components.pages.dropdown(id=self.page_id_1).wait_for_visible() + + def setup_shared_state(self): + self.user1_email = self._get_random_email("test1") + self.user2_email = self._get_random_email("test2") + self.register(self.user1_email) + page_1 = self.new_public_page() + self.page_id_1 = page_1["id"] + self.slug_1 = page_1["slug"] + self.logout_if_needed() + + self.register(self.user2_email) + page_2 = self.new_public_page() + self.page_id_2 = page_2["id"] + self.slug_2 = page_2["slug"] + + def new_public_page(self): + slug = self._get_random_name() + response = self.dataset_populator.new_page(slug=slug) + self.dataset_populator.make_page_public(response["id"]) + return response From 3ba401fa4a128ae10e333fd86bbabdec4c87c8ae Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Wed, 7 Jun 2023 15:08:04 -0700 Subject: [PATCH 08/27] juggle the enums in schema see https://github.com/galaxyproject/galaxy/pull/15774 for context update page =pdf export selenium test update return type to reflect matches being returned cleanup the test file run make format --- client/src/schema/schema.ts | 8 +------- client/src/utils/navigation/navigation.yml | 5 ++++- lib/galaxy/schema/schema.py | 9 ++------- lib/galaxy/webapps/galaxy/api/pages.py | 2 +- lib/galaxy/webapps/galaxy/services/pages.py | 5 ++++- test/integration_selenium/test_pages_pdf_export.py | 8 +++++--- 6 files changed, 17 insertions(+), 20 deletions(-) diff --git a/client/src/schema/schema.ts b/client/src/schema/schema.ts index eca110817f23..528cc8ed5381 100644 --- a/client/src/schema/schema.ts +++ b/client/src/schema/schema.ts @@ -6058,12 +6058,6 @@ export interface components { */ username: string; }; - /** - * PageSortByEnum - * @description An enumeration. - * @enum {string} - */ - PageSortByEnum: "update_time" | "title" | "username"; /** * PageSummary * @description Base model definition with common configuration used by all derived models. @@ -13044,7 +13038,7 @@ export interface operations { user_id?: string; show_published?: boolean; show_shared?: boolean; - sort_by?: components["schemas"]["PageSortByEnum"]; + sort_by?: "update_time" | "title" | "username"; sort_desc?: boolean; limit?: number; offset?: number; diff --git a/client/src/utils/navigation/navigation.yml b/client/src/utils/navigation/navigation.yml index 0db6cdd51042..db9a01bb3d72 100644 --- a/client/src/utils/navigation/navigation.yml +++ b/client/src/utils/navigation/navigation.yml @@ -485,8 +485,11 @@ history_import: pages: selectors: - create: '.manage-table-actions .action-button' + create: '#page-create' submit: '#submit' + drop: '.page-dropdown' + edit: '.dropdown-item-edit' + view: '.dropdown-item-view' export: '.markdown-pdf-export' dropdown: '[data-page-dropdown*="${id}"]' index_table: "#page-table" diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index 1f22b6844d50..bc2511d170b0 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -1257,10 +1257,7 @@ class InvocationIndexQueryPayload(Model): offset: Optional[int] = Field(default=0, description="Number of invocations to skip") -class PageSortByEnum(str, Enum): - update_time = "update_time" - title = "title" - username = "username" +PageSortByEnum = Literal["update_time", "title", "username"] class PageIndexQueryPayload(Model): @@ -1268,9 +1265,7 @@ class PageIndexQueryPayload(Model): show_published: Optional[bool] = None show_shared: Optional[bool] = None user_id: Optional[DecodedDatabaseIdField] = None - sort_by: PageSortByEnum = Field( - PageSortByEnum.update_time, title="Sort By", description="Sort pages by this attribute" - ) + sort_by: PageSortByEnum = Field("update_time", title="Sort By", description="Sort pages by this attribute") sort_desc: Optional[bool] = Field(default=False, title="Sort descending", description="Sort in descending order.") search: Optional[str] = Field(default=None, title="Filter text", description="Freetext to search.") limit: Optional[int] = Field(default=100, lt=1000, title="Limit", description="Maximum number of pages to return.") diff --git a/lib/galaxy/webapps/galaxy/api/pages.py b/lib/galaxy/webapps/galaxy/api/pages.py index 01bc1df83f5e..22b836b13694 100644 --- a/lib/galaxy/webapps/galaxy/api/pages.py +++ b/lib/galaxy/webapps/galaxy/api/pages.py @@ -61,7 +61,7 @@ SortByQueryParam: PageSortByEnum = Query( - default=PageSortByEnum.update_time, + default="update_time", title="Sort attribute", description="Sort page index by this specified attribute on the page model", ) diff --git a/lib/galaxy/webapps/galaxy/services/pages.py b/lib/galaxy/webapps/galaxy/services/pages.py index 3514df795dc3..5de610027603 100644 --- a/lib/galaxy/webapps/galaxy/services/pages.py +++ b/lib/galaxy/webapps/galaxy/services/pages.py @@ -1,4 +1,5 @@ import logging +from typing import Tuple from galaxy import exceptions from galaxy.celery.tasks import prepare_pdf_download @@ -56,7 +57,9 @@ def __init__( self.shareable_service = ShareableService(self.manager, self.serializer) self.short_term_storage_allocator = short_term_storage_allocator - def index(self, trans, payload: PageIndexQueryPayload, include_total_count: bool = False) -> PageSummaryList: + def index( + self, trans, payload: PageIndexQueryPayload, include_total_count: bool = False + ) -> Tuple[PageSummaryList, int]: """Return a list of Pages viewable by the user :param deleted: Display deleted pages diff --git a/test/integration_selenium/test_pages_pdf_export.py b/test/integration_selenium/test_pages_pdf_export.py index eb291c13efa1..6712baef1355 100644 --- a/test/integration_selenium/test_pages_pdf_export.py +++ b/test/integration_selenium/test_pages_pdf_export.py @@ -16,8 +16,9 @@ def handle_galaxy_config_kwds(cls, config): def test_page_pdf_export(self): self.navigate_to_pages() self.screenshot("pages_grid") - name = self.create_page() - self.click_grid_popup_option(name, "Edit content") + self.create_page() + self.components.pages.drop.wait_for_and_click() + self.components.pages.edit.wait_for_and_click() self.components.pages.editor.markdown_editor.wait_for_and_send_keys("moo\n\n\ncow\n\n") self.screenshot("pages_markdown_editor") self.sleep_for(self.wait_types.UX_RENDER) @@ -26,7 +27,8 @@ def test_page_pdf_export(self): self.screenshot("pages_markdown_editor_saved") self.sleep_for(self.wait_types.UX_RENDER) self.navigate_to_pages() - self.click_grid_popup_option(name, "View") + self.components.pages.drop.wait_for_and_click() + self.components.pages.view.wait_for_and_click() self.screenshot("pages_view_simple") self.components.pages.export.wait_for_and_click() self.sleep_for(self.wait_types.UX_RENDER) From 06125f389ed64992ec77cc957a53cd2b0a0cad63 Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Fri, 9 Jun 2023 13:36:57 -0700 Subject: [PATCH 09/27] adjust test_pages selenium test for new grid markup --- lib/galaxy/selenium/navigates_galaxy.py | 8 +++++--- lib/galaxy_test/selenium/test_pages.py | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index cd159817070e..6e96565b897c 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -1167,10 +1167,11 @@ def libraries_open_with_name(self, name): self.libraries_index_search_for(name) self.libraries_index_table_elements()[0].find_element(By.CSS_SELECTOR, "td a").click() - def page_open_with_name(self, name, screenshot_name): + def page_open_and_screenshot(self, screenshot_name): self.home() self.navigate_to_pages() - self.click_grid_popup_option(name, "View") + self.components.pages.drop.wait_for_and_click() + self.components.pages.view.wait_for_and_click() if screenshot_name: self.sleep_for(self.wait_types.UX_RENDER) self.screenshot(screenshot_name) @@ -1478,7 +1479,8 @@ def select_storage(self, storage_id: str) -> None: def create_page_and_edit(self, name=None, slug=None, screenshot_name=None): name = self.create_page(name=name, slug=slug, screenshot_name=screenshot_name) - self.click_grid_popup_option(name, "Edit content") + self.components.pages.drop.wait_for_and_click() + self.components.pages.edit.wait_for_and_click() self.components.pages.editor.markdown_editor.wait_for_visible() return name diff --git a/lib/galaxy_test/selenium/test_pages.py b/lib/galaxy_test/selenium/test_pages.py index 4e0e75d526dd..669f42b8f613 100644 --- a/lib/galaxy_test/selenium/test_pages.py +++ b/lib/galaxy_test/selenium/test_pages.py @@ -21,7 +21,7 @@ def test_simple_page_creation_edit_and_view(self): self.history_panel_wait_for_hid_ok(1) self.navigate_to_pages() self.screenshot("pages_grid") - name = self.create_page_and_edit(screenshot_name="pages_create_form") + self.create_page_and_edit(screenshot_name="pages_create_form") self.screenshot("pages_editor_new") editor = self._page_editor editor.markdown_editor.wait_for_and_send_keys("moo\n\n\ncow\n\n") @@ -31,7 +31,7 @@ def test_simple_page_creation_edit_and_view(self): self.sleep_for(self.wait_types.UX_RENDER) editor.save.wait_for_and_click() self.screenshot("pages_editor_saved") - self.page_open_with_name(name, "page_view_with_embedded_dataset") + self.page_open_and_screenshot("page_view_with_embedded_dataset") @selenium_test @managed_history @@ -45,7 +45,7 @@ def test_workflow_problem_display(self): ) self.navigate_to_pages() - name = self.create_page_and_edit() + self.create_page_and_edit() editor = self._page_editor editor.markdown_editor.wait_for_and_send_keys("moo\n\n\ncow\n\n") editor.embed_workflow_display.wait_for_and_click() @@ -56,7 +56,7 @@ def test_workflow_problem_display(self): editor.workflow_selection(id=problem_workflow_2_id).wait_for_and_click() self.sleep_for(self.wait_types.UX_RENDER) editor.save.wait_for_and_click() - self.page_open_with_name(name, "page_view_with_workflow_problems") + self.page_open_and_screenshot("page_view_with_workflow_problems") @property def _page_editor(self): From 8cb51882a8149cd6e813a0a47736b3b3b481963f Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Mon, 12 Jun 2023 14:19:46 -0700 Subject: [PATCH 10/27] correct grammar in desc --- client/src/schema/schema.ts | 8 ++++---- lib/galaxy/webapps/galaxy/api/pages.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/schema/schema.ts b/client/src/schema/schema.ts index 528cc8ed5381..80be4a758466 100644 --- a/client/src/schema/schema.ts +++ b/client/src/schema/schema.ts @@ -13017,16 +13017,16 @@ export interface operations { * ## GitHub-style Tags Available * * `title` - * : The pages's title. + * : The page's title. * * `slug` - * : The pages's slug. (The tag `s` can be used a short hand alias for this tag to filter on this attribute.) + * : The page's slug. (The tag `s` can be used a short hand alias for this tag to filter on this attribute.) * * `tag` - * : The pages's tags. (The tag `t` can be used a short hand alias for this tag to filter on this attribute.) + * : The page's tags. (The tag `t` can be used a short hand alias for this tag to filter on this attribute.) * * `user` - * : The pages's owner's username. (The tag `u` can be used a short hand alias for this tag to filter on this attribute.) + * : The page's owner's username. (The tag `u` can be used a short hand alias for this tag to filter on this attribute.) * * ## Free Text * diff --git a/lib/galaxy/webapps/galaxy/api/pages.py b/lib/galaxy/webapps/galaxy/api/pages.py index 22b836b13694..461ca5f7f9e4 100644 --- a/lib/galaxy/webapps/galaxy/api/pages.py +++ b/lib/galaxy/webapps/galaxy/api/pages.py @@ -81,10 +81,10 @@ ) query_tags = [ - IndexQueryTag("title", "The pages's title."), - IndexQueryTag("slug", "The pages's slug.", "s"), - IndexQueryTag("tag", "The pages's tags.", "t"), - IndexQueryTag("user", "The pages's owner's username.", "u"), + IndexQueryTag("title", "The page's title."), + IndexQueryTag("slug", "The page's slug.", "s"), + IndexQueryTag("tag", "The page's tags.", "t"), + IndexQueryTag("user", "The page's owner's username.", "u"), ] SearchQueryParam: Optional[str] = search_query_param( From e7904a787e5c2301b807c584a4f66f2c53f8baff Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Mon, 12 Jun 2023 14:33:06 -0700 Subject: [PATCH 11/27] unify punctuation handling in pages api desc --- client/src/schema/schema.ts | 10 +++++----- lib/galaxy/schema/schema.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/src/schema/schema.ts b/client/src/schema/schema.ts index 80be4a758466..5639e488e68f 100644 --- a/client/src/schema/schema.ts +++ b/client/src/schema/schema.ts @@ -2164,7 +2164,7 @@ export interface components { slug: string; /** * Title - * @description The name of the page + * @description The name of the page. */ title: string; }; @@ -5988,7 +5988,7 @@ export interface components { deleted: boolean; /** * Encoded email - * @description The encoded email of the user + * @description The encoded email of the user. */ email_hash: string; /** @@ -6043,7 +6043,7 @@ export interface components { tags: components["schemas"]["TagCollection"]; /** * Title - * @description The name of the page + * @description The name of the page. */ title: string; /** @@ -6076,7 +6076,7 @@ export interface components { deleted: boolean; /** * Encoded email - * @description The encoded email of the user + * @description The encoded email of the user. */ email_hash: string; /** @@ -6121,7 +6121,7 @@ export interface components { tags: components["schemas"]["TagCollection"]; /** * Title - * @description The name of the page + * @description The name of the page. */ title: string; /** diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index bc2511d170b0..cf60c0b5fbc8 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -1265,11 +1265,11 @@ class PageIndexQueryPayload(Model): show_published: Optional[bool] = None show_shared: Optional[bool] = None user_id: Optional[DecodedDatabaseIdField] = None - sort_by: PageSortByEnum = Field("update_time", title="Sort By", description="Sort pages by this attribute") + sort_by: PageSortByEnum = Field("update_time", title="Sort By", description="Sort pages by this attribute.") sort_desc: Optional[bool] = Field(default=False, title="Sort descending", description="Sort in descending order.") search: Optional[str] = Field(default=None, title="Filter text", description="Freetext to search.") limit: Optional[int] = Field(default=100, lt=1000, title="Limit", description="Maximum number of pages to return.") - offset: Optional[int] = Field(default=0, title="Offset", description="Number of pages to skip") + offset: Optional[int] = Field(default=0, title="Offset", description="Number of pages to skip.") class CreateHistoryPayload(Model): @@ -3257,7 +3257,7 @@ class PageSummaryBase(Model): title: str = Field( ..., # Required title="Title", - description="The name of the page", + description="The name of the page.", ) slug: str = Field( ..., # Required @@ -3346,7 +3346,7 @@ class PageSummary(PageSummaryBase): email_hash: str = Field( ..., # Required title="Encoded email", - description="The encoded email of the user", + description="The encoded email of the user.", ) published: bool = Field( ..., # Required From 0940ef206aee5eb0cadad427eeb94b1c9bd9f20e Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Mon, 12 Jun 2023 14:35:05 -0700 Subject: [PATCH 12/27] rename method to have a more proper name --- lib/galaxy_test/api/test_pages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy_test/api/test_pages.py b/lib/galaxy_test/api/test_pages.py index a4b41ac1d007..a74909dfc1a9 100644 --- a/lib/galaxy_test/api/test_pages.py +++ b/lib/galaxy_test/api/test_pages.py @@ -112,7 +112,7 @@ def test_index(self): create_response_json = self._create_valid_page_with_slug("indexpage") assert self._users_index_has_page_with_id(create_response_json) - def test_index_published_shared(self): + def test_400_on_index_deleted_shared(self): response = self._index_raw(params=dict(show_shared=True, deleted=True)) assert response.status_code == 400 From c52a431f102122be1a3ca77133dfe425f71b6a26 Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Tue, 13 Jun 2023 10:25:45 -0700 Subject: [PATCH 13/27] prettier than the prettier --- client/src/components/Page/PageList.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/components/Page/PageList.vue b/client/src/components/Page/PageList.vue index ca674d1a6aff..c184c9f108ce 100644 --- a/client/src/components/Page/PageList.vue +++ b/client/src/components/Page/PageList.vue @@ -47,9 +47,9 @@ class="page-filter-link-owner" href="#" :title="'Search more from ' + row.item.username" - @click="appendTagFilter('user', row.item.username)" - >{{ row.item.username }} + @click="appendTagFilter('user', row.item.username)"> + {{ row.item.username }} +