Forms migration: Org users page (#23372)

* Migrate UsersActionBar

* Invites table

* Migrate Users page

* Select version of OrgPicker

* OrgRolePicker to use Select only

* Fix modal issue

* Move legacy Switch

* Move from Forms folder

* Fix failing test

* Merge and fix issues

* Update OrgRole issues

* OrgUser type

* Remove unused import

* Update Snapshot
This commit is contained in:
Tobias Skarhed 2020-04-15 16:49:20 +02:00 committed by GitHub
parent 1864807b15
commit cff70b6648
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 222 additions and 382 deletions

View File

@ -23,18 +23,20 @@ const getKnobs = () => {
'primary'
),
disabled: boolean('Disabled', false),
closeOnConfirm: boolean('Close on confirm', true),
};
};
storiesOf('General/ConfirmButton', module)
.addDecorator(withCenteredStory)
.add('default', () => {
const { size, buttonText, confirmText, confirmVariant, disabled } = getKnobs();
const { size, buttonText, confirmText, confirmVariant, disabled, closeOnConfirm } = getKnobs();
return (
<>
<div className="gf-form-group">
<div className="gf-form">
<ConfirmButton
closeOnConfirm={closeOnConfirm}
size={size}
confirmText={confirmText}
disabled={disabled}
@ -51,12 +53,13 @@ storiesOf('General/ConfirmButton', module)
);
})
.add('with custom button', () => {
const { buttonText, confirmText, confirmVariant, disabled, size } = getKnobs();
const { buttonText, confirmText, confirmVariant, disabled, size, closeOnConfirm } = getKnobs();
return (
<>
<div className="gf-form-group">
<div className="gf-form">
<ConfirmButton
closeOnConfirm={closeOnConfirm}
size={size}
confirmText={confirmText}
disabled={disabled}

View File

@ -58,6 +58,7 @@ interface Props extends Themeable {
confirmText?: string;
disabled?: boolean;
confirmVariant?: ButtonVariant;
closeOnConfirm?: boolean;
onConfirm(): void;
onClick?(): void;
@ -105,6 +106,14 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
this.props.onCancel();
}
};
onConfirm = (event: SyntheticEvent) => {
this.props.onConfirm();
if (this.props.closeOnConfirm) {
this.setState({
showConfirm: false,
});
}
};
render() {
const {
@ -114,7 +123,6 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
disabled,
confirmText,
confirmVariant: confirmButtonVariant,
onConfirm,
children,
} = this.props;
const styles = getStyles(theme);
@ -147,7 +155,7 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
<Button size={size} variant="link" onClick={this.onClickCancel}>
Cancel
</Button>
<Button size={size} variant={confirmButtonVariant} onClick={onConfirm}>
<Button size={size} variant={confirmButtonVariant} onClick={this.onConfirm}>
{confirmText}
</Button>
</span>

View File

@ -143,6 +143,7 @@ export { HorizontalGroup, VerticalGroup, Container } from './Layout/Layout';
export { RadioButtonGroup } from './Forms/RadioButtonGroup/RadioButtonGroup';
export { Input } from './Input/Input';
export { FormInputSize } from './Forms/types';
export { Switch } from './Switch/Switch';
export { Checkbox } from './Forms/Checkbox';

View File

@ -1,14 +1,21 @@
import React, { FC } from 'react';
import { OrgRole } from '@grafana/data';
import { RadioButtonGroup } from '@grafana/ui';
import { Select, FormInputSize } from '@grafana/ui';
interface Props {
value: OrgRole;
size?: FormInputSize;
onChange: (role: OrgRole) => void;
}
const options = Object.keys(OrgRole).map(key => ({ label: key, value: key }));
export const OrgRolePicker: FC<Props> = ({ value, onChange }) => (
<RadioButtonGroup options={options} onChange={onChange} value={value} />
export const OrgRolePicker: FC<Props> = ({ value, onChange, size }) => (
<Select
size={size}
value={value}
options={options}
onChange={val => onChange(val.value as OrgRole)}
placeholder="Choose role..."
/>
);

View File

@ -1,8 +1,8 @@
import React, { createRef, PureComponent } from 'react';
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { Invitee } from 'app/types';
import { revokeInvite } from './state/actions';
import { Icon } from '@grafana/ui';
import { Button, ClipboardButton } from '@grafana/ui';
export interface Props {
invitee: Invitee;
@ -10,17 +10,6 @@ export interface Props {
}
class InviteeRow extends PureComponent<Props> {
private copyUrlRef = createRef<HTMLTextAreaElement>();
copyToClipboard = () => {
const node = this.copyUrlRef.current;
if (node) {
node.select();
document.execCommand('copy');
}
};
render() {
const { invitee, revokeInvite } = this.props;
return (
@ -28,21 +17,13 @@ class InviteeRow extends PureComponent<Props> {
<td>{invitee.email}</td>
<td>{invitee.name}</td>
<td className="text-right">
<button className="btn btn-inverse btn-small" onClick={this.copyToClipboard}>
<textarea
readOnly={true}
value={invitee.url}
style={{ position: 'absolute', bottom: 0, right: 0, opacity: 0, zIndex: -10 }}
ref={this.copyUrlRef}
/>
<ClipboardButton variant="secondary" size="sm" getText={() => invitee.url}>
Copy Invite
</button>
</ClipboardButton>
&nbsp;
</td>
<td>
<button className="btn btn-danger btn-small" onClick={() => revokeInvite(invitee.code)}>
<Icon name="times" style={{ marginBottom: 0 }} />
</button>
<Button variant="destructive" size="sm" icon="times" onClick={() => revokeInvite(invitee.code)} />
</td>
</tr>
);

View File

@ -1,9 +1,9 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { setUsersSearchQuery } from './state/reducers';
import { getInviteesCount, getUsersSearchQuery } from './state/selectors';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { RadioButtonGroup, LinkButton } from '@grafana/ui';
export interface Props {
searchQuery: string;
@ -28,18 +28,10 @@ export class UsersActionBar extends PureComponent<Props> {
onShowInvites,
showInvites,
} = this.props;
const pendingInvitesButtonStyle = classNames({
btn: true,
'toggle-btn': true,
active: showInvites,
});
const usersButtonStyle = classNames({
btn: true,
'toggle-btn': true,
active: !showInvites,
});
const options = [
{ label: 'Users', value: 'users' },
{ label: `Pending Invites (${pendingInvitesCount})`, value: 'invites' },
];
return (
<div className="page-action-bar">
@ -53,24 +45,15 @@ export class UsersActionBar extends PureComponent<Props> {
/>
{pendingInvitesCount > 0 && (
<div style={{ marginLeft: '1rem' }}>
<button className={usersButtonStyle} key="users" onClick={onShowInvites}>
Users
</button>
<button className={pendingInvitesButtonStyle} onClick={onShowInvites} key="pending-invites">
Pending Invites ({pendingInvitesCount})
</button>
<RadioButtonGroup value={showInvites ? 'invites' : 'users'} options={options} onChange={onShowInvites} />
</div>
)}
<div className="page-action-bar__spacer" />
{canInvite && (
<a className="btn btn-primary" href="org/users/invite">
<span>Invite</span>
</a>
)}
{canInvite && <LinkButton href="org/users/invite">Invite</LinkButton>}
{externalUserMngLinkUrl && (
<a className="btn btn-primary" href={externalUserMngLinkUrl} target="_blank" rel="noopener">
<LinkButton href={externalUserMngLinkUrl} target="_blank" rel="noopener">
{externalUserMngLinkName}
</a>
</LinkButton>
)}
</div>
</div>

View File

@ -2,8 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import { Props, UsersListPage } from './UsersListPage';
import { Invitee, OrgUser } from 'app/types';
import { getMockUser } from './__mocks__/userMocks';
import appEvents from '../../core/app_events';
// import { getMockUser } from './__mocks__/userMocks';
import { NavModel } from '@grafana/data';
import { mockToolkitActionCreator } from 'test/core/redux/mocks';
import { setUsersSearchQuery } from './state/reducers';
@ -60,14 +59,3 @@ describe('Render', () => {
expect(wrapper).toMatchSnapshot();
});
});
describe('Functions', () => {
it('should emit show remove user modal', () => {
const { instance } = setup();
const mockUser = getMockUser();
instance.onRemoveUser(mockUser);
expect(appEvents.emit).toHaveBeenCalled();
});
});

View File

@ -7,8 +7,7 @@ import Page from 'app/core/components/Page/Page';
import UsersActionBar from './UsersActionBar';
import UsersTable from './UsersTable';
import InviteesTable from './InviteesTable';
import { CoreEvents, Invitee, OrgUser } from 'app/types';
import appEvents from 'app/core/app_events';
import { Invitee, OrgUser, OrgRole } from 'app/types';
import { loadInvitees, loadUsers, removeUser, updateUser } from './state/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { getInvitees, getUsers, getUsersSearchQuery } from './state/selectors';
@ -60,24 +59,12 @@ export class UsersListPage extends PureComponent<Props, State> {
return await this.props.loadInvitees();
}
onRoleChange = (role: string, user: OrgUser) => {
onRoleChange = (role: OrgRole, user: OrgUser) => {
const updatedUser = { ...user, role: role };
this.props.updateUser(updatedUser);
};
onRemoveUser = (user: OrgUser) => {
appEvents.emit(CoreEvents.showConfirmModal, {
title: 'Delete',
text: 'Are you sure you want to delete user ' + user.login + '?',
yesText: 'Delete',
icon: 'exclamation-triangle',
onConfirm: () => {
this.props.removeUser(user.userId);
},
});
};
onShowInvites = () => {
this.setState(prevState => ({
showInvites: !prevState.showInvites,
@ -94,7 +81,7 @@ export class UsersListPage extends PureComponent<Props, State> {
<UsersTable
users={users}
onRoleChange={(role, user) => this.onRoleChange(role, user)}
onRemoveUser={user => this.onRemoveUser(user)}
onRemoveUser={user => this.props.removeUser(user.userId)}
/>
);
}

View File

@ -3,6 +3,7 @@ import { shallow } from 'enzyme';
import UsersTable, { Props } from './UsersTable';
import { OrgUser } from 'app/types';
import { getMockUsers } from './__mocks__/userMocks';
import { ConfirmModal } from '@grafana/ui';
const setup = (propOverrides?: object) => {
const props: Props = {
@ -31,3 +32,12 @@ describe('Render', () => {
expect(wrapper).toMatchSnapshot();
});
});
describe('Remove modal', () => {
it('should render correct amount', () => {
const wrapper = setup({
users: getMockUsers(3),
});
expect(wrapper.find(ConfirmModal).length).toEqual(4);
});
});

View File

@ -1,16 +1,19 @@
import React, { FC } from 'react';
import React, { FC, useState } from 'react';
import { OrgUser } from 'app/types';
import { Icon } from '@grafana/ui';
import { OrgRolePicker } from '../admin/OrgRolePicker';
import { Button, ConfirmModal } from '@grafana/ui';
import { OrgRole } from '@grafana/data';
export interface Props {
users: OrgUser[];
onRoleChange: (role: string, user: OrgUser) => void;
onRoleChange: (role: OrgRole, user: OrgUser) => void;
onRemoveUser: (user: OrgUser) => void;
}
const UsersTable: FC<Props> = props => {
const { users, onRoleChange, onRemoveUser } = props;
const [showRemoveModal, setShowRemoveModal] = useState<string | boolean>(false);
return (
<table className="filter-table form-inline">
<thead>
@ -32,32 +35,29 @@ const UsersTable: FC<Props> = props => {
<img className="filter-table__avatar" src={user.avatarUrl} />
</td>
<td>{user.login}</td>
<td>
<span className="ellipsis">{user.email}</span>
</td>
<td>{user.name}</td>
<td>{user.lastSeenAtAge}</td>
<td>
<div className="gf-form-select-wrapper width-12">
<select
value={user.role}
className="gf-form-input"
onChange={event => onRoleChange(event.target.value, user)}
>
{['Viewer', 'Editor', 'Admin'].map((option, index) => {
return (
<option value={option} key={`${option}-${index}`}>
{option}
</option>
);
})}
</select>
</div>
<td className="width-8">
<OrgRolePicker value={user.role} onChange={newRole => onRoleChange(newRole, user)} />
</td>
<td>
<div onClick={() => onRemoveUser(user)} className="btn btn-danger btn-small">
<Icon name="times" style={{ marginBottom: 0 }} />
</div>
<Button size="sm" variant="destructive" onClick={() => setShowRemoveModal(user.login)} icon="times" />
<ConfirmModal
body={`Are you sure you want to delete user ${user.login}?`}
confirmText="Delete"
title="Delete"
onDismiss={() => setShowRemoveModal(false)}
isOpen={user.login === showRemoveModal}
onConfirm={() => {
onRemoveUser(user);
}}
/>
</td>
</tr>
);

View File

@ -1,3 +1,5 @@
import { OrgRole, OrgUser } from 'app/types';
export const getMockUsers = (amount: number) => {
const users = [];
@ -15,7 +17,7 @@ export const getMockUsers = (amount: number) => {
});
}
return users;
return users as OrgUser[];
};
export const getMockUser = () => {
@ -27,9 +29,9 @@ export const getMockUser = () => {
lastSeenAtAge: '',
login: `user`,
orgId: 1,
role: 'Admin',
role: 'Admin' as OrgRole,
userId: 2,
};
} as OrgUser;
};
export const getMockInvitees = (amount: number) => {

View File

@ -42,22 +42,22 @@ exports[`Render should render pending invites button 1`] = `
}
}
>
<button
className="btn toggle-btn active"
key="users"
onClick={[MockFunction]}
>
Users
</button>
<button
className="btn toggle-btn"
key="pending-invites"
onClick={[MockFunction]}
>
Pending Invites (
5
)
</button>
<RadioButtonGroup
onChange={[MockFunction]}
options={
Array [
Object {
"label": "Users",
"value": "users",
},
Object {
"label": "Pending Invites (5)",
"value": "invites",
},
]
}
value="users"
/>
</div>
<div
className="page-action-bar__spacer"
@ -83,8 +83,7 @@ exports[`Render should show external user management button 1`] = `
<div
className="page-action-bar__spacer"
/>
<a
className="btn btn-primary"
<LinkButton
href="some/url"
rel="noopener"
target="_blank"
@ -110,14 +109,11 @@ exports[`Render should show invite button 1`] = `
<div
className="page-action-bar__spacer"
/>
<a
className="btn btn-primary"
<LinkButton
href="org/users/invite"
>
<span>
Invite
</span>
</a>
Invite
</LinkButton>
</div>
</div>
`;

View File

@ -92,50 +92,29 @@ exports[`Render should render users table 1`] = `
user-0 test
</td>
<td />
<td>
<div
className="gf-form-select-wrapper width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
value="Admin"
>
<option
key="Viewer-0"
value="Viewer"
>
Viewer
</option>
<option
key="Editor-1"
value="Editor"
>
Editor
</option>
<option
key="Admin-2"
value="Admin"
>
Admin
</option>
</select>
</div>
<td
className="width-8"
>
<Component
onChange={[Function]}
value="Admin"
/>
</td>
<td>
<div
className="btn btn-danger btn-small"
<Button
icon="times"
onClick={[Function]}
>
<Icon
name="times"
style={
Object {
"marginBottom": 0,
}
}
/>
</div>
size="sm"
variant="destructive"
/>
<Component
body="Are you sure you want to delete user user-0?"
confirmText="Delete"
isOpen={false}
onConfirm={[Function]}
onDismiss={[Function]}
title="Delete"
/>
</td>
</tr>
<tr
@ -163,50 +142,29 @@ exports[`Render should render users table 1`] = `
user-1 test
</td>
<td />
<td>
<div
className="gf-form-select-wrapper width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
value="Admin"
>
<option
key="Viewer-0"
value="Viewer"
>
Viewer
</option>
<option
key="Editor-1"
value="Editor"
>
Editor
</option>
<option
key="Admin-2"
value="Admin"
>
Admin
</option>
</select>
</div>
<td
className="width-8"
>
<Component
onChange={[Function]}
value="Admin"
/>
</td>
<td>
<div
className="btn btn-danger btn-small"
<Button
icon="times"
onClick={[Function]}
>
<Icon
name="times"
style={
Object {
"marginBottom": 0,
}
}
/>
</div>
size="sm"
variant="destructive"
/>
<Component
body="Are you sure you want to delete user user-1?"
confirmText="Delete"
isOpen={false}
onConfirm={[Function]}
onDismiss={[Function]}
title="Delete"
/>
</td>
</tr>
<tr
@ -234,50 +192,29 @@ exports[`Render should render users table 1`] = `
user-2 test
</td>
<td />
<td>
<div
className="gf-form-select-wrapper width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
value="Admin"
>
<option
key="Viewer-0"
value="Viewer"
>
Viewer
</option>
<option
key="Editor-1"
value="Editor"
>
Editor
</option>
<option
key="Admin-2"
value="Admin"
>
Admin
</option>
</select>
</div>
<td
className="width-8"
>
<Component
onChange={[Function]}
value="Admin"
/>
</td>
<td>
<div
className="btn btn-danger btn-small"
<Button
icon="times"
onClick={[Function]}
>
<Icon
name="times"
style={
Object {
"marginBottom": 0,
}
}
/>
</div>
size="sm"
variant="destructive"
/>
<Component
body="Are you sure you want to delete user user-2?"
confirmText="Delete"
isOpen={false}
onConfirm={[Function]}
onDismiss={[Function]}
title="Delete"
/>
</td>
</tr>
<tr
@ -305,50 +242,29 @@ exports[`Render should render users table 1`] = `
user-3 test
</td>
<td />
<td>
<div
className="gf-form-select-wrapper width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
value="Admin"
>
<option
key="Viewer-0"
value="Viewer"
>
Viewer
</option>
<option
key="Editor-1"
value="Editor"
>
Editor
</option>
<option
key="Admin-2"
value="Admin"
>
Admin
</option>
</select>
</div>
<td
className="width-8"
>
<Component
onChange={[Function]}
value="Admin"
/>
</td>
<td>
<div
className="btn btn-danger btn-small"
<Button
icon="times"
onClick={[Function]}
>
<Icon
name="times"
style={
Object {
"marginBottom": 0,
}
}
/>
</div>
size="sm"
variant="destructive"
/>
<Component
body="Are you sure you want to delete user user-3?"
confirmText="Delete"
isOpen={false}
onConfirm={[Function]}
onDismiss={[Function]}
title="Delete"
/>
</td>
</tr>
<tr
@ -376,50 +292,29 @@ exports[`Render should render users table 1`] = `
user-4 test
</td>
<td />
<td>
<div
className="gf-form-select-wrapper width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
value="Admin"
>
<option
key="Viewer-0"
value="Viewer"
>
Viewer
</option>
<option
key="Editor-1"
value="Editor"
>
Editor
</option>
<option
key="Admin-2"
value="Admin"
>
Admin
</option>
</select>
</div>
<td
className="width-8"
>
<Component
onChange={[Function]}
value="Admin"
/>
</td>
<td>
<div
className="btn btn-danger btn-small"
<Button
icon="times"
onClick={[Function]}
>
<Icon
name="times"
style={
Object {
"marginBottom": 0,
}
}
/>
</div>
size="sm"
variant="destructive"
/>
<Component
body="Are you sure you want to delete user user-4?"
confirmText="Delete"
isOpen={false}
onConfirm={[Function]}
onDismiss={[Function]}
title="Delete"
/>
</td>
</tr>
<tr
@ -447,50 +342,29 @@ exports[`Render should render users table 1`] = `
user-5 test
</td>
<td />
<td>
<div
className="gf-form-select-wrapper width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
value="Admin"
>
<option
key="Viewer-0"
value="Viewer"
>
Viewer
</option>
<option
key="Editor-1"
value="Editor"
>
Editor
</option>
<option
key="Admin-2"
value="Admin"
>
Admin
</option>
</select>
</div>
<td
className="width-8"
>
<Component
onChange={[Function]}
value="Admin"
/>
</td>
<td>
<div
className="btn btn-danger btn-small"
<Button
icon="times"
onClick={[Function]}
>
<Icon
name="times"
style={
Object {
"marginBottom": 0,
}
}
/>
</div>
size="sm"
variant="destructive"
/>
<Component
body="Are you sure you want to delete user user-5?"
confirmText="Delete"
isOpen={false}
onConfirm={[Function]}
onDismiss={[Function]}
title="Delete"
/>
</td>
</tr>
</tbody>

View File

@ -9,7 +9,7 @@ export interface OrgUser {
login: string;
name: string;
orgId: number;
role: string;
role: OrgRole;
userId: number;
}