Skip to content

Commit

Permalink
feature to update owner of query
Browse files Browse the repository at this point in the history
  • Loading branch information
Jared Blumen committed Feb 29, 2024
1 parent 81d22f1 commit fb08731
Show file tree
Hide file tree
Showing 7 changed files with 200 additions and 2 deletions.
147 changes: 147 additions & 0 deletions client/app/components/QueryOwnerEditorDialog/index.jsx
Original file line number Diff line number Diff line change
@@ -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
<div className="modal-header-desc">
{`Updating the ${context} owner is enabled for the author of the query and for admins. `}
</div>
</>
);
}

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 (
<Select
className="w-100 m-b-10"
placeholder="Select new owner..."
showSearch
onSearch={setSearchTerm}
suffixIcon={
loadingUsers ? (
<span role="status" aria-live="polite" aria-relevant="additions removals">
<i className="fa fa-spinner fa-pulse" aria-hidden="true" />
<span className="sr-only">Loading...</span>
</span>
) : (
<i className="fa fa-search" aria-hidden="true" />
)
}
filterOption={false}
notFoundContent={null}
value={undefined}
getPopupContainer={trigger => trigger.parentNode}
onSelect={onSelect}>
{users.filter(shouldShowUser).map(user => (
<Option key={user.id} value={user.id}>
<UserPreviewCard user={user} />
</Option>
))}
</Select>
);
}

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 (
<Modal
{...dialog.props}
className="query-owner-editor-dialog"
title={<OwnerEditorDialogHeader context={context} />}
onOk={() => dialog.close(owner)}>
<UserSelect
onSelect={userId => loadOwner(userId)}
shouldShowUser={user => !userIsOwner(user)}
/>
<div className="d-flex align-items-center m-t-5">
<h5 className="flex-fill">Query Owner</h5>
</div>
<UserPreviewCard key={owner.id} user={owner}>
{owner.id === author.id ? (
<Tag className="m-0">Current Owner</Tag>
) : ( <Tag className="m-0">New Owner</Tag> )}
</UserPreviewCard>
</Modal>
);
}

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);
8 changes: 8 additions & 0 deletions client/app/components/QueryOwnerEditorDialog/index.less
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
2 changes: 1 addition & 1 deletion client/app/pages/queries-list/QueriesList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions client/app/pages/queries/components/QueryPageHeader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
() =>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -143,6 +151,7 @@ export default function QueryPageHeader({
publishQuery,
unpublishQuery,
openApiKeyDialog,
openQueryOwnerEditorDialog,
]
);

Expand Down
19 changes: 19 additions & 0 deletions client/app/pages/queries/hooks/useQueryOwnerEditorDialog.js
Original file line number Diff line number Diff line change
@@ -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]);
}
1 change: 1 addition & 0 deletions client/app/pages/queries/hooks/useUpdateQuery.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export default function useUpdateQuery(query, onChange) {
"latest_query_data_id",
"is_draft",
"tags",
"user"
]);
}

Expand Down
16 changes: 15 additions & 1 deletion redash/handlers/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -335,7 +336,6 @@ def post(self, query_id):
"api_key",
"visualizations",
"latest_query_data",
"user",
"last_modified_by",
"org",
]:
Expand All @@ -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"])

Check warning on line 356 in redash/handlers/queries.py

View check run for this annotation

Codecov / codecov/patch

redash/handlers/queries.py#L355-L356

Added lines #L355 - L356 were not covered by tests
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)

Check warning on line 359 in redash/handlers/queries.py

View check run for this annotation

Codecov / codecov/patch

redash/handlers/queries.py#L358-L359

Added lines #L358 - L359 were not covered by tests
else:
data_source = query.data_source
require_access(data_source, new_user, not_view_only)
query_def["user"] = new_user

Check warning on line 363 in redash/handlers/queries.py

View check run for this annotation

Codecov / codecov/patch

redash/handlers/queries.py#L361-L363

Added lines #L361 - L363 were not covered by tests

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
Expand All @@ -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)

Check warning on line 377 in redash/handlers/queries.py

View check run for this annotation

Codecov / codecov/patch

redash/handlers/queries.py#L376-L377

Added lines #L376 - L377 were not covered by tests
models.db.session.commit()
except StaleDataError:
abort(409)
Expand Down

0 comments on commit fb08731

Please sign in to comment.