Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions client/app/pages/queries-list/QueriesList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ const sidebarMenu = [
title: "All Queries",
icon: () => <Sidebar.MenuIcon icon="fa fa-code" />,
},
{
key: "unassigned",
href: "queries/unassigned",
title: "Unassigned Queries",
icon: () => <Sidebar.MenuIcon icon="fa fa-code" />,
},
{
key: "my",
href: "queries/my",
Expand Down Expand Up @@ -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() {
Expand All @@ -219,6 +226,14 @@ routes.register(
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="favorites" />,
})
);
routes.register(
"Queries.Unassigned",
routeWithUserSession({
path: "/queries/unassigned",
title: "Unassigned Queries",
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="unassigned" />,
})
);
routes.register(
"Queries.Archived",
routeWithUserSession({
Expand Down
82 changes: 39 additions & 43 deletions client/app/services/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] === "#") {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 !== "") {
Expand Down Expand Up @@ -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) {
Expand All @@ -242,26 +237,26 @@ 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)
);
}
return;
}

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({
Expand All @@ -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);
});
}
Expand All @@ -294,16 +289,16 @@ 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;
}

getMissing() {
return map(
filter(this.get(), p => p.isEmpty),
i => i.title
filter(this.get(), (p) => p.isEmpty),
(i) => i.title
);
}

Expand All @@ -314,28 +309,28 @@ 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() {
if (this.get().length === 0) {
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("&");
}
}
Expand Down Expand Up @@ -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() {
Expand Down
23 changes: 23 additions & 0 deletions client/cypress/integration/query/query_list.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
2 changes: 2 additions & 0 deletions redash/handlers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
QueryResource,
QuerySearchResource,
QueryTagsResource,
QueryUnassignedResource,
)
from redash.handlers.query_results import (
JobResource,
Expand Down Expand Up @@ -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")
Expand Down
22 changes: 22 additions & 0 deletions redash/handlers/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 14 additions & 1 deletion redash/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -653,13 +664,15 @@ def search(
include_drafts=False,
limit=None,
include_archived=False,
unassigned_only=False,
multi_byte_search=False,
):
all_queries = cls.all_queries(
group_ids,
user_id=user_id,
include_drafts=include_drafts,
include_archived=include_archived,
unassigned_only=unassigned_only,
)

if multi_byte_search:
Expand Down
30 changes: 30 additions & 0 deletions tests/handlers/test_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading