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 @@ + diff --git a/client/src/components/Page/PageIndexActions.test.ts b/client/src/components/Page/PageIndexActions.test.ts new file mode 100644 index 000000000000..97392166dacc --- /dev/null +++ b/client/src/components/Page/PageIndexActions.test.ts @@ -0,0 +1,31 @@ +import PageIndexActions from "./PageIndexActions.vue"; +import { shallowMount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; + +import "jest-location-mock"; + +const localVue = getLocalVue(); + +describe("PageIndexActions.vue", () => { + let wrapper: any; + const mockRouter = { + push: jest.fn(), + }; + + beforeEach(async () => { + wrapper = shallowMount(PageIndexActions, { + mocks: { + $router: mockRouter, + }, + localVue, + }); + }); + + describe("navigation", () => { + it("should create a page when create is clicked", async () => { + await wrapper.find("#page-create").trigger("click"); + expect(mockRouter.push).toHaveBeenCalledTimes(1); + expect(mockRouter.push).toHaveBeenCalledWith("/pages/create"); + }); + }); +}); diff --git a/client/src/components/Page/PageIndexActions.vue b/client/src/components/Page/PageIndexActions.vue new file mode 100644 index 000000000000..8c658fe079e3 --- /dev/null +++ b/client/src/components/Page/PageIndexActions.vue @@ -0,0 +1,23 @@ + + 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..c184c9f108ce --- /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 44a57245ba23..5127a8548b01 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"; @@ -347,11 +348,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, }), }, { diff --git a/client/src/schema/schema.ts b/client/src/schema/schema.ts index e726cb66049a..c7afad5351aa 100644 --- a/client/src/schema/schema.ts +++ b/client/src/schema/schema.ts @@ -2675,7 +2675,7 @@ export interface components { slug: string; /** * Title - * @description The name of the page + * @description The name of the page. */ title: string; }; @@ -6867,6 +6867,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. @@ -6874,7 +6880,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; /** @@ -6926,28 +6932,35 @@ 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 + * @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. */ username: string; }; - /** - * PageSortByEnum - * @description An enumeration. - * @enum {string} - */ - PageSortByEnum: "create_time" | "update_time"; /** * 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. @@ -6955,7 +6968,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; /** @@ -6997,11 +7010,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 + * @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. @@ -14602,15 +14622,52 @@ 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 page's title. + * + * `slug` + * : The page's slug. (The tag `s` can be used a short hand alias for this tag to filter on this attribute.) + * + * `tag` + * : The page's tags. (The tag `t` can be used a short hand alias for this tag to filter on this attribute.) + * + * `user` + * : 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 + * + * Free text search terms will be searched against the following attributes of the + * Pages: `title`, `slug`, `tag`, `user`. + */ query?: { deleted?: boolean; 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; + 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/client/src/utils/navigation/navigation.yml b/client/src/utils/navigation/navigation.yml index 67ab203b29c0..f88d0981b59c 100644 --- a/client/src/utils/navigation/navigation.yml +++ b/client/src/utils/navigation/navigation.yml @@ -493,12 +493,18 @@ history_import: pages: selectors: - create: '.manage-table-actions .action-button' + create: '#page-create' submit: '#submit' + drop: '.page-dropdown' + drop_edit: '.dropdown-item-edit' + drop_view: '.dropdown-item-view' + create_title_input: '#form-element-title input' + create_slug_input: '#form-element-slug input' export: '.markdown-pdf-export' dropdown: '[data-page-dropdown*="${id}"]' index_table: "#page-table" index_rows: "#page-table > tbody > tr:not(.b-table-empty-row, [style*='display: none'])" + delete_modal_confirm: '#delete-page-modal-${id}___BV_modal_footer_ .btn-primary' editor: selectors: 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 39ad3f72eddd..57e2a5620946 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -9577,6 +9577,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 3ea6bcd95072..863f4543f149 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -1253,7 +1253,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, @@ -1262,20 +1262,19 @@ class InvocationIndexQueryPayload(Model): offset: Optional[int] = Field(default=0, description="Number of invocations to skip") -class PageSortByEnum(str, Enum): - create_time = "create_time" - update_time = "update_time" +PageSortByEnum = Literal["update_time", "title", "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("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): @@ -3320,7 +3319,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 @@ -3409,7 +3408,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 @@ -3436,6 +3435,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/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index f57019b6ea0f..4c947046e0c7 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -1187,10 +1187,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.drop_view.wait_for_and_click() if screenshot_name: self.sleep_for(self.wait_types.UX_RENDER) self.screenshot(screenshot_name) @@ -1498,20 +1499,26 @@ 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.sleep_for(self.wait_types.UX_RENDER) + self.components.pages.drop_edit.wait_for_and_click() self.components.pages.editor.markdown_editor.wait_for_visible() return name def create_page(self, name=None, slug=None, screenshot_name=None): - self.components.pages.create.wait_for_and_click() name = name or self._get_random_name(prefix="page") slug = slug = self._get_random_name(prefix="pageslug") - self.tool_set_value("title", name) - self.tool_set_value("slug", slug) - self.screenshot_if(screenshot_name) - # Sometimes 'submit' button not yet hooked up? + self.components.pages.create.wait_for_and_click() + self.sleep_for(self.wait_types.UX_TRANSITION) + self.screenshot("before_title_input") + self.components.pages.create_title_input.wait_for_and_send_keys(name) self.sleep_for(self.wait_types.UX_RENDER) + self.screenshot("before_slug_input") + self.components.pages.create_slug_input.wait_for_and_send_keys(slug) + self.sleep_for(self.wait_types.UX_RENDER) + self.screenshot_if(screenshot_name) self.components.pages.submit.wait_for_and_click() + self.sleep_for(self.wait_types.UX_RENDER) return name def tool_parameter_div(self, expanded_parameter_id): diff --git a/lib/galaxy/webapps/galaxy/api/pages.py b/lib/galaxy/webapps/galaxy/api/pages.py index 7837b30a8489..461ca5f7f9e4 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 @@ -59,14 +61,14 @@ 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", ) 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 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( + 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 a72f7ac02954..bd23d097045e 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 @@ -58,7 +59,9 @@ def __init__( self.shareable_service = ShareableService(self.manager, self.serializer, notification_manager) 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 + ) -> Tuple[PageSummaryList, int]: """Return a list of Pages viewable by the user :param deleted: Display deleted pages @@ -67,13 +70,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: @@ -88,11 +94,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..a74909dfc1a9 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_400_on_index_deleted_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 72ee71034b12..5f3dc5f72187 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() + def wait_for_export_task_on_record(self, export_record): if export_record["preparing"]: assert export_record["task_uuid"] diff --git a/lib/galaxy_test/selenium/test_pages.py b/lib/galaxy_test/selenium/test_pages.py index 88ada1ff8d01..8a7153874918 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 @@ -43,9 +43,8 @@ def test_workflow_problem_display(self): problem_workflow_2_id = workflow_populator.upload_yaml_workflow( WORKFLOW_WITH_BAD_COLUMN_PARAMETER, exact_tools=True ) - 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 +55,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") @selenium_test @managed_history @@ -66,13 +65,13 @@ def test_history_links(self): self.current_history_publish() history_id = self.current_history_id() self.navigate_to_pages() - name = self.create_page_and_edit() + self.create_page_and_edit() editor = self._page_editor editor.history_link.wait_for_and_click() editor.history_selection(id=history_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_history_link") + self.page_open_and_screenshot("page_view_with_history_link") view = self.components.pages.view view.history_link(history_id=history_id).wait_for_and_click() self.sleep_for(self.wait_types.UX_RENDER) 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..f7f82676b7e6 --- /dev/null +++ b/lib/galaxy_test/selenium/test_pages_index.py @@ -0,0 +1,34 @@ +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.sleep_for(self.wait_types.UX_RENDER) + self.components.pages.delete_modal_confirm(id=page_id).wait_for_and_click() + 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 diff --git a/test/integration_selenium/test_pages_pdf_export.py b/test/integration_selenium/test_pages_pdf_export.py index eb291c13efa1..801dcbae620b 100644 --- a/test/integration_selenium/test_pages_pdf_export.py +++ b/test/integration_selenium/test_pages_pdf_export.py @@ -16,8 +16,10 @@ 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.sleep_for(self.wait_types.UX_RENDER) + self.components.pages.drop_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 +28,9 @@ 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.sleep_for(self.wait_types.UX_RENDER) + self.components.pages.drop_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)