From 95561739ce8c0500461e676a3b4b00bbb51031f5 Mon Sep 17 00:00:00 2001 From: Eric Radman Date: Fri, 18 Apr 2025 09:01:21 -0400 Subject: [PATCH] Add "Unassigned Queries" view --- client/app/pages/queries-list/QueriesList.jsx | 15 ++++ client/app/services/query.js | 82 +++++++++---------- .../cypress/integration/query/query_list.js | 23 ++++++ redash/handlers/api.py | 2 + redash/handlers/queries.py | 22 +++++ redash/models/__init__.py | 15 +++- tests/handlers/test_queries.py | 30 +++++++ 7 files changed, 145 insertions(+), 44 deletions(-) create mode 100644 client/cypress/integration/query/query_list.js diff --git a/client/app/pages/queries-list/QueriesList.jsx b/client/app/pages/queries-list/QueriesList.jsx index 2a200e98b4..c3490d1661 100644 --- a/client/app/pages/queries-list/QueriesList.jsx +++ b/client/app/pages/queries-list/QueriesList.jsx @@ -35,6 +35,12 @@ const sidebarMenu = [ title: "All Queries", icon: () => , }, + { + key: "unassigned", + href: "queries/unassigned", + title: "Unassigned Queries", + icon: () => , + }, { key: "my", href: "queries/my", @@ -194,6 +200,7 @@ const QueriesListPage = itemsList( my: Query.myQueries.bind(Query), favorites: Query.favorites.bind(Query), archive: Query.archive.bind(Query), + unassigned: Query.unassigned.bind(Query), }[currentPage]; }, getItemProcessor() { @@ -219,6 +226,14 @@ routes.register( render: (pageProps) => , }) ); +routes.register( + "Queries.Unassigned", + routeWithUserSession({ + path: "/queries/unassigned", + title: "Unassigned Queries", + render: (pageProps) => , + }) +); routes.register( "Queries.Archived", routeWithUserSession({ diff --git a/client/app/services/query.js b/client/app/services/query.js index a8cf624cb8..9d5e912658 100644 --- a/client/app/services/query.js +++ b/client/app/services/query.js @@ -33,7 +33,7 @@ const logger = debug("redash:services:query"); function collectParams(parts) { let parameters = []; - parts.forEach(part => { + parts.forEach((part) => { if (part[0] === "name" || part[0] === "&") { parameters.push(part[1].split(".")[0]); } else if (part[0] === "#") { @@ -68,12 +68,7 @@ export class Query { scheduleInLocalTime() { const parts = this.schedule.split(":"); - return moment - .utc() - .hour(parts[0]) - .minute(parts[1]) - .local() - .format("HH:mm"); + return moment.utc().hour(parts[0]).minute(parts[1]).local().format("HH:mm"); } hasResult() { @@ -158,11 +153,11 @@ export class Query { let params = {}; if (this.getParameters().isRequired()) { - this.getParametersDefs().forEach(param => { + this.getParametersDefs().forEach((param) => { extend(params, param.toUrlParams()); }); } - Object.keys(params).forEach(key => params[key] == null && delete params[key]); + Object.keys(params).forEach((key) => params[key] == null && delete params[key]); params = map(params, (value, name) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join("&"); if (params !== "") { @@ -220,7 +215,7 @@ class Parameters { } parseQuery() { - const fallback = () => map(this.query.options.parameters, i => i.name); + const fallback = () => map(this.query.options.parameters, (i) => i.name); let parameters = []; if (this.query.query !== undefined) { @@ -242,9 +237,9 @@ class Parameters { updateParameters(update) { if (this.query.query === this.cachedQueryText) { const parameters = this.query.options.parameters; - const hasUnprocessedParameters = find(parameters, p => !(p instanceof Parameter)); + const hasUnprocessedParameters = find(parameters, (p) => !(p instanceof Parameter)); if (hasUnprocessedParameters) { - this.query.options.parameters = map(parameters, p => + this.query.options.parameters = map(parameters, (p) => p instanceof Parameter ? p : createParameter(p, this.query.id) ); } @@ -252,16 +247,16 @@ class Parameters { } this.cachedQueryText = this.query.query; - const parameterNames = update ? this.parseQuery() : map(this.query.options.parameters, p => p.name); + const parameterNames = update ? this.parseQuery() : map(this.query.options.parameters, (p) => p.name); this.query.options.parameters = this.query.options.parameters || []; const parametersMap = {}; - this.query.options.parameters.forEach(param => { + this.query.options.parameters.forEach((param) => { parametersMap[param.name] = param; }); - parameterNames.forEach(param => { + parameterNames.forEach((param) => { if (!has(parametersMap, param)) { this.query.options.parameters.push( createParameter({ @@ -275,15 +270,15 @@ class Parameters { } }); - const parameterExists = p => includes(parameterNames, p.name); + const parameterExists = (p) => includes(parameterNames, p.name); const parameters = this.query.options.parameters; this.query.options.parameters = parameters .filter(parameterExists) - .map(p => (p instanceof Parameter ? p : createParameter(p, this.query.id))); + .map((p) => (p instanceof Parameter ? p : createParameter(p, this.query.id))); } initFromQueryString(query) { - this.get().forEach(param => { + this.get().forEach((param) => { param.fromUrlParams(query); }); } @@ -294,7 +289,7 @@ class Parameters { } add(parameterDef) { - this.query.options.parameters = this.query.options.parameters.filter(p => p.name !== parameterDef.name); + this.query.options.parameters = this.query.options.parameters.filter((p) => p.name !== parameterDef.name); const param = createParameter(parameterDef); this.query.options.parameters.push(param); return param; @@ -302,8 +297,8 @@ class Parameters { getMissing() { return map( - filter(this.get(), p => p.isEmpty), - i => i.title + filter(this.get(), (p) => p.isEmpty), + (i) => i.title ); } @@ -314,17 +309,17 @@ class Parameters { getExecutionValues(extra = {}) { const params = this.get(); return zipObject( - map(params, i => i.name), - map(params, i => i.getExecutionValue(extra)) + map(params, (i) => i.name), + map(params, (i) => i.getExecutionValue(extra)) ); } hasPendingValues() { - return some(this.get(), p => p.hasPendingValue); + return some(this.get(), (p) => p.hasPendingValue); } applyPendingValues() { - each(this.get(), p => p.applyPendingValue()); + each(this.get(), (p) => p.applyPendingValue()); } toUrlParams() { @@ -332,10 +327,10 @@ class Parameters { return ""; } - const params = Object.assign(...this.get().map(p => p.toUrlParams())); - Object.keys(params).forEach(key => params[key] == null && delete params[key]); + const params = Object.assign(...this.get().map((p) => p.toUrlParams())); + Object.keys(params).forEach((key) => params[key] == null && delete params[key]); return Object.keys(params) - .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) + .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) .join("&"); } } @@ -374,26 +369,27 @@ export class QueryResultError { } } -const getQuery = query => new Query(query); -const saveOrCreateUrl = data => (data.id ? `api/queries/${data.id}` : "api/queries"); -const mapResults = data => ({ ...data, results: map(data.results, getQuery) }); +const getQuery = (query) => new Query(query); +const saveOrCreateUrl = (data) => (data.id ? `api/queries/${data.id}` : "api/queries"); +const mapResults = (data) => ({ ...data, results: map(data.results, getQuery) }); const QueryService = { - query: params => axios.get("api/queries", { params }).then(mapResults), - get: data => axios.get(`api/queries/${data.id}`, data).then(getQuery), - save: data => axios.post(saveOrCreateUrl(data), data).then(getQuery), - delete: data => axios.delete(`api/queries/${data.id}`), - recent: params => axios.get(`api/queries/recent`, { params }).then(data => map(data, getQuery)), - archive: params => axios.get(`api/queries/archive`, { params }).then(mapResults), - myQueries: params => axios.get("api/queries/my", { params }).then(mapResults), + query: (params) => axios.get("api/queries", { params }).then(mapResults), + get: (data) => axios.get(`api/queries/${data.id}`, data).then(getQuery), + save: (data) => axios.post(saveOrCreateUrl(data), data).then(getQuery), + delete: (data) => axios.delete(`api/queries/${data.id}`), + recent: (params) => axios.get(`api/queries/recent`, { params }).then((data) => map(data, getQuery)), + unassigned: (params) => axios.get(`api/queries/unassigned`, { params }).then(mapResults), + archive: (params) => axios.get(`api/queries/archive`, { params }).then(mapResults), + myQueries: (params) => axios.get("api/queries/my", { params }).then(mapResults), fork: ({ id }) => axios.post(`api/queries/${id}/fork`, { id }).then(getQuery), - resultById: data => axios.get(`api/queries/${data.id}/results.json`), - asDropdown: data => axios.get(`api/queries/${data.id}/dropdown`), + resultById: (data) => axios.get(`api/queries/${data.id}/results.json`), + asDropdown: (data) => axios.get(`api/queries/${data.id}/dropdown`), associatedDropdown: ({ queryId, dropdownQueryId }) => axios.get(`api/queries/${queryId}/dropdowns/${dropdownQueryId}`), - favorites: params => axios.get("api/queries/favorites", { params }).then(mapResults), - favorite: data => axios.post(`api/queries/${data.id}/favorite`), - unfavorite: data => axios.delete(`api/queries/${data.id}/favorite`), + favorites: (params) => axios.get("api/queries/favorites", { params }).then(mapResults), + favorite: (data) => axios.post(`api/queries/${data.id}/favorite`), + unfavorite: (data) => axios.delete(`api/queries/${data.id}/favorite`), }; QueryService.newQuery = function newQuery() { diff --git a/client/cypress/integration/query/query_list.js b/client/cypress/integration/query/query_list.js new file mode 100644 index 0000000000..536c154985 --- /dev/null +++ b/client/cypress/integration/query/query_list.js @@ -0,0 +1,23 @@ +describe("Query list sort", () => { + beforeEach(() => { + cy.login(); + }); + + describe("Sorting table does not crash page ", () => { + it("sorts", () => { + cy.visit("/queries"); + cy.contains("Name").click(); + cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting + cy.getByTestId("ErrorMessage").should("not.exist"); + }); + }); + + describe("Show only unassigned queries ", () => { + it("sorts", () => { + cy.visit("/queries/unassigned"); + cy.contains("Name").click(); + cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting + cy.getByTestId("ErrorMessage").should("not.exist"); + }); + }); +}); diff --git a/redash/handlers/api.py b/redash/handlers/api.py index 643bee371f..5c33ba8762 100644 --- a/redash/handlers/api.py +++ b/redash/handlers/api.py @@ -68,6 +68,7 @@ QueryResource, QuerySearchResource, QueryTagsResource, + QueryUnassignedResource, ) from redash.handlers.query_results import ( JobResource, @@ -202,6 +203,7 @@ def json_representation(data, code, headers=None): api.add_org_resource(QuerySearchResource, "/api/queries/search", endpoint="queries_search") api.add_org_resource(QueryRecentResource, "/api/queries/recent", endpoint="recent_queries") +api.add_org_resource(QueryUnassignedResource, "/api/queries/unassigned", endpoint="unassigned_queries") api.add_org_resource(QueryArchiveResource, "/api/queries/archive", endpoint="queries_archive") api.add_org_resource(QueryListResource, "/api/queries", endpoint="queries") api.add_org_resource(MyQueriesResource, "/api/queries/my", endpoint="my_queries") diff --git a/redash/handlers/queries.py b/redash/handlers/queries.py index 71ae418da8..f2b01cc5fc 100644 --- a/redash/handlers/queries.py +++ b/redash/handlers/queries.py @@ -245,6 +245,28 @@ def post(self): return QuerySerializer(query, with_visualizations=True).serialize() +class QueryUnassignedResource(BaseQueryListResource): + def get_queries(self, search_term): + if search_term: + return models.Query.search( + search_term, + self.current_user.group_ids, + self.current_user.id, + include_drafts=False, + include_archived=False, + unassigned_only=True, + multi_byte_search=current_org.get_setting("multi_byte_search_enabled"), + ) + else: + return models.Query.all_queries( + self.current_user.group_ids, + self.current_user.id, + include_drafts=False, + include_archived=False, + unassigned_only=True, + ) + + class QueryArchiveResource(BaseQueryListResource): def get_queries(self, search_term): if search_term: diff --git a/redash/models/__init__.py b/redash/models/__init__.py index 38f306bb73..636d818da4 100644 --- a/redash/models/__init__.py +++ b/redash/models/__init__.py @@ -519,7 +519,7 @@ def create(cls, **kwargs): return query @classmethod - def all_queries(cls, group_ids, user_id=None, include_drafts=False, include_archived=False): + def all_queries(cls, group_ids, user_id=None, include_drafts=False, include_archived=False, unassigned_only=False): query_ids = ( db.session.query(distinct(cls.id)) .join(DataSourceGroup, Query.data_source_id == DataSourceGroup.data_source_id) @@ -538,6 +538,17 @@ def all_queries(cls, group_ids, user_id=None, include_drafts=False, include_arch .options(contains_eager(Query.user), contains_eager(Query.latest_query_data)) ) + if unassigned_only: + excluded_query_ids = ( + db.session.query(distinct(cls.id)) + .join(Visualization) + .join(Widget) + .join(Dashboard) + .filter(cls.id.in_(query_ids)) + .filter(Dashboard.is_archived.is_(False)) + ) + queries = queries.filter(~cls.id.in_(excluded_query_ids)) + if not include_drafts: queries = queries.filter(or_(Query.is_draft.is_(False), Query.user_id == user_id)) return queries @@ -653,6 +664,7 @@ def search( include_drafts=False, limit=None, include_archived=False, + unassigned_only=False, multi_byte_search=False, ): all_queries = cls.all_queries( @@ -660,6 +672,7 @@ def search( user_id=user_id, include_drafts=include_drafts, include_archived=include_archived, + unassigned_only=unassigned_only, ) if multi_byte_search: diff --git a/tests/handlers/test_queries.py b/tests/handlers/test_queries.py index 7fbf51128c..f3d41bf8d1 100644 --- a/tests/handlers/test_queries.py +++ b/tests/handlers/test_queries.py @@ -326,6 +326,36 @@ def test_prevents_association_with_non_existing_dropdown_queries(self): self.assertEqual(rv.status_code, 400) +class TestQueryUnassignedResourceGet(BaseTestCase): + def test_returns_queries(self): + q1 = self.factory.create_query() + self.factory.create_query(is_archived=True) + + rv = self.make_request("get", "/api/queries/unassigned") + + assert len(rv.json["results"]) == 1 + assert set([result["id"] for result in rv.json["results"]]) == {q1.id} + + def test_search_term(self): + q1 = self.factory.create_query(name="Sales") + q2 = self.factory.create_query(name="Q1 sales") + self.factory.create_query(name="Q2 sales", is_archived=True) + + rv = self.make_request("get", "/api/queries/unassigned?q=sales") + assert len(rv.json["results"]) == 2 + assert set([result["id"] for result in rv.json["results"]]) == {q1.id, q2.id} + + def test_returns_queries_only_unassigned(self): + q1 = self.factory.create_query() + self.factory.create_widget() + + rv = self.make_request("get", "/api/queries") + assert len(rv.json["results"]) == 2 + rv = self.make_request("get", "/api/queries/unassigned") + assert len(rv.json["results"]) == 1 + assert set([result["id"] for result in rv.json["results"]]) == {q1.id} + + class TestQueryArchiveResourceGet(BaseTestCase): def test_returns_queries(self): q1 = self.factory.create_query(is_archived=True)