Commit 5293c9dd by Shavonn Brown Committed by GitHub

Convert remaining profile bits to React (#24310)

* reactify user sessions

* all reactified

* more cleanup

* comment edit

* Profile: Fix casing

* Profile: Add Page wrapper

* Profile: New form styles for UserProfileEditForm

* Profile: Use new form styles for SharedPreferences

* Profile: Use radioButtonGroup for SharedPreferences

* Grafana UI: Add FieldSet

* Grafana UI: Add story

* Grafana UI: Add docs

* Grafana UI: Export FieldSet

* Profile: USe FieldSet

* Profile: Sort sessions

Co-authored-by: Clarity-89 <homes89@ukr.net>
parent 8474794a
......@@ -20,7 +20,6 @@ import {
} from '@grafana/ui';
const { SecretFormField } = LegacyForms;
import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor';
import ReactProfileWrapper from 'app/features/profile/ReactProfileWrapper';
import { LokiAnnotationsQueryEditor } from '../plugins/datasource/loki/components/AnnotationsQueryEditor';
import { HelpModal } from './components/help/HelpModal';
import { Footer } from './components/Footer/Footer';
......@@ -164,8 +163,6 @@ export function registerAngularDirectives() {
['onChange', { watchDepth: 'reference', wrapApply: true }],
]);
react2AngularDirective('reactProfileWrapper', ReactProfileWrapper, []);
react2AngularDirective('lokiAnnotationsQueryEditor', LokiAnnotationsQueryEditor, [
'expr',
'onChange',
......
import React, { PureComponent } from 'react';
import { InlineFormLabel, LegacyForms } from '@grafana/ui';
const { Select } = LegacyForms;
import { css } from 'emotion';
import {
Select,
Field,
Form,
Tooltip,
Icon,
stylesFactory,
Label,
Button,
RadioButtonGroup,
FieldSet,
} from '@grafana/ui';
import { getTimeZoneGroups, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
import { backendSrv } from 'app/core/services/backend_srv';
import { getTimeZoneGroups, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
export interface Props {
resourceUri: string;
......@@ -19,7 +30,7 @@ export interface State {
dashboards: DashboardSearchHit[];
}
const themes = [
const themes: SelectableValue[] = [
{ value: '', label: 'Default' },
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
......@@ -86,9 +97,7 @@ export class SharedPreferences extends PureComponent<Props, State> {
});
}
onSubmitForm = async (event: React.SyntheticEvent) => {
event.preventDefault();
onSubmitForm = async () => {
const { homeDashboardId, theme, timezone } = this.state;
await backendSrv.put(`/api/${this.props.resourceUri}/preferences`, {
......@@ -99,11 +108,8 @@ export class SharedPreferences extends PureComponent<Props, State> {
window.location.reload();
};
onThemeChanged = (theme: SelectableValue<string>) => {
if (!theme || typeof theme.value !== 'string') {
return;
}
this.setState({ theme: theme.value });
onThemeChanged = (value: string) => {
this.setState({ theme: value });
};
onTimeZoneChanged = (timezone: SelectableValue<string>) => {
......@@ -126,55 +132,66 @@ export class SharedPreferences extends PureComponent<Props, State> {
render() {
const { theme, timezone, homeDashboardId, dashboards } = this.state;
const styles = getStyles();
return (
<form className="section gf-form-group" onSubmit={this.onSubmitForm}>
<h3 className="page-heading">Preferences</h3>
<div className="gf-form">
<span className="gf-form-label width-11">UI Theme</span>
<Select
isSearchable={false}
value={themes.find(item => item.value === theme)}
options={themes}
onChange={this.onThemeChanged}
width={20}
/>
</div>
<div className="gf-form">
<InlineFormLabel
width={11}
tooltip="Not finding dashboard you want? Star it first, then it should appear in this select box."
>
Home Dashboard
</InlineFormLabel>
<Select
value={dashboards.find(dashboard => dashboard.id === homeDashboardId)}
getOptionValue={i => i.id}
getOptionLabel={this.getFullDashName}
onChange={(dashboard: DashboardSearchHit) => this.onHomeDashboardChanged(dashboard.id)}
options={dashboards}
placeholder="Choose default dashboard"
width={20}
/>
</div>
<div className="gf-form" aria-label={selectors.components.TimeZonePicker.container}>
<label className="gf-form-label width-11">Timezone</label>
<Select
isSearchable={true}
value={timeZones.find(item => item.value === timezone)}
onChange={this.onTimeZoneChanged}
options={timeZones}
width={20}
/>
</div>
<div className="gf-form-button-row">
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
<Form onSubmit={this.onSubmitForm}>
{() => {
return (
<FieldSet label="Preferences">
<Field label="UI Theme">
<RadioButtonGroup
options={themes}
value={themes.find(item => item.value === theme)?.value}
onChange={this.onThemeChanged}
/>
</Field>
<Field
label={
<Label>
<span className={styles.labelText}>Home Dashboard</span>
<Tooltip content="Not finding dashboard you want? Star it first, then it should appear in this select box.">
<Icon name="info-circle" />
</Tooltip>
</Label>
}
>
<Select
value={dashboards.find(dashboard => dashboard.id === homeDashboardId)}
getOptionValue={i => i.id}
getOptionLabel={this.getFullDashName}
onChange={(dashboard: DashboardSearchHit) => this.onHomeDashboardChanged(dashboard.id)}
options={dashboards}
placeholder="Choose default dashboard"
/>
</Field>
<Field label="Timezone" aria-label={selectors.components.TimeZonePicker.container}>
<Select
isSearchable={true}
value={timeZones.find(item => item.value === timezone)}
onChange={this.onTimeZoneChanged}
options={timeZones}
/>
</Field>
<div className="gf-form-button-row">
<Button variant="primary">Save</Button>
</div>
</FieldSet>
);
}}
</Form>
);
}
}
export default SharedPreferences;
const getStyles = stylesFactory(() => {
return {
labelText: css`
margin-right: 6px;
`,
};
});
import React, { PureComponent } from 'react';
import { getBackendSrv } from '@grafana/runtime';
import { User, Team } from 'app/types';
import { User, Team, UserOrg, UserSession } from 'app/types';
import { config } from 'app/core/config';
import { dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
export interface UserAPI {
changePassword: (changePassword: ChangePasswordFields) => void;
......@@ -9,14 +10,17 @@ export interface UserAPI {
loadUser: () => void;
loadTeams: () => void;
loadOrgs: () => void;
loadSessions: () => void;
setUserOrg: (org: UserOrg) => void;
revokeUserSession: (tokenId: number) => void;
}
interface LoadingStates {
export interface LoadingStates {
changePassword: boolean;
loadUser: boolean;
loadTeams: boolean;
loadOrgs: boolean;
loadSessions: boolean;
updateUserProfile: boolean;
updateUserOrg: boolean;
}
......@@ -33,21 +37,23 @@ export interface ProfileUpdateFields {
login: string;
}
export interface UserOrg {
orgId: number;
name: string;
role: string;
}
export interface Props {
userId?: number; // passed, will load user on mount
children: (api: UserAPI, states: LoadingStates, teams: Team[], orgs: UserOrg[], user?: User) => JSX.Element;
children: (
api: UserAPI,
states: LoadingStates,
teams: Team[],
orgs: UserOrg[],
sessions: UserSession[],
user?: User
) => JSX.Element;
}
export interface State {
user?: User;
teams: Team[];
orgs: UserOrg[];
sessions: UserSession[];
loadingStates: LoadingStates;
}
......@@ -55,11 +61,13 @@ export class UserProvider extends PureComponent<Props, State> {
state: State = {
teams: [] as Team[],
orgs: [] as UserOrg[],
sessions: [] as UserSession[],
loadingStates: {
changePassword: false,
loadUser: true,
loadTeams: false,
loadOrgs: false,
loadSessions: false,
updateUserProfile: false,
updateUserOrg: false,
},
......@@ -101,6 +109,50 @@ export class UserProvider extends PureComponent<Props, State> {
this.setState({ orgs, loadingStates: { ...this.state.loadingStates, loadOrgs: false } });
};
loadSessions = async () => {
this.setState({
loadingStates: { ...this.state.loadingStates, loadSessions: true },
});
await getBackendSrv()
.get('/api/user/auth-tokens')
.then((sessions: UserSession[]) => {
sessions = sessions
// Show active sessions first
.sort((a, b) => Number(b.isActive) - Number(a.isActive))
.map((session: UserSession) => {
return {
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,
};
});
this.setState({ sessions, loadingStates: { ...this.state.loadingStates, loadSessions: false } });
});
};
revokeUserSession = async (tokenId: number) => {
await getBackendSrv()
.post('/api/user/revoke-auth-token', {
authTokenId: tokenId,
})
.then(() => {
const sessions = this.state.sessions.filter((session: UserSession) => {
return session.id !== tokenId;
});
this.setState({ sessions });
});
};
setUserOrg = async (org: UserOrg) => {
this.setState({
loadingStates: { ...this.state.loadingStates, updateUserOrg: true },
......@@ -119,9 +171,7 @@ export class UserProvider extends PureComponent<Props, State> {
this.setState({ loadingStates: { ...this.state.loadingStates, updateUserProfile: true } });
await getBackendSrv()
.put('/api/user', payload)
.then(() => {
this.loadUser();
})
.then(this.loadUser)
.catch(e => console.log(e))
.finally(() => {
this.setState({ loadingStates: { ...this.state.loadingStates, updateUserProfile: false } });
......@@ -130,18 +180,20 @@ export class UserProvider extends PureComponent<Props, State> {
render() {
const { children } = this.props;
const { loadingStates, teams, orgs, user } = this.state;
const { loadingStates, teams, orgs, sessions, user } = this.state;
const api = {
const api: UserAPI = {
changePassword: this.changePassword,
loadUser: this.loadUser,
loadTeams: this.loadTeams,
loadOrgs: this.loadOrgs,
loadSessions: this.loadSessions,
revokeUserSession: this.revokeUserSession,
updateUserProfile: this.updateUserProfile,
setUserOrg: this.setUserOrg,
};
return <>{children(api, loadingStates, teams, orgs, user)}</>;
return <>{children(api, loadingStates, teams, orgs, sessions, user)}</>;
}
}
......
import React, { PureComponent } from 'react';
import { User } from 'app/types';
import { UserOrg } from 'app/core/utils/UserProvider';
import { User, UserOrg } from 'app/types';
import { LoadingPlaceholder, Button } from '@grafana/ui';
export interface Props {
......
import React from 'react';
import { UserProvider } from 'app/core/utils/UserProvider';
import { UserProfileEditForm } from './UserProfileEditForm';
import React, { FC } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
import { LoadingPlaceholder } 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 { User, 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 { config } from '@grafana/runtime';
import { LoadingPlaceholder } from '@grafana/ui';
import { UserProfileEditForm } from './UserProfileEditForm';
export const ReactProfileWrapper = () => (
<UserProvider userId={config.bootData.user.id}>
{(api, states, teams, orgs, user) => {
return (
<>
{states.loadUser ? (
<LoadingPlaceholder text="Loading user profile..." />
) : (
<UserProfileEditForm
updateProfile={api.updateUserProfile}
isSavingUser={states.updateUserProfile}
user={user}
/>
)}
<SharedPreferences resourceUri="user" />
<UserTeams isLoading={states.loadTeams} loadTeams={api.loadTeams} teams={teams} />
{!states.loadUser && (
<UserOrganizations
isLoading={states.loadOrgs}
setUserOrg={api.setUserOrg}
loadOrgs={api.loadOrgs}
orgs={orgs}
user={user}
/>
)}
</>
);
}}
</UserProvider>
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: User) => {
return (
<Page.Contents>
{states.loadUser ? (
<LoadingPlaceholder text="Loading user profile..." />
) : (
<UserProfileEditForm
updateProfile={api.updateUserProfile}
isSavingUser={states.updateUserProfile}
user={user}
/>
)}
<SharedPreferences resourceUri="user" />
<UserTeams isLoading={states.loadTeams} loadTeams={api.loadTeams} teams={teams} />
{!states.loadUser && (
<>
<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}
/>
</>
)}
</Page.Contents>
);
}}
</UserProvider>
</Page>
);
export default ReactProfileWrapper;
function mapStateToProps(state: StoreState) {
return {
navModel: getNavModel(state.navIndex, 'profile-settings'),
};
}
export default hot(module)(connect(mapStateToProps, null)(UserProfileEdit));
import React, { PureComponent, ChangeEvent, MouseEvent } from 'react';
import { Button, InlineFormLabel, LegacyForms, Tooltip, Icon } from '@grafana/ui';
const { Input } = LegacyForms;
import React, { FC } from 'react';
import { Button, Tooltip, Icon, Form, Input, Field, FieldSet } from '@grafana/ui';
import { User } from 'app/types';
import config from 'app/core/config';
import { ProfileUpdateFields } from 'app/core/utils/UserProvider';
......@@ -11,96 +10,57 @@ export interface Props {
updateProfile: (payload: ProfileUpdateFields) => void;
}
export interface State {
name: string;
email: string;
login: string;
}
export class UserProfileEditForm extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
const {
user: { name, email, login },
} = this.props;
this.state = {
name,
email,
login,
};
}
const { disableLoginForm } = config;
onNameChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ name: event.target.value });
export const UserProfileEditForm: FC<Props> = ({ user, isSavingUser, updateProfile }) => {
const onSubmitProfileUpdate = (data: ProfileUpdateFields) => {
updateProfile(data);
};
onEmailChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ email: event.target.value });
};
onLoginChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ login: event.target.value });
};
onSubmitProfileUpdate = (event: MouseEvent<HTMLInputElement>) => {
event.preventDefault();
this.props.updateProfile({ ...this.state });
};
render() {
const { name, email, login } = this.state;
const { isSavingUser } = this.props;
const { disableLoginForm } = config;
return (
<>
<h3 className="page-sub-heading">Edit Profile</h3>
<form name="userForm" className="gf-form-group">
<div className="gf-form max-width-30">
<InlineFormLabel className="width-8">Name</InlineFormLabel>
<Input className="gf-form-input max-width-22" type="text" onChange={this.onNameChange} value={name} />
</div>
<div className="gf-form max-width-30">
<InlineFormLabel className="width-8">Email</InlineFormLabel>
<Input
className="gf-form-input max-width-22"
type="text"
onChange={this.onEmailChange}
value={email}
disabled={disableLoginForm}
/>
{disableLoginForm && (
<Tooltip content="Login Details Locked - managed in another system.">
<Icon name="lock" className="gf-form-icon--right-absolute" />
</Tooltip>
)}
</div>
<div className="gf-form max-width-30">
<InlineFormLabel className="width-8">Username</InlineFormLabel>
<Input
className="gf-form-input max-width-22"
type="text"
onChange={this.onLoginChange}
value={login}
disabled={disableLoginForm}
/>
{disableLoginForm && (
<Tooltip content="Login Details Locked - managed in another system.">
<Icon name="lock" className="gf-form-icon--right-absolute" />
</Tooltip>
)}
</div>
<div className="gf-form-button-row">
<Button variant="primary" onClick={this.onSubmitProfileUpdate} disabled={isSavingUser}>
Save
</Button>
</div>
</form>
</>
);
}
}
return (
<Form onSubmit={onSubmitProfileUpdate} validateOn="onBlur">
{({ register, errors }) => {
return (
<FieldSet label="Edit Profile">
<Field label="Name" invalid={!!errors.name} error="Name is required">
<Input name="name" ref={register({ required: true })} placeholder="Name" defaultValue={user.name} />
</Field>
<Field label="Email" invalid={!!errors.email} error="Email is required" disabled={disableLoginForm}>
<Input
name="email"
ref={register({ required: true })}
placeholder="Email"
defaultValue={user.email}
suffix={<InputSuffix />}
/>
</Field>
<Field label="Username" disabled={disableLoginForm}>
<Input
name="login"
ref={register}
defaultValue={user.login}
placeholder="Username"
suffix={<InputSuffix />}
/>
</Field>
<div className="gf-form-button-row">
<Button variant="primary" disabled={isSavingUser}>
Save
</Button>
</div>
</FieldSet>
);
}}
</Form>
);
};
export default UserProfileEditForm;
const InputSuffix: FC = () => {
return disableLoginForm ? (
<Tooltip content="Login Details Locked - managed in another system.">
<Icon name="lock" />
</Tooltip>
) : null;
};
import React, { PureComponent } from 'react';
import { User, UserSession } from 'app/types';
import { LoadingPlaceholder, Button, Icon } from '@grafana/ui';
export interface Props {
user: User;
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;
if (isLoading) {
return <LoadingPlaceholder text="Loading sessions..." />;
}
return (
<>
{sessions.length > 0 && (
<>
<h3 className="page-sub-heading">Sessions</h3>
<div className="gf-form-group">
<table className="filter-table form-inline">
<thead>
<tr>
<th>Last seen</th>
<th>Logged on</th>
<th>IP address</th>
<th>Browser &amp; OS</th>
<th></th>
</tr>
</thead>
<tbody>
{sessions.map((session: UserSession, index) => (
<tr key={index}>
{session.isActive ? <td>Now</td> : <td>{session.seenAt}</td>}
<td>{session.createdAt}</td>
<td>{session.clientIp}</td>
<td>
{session.browser} on {session.os} {session.osVersion}
</td>
<td>
<Button size="sm" variant="destructive" onClick={() => revokeUserSession(session.id)}>
<Icon name="power" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
);
}
}
export default UserSessions;
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<react-profile-wrapper></react-profile-wrapper>
<h3 class="page-heading">Sessions</h3>
<div class="gf-form-group">
<table class="filter-table form-inline">
<thead>
<tr>
<th>Last seen</th>
<th>Logged on</th>
<th>IP address</th>
<th>Browser &amp; OS</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="session in ctrl.sessions">
<td ng-if="session.isActive">Now</td>
<td ng-if="!session.isActive">{{ session.seenAt }}</td>
<td>{{ session.createdAt }}</td>
<td>{{ session.clientIp }}</td>
<td>{{ session.browser }} on {{ session.os }} {{ session.osVersion }}</td>
<td>
<button class="btn btn-danger btn-small" ng-click="ctrl.revokeUserSession(session.id)">
<icon name="'power'"></icon>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<footer />
......@@ -302,10 +302,12 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
},
})
.when('/profile', {
templateUrl: 'public/app/features/profile/partials/profile.html',
controller: 'ProfileCtrl',
controllerAs: 'ctrl',
template: '<react-container />',
reloadOnSearch: false,
resolve: {
component: () =>
SafeDynamicImport(import(/* webpackChunkName: "UserProfileEdit" */ 'app/features/profile/UserProfileEdit')),
},
})
.when('/profile/password', {
template: '<react-container />',
......@@ -313,7 +315,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
resolve: {
component: () =>
SafeDynamicImport(
import(/* webPackChunkName: "ChangePasswordPage" */ 'app/features/profile/ChangePasswordPage')
import(/* webpackChunkName: "ChangePasswordPage" */ 'app/features/profile/ChangePasswordPage')
),
},
})
......@@ -322,7 +324,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
reloadOnSearch: false,
resolve: {
component: () =>
SafeDynamicImport(import(/* webPackChunkName: "SelectOrgPage" */ 'app/features/org/SelectOrgPage')),
SafeDynamicImport(import(/* webpackChunkName: "SelectOrgPage" */ 'app/features/org/SelectOrgPage')),
},
})
// ADMIN
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment