Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate PermissionsEditor to React #4266

Merged
merged 12 commits into from
Oct 29, 2019
4 changes: 4 additions & 0 deletions client/app/components/HelpTrigger.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ export const TYPES = {
'/user-guide/querying/favorites-tagging/#Favorites',
'Guide: Favorites',
],
MANAGE_PERMISSIONS: [
'/user-guide/querying/writing-queries#Managing-Query-Permissions',
'Guide: Managing Query Permissions',
],
};

export default class HelpTrigger extends React.Component {
Expand Down
179 changes: 179 additions & 0 deletions client/app/components/permissions-editor/PermissionsEditorDialog.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import React, { useState, useEffect, useCallback } from 'react';
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 'antd/lib/tooltip';
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
import { $http } from '@/services/ng';
import { toHuman } from '@/filters';
import HelpTrigger from '@/components/HelpTrigger';
import { UserPreviewCard } from '@/components/PreviewCard';
import notification from '@/services/notification';
import { User } from '@/services/user';

import './PermissionsEditorDialog.less';

const { Option } = Select;
const DEBOUNCE_SEARCH_DURATION = 200;

function useGrantees(url) {
const loadGrantees = useCallback(() => $http.get(url).then(({ data }) => {
const resultGrantees = [];
each(data, (grantees, accessType) => {
grantees.forEach((grantee) => {
grantee.accessType = toHuman(accessType);
resultGrantees.push(grantee);
});
});
return resultGrantees;
}), [url]);

const addPermission = useCallback((userId, accessType = 'modify') => $http.post(
url, { access_type: accessType, user_id: userId },
).catch(() => notification.error('Could not grant permission to the user'), [url]));

const removePermission = useCallback((userId, accessType = 'modify') => $http.delete(
url, { data: { access_type: accessType, user_id: userId } },
).catch(() => notification.error('Could not remove permission from the user')), [url]);

return { loadGrantees, addPermission, removePermission };
}

const searchUsers = searchTerm => User.query({ q: searchTerm }).$promise
.then(({ results }) => results)
.catch(() => []);

function PermissionsEditorDialogHeader({ context }) {
return (
<>
Manage Permissions
<div className="modal-header-desc">
{`Editing this ${context} is enabled for the users in this list and for admins. `}
<HelpTrigger type="MANAGE_PERMISSIONS" />
</div>
</>
);
}

PermissionsEditorDialogHeader.propTypes = { context: PropTypes.oneOf(['query', 'dashboard']) };
PermissionsEditorDialogHeader.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);
}, [searchTerm]);

return (
<Select
className="w-100 m-b-10"
placeholder="Add users..."
showSearch
onSearch={setSearchTerm}
suffixIcon={loadingUsers ? <i className="fa fa-spinner fa-pulse" /> : <i className="fa fa-search" />}
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 PermissionsEditorDialog({ dialog, author, context, aclUrl }) {
const [loadingGrantees, setLoadingGrantees] = useState(true);
const [grantees, setGrantees] = useState([]);
const { loadGrantees, addPermission, removePermission } = useGrantees(aclUrl);
const loadUsersWithPermissions = useCallback(() => {
setLoadingGrantees(true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swapping the list for a loading indicator on each add and remove is quite jarring.
How about adding a small indicator to the top right of the scrollbox?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was bothering me too, I was waiting for you to say sth about it. I tried a few options like using an indicator in the Search bar, but ended up with more complexity and not satisfactory. Will try that one 👍

loadGrantees()
.then(setGrantees)
.catch(() => notification.error('Failed to load grantees list'))
.finally(() => setLoadingGrantees(false));
}, []);

const userHasPermission = useCallback(
user => (user.id === author.id || !!get(find(grantees, { id: user.id }), 'accessType')),
[grantees],
);

useEffect(() => {
loadUsersWithPermissions();
}, [aclUrl]);

return (
<Modal
{...dialog.props}
className="permissions-editor-dialog"
title={<PermissionsEditorDialogHeader context={context} />}
footer={(<Button onClick={dialog.dismiss}>Close</Button>)}
>
<UserSelect
onSelect={userId => addPermission(userId).then(loadUsersWithPermissions)}
shouldShowUser={user => !userHasPermission(user)}
/>
<div className="d-flex align-items-center m-t-5">
<h5 className="flex-fill">Users with permissions</h5>
{loadingGrantees && <i className="fa fa-spinner fa-pulse" />}
</div>
<div className="scrollbox p-5" style={{ maxHeight: '40vh' }}>
<List
size="small"
dataSource={[author, ...grantees]}
renderItem={user => (
<List.Item>
<UserPreviewCard key={user.id} user={user}>
{user.id === author.id ? (<Tag className="m-0">Author</Tag>) : (
<Tooltip title="Remove user permissions">
<i
className="fa fa-remove clickable"
onClick={() => removePermission(user.id).then(loadUsersWithPermissions)}
/>
</Tooltip>
)}
</UserPreviewCard>
</List.Item>
)}
/>
</div>
</Modal>
);
}

PermissionsEditorDialog.propTypes = {
dialog: DialogPropType.isRequired,
author: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
context: PropTypes.oneOf(['query', 'dashboard']),
aclUrl: PropTypes.string.isRequired,
};

PermissionsEditorDialog.defaultProps = { context: 'query' };

export default wrapDialog(PermissionsEditorDialog);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.permissions-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);
}
}
}
96 changes: 0 additions & 96 deletions client/app/components/permissions-editor/index.js

This file was deleted.

47 changes: 0 additions & 47 deletions client/app/components/permissions-editor/permissions-editor.html

This file was deleted.

12 changes: 6 additions & 6 deletions client/app/pages/dashboards/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import template from './dashboard.html';
import ShareDashboardDialog from './ShareDashboardDialog';
import AddWidgetDialog from '@/components/dashboards/AddWidgetDialog';
import TextboxDialog from '@/components/dashboards/TextboxDialog';
import PermissionsEditorDialog from '@/components/permissions-editor/PermissionsEditorDialog';
import notification from '@/services/notification';

import './dashboard.less';
Expand Down Expand Up @@ -250,12 +251,11 @@ function DashboardCtrl(
};

this.showManagePermissionsModal = () => {
$uibModal.open({
component: 'permissionsEditor',
resolve: {
aclUrl: { url: `api/dashboards/${this.dashboard.id}/acl` },
owner: this.dashboard.user,
},
const aclUrl = `api/dashboards/${this.dashboard.id}/acl`;
PermissionsEditorDialog.showModal({
aclUrl,
context: 'dashboard',
author: this.dashboard.user,
});
};

Expand Down
12 changes: 6 additions & 6 deletions client/app/pages/queries/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ScheduleDialog from '@/components/queries/ScheduleDialog';
import { newVisualization } from '@/visualizations';
import EditVisualizationDialog from '@/visualizations/EditVisualizationDialog';
import EmbedQueryDialog from '@/components/queries/EmbedQueryDialog';
import PermissionsEditorDialog from '@/components/permissions-editor/PermissionsEditorDialog';
import notification from '@/services/notification';
import template from './query.html';

Expand Down Expand Up @@ -528,12 +529,11 @@ function QueryViewCtrl(
);

$scope.showManagePermissionsModal = () => {
$uibModal.open({
component: 'permissionsEditor',
resolve: {
aclUrl: { url: `api/queries/${$routeParams.queryId}/acl` },
owner: $scope.query.user,
},
const aclUrl = `api/queries/${$routeParams.queryId}/acl`;
PermissionsEditorDialog.showModal({
aclUrl,
context: 'query',
author: $scope.query.user,
});
};
}
Expand Down