Admin: Fixes infinite loading edit profile page (#34627)

* UserProfile: Fixes infinite loading spinner

* Refactor: some clean up

* Refactor: some more cleanup

* Tests: Adds tests for UserProfileEditPage

* Chore: updates after PR comments

* Refactor: removes unnecessary unmount/mount
This commit is contained in:
Hugo Häggmark
2021-05-25 14:48:16 +02:00
committed by GitHub
parent 92ac2c40d3
commit 881abb3af8
21 changed files with 875 additions and 376 deletions
@@ -1,9 +1,10 @@
import React, { FC } from 'react';
import { css } from '@emotion/css';
import { Button, Field, Form, HorizontalGroup, Input, LinkButton } from '@grafana/ui';
import config from 'app/core/config';
import { UserDTO } from 'app/types';
import { Button, LinkButton, Form, Field, Input, HorizontalGroup } from '@grafana/ui';
import { ChangePasswordFields } from 'app/core/utils/UserProvider';
import { css } from '@emotion/css';
import { ChangePasswordFields } from './types';
export interface Props {
user: UserDTO;
@@ -1,51 +1,53 @@
import React, { FC } from 'react';
import React from 'react';
import { useMount } from 'react-use';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { config } from '@grafana/runtime';
import { LoadingPlaceholder } from '@grafana/ui';
import { UserDTO, Team, UserOrg, UserSession, StoreState } from 'app/types';
import { connect, ConnectedProps } from 'react-redux';
import { NavModel } from '@grafana/data';
import { StoreState } from 'app/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { UserProvider, UserAPI, LoadingStates } from 'app/core/utils/UserProvider';
import Page from 'app/core/components/Page/Page';
import { ChangePasswordForm } from './ChangePasswordForm';
import { changePassword, loadUser } from './state/actions';
export interface Props {
export interface OwnProps {
navModel: NavModel;
}
export const ChangePasswordPage: FC<Props> = ({ navModel }) => (
<Page navModel={navModel}>
<UserProvider userId={config.bootData.user.id}>
{(
api: UserAPI,
states: LoadingStates,
teams: Team[],
orgs: UserOrg[],
sessions: UserSession[],
user?: UserDTO
) => {
return (
<Page.Contents>
<h3 className="page-heading">Change Your Password</h3>
{states.loadUser ? (
<LoadingPlaceholder text="Loading user profile..." />
) : (
<ChangePasswordForm user={user!} onChangePassword={api.changePassword} isSaving={states.changePassword} />
)}
</Page.Contents>
);
}}
</UserProvider>
</Page>
);
function mapStateToProps(state: StoreState) {
const userState = state.user;
const { isUpdating, user } = userState;
return {
navModel: getNavModel(state.navIndex, `change-password`),
isUpdating,
user,
};
}
const mapDispatchToProps = {};
const mapDispatchToProps = {
loadUser,
changePassword,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(ChangePasswordPage));
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = OwnProps & ConnectedProps<typeof connector>;
export function ChangePasswordPage({ navModel, loadUser, isUpdating, user, changePassword }: Props) {
useMount(() => loadUser());
return (
<Page navModel={navModel}>
<Page.Contents isLoading={!Boolean(user)}>
{user ? (
<>
<h3 className="page-heading">Change Your Password</h3>
<ChangePasswordForm user={user} onChangePassword={changePassword} isSaving={isUpdating} />
</>
) : null}
</Page.Contents>
</Page>
);
}
export default hot(module)(connector(ChangePasswordPage));
@@ -1,20 +1,15 @@
import React, { PureComponent } from 'react';
import { UserDTO, UserOrg } from 'app/types';
import { LoadingPlaceholder, Button } from '@grafana/ui';
import { Button, LoadingPlaceholder } from '@grafana/ui';
export interface Props {
user: UserDTO;
user: UserDTO | null;
orgs: UserOrg[];
isLoading: boolean;
loadOrgs: () => void;
setUserOrg: (org: UserOrg) => void;
}
export class UserOrganizations extends PureComponent<Props> {
componentDidMount() {
this.props.loadOrgs();
}
render() {
const { isLoading, orgs, user } = this.props;
@@ -30,7 +25,7 @@ export class UserOrganizations extends PureComponent<Props> {
<div>
<h3 className="page-sub-heading">Organizations</h3>
<div className="gf-form-group">
<table className="filter-table form-inline">
<table className="filter-table form-inline" aria-label="User organizations table">
<thead>
<tr>
<th>Name</th>
@@ -45,7 +40,7 @@ export class UserOrganizations extends PureComponent<Props> {
<td>{org.name}</td>
<td>{org.role}</td>
<td className="text-right">
{org.orgId === user.orgId ? (
{org.orgId === user?.orgId ? (
<Button variant="secondary" size="sm" disabled>
Current
</Button>
@@ -1,75 +0,0 @@
import React, { FC } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
import { LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { NavModel } from '@grafana/data';
import { UserProvider, UserAPI, LoadingStates } from 'app/core/utils/UserProvider';
import { getNavModel } from 'app/core/selectors/navModel';
import { UserDTO, Team, UserOrg, UserSession, StoreState } from 'app/types';
import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
import Page from 'app/core/components/Page/Page';
import { UserTeams } from './UserTeams';
import { UserSessions } from './UserSessions';
import { UserOrganizations } from './UserOrganizations';
import { UserProfileEditForm } from './UserProfileEditForm';
export interface Props {
navModel: NavModel;
}
export const UserProfileEdit: FC<Props> = ({ navModel }) => (
<Page navModel={navModel}>
<UserProvider userId={config.bootData.user.id}>
{(
api: UserAPI,
states: LoadingStates,
teams: Team[],
orgs: UserOrg[],
sessions: UserSession[],
user?: UserDTO
) => {
return (
<Page.Contents>
{states.loadUser ? (
<LoadingPlaceholder text="Loading user profile..." />
) : (
<VerticalGroup spacing="md">
<UserProfileEditForm
updateProfile={api.updateUserProfile}
isSavingUser={states.updateUserProfile}
user={user!}
/>
<SharedPreferences resourceUri="user" />
<UserTeams isLoading={states.loadTeams} loadTeams={api.loadTeams} teams={teams} />
<UserOrganizations
isLoading={states.loadOrgs}
setUserOrg={api.setUserOrg}
loadOrgs={api.loadOrgs}
orgs={orgs}
user={user!}
/>
<UserSessions
isLoading={states.loadSessions}
loadSessions={api.loadSessions}
revokeUserSession={api.revokeUserSession}
sessions={sessions}
user={user!}
/>
</VerticalGroup>
)}
</Page.Contents>
);
}}
</UserProvider>
</Page>
);
function mapStateToProps(state: StoreState) {
return {
navModel: getNavModel(state.navIndex, 'profile-settings'),
};
}
export default hot(module)(connect(mapStateToProps, null)(UserProfileEdit));
@@ -1,11 +1,11 @@
import React, { FC } from 'react';
import { Button, Tooltip, Icon, Form, Input, Field, FieldSet } from '@grafana/ui';
import { Button, Field, FieldSet, Form, Icon, Input, Tooltip } from '@grafana/ui';
import { UserDTO } from 'app/types';
import config from 'app/core/config';
import { ProfileUpdateFields } from 'app/core/utils/UserProvider';
import { ProfileUpdateFields } from './types';
export interface Props {
user: UserDTO;
user: UserDTO | null;
isSavingUser: boolean;
updateProfile: (payload: ProfileUpdateFields) => void;
}
@@ -25,24 +25,32 @@ export const UserProfileEditForm: FC<Props> = ({ user, isSavingUser, updateProfi
<Field label="Name" invalid={!!errors.name} error="Name is required" disabled={disableLoginForm}>
<Input
{...register('name', { required: true })}
id="edit-user-profile-name"
placeholder="Name"
defaultValue={user.name}
defaultValue={user?.name ?? ''}
suffix={<InputSuffix />}
/>
</Field>
<Field label="Email" invalid={!!errors.email} error="Email is required" disabled={disableLoginForm}>
<Input
{...register('email', { required: true })}
id="edit-user-profile-email"
placeholder="Email"
defaultValue={user.email}
defaultValue={user?.email ?? ''}
suffix={<InputSuffix />}
/>
</Field>
<Field label="Username" disabled={disableLoginForm}>
<Input {...register('login')} defaultValue={user.login} placeholder="Username" suffix={<InputSuffix />} />
<Input
{...register('login')}
id="edit-user-profile-username"
defaultValue={user?.login ?? ''}
placeholder="Username"
suffix={<InputSuffix />}
/>
</Field>
<div className="gf-form-button-row">
<Button variant="primary" disabled={isSavingUser}>
<Button variant="primary" disabled={isSavingUser} aria-label="Edit user profile save button">
Save
</Button>
</div>
@@ -0,0 +1,279 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { within } from '@testing-library/dom';
import { OrgRole } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Props, UserProfileEditPage } from './UserProfileEditPage';
import { initialUserState } from './state/reducers';
import { getNavModel } from '../../core/selectors/navModel';
import { backendSrv } from '../../core/services/backend_srv';
import { TeamPermissionLevel } from '../../types';
const defaultProps: Props = {
...initialUserState,
user: {
id: 1,
name: 'Test User',
email: 'test@test.com',
login: 'test',
isDisabled: false,
isGrafanaAdmin: false,
orgId: 0,
},
teams: [
{
id: 0,
name: 'Team One',
email: 'team.one@test.com',
avatarUrl: '/avatar/07d881f402480a2a511a9a15b5fa82c0',
memberCount: 2000,
permission: TeamPermissionLevel.Admin,
},
],
orgs: [
{
name: 'Main',
orgId: 0,
role: OrgRole.Editor,
},
{
name: 'Second',
orgId: 1,
role: OrgRole.Viewer,
},
{
name: 'Third',
orgId: 2,
role: OrgRole.Admin,
},
],
sessions: [
{
id: 0,
browser: 'Chrome',
browserVersion: '90',
clientIp: 'localhost',
createdAt: '2021-01-01 04:00:00',
device: 'Macbook Pro',
isActive: true,
os: 'Mac OS X',
osVersion: '11',
seenAt: new Date().toUTCString(),
},
],
navModel: getNavModel(
{
'profile-settings': {
icon: 'sliders-v-alt',
id: 'profile-settings',
parentItem: {
id: 'profile',
text: 'Test User',
img: '/avatar/46d229b033af06a191ff2267bca9ae56',
url: '/profile',
},
text: 'Preferences',
url: '/profile',
},
},
'profile-settings'
),
initUserProfilePage: jest.fn().mockResolvedValue(undefined),
revokeUserSession: jest.fn().mockResolvedValue(undefined),
changeUserOrg: jest.fn().mockResolvedValue(undefined),
updateUserProfile: jest.fn().mockResolvedValue(undefined),
};
function getSelectors() {
const dashboardSelect = () => screen.getByLabelText(/user preferences home dashboard drop down/i);
const timepickerSelect = () => screen.getByLabelText(selectors.components.TimeZonePicker.container);
const teamsTable = () => screen.getByRole('table', { name: /user teams table/i });
const orgsTable = () => screen.getByRole('table', { name: /user organizations table/i });
const sessionsTable = () => screen.getByRole('table', { name: /user sessions table/i });
return {
name: () => screen.getByRole('textbox', { name: /^name$/i }),
email: () => screen.getByRole('textbox', { name: /email/i }),
username: () => screen.getByRole('textbox', { name: /username/i }),
saveProfile: () => screen.getByRole('button', { name: /edit user profile save button/i }),
dashboardSelect,
dashboardValue: () => within(dashboardSelect()).getByText(/default/i),
timepickerSelect,
timepickerValue: () => within(timepickerSelect()).getByText(/coordinated universal time/i),
savePreferences: () => screen.getByRole('button', { name: /user preferences save button/i }),
teamsTable,
teamsRow: () => within(teamsTable()).getByRole('row', { name: /team one team.one@test\.com 2000/i }),
orgsTable,
orgsEditorRow: () => within(orgsTable()).getByRole('row', { name: /main editor current/i }),
orgsViewerRow: () => within(orgsTable()).getByRole('row', { name: /second viewer select/i }),
orgsAdminRow: () => within(orgsTable()).getByRole('row', { name: /third admin select/i }),
sessionsTable,
sessionsRow: () =>
within(sessionsTable()).getByRole('row', {
name: /now 2021-01-01 04:00:00 localhost chrome on mac os x 11/i,
}),
};
}
async function getTestContext(overrides: Partial<Props> = {}) {
jest.clearAllMocks();
const putSpy = jest.spyOn(backendSrv, 'put');
const getSpy = jest
.spyOn(backendSrv, 'get')
.mockResolvedValue({ timezone: 'UTC', homeDashboardId: 0, theme: 'dark' });
const searchSpy = jest.spyOn(backendSrv, 'search').mockResolvedValue([]);
const props = { ...defaultProps, ...overrides };
const { rerender } = render(<UserProfileEditPage {...props} />);
await waitFor(() => expect(props.initUserProfilePage).toHaveBeenCalledTimes(1));
return { rerender, putSpy, getSpy, searchSpy, props };
}
describe('UserProfileEditPage', () => {
describe('when loading user', () => {
it('should show loading placeholder', async () => {
await getTestContext({ user: null });
expect(screen.getByText(/loading \.\.\./i)).toBeInTheDocument();
});
});
describe('when user has loaded', () => {
it('should show edit profile form', async () => {
await getTestContext();
const { name, email, username, saveProfile } = getSelectors();
expect(screen.getByText(/edit profile/i)).toBeInTheDocument();
expect(name()).toBeInTheDocument();
expect(name()).toHaveValue('Test User');
expect(email()).toBeInTheDocument();
expect(email()).toHaveValue('test@test.com');
expect(username()).toBeInTheDocument();
expect(username()).toHaveValue('test');
expect(saveProfile()).toBeInTheDocument();
});
it('should show shared preferences', async () => {
await getTestContext();
const { dashboardSelect, dashboardValue, timepickerSelect, timepickerValue, savePreferences } = getSelectors();
expect(screen.getByRole('group', { name: /preferences/i })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: /default/i })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: /dark/i })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: /light/i })).toBeInTheDocument();
expect(dashboardSelect()).toBeInTheDocument();
expect(dashboardValue()).toBeInTheDocument();
expect(timepickerSelect()).toBeInTheDocument();
expect(timepickerValue()).toBeInTheDocument();
expect(savePreferences()).toBeInTheDocument();
});
describe('and teams are loading', () => {
it('should show teams loading placeholder', async () => {
await getTestContext({ teamsAreLoading: true });
expect(screen.getByText(/loading teams\.\.\./i)).toBeInTheDocument();
});
});
describe('and teams are loaded', () => {
it('should show teams', async () => {
await getTestContext();
const { teamsTable, teamsRow } = getSelectors();
expect(screen.getByRole('heading', { name: /teams/i })).toBeInTheDocument();
expect(teamsTable()).toBeInTheDocument();
expect(teamsRow()).toBeInTheDocument();
});
});
describe('and organizations are loading', () => {
it('should show teams loading placeholder', async () => {
await getTestContext({ orgsAreLoading: true });
expect(screen.getByText(/loading organizations\.\.\./i)).toBeInTheDocument();
});
});
describe('and organizations are loaded', () => {
it('should show organizations', async () => {
await getTestContext();
const { orgsTable, orgsEditorRow, orgsViewerRow, orgsAdminRow } = getSelectors();
expect(screen.getByRole('heading', { name: /organizations/i })).toBeInTheDocument();
expect(orgsTable()).toBeInTheDocument();
expect(orgsEditorRow()).toBeInTheDocument();
expect(orgsViewerRow()).toBeInTheDocument();
expect(orgsAdminRow()).toBeInTheDocument();
});
});
describe('and sessions are loading', () => {
it('should show teams loading placeholder', async () => {
await getTestContext({ sessionsAreLoading: true });
expect(screen.getByText(/loading sessions\.\.\./i)).toBeInTheDocument();
});
});
describe('and sessions are loaded', () => {
it('should show sessions', async () => {
await getTestContext();
const { sessionsTable, sessionsRow } = getSelectors();
expect(sessionsTable()).toBeInTheDocument();
expect(sessionsRow()).toBeInTheDocument();
});
});
describe('and user is edited and saved', () => {
it('should call updateUserProfile', async () => {
const { props } = await getTestContext();
const { email, saveProfile } = getSelectors();
userEvent.clear(email());
await userEvent.type(email(), 'test@test.se');
userEvent.click(saveProfile());
await waitFor(() => expect(props.updateUserProfile).toHaveBeenCalledTimes(1));
expect(props.updateUserProfile).toHaveBeenCalledWith({
email: 'test@test.se',
login: 'test',
name: 'Test User',
});
});
});
describe('and organization is changed', () => {
it('should call changeUserOrg', async () => {
const { props } = await getTestContext();
const orgsAdminSelectButton = () =>
within(getSelectors().orgsAdminRow()).getByRole('button', { name: /select/i });
userEvent.click(orgsAdminSelectButton());
await waitFor(() => expect(props.changeUserOrg).toHaveBeenCalledTimes(1));
expect(props.changeUserOrg).toHaveBeenCalledWith({
name: 'Third',
orgId: 2,
role: 'Admin',
});
});
});
describe('and session is revoked', () => {
it('should call revokeUserSession', async () => {
const { props } = await getTestContext();
const sessionsRevokeButton = () => within(getSelectors().sessionsRow()).getByRole('button');
userEvent.click(sessionsRevokeButton());
await waitFor(() => expect(props.revokeUserSession).toHaveBeenCalledTimes(1));
expect(props.revokeUserSession).toHaveBeenCalledWith(0);
});
});
});
});
@@ -0,0 +1,81 @@
import React from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { useMount } from 'react-use';
import { hot } from 'react-hot-loader';
import { NavModel } from '@grafana/data';
import { VerticalGroup } from '@grafana/ui';
import { getNavModel } from 'app/core/selectors/navModel';
import { StoreState } from 'app/types';
import Page from 'app/core/components/Page/Page';
import { changeUserOrg, initUserProfilePage, revokeUserSession, updateUserProfile } from './state/actions';
import UserProfileEditForm from './UserProfileEditForm';
import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences';
import { UserTeams } from './UserTeams';
import UserOrganizations from './UserOrganizations';
import UserSessions from './UserSessions';
export interface OwnProps {
navModel: NavModel;
}
function mapStateToProps(state: StoreState) {
const userState = state.user;
const { user, teams, orgs, sessions, teamsAreLoading, orgsAreLoading, sessionsAreLoading, isUpdating } = userState;
return {
navModel: getNavModel(state.navIndex, 'profile-settings'),
orgsAreLoading,
sessionsAreLoading,
teamsAreLoading,
orgs,
sessions,
teams,
isUpdating,
user,
};
}
const mapDispatchToProps = {
initUserProfilePage,
revokeUserSession,
changeUserOrg,
updateUserProfile,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = OwnProps & ConnectedProps<typeof connector>;
export function UserProfileEditPage({
navModel,
orgsAreLoading,
sessionsAreLoading,
teamsAreLoading,
initUserProfilePage,
orgs,
sessions,
teams,
isUpdating,
user,
revokeUserSession,
changeUserOrg,
updateUserProfile,
}: Props) {
useMount(() => initUserProfilePage());
return (
<Page navModel={navModel}>
<Page.Contents isLoading={!user}>
<VerticalGroup spacing="md">
<UserProfileEditForm updateProfile={updateUserProfile} isSavingUser={isUpdating} user={user} />
<SharedPreferences resourceUri="user" />
<UserTeams isLoading={teamsAreLoading} teams={teams} />
<UserOrganizations isLoading={orgsAreLoading} setUserOrg={changeUserOrg} orgs={orgs} user={user} />
<UserSessions isLoading={sessionsAreLoading} revokeUserSession={revokeUserSession} sessions={sessions} />
</VerticalGroup>
</Page.Contents>
</Page>
);
}
export default hot(module)(connector(UserProfileEditPage));
+3 -9
View File
@@ -1,20 +1,14 @@
import React, { PureComponent } from 'react';
import { UserDTO, UserSession } from 'app/types';
import { LoadingPlaceholder, Button, Icon } from '@grafana/ui';
import { UserSession } from 'app/types';
import { Button, Icon, LoadingPlaceholder } from '@grafana/ui';
export interface Props {
user: UserDTO;
sessions: UserSession[];
isLoading: boolean;
loadSessions: () => void;
revokeUserSession: (tokenId: number) => void;
}
export class UserSessions extends PureComponent<Props> {
componentDidMount() {
this.props.loadSessions();
}
render() {
const { isLoading, sessions, revokeUserSession } = this.props;
@@ -28,7 +22,7 @@ export class UserSessions extends PureComponent<Props> {
<>
<h3 className="page-sub-heading">Sessions</h3>
<div className="gf-form-group">
<table className="filter-table form-inline">
<table className="filter-table form-inline" aria-label="User sessions table">
<thead>
<tr>
<th>Last seen</th>
+1 -6
View File
@@ -5,14 +5,9 @@ import { LoadingPlaceholder } from '@grafana/ui';
export interface Props {
teams: Team[];
isLoading: boolean;
loadTeams: () => void;
}
export class UserTeams extends PureComponent<Props> {
componentDidMount() {
this.props.loadTeams();
}
render() {
const { isLoading, teams } = this.props;
@@ -28,7 +23,7 @@ export class UserTeams extends PureComponent<Props> {
<div>
<h3 className="page-sub-heading">Teams</h3>
<div className="gf-form-group">
<table className="filter-table form-inline">
<table className="filter-table form-inline" aria-label="User teams table">
<thead>
<tr>
<th />
+57
View File
@@ -0,0 +1,57 @@
import { getBackendSrv } from '@grafana/runtime';
import { ChangePasswordFields, ProfileUpdateFields } from './types';
import { Team, UserDTO, UserOrg, UserSession } from '../../types';
async function changePassword(payload: ChangePasswordFields): Promise<void> {
try {
await getBackendSrv().put('/api/user/password', payload);
} catch (err) {
console.error(err);
}
}
function loadUser(): Promise<UserDTO> {
return getBackendSrv().get('/api/user');
}
function loadTeams(): Promise<Team[]> {
return getBackendSrv().get('/api/user/teams');
}
function loadOrgs(): Promise<UserOrg[]> {
return getBackendSrv().get('/api/user/orgs');
}
function loadSessions(): Promise<UserSession[]> {
return getBackendSrv().get('/api/user/auth-tokens');
}
async function revokeUserSession(tokenId: number): Promise<void> {
await getBackendSrv().post('/api/user/revoke-auth-token', {
authTokenId: tokenId,
});
}
async function setUserOrg(org: UserOrg): Promise<void> {
await getBackendSrv().post('/api/user/using/' + org.orgId, {});
}
async function updateUserProfile(payload: ProfileUpdateFields): Promise<void> {
try {
await getBackendSrv().put('/api/user', payload);
} catch (err) {
console.error(err);
}
}
export const api = {
changePassword,
revokeUserSession,
loadUser,
loadSessions,
loadOrgs,
loadTeams,
setUserOrg,
updateUserProfile,
};
@@ -0,0 +1,89 @@
import { config } from '@grafana/runtime';
import { ChangePasswordFields, ProfileUpdateFields } from '../types';
import { ThunkResult, UserOrg } from '../../../types';
import {
initLoadOrgs,
initLoadSessions,
initLoadTeams,
orgsLoaded,
sessionsLoaded,
setUpdating,
teamsLoaded,
userLoaded,
userSessionRevoked,
} from './reducers';
import { api } from '../api';
export function changePassword(payload: ChangePasswordFields): ThunkResult<void> {
return async function (dispatch) {
dispatch(setUpdating({ updating: true }));
await api.changePassword(payload);
dispatch(setUpdating({ updating: false }));
};
}
export function initUserProfilePage(): ThunkResult<void> {
return async function (dispatch) {
await dispatch(loadUser());
dispatch(loadTeams());
dispatch(loadOrgs());
dispatch(loadSessions());
};
}
export function loadUser(): ThunkResult<void> {
return async function (dispatch) {
const user = await api.loadUser();
dispatch(userLoaded({ user }));
};
}
function loadTeams(): ThunkResult<void> {
return async function (dispatch) {
dispatch(initLoadTeams());
const teams = await api.loadTeams();
dispatch(teamsLoaded({ teams }));
};
}
function loadOrgs(): ThunkResult<void> {
return async function (dispatch) {
dispatch(initLoadOrgs());
const orgs = await api.loadOrgs();
dispatch(orgsLoaded({ orgs }));
};
}
function loadSessions(): ThunkResult<void> {
return async function (dispatch) {
dispatch(initLoadSessions());
const sessions = await api.loadSessions();
dispatch(sessionsLoaded({ sessions }));
};
}
export function revokeUserSession(tokenId: number): ThunkResult<void> {
return async function (dispatch) {
dispatch(setUpdating({ updating: true }));
await api.revokeUserSession(tokenId);
dispatch(userSessionRevoked({ tokenId }));
};
}
export function changeUserOrg(org: UserOrg): ThunkResult<void> {
return async function (dispatch) {
dispatch(setUpdating({ updating: true }));
await api.setUserOrg(org);
window.location.href = config.appSubUrl + '/profile';
};
}
export function updateUserProfile(payload: ProfileUpdateFields): ThunkResult<void> {
return async function (dispatch) {
dispatch(setUpdating({ updating: true }));
await api.updateUserProfile(payload);
await dispatch(loadUser());
dispatch(setUpdating({ updating: false }));
};
}
@@ -0,0 +1,187 @@
import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { OrgRole, TeamPermissionLevel } from '../../../types';
import {
initialUserState,
orgsLoaded,
sessionsLoaded,
setUpdating,
teamsLoaded,
updateTimeZone,
userLoaded,
userReducer,
userSessionRevoked,
UserState,
} from './reducers';
describe('userReducer', () => {
describe('when updateTimeZone is dispatched', () => {
it('then state should be correct', () => {
reducerTester<UserState>()
.givenReducer(userReducer, { ...initialUserState })
.whenActionIsDispatched(updateTimeZone({ timeZone: 'xyz' }))
.thenStateShouldEqual({ ...initialUserState, timeZone: 'xyz' });
});
});
describe('when setUpdating is dispatched', () => {
it('then state should be correct', () => {
reducerTester<UserState>()
.givenReducer(userReducer, { ...initialUserState, isUpdating: false })
.whenActionIsDispatched(setUpdating({ updating: true }))
.thenStateShouldEqual({ ...initialUserState, isUpdating: true });
});
});
describe('when userLoaded is dispatched', () => {
it('then state should be correct', () => {
reducerTester<UserState>()
.givenReducer(userReducer, { ...initialUserState, user: null })
.whenActionIsDispatched(
userLoaded({
user: {
id: 2021,
email: 'test@test.com',
isDisabled: true,
login: 'test',
name: 'Test Account',
isGrafanaAdmin: false,
},
})
)
.thenStateShouldEqual({
...initialUserState,
user: {
id: 2021,
email: 'test@test.com',
isDisabled: true,
login: 'test',
name: 'Test Account',
isGrafanaAdmin: false,
},
});
});
});
describe('when teamsLoaded is dispatched', () => {
it('then state should be correct', () => {
reducerTester<UserState>()
.givenReducer(userReducer, { ...initialUserState, teamsAreLoading: true })
.whenActionIsDispatched(
teamsLoaded({
teams: [
{
id: 1,
email: 'team@team.com',
name: 'Team',
avatarUrl: '/avatar/12345',
memberCount: 4,
permission: TeamPermissionLevel.Admin,
},
],
})
)
.thenStateShouldEqual({
...initialUserState,
teamsAreLoading: false,
teams: [
{
id: 1,
email: 'team@team.com',
name: 'Team',
avatarUrl: '/avatar/12345',
memberCount: 4,
permission: TeamPermissionLevel.Admin,
},
],
});
});
});
describe('when orgsLoaded is dispatched', () => {
it('then state should be correct', () => {
reducerTester<UserState>()
.givenReducer(userReducer, { ...initialUserState, orgsAreLoading: true })
.whenActionIsDispatched(
orgsLoaded({
orgs: [{ orgId: 1, name: 'Main', role: OrgRole.Viewer }],
})
)
.thenStateShouldEqual({
...initialUserState,
orgsAreLoading: false,
orgs: [{ orgId: 1, name: 'Main', role: OrgRole.Viewer }],
});
});
});
describe('when sessionsLoaded is dispatched', () => {
it('then state should be correct', () => {
reducerTester<UserState>()
.givenReducer(userReducer, { ...initialUserState, sessionsAreLoading: true })
.whenActionIsDispatched(
sessionsLoaded({
sessions: [
{
id: 1,
browser: 'Chrome',
browserVersion: '90',
osVersion: '95',
clientIp: '192.168.1.1',
createdAt: '2021-01-01 04:00:00',
device: 'Computer',
os: 'Windows',
isActive: false,
seenAt: '1996-01-01 04:00:00',
},
],
})
)
.thenStateShouldEqual({
...initialUserState,
sessionsAreLoading: false,
sessions: [
{
id: 1,
browser: 'Chrome',
browserVersion: '90',
osVersion: '95',
clientIp: '192.168.1.1',
createdAt: 'December 31, 2020',
device: 'Computer',
os: 'Windows',
isActive: false,
seenAt: '25 years ago',
},
],
});
});
});
describe('when userSessionRevoked is dispatched', () => {
it('then state should be correct', () => {
reducerTester<UserState>()
.givenReducer(userReducer, {
...initialUserState,
sessions: [
{
id: 1,
browser: 'Chrome',
browserVersion: '90',
osVersion: '95',
clientIp: '192.168.1.1',
createdAt: '2021-01-01',
device: 'Computer',
os: 'Windows',
isActive: false,
seenAt: '1996-01-01',
},
],
})
.whenActionIsDispatched(userSessionRevoked({ tokenId: 1 }))
.thenStateShouldEqual({
...initialUserState,
sessions: [],
});
});
});
});
+89 -14
View File
@@ -1,40 +1,115 @@
import { isString, isEmpty, set } from 'lodash';
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { UserState, ThunkResult } from 'app/types';
import { isEmpty, isString, set } from 'lodash';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { dateTimeFormat, dateTimeFormatTimeAgo, TimeZone } from '@grafana/data';
import { Team, ThunkResult, UserDTO, UserOrg, UserSession } from 'app/types';
import config from 'app/core/config';
import { TimeZone } from '@grafana/data';
import { contextSrv } from 'app/core/core';
export const initialState: UserState = {
export interface UserState {
orgId: number;
timeZone: TimeZone;
user: UserDTO | null;
teams: Team[];
orgs: UserOrg[];
sessions: UserSession[];
teamsAreLoading: boolean;
orgsAreLoading: boolean;
sessionsAreLoading: boolean;
isUpdating: boolean;
}
export const initialUserState: UserState = {
orgId: config.bootData.user.orgId,
timeZone: config.bootData.user.timezone,
orgsAreLoading: false,
sessionsAreLoading: false,
teamsAreLoading: false,
isUpdating: false,
orgs: [],
sessions: [],
teams: [],
user: null,
};
export const slice = createSlice({
name: 'user/profile',
initialState,
initialState: initialUserState,
reducers: {
updateTimeZone: (state, action: PayloadAction<TimeZone>): UserState => {
return {
...state,
timeZone: action.payload,
};
updateTimeZone: (state, action: PayloadAction<{ timeZone: TimeZone }>) => {
state.timeZone = action.payload.timeZone;
},
setUpdating: (state, action: PayloadAction<{ updating: boolean }>) => {
state.isUpdating = action.payload.updating;
},
userLoaded: (state, action: PayloadAction<{ user: UserDTO }>) => {
state.user = action.payload.user;
},
initLoadTeams: (state, action: PayloadAction<undefined>) => {
state.teamsAreLoading = true;
},
teamsLoaded: (state, action: PayloadAction<{ teams: Team[] }>) => {
state.teams = action.payload.teams;
state.teamsAreLoading = false;
},
initLoadOrgs: (state, action: PayloadAction<undefined>) => {
state.orgsAreLoading = true;
},
orgsLoaded: (state, action: PayloadAction<{ orgs: UserOrg[] }>) => {
state.orgs = action.payload.orgs;
state.orgsAreLoading = false;
},
initLoadSessions: (state, action: PayloadAction<undefined>) => {
state.sessionsAreLoading = true;
},
sessionsLoaded: (state, action: PayloadAction<{ sessions: UserSession[] }>) => {
const sorted = action.payload.sessions.sort((a, b) => Number(b.isActive) - Number(a.isActive)); // Show active sessions first
state.sessions = sorted.map((session) => ({
id: session.id,
isActive: session.isActive,
seenAt: dateTimeFormatTimeAgo(session.seenAt),
createdAt: dateTimeFormat(session.createdAt, { format: 'MMMM DD, YYYY' }),
clientIp: session.clientIp,
browser: session.browser,
browserVersion: session.browserVersion,
os: session.os,
osVersion: session.osVersion,
device: session.device,
}));
state.sessionsAreLoading = false;
},
userSessionRevoked: (state, action: PayloadAction<{ tokenId: number }>) => {
state.sessions = state.sessions.filter((session: UserSession) => {
return session.id !== action.payload.tokenId;
});
state.isUpdating = false;
},
},
});
export const updateTimeZoneForSession = (timeZone: TimeZone): ThunkResult<void> => {
return async (dispatch) => {
const { updateTimeZone } = slice.actions;
if (!isString(timeZone) || isEmpty(timeZone)) {
timeZone = config?.bootData?.user?.timezone;
}
set(contextSrv, 'user.timezone', timeZone);
dispatch(updateTimeZone(timeZone));
dispatch(updateTimeZone({ timeZone }));
};
};
export const {
setUpdating,
initLoadOrgs,
orgsLoaded,
initLoadTeams,
teamsLoaded,
userLoaded,
userSessionRevoked,
initLoadSessions,
sessionsLoaded,
updateTimeZone,
} = slice.actions;
export const userReducer = slice.reducer;
export default { user: slice.reducer };
@@ -1,3 +1,3 @@
import { UserState } from 'app/types';
import { UserState } from './reducers';
export const getTimeZone = (state: UserState) => state.timeZone;
+11
View File
@@ -0,0 +1,11 @@
export interface ChangePasswordFields {
oldPassword: string;
newPassword: string;
confirmNew: string;
}
export interface ProfileUpdateFields {
name: string;
email: string;
login: string;
}