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)