diff --git a/client/app/components/QueryOwnerEditorDialog/index.jsx b/client/app/components/QueryOwnerEditorDialog/index.jsx
new file mode 100644
index 0000000000..ca7d970d0c
--- /dev/null
+++ b/client/app/components/QueryOwnerEditorDialog/index.jsx
@@ -0,0 +1,147 @@
+import React, { useState, useEffect, useCallback } from "react";
+import { axios } from "@/services/axios";
+import PropTypes from "prop-types";
+import { each, debounce, get, find } from "lodash";
+import Button from "antd/lib/button";
+import List from "antd/lib/list";
+import Modal from "antd/lib/modal";
+import Select from "antd/lib/select";
+import Tag from "antd/lib/tag";
+import Tooltip from "@/components/Tooltip";
+import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
+import { toHuman } from "@/lib/utils";
+import HelpTrigger from "@/components/HelpTrigger";
+import { UserPreviewCard } from "@/components/PreviewCard";
+import PlainButton from "@/components/PlainButton";
+import notification from "@/services/notification";
+import User from "@/services/user";
+
+import "./index.less";
+
+const { Option } = Select;
+const DEBOUNCE_SEARCH_DURATION = 200;
+
+const searchUsers = searchTerm =>
+ User.query({ q: searchTerm })
+ .then(({ results }) => results)
+ .catch(() => []);
+
+function OwnerEditorDialogHeader({ context }) {
+ return (
+ <>
+ Update Query Owner
+
+ {`Updating the ${context} owner is enabled for the author of the query and for admins. `}
+
+ >
+ );
+}
+
+OwnerEditorDialogHeader.propTypes = { context: PropTypes.oneOf(["query"]) };
+OwnerEditorDialogHeader.defaultProps = { context: "query" };
+
+function UserSelect({ onSelect, shouldShowUser }) {
+ const [loadingUsers, setLoadingUsers] = useState(true);
+ const [users, setUsers] = useState([]);
+ const [searchTerm, setSearchTerm] = useState("");
+
+ const debouncedSearchUsers = useCallback(
+ debounce(
+ search =>
+ searchUsers(search)
+ .then(setUsers)
+ .finally(() => setLoadingUsers(false)),
+ DEBOUNCE_SEARCH_DURATION
+ ),
+ []
+ );
+
+ useEffect(() => {
+ setLoadingUsers(true);
+ debouncedSearchUsers(searchTerm);
+ }, [debouncedSearchUsers, searchTerm]);
+
+ return (
+
+ );
+}
+
+UserSelect.propTypes = {
+ onSelect: PropTypes.func,
+ shouldShowUser: PropTypes.func,
+};
+UserSelect.defaultProps = { onSelect: () => {}, shouldShowUser: () => true };
+
+function OwnerEditorDialog({ dialog, author, context }) {
+
+ const [owner, setOwner] = useState(author);
+
+ const loadOwner = useCallback((userId) => {
+ User.get({ id: userId }).then(user => setOwner(user));
+ }, []);
+
+ const userIsOwner = useCallback(
+ user => user.id === author.id,
+ [author.id]
+ );
+
+ // useEffect(() => {
+ // loadOwner(author);
+ // }, [author, loadOwner]);
+
+ return (
+ }
+ onOk={() => dialog.close(owner)}>
+ loadOwner(userId)}
+ shouldShowUser={user => !userIsOwner(user)}
+ />
+
+
Query Owner
+
+
+ {owner.id === author.id ? (
+ Current Owner
+ ) : ( New Owner )}
+
+
+ );
+}
+
+OwnerEditorDialog.propTypes = {
+ dialog: DialogPropType.isRequired,
+ author: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+ context: PropTypes.oneOf(["query"]),
+};
+
+OwnerEditorDialog.defaultProps = { context: "query" };
+
+export default wrapDialog(OwnerEditorDialog);
\ No newline at end of file
diff --git a/client/app/components/QueryOwnerEditorDialog/index.less b/client/app/components/QueryOwnerEditorDialog/index.less
new file mode 100644
index 0000000000..576236a385
--- /dev/null
+++ b/client/app/components/QueryOwnerEditorDialog/index.less
@@ -0,0 +1,8 @@
+.query-owner-editor-dialog {
+ .ant-select-dropdown-menu-item-disabled {
+ // make sure .text-muted has the disabled color
+ &, .text-muted {
+ color: rgba(0, 0, 0, 0.25);
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/app/pages/queries-list/QueriesList.jsx b/client/app/pages/queries-list/QueriesList.jsx
index f49358d457..83fc119088 100644
--- a/client/app/pages/queries-list/QueriesList.jsx
+++ b/client/app/pages/queries-list/QueriesList.jsx
@@ -72,7 +72,7 @@ const listColumns = [
width: null,
}
),
- Columns.custom((text, item) => item.user.name, { title: "Created By", width: "1%" }),
+ Columns.custom((text, item) => item.user.name, { title: "Owner", width: "1%" }),
Columns.dateTime.sortable({ title: "Created At", field: "created_at", width: "1%" }),
Columns.dateTime.sortable({
title: "Last Executed At",
diff --git a/client/app/pages/queries/components/QueryPageHeader.jsx b/client/app/pages/queries/components/QueryPageHeader.jsx
index b788409e08..7b963b73c1 100644
--- a/client/app/pages/queries/components/QueryPageHeader.jsx
+++ b/client/app/pages/queries/components/QueryPageHeader.jsx
@@ -21,6 +21,7 @@ import useRenameQuery from "../hooks/useRenameQuery";
import useDuplicateQuery from "../hooks/useDuplicateQuery";
import useApiKeyDialog from "../hooks/useApiKeyDialog";
import usePermissionsEditorDialog from "../hooks/usePermissionsEditorDialog";
+import useQueryOwnerEditorDialog from "../hooks/useQueryOwnerEditorDialog";
import "./QueryPageHeader.less";
@@ -81,6 +82,7 @@ export default function QueryPageHeader({
const [isDuplicating, duplicateQuery] = useDuplicateQuery(query);
const openApiKeyDialog = useApiKeyDialog(query, onChange);
const openPermissionsEditorDialog = usePermissionsEditorDialog(query);
+ const openQueryOwnerEditorDialog = useQueryOwnerEditorDialog(query, onChange);
const moreActionsMenu = useMemo(
() =>
@@ -109,6 +111,12 @@ export default function QueryPageHeader({
title: "Manage Permissions",
onClick: openPermissionsEditorDialog,
},
+ updateQueryOwner: {
+ isAvailable:
+ !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isArchived && clientConfig.showPermissionsControl,
+ title: "Update Query Owner",
+ onClick: openQueryOwnerEditorDialog,
+ },
publish: {
isAvailable:
!isDesktop && queryFlags.isDraft && !queryFlags.isArchived && !queryFlags.isNew && queryFlags.canEdit,
@@ -143,6 +151,7 @@ export default function QueryPageHeader({
publishQuery,
unpublishQuery,
openApiKeyDialog,
+ openQueryOwnerEditorDialog,
]
);
diff --git a/client/app/pages/queries/hooks/useQueryOwnerEditorDialog.js b/client/app/pages/queries/hooks/useQueryOwnerEditorDialog.js
new file mode 100644
index 0000000000..f1771f1866
--- /dev/null
+++ b/client/app/pages/queries/hooks/useQueryOwnerEditorDialog.js
@@ -0,0 +1,19 @@
+import { useCallback } from "react";
+import QueryOwnerEditorDialog from "@/components/QueryOwnerEditorDialog";
+import useUpdateQuery from "./useUpdateQuery";
+import recordEvent from "@/services/recordEvent";
+
+export default function useQueryOwnerEditorDialog(query, onChange) {
+
+ const updateQuery = useUpdateQuery(query, onChange);
+
+ return useCallback(() => {
+ QueryOwnerEditorDialog.showModal({
+ context: "query",
+ author: query.user,
+ }).onClose(user => {
+ recordEvent("edit_query_owner", "query", query.id);
+ updateQuery({ user: user });
+ });
+ }, [query.id, query.user, updateQuery]);
+}
\ No newline at end of file
diff --git a/client/app/pages/queries/hooks/useUpdateQuery.jsx b/client/app/pages/queries/hooks/useUpdateQuery.jsx
index 8c70f0564e..26eb9ff4d8 100644
--- a/client/app/pages/queries/hooks/useUpdateQuery.jsx
+++ b/client/app/pages/queries/hooks/useUpdateQuery.jsx
@@ -96,6 +96,7 @@ export default function useUpdateQuery(query, onChange) {
"latest_query_data_id",
"is_draft",
"tags",
+ "user"
]);
}
diff --git a/redash/handlers/queries.py b/redash/handlers/queries.py
index 71ae418da8..b277c2cbc7 100644
--- a/redash/handlers/queries.py
+++ b/redash/handlers/queries.py
@@ -325,6 +325,7 @@ def post(self, query_id):
"""
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
query_def = request.get_json(force=True)
+ previous_query_user = query.user
require_object_modify_permission(query, self.current_user)
require_access_to_dropdown_queries(self.current_user, query_def)
@@ -335,7 +336,6 @@ def post(self, query_id):
"api_key",
"visualizations",
"latest_query_data",
- "user",
"last_modified_by",
"org",
]:
@@ -351,6 +351,17 @@ def post(self, query_id):
data_source = models.DataSource.get_by_id_and_org(query_def["data_source_id"], self.current_org)
require_access(data_source, self.current_user, not_view_only)
+ if "user" in query_def:
+ require_admin_or_owner(self.current_user.id)
+ new_user = models.User.get_by_id(query_def["user"]["id"])
+ if "data_source_id" in query_def:
+ data_source = models.DataSource.get_by_id_and_org(query_def["data_source_id"], self.current_org)
+ require_access(data_source, new_user, not_view_only)
+ else:
+ data_source = query.data_source
+ require_access(data_source, new_user, not_view_only)
+ query_def["user"] = new_user
+
query_def["last_modified_by"] = self.current_user
query_def["changed_by"] = self.current_user
# SQLAlchemy handles the case where a concurrent transaction beats us
@@ -361,6 +372,9 @@ def post(self, query_id):
try:
self.update_model(query, query_def)
+ if "user" in query_def and query_def["user"] != previous_query_user:
+ models.AccessPermission.revoke(query, query_def["user"], "modify")
+ models.AccessPermission.grant(query, "modify", previous_query_user, self.current_user)
models.db.session.commit()
except StaleDataError:
abort(409)