Refactor User Profile page and add extension points to it (#4996)

* Move components specific to UserProfile page to corresponding folder

* Split UserProfile page into components

* Rename components, refine code a bit

* Add some extension points

* Fix margin
This commit is contained in:
Levko Kravets 2020-06-25 12:03:19 +03:00 committed by GitHub
parent a563900f0a
commit 6f842ef94a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 547 additions and 397 deletions

View File

@ -1,295 +0,0 @@
import React, { Fragment } from "react";
import { includes, get } from "lodash";
import Alert from "antd/lib/alert";
import Button from "antd/lib/button";
import Form from "antd/lib/form";
import Modal from "antd/lib/modal";
import Tag from "antd/lib/tag";
import User from "@/services/user";
import Group from "@/services/group";
import { currentUser } from "@/services/auth";
import { absoluteUrl } from "@/services/utils";
import { UserProfile } from "../proptypes";
import DynamicForm from "../dynamic-form/DynamicForm";
import ChangePasswordDialog from "./ChangePasswordDialog";
import InputWithCopy from "../InputWithCopy";
export default class UserEdit extends React.Component {
static propTypes = {
user: UserProfile.isRequired,
};
constructor(props) {
super(props);
this.state = {
user: this.props.user,
groups: [],
loadingGroups: true,
regeneratingApiKey: false,
sendingPasswordEmail: false,
resendingInvitation: false,
togglingUser: false,
};
}
componentDidMount() {
Group.query().then(groups => {
this.setState({
groups: groups.map(({ id, name }) => ({ value: id, name })),
loadingGroups: false,
});
});
}
changePassword = () => {
ChangePasswordDialog.showModal({ user: this.props.user });
};
sendPasswordReset = () => {
this.setState({ sendingPasswordEmail: true });
User.sendPasswordReset(this.state.user)
.then(passwordLink => {
this.setState({ passwordLink });
})
.finally(() => {
this.setState({ sendingPasswordEmail: false });
});
};
resendInvitation = () => {
this.setState({ resendingInvitation: true });
User.resendInvitation(this.state.user)
.then(passwordLink => {
this.setState({ passwordLink });
})
.finally(() => {
this.setState({ resendingInvitation: false });
});
};
regenerateApiKey = () => {
const doRegenerate = () => {
this.setState({ regeneratingApiKey: true });
User.regenerateApiKey(this.state.user)
.then(apiKey => {
if (apiKey) {
const { user } = this.state;
this.setState({ user: { ...user, apiKey } });
}
})
.finally(() => {
this.setState({ regeneratingApiKey: false });
});
};
Modal.confirm({
title: "Regenerate API Key",
content: "Are you sure you want to regenerate?",
okText: "Regenerate",
onOk: doRegenerate,
maskClosable: true,
autoFocusButton: null,
});
};
toggleUser = () => {
const { user } = this.state;
const toggleUser = user.isDisabled ? User.enableUser : User.disableUser;
this.setState({ togglingUser: true });
toggleUser(user)
.then(data => {
if (data) {
this.setState({ user: User.convertUserInfo(data) });
}
})
.finally(() => {
this.setState({ togglingUser: false });
});
};
saveUser = (values, successCallback, errorCallback) => {
const data = {
id: this.props.user.id,
...values,
};
User.save(data)
.then(user => {
successCallback("Saved.");
this.setState({ user: User.convertUserInfo(user) });
})
.catch(error => {
errorCallback(get(error, "response.data.message", "Failed saving."));
});
};
renderUserInfoForm() {
const { user, groups, loadingGroups } = this.state;
const formFields = [
{
name: "name",
title: "Name",
type: "text",
initialValue: user.name,
},
{
name: "email",
title: "Email",
type: "email",
initialValue: user.email,
},
!user.isDisabled && currentUser.id !== user.id
? {
name: "group_ids",
title: "Groups",
type: "select",
mode: "multiple",
options: groups,
initialValue: groups.filter(group => includes(user.groupIds, group.value)).map(group => group.value),
loading: loadingGroups,
placeholder: loadingGroups ? "Loading..." : "",
}
: {
name: "group_ids",
title: "Groups",
type: "content",
content: this.renderUserGroups(),
},
].map(field => ({ readOnly: user.isDisabled, required: true, ...field }));
return <DynamicForm fields={formFields} onSubmit={this.saveUser} hideSubmitButton={user.isDisabled} />;
}
renderUserGroups() {
const { user, groups, loadingGroups } = this.state;
return loadingGroups ? (
"Loading..."
) : (
<div data-test="Groups">
{groups
.filter(group => includes(user.groupIds, group.value))
.map(group => (
<Tag className="m-b-5 m-r-5" key={group.value}>
<a href={`groups/${group.value}`}>{group.name}</a>
</Tag>
))}
</div>
);
}
renderApiKey() {
const { user, regeneratingApiKey } = this.state;
return (
<Form layout="vertical">
<hr />
<Form.Item label="API Key" className="m-b-10">
<InputWithCopy id="apiKey" className="hide-in-percy" value={user.apiKey} data-test="ApiKey" readOnly />
</Form.Item>
<Button
className="w-100"
onClick={this.regenerateApiKey}
loading={regeneratingApiKey}
data-test="RegenerateApiKey">
Regenerate
</Button>
</Form>
);
}
renderPasswordLinkAlert() {
const { user, passwordLink } = this.state;
return (
<Alert
message="Email not sent!"
description={
<Fragment>
<p>
The mail server is not configured, please send the following link to <b>{user.name}</b>:
</p>
<InputWithCopy value={absoluteUrl(passwordLink)} readOnly />
</Fragment>
}
type="warning"
className="m-t-20"
afterClose={() => {
this.setState({ passwordLink: null });
}}
closable
/>
);
}
renderResendInvitation() {
return (
<Button className="w-100 m-t-10" onClick={this.resendInvitation} loading={this.state.resendingInvitation}>
Resend Invitation
</Button>
);
}
renderSendPasswordReset() {
const { sendingPasswordEmail } = this.state;
return (
<Fragment>
<Button className="w-100 m-t-10" onClick={this.sendPasswordReset} loading={sendingPasswordEmail}>
Send Password Reset Email
</Button>
</Fragment>
);
}
rendertoggleUser() {
const { user, togglingUser } = this.state;
return user.isDisabled ? (
<Button className="w-100 m-t-10" type="primary" onClick={this.toggleUser} loading={togglingUser}>
Enable User
</Button>
) : (
<Button className="w-100 m-t-10" type="danger" onClick={this.toggleUser} loading={togglingUser}>
Disable User
</Button>
);
}
render() {
const { user, passwordLink } = this.state;
return (
<div className="col-md-4 col-md-offset-4">
<img alt="Profile" src={user.profileImageUrl} className="profile__image" width="40" />
<h3 className="profile__h3">{user.name}</h3>
<hr />
{this.renderUserInfoForm()}
{!user.isDisabled && (
<Fragment>
{this.renderApiKey()}
<hr />
<h5>Password</h5>
{user.id === currentUser.id && (
<Button className="w-100 m-t-10" onClick={this.changePassword} data-test="ChangePassword">
Change Password
</Button>
)}
{currentUser.isAdmin && user.id !== currentUser.id && (
<Fragment>
{user.isInvitationPending ? this.renderResendInvitation() : this.renderSendPasswordReset()}
{passwordLink && this.renderPasswordLinkAlert()}
</Fragment>
)}
</Fragment>
)}
<hr />
{currentUser.isAdmin && user.id !== currentUser.id && this.rendertoggleUser()}
</div>
);
}
}

View File

@ -1,63 +0,0 @@
import React from "react";
import { includes } from "lodash";
import Tag from "antd/lib/tag";
import Group from "@/services/group";
import { UserProfile } from "../proptypes";
export default class UserShow extends React.Component {
static propTypes = {
user: UserProfile.isRequired,
};
constructor(props) {
super(props);
this.state = { groups: [], loadingGroups: true };
}
componentDidMount() {
Group.query().then(groups => {
this.setState({ groups, loadingGroups: false });
});
}
renderUserGroups() {
const { groupIds } = this.props.user;
const { groups } = this.state;
return (
<div>
{groups
.filter(group => includes(groupIds, group.id))
.map(group => (
<Tag className="m-t-5 m-r-5" key={group.id}>
<a href={`groups/${group.id}`}>{group.name}</a>
</Tag>
))}
</div>
);
}
render() {
const { name, email, profileImageUrl } = this.props.user;
const { loadingGroups } = this.state;
return (
<div className="col-md-4 col-md-offset-4 profile__container">
<img alt="profile" src={profileImageUrl} className="profile__image" width="40" />
<h3 className="profile__h3">{name}</h3>
<hr />
<dl className="profile__dl">
<dt>Name:</dt>
<dd>{name}</dd>
<dt>Email:</dt>
<dd>{email}</dd>
<dt>Groups:</dt>
<dd>{loadingGroups ? "Loading..." : this.renderUserGroups()}</dd>
</dl>
</div>
);
}
}

View File

@ -1,52 +1,72 @@
import React from "react";
import React, { useState, useRef, useEffect } from "react";
import PropTypes from "prop-types";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import EmailSettingsWarning from "@/components/EmailSettingsWarning";
import UserEdit from "@/components/users/UserEdit";
import UserShow from "@/components/users/UserShow";
import DynamicComponent from "@/components/DynamicComponent";
import LoadingState from "@/components/items-list/components/LoadingState";
import wrapSettingsTab from "@/components/SettingsWrapper";
import User from "@/services/user";
import { currentUser } from "@/services/auth";
import EditableUserProfile from "./components/EditableUserProfile";
import ReadOnlyUserProfile from "./components/ReadOnlyUserProfile";
import "./settings.less";
class UserProfile extends React.Component {
static propTypes = {
userId: PropTypes.string,
onError: PropTypes.func,
};
function UserProfile({ userId, onError }) {
const [user, setUser] = useState(null);
static defaultProps = {
userId: null, // defaults to `currentUser.id`
onError: () => {},
};
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
constructor(props) {
super(props);
this.state = { user: null };
}
useEffect(() => {
let isCancelled = false;
User.get({ id: userId || currentUser.id })
.then(user => {
if (!isCancelled) {
setUser(User.convertUserInfo(user));
}
})
.catch(error => {
if (!isCancelled) {
onErrorRef.current(error);
}
});
componentDidMount() {
const userId = this.props.userId || currentUser.id;
User.get({ id: userId })
.then(user => this.setState({ user: User.convertUserInfo(user) }))
.catch(error => this.props.onError(error));
}
return () => {
isCancelled = true;
};
}, [userId]);
render() {
const { user } = this.state;
const canEdit = user && (currentUser.isAdmin || currentUser.id === user.id);
const UserComponent = canEdit ? UserEdit : UserShow;
return (
<React.Fragment>
<EmailSettingsWarning featureName="invite emails" className="m-b-20" adminOnly />
<div className="row">{user ? <UserComponent user={user} /> : <LoadingState className="" />}</div>
</React.Fragment>
);
}
const canEdit = user && (currentUser.isAdmin || currentUser.id === user.id);
return (
<React.Fragment>
<EmailSettingsWarning featureName="invite emails" className="m-b-20" adminOnly />
<div className="row">
{!user && <LoadingState className="" />}
{user && (
<DynamicComponent name="UserProfile" user={user}>
{!canEdit && <ReadOnlyUserProfile user={user} />}
{canEdit && <EditableUserProfile user={user} />}
</DynamicComponent>
)}
</div>
</React.Fragment>
);
}
UserProfile.propTypes = {
userId: PropTypes.string,
onError: PropTypes.func,
};
UserProfile.defaultProps = {
userId: null, // defaults to `currentUser.id`
onError: () => {},
};
const UserProfilePage = wrapSettingsTab(
{
title: "Account",

View File

@ -20,7 +20,6 @@ import * as Sidebar from "@/components/items-list/components/Sidebar";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import Layout from "@/components/layouts/ContentWithSidebar";
import CreateUserDialog from "@/components/users/CreateUserDialog";
import wrapSettingsTab from "@/components/SettingsWrapper";
import { currentUser } from "@/services/auth";
@ -30,6 +29,8 @@ import navigateTo from "@/components/ApplicationArea/navigateTo";
import notification from "@/services/notification";
import { absoluteUrl } from "@/services/utils";
import CreateUserDialog from "./components/CreateUserDialog";
function UsersListActions({ user, enableUser, disableUser, deleteUser }) {
if (user.id === currentUser.id) {
return null;

View File

@ -0,0 +1,64 @@
import React, { useState, useRef, useCallback } from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Form from "antd/lib/form";
import Modal from "antd/lib/modal";
import DynamicComponent from "@/components/DynamicComponent";
import InputWithCopy from "@/components/InputWithCopy";
import { UserProfile } from "@/components/proptypes";
import User from "@/services/user";
export default function ApiKeyForm(props) {
const { user, onChange } = props;
const [loading, setLoading] = useState(false);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const regenerateApiKey = useCallback(() => {
const doRegenerate = () => {
setLoading(true);
User.regenerateApiKey(user)
.then(apiKey => {
if (apiKey) {
onChangeRef.current({ ...user, apiKey });
}
})
.finally(() => {
setLoading(false);
});
};
Modal.confirm({
title: "Regenerate API Key",
content: "Are you sure you want to regenerate?",
okText: "Regenerate",
onOk: doRegenerate,
maskClosable: true,
autoFocusButton: null,
});
}, [user]);
return (
<DynamicComponent name="UserProfile.ApiKeyForm" {...props}>
<Form layout="vertical">
<hr />
<Form.Item label="API Key" className="m-b-10">
<InputWithCopy id="apiKey" className="hide-in-percy" value={user.apiKey} data-test="ApiKey" readOnly />
</Form.Item>
<Button className="w-100" onClick={regenerateApiKey} loading={loading} data-test="RegenerateApiKey">
Regenerate
</Button>
</Form>
</DynamicComponent>
);
}
ApiKeyForm.propTypes = {
user: UserProfile.isRequired,
onChange: PropTypes.func,
};
ApiKeyForm.defaultProps = {
onChange: () => {},
};

View File

@ -0,0 +1,37 @@
import React, { useState, useEffect } from "react";
import { UserProfile } from "@/components/proptypes";
import UserInfoForm from "./UserInfoForm";
import ApiKeyForm from "./ApiKeyForm";
import PasswordForm from "./PasswordForm";
import ToggleUserForm from "./ToggleUserForm";
export default function EditableUserProfile(props) {
const [user, setUser] = useState(props.user);
useEffect(() => {
setUser(props.user);
}, [props.user]);
return (
<div className="col-md-4 col-md-offset-4">
<img alt="Profile" src={user.profileImageUrl} className="profile__image" width="40" />
<h3 className="profile__h3">{user.name}</h3>
<hr />
<UserInfoForm user={user} onChange={setUser} />
{!user.isDisabled && (
<React.Fragment>
<ApiKeyForm user={user} onChange={setUser} />
<hr />
<PasswordForm user={user} />
</React.Fragment>
)}
<hr />
<ToggleUserForm user={user} onChange={setUser} />
</div>
);
}
EditableUserProfile.propTypes = {
user: UserProfile.isRequired,
};

View File

@ -3,10 +3,10 @@ import React from "react";
import Form from "antd/lib/form";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import { UserProfile } from "@/components/proptypes";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import User from "@/services/user";
import notification from "@/services/notification";
import { UserProfile } from "../proptypes";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
class ChangePasswordDialog extends React.Component {
static propTypes = {

View File

@ -0,0 +1,45 @@
import { isString } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Alert from "antd/lib/alert";
import DynamicComponent from "@/components/DynamicComponent";
import InputWithCopy from "@/components/InputWithCopy";
import { UserProfile } from "@/components/proptypes";
import { absoluteUrl } from "@/services/utils";
export default function PasswordLinkAlert(props) {
const { user, passwordLink, ...restProps } = props;
if (!isString(passwordLink)) {
return null;
}
return (
<DynamicComponent name="UserProfile.PasswordLinkAlert" {...props}>
<Alert
message="Email not sent!"
description={
<React.Fragment>
<p>
The mail server is not configured, please send the following link to <b>{user.name}</b>:
</p>
<InputWithCopy value={absoluteUrl(passwordLink)} readOnly />
</React.Fragment>
}
type="warning"
className="m-t-20"
closable
{...restProps}
/>
</DynamicComponent>
);
}
PasswordLinkAlert.propTypes = {
user: UserProfile.isRequired,
passwordLink: PropTypes.string,
};
PasswordLinkAlert.defaultProps = {
passwordLink: null,
};

View File

@ -0,0 +1,37 @@
import React, { useState, useCallback } from "react";
import Button from "antd/lib/button";
import DynamicComponent from "@/components/DynamicComponent";
import { UserProfile } from "@/components/proptypes";
import User from "@/services/user";
import PasswordLinkAlert from "./PasswordLinkAlert";
export default function PasswordResetForm(props) {
const { user } = props;
const [loading, setLoading] = useState(false);
const [passwordLink, setPasswordLink] = useState(null);
const sendPasswordReset = useCallback(() => {
setLoading(true);
User.sendPasswordReset(user)
.then(passwordLink => {
setPasswordLink(passwordLink);
})
.finally(() => {
setLoading(false);
});
}, [user]);
return (
<DynamicComponent name="UserProfile.PasswordResetForm" {...props}>
<Button className="w-100 m-t-10" onClick={sendPasswordReset} loading={loading}>
Send Password Reset Email
</Button>
<PasswordLinkAlert user={user} passwordLink={passwordLink} afterClose={() => setPasswordLink(null)} />
</DynamicComponent>
);
}
PasswordResetForm.propTypes = {
user: UserProfile.isRequired,
};

View File

@ -0,0 +1,38 @@
import React, { useState, useCallback } from "react";
import Button from "antd/lib/button";
import DynamicComponent from "@/components/DynamicComponent";
import { UserProfile } from "@/components/proptypes";
import User from "@/services/user";
import PasswordLinkAlert from "./PasswordLinkAlert";
export default function ResendInvitationForm(props) {
const { user } = props;
const [loading, setLoading] = useState(false);
const [passwordLink, setPasswordLink] = useState(null);
const resendInvitation = useCallback(() => {
setLoading(true);
User.resendInvitation(user)
.then(passwordLink => {
setPasswordLink(passwordLink);
})
.finally(() => {
setLoading(false);
});
}, [user]);
return (
<DynamicComponent name="UserProfile.ResendInvitationForm" {...props}>
<Button className="w-100 m-t-10" onClick={resendInvitation} loading={loading}>
Resend Invitation
</Button>
<PasswordLinkAlert user={user} passwordLink={passwordLink} afterClose={() => setPasswordLink(null)} />
</DynamicComponent>
);
}
ResendInvitationForm.propTypes = {
user: UserProfile.isRequired,
};

View File

@ -0,0 +1,37 @@
import React, { useCallback } from "react";
import Button from "antd/lib/button";
import DynamicComponent from "@/components/DynamicComponent";
import { UserProfile } from "@/components/proptypes";
import { currentUser } from "@/services/auth";
import ChangePasswordDialog from "./ChangePasswordDialog";
import PasswordResetForm from "./PasswordResetForm";
import ResendInvitationForm from "./ResendInvitationForm";
export default function PasswordForm(props) {
const { user } = props;
const changePassword = useCallback(() => {
ChangePasswordDialog.showModal({ user });
}, [user]);
return (
<DynamicComponent name="UserProfile.PasswordForm" {...props}>
<h5>Password</h5>
{user.id === currentUser.id && (
<Button className="w-100 m-t-10" onClick={changePassword} data-test="ChangePassword">
Change Password
</Button>
)}
{user.id !== currentUser.id && currentUser.isAdmin && (
<React.Fragment>
{user.isInvitationPending ? <ResendInvitationForm user={user} /> : <PasswordResetForm user={user} />}
</React.Fragment>
)}
</DynamicComponent>
);
}
PasswordForm.propTypes = {
user: UserProfile.isRequired,
};

View File

@ -0,0 +1,29 @@
import React from "react";
import { UserProfile } from "@/components/proptypes";
import UserGroups from "./UserGroups";
import useUserGroups from "../hooks/useUserGroups";
export default function ReadOnlyUserProfile({ user }) {
const { groups, isLoading: isLoadingGroups } = useUserGroups(user);
return (
<div className="col-md-4 col-md-offset-4 profile__container">
<img alt="profile" src={user.profileImageUrl} className="profile__image" width="40" />
<h3 className="profile__h3">{user.name}</h3>
<hr />
<dl className="profile__dl">
<dt>Name:</dt>
<dd>{user.name}</dd>
<dt>Email:</dt>
<dd>{user.email}</dd>
<dt className="m-b-5">Groups:</dt>
<dd>{isLoadingGroups ? "Loading..." : <UserGroups groups={groups} />}</dd>
</dl>
</div>
);
}
ReadOnlyUserProfile.propTypes = {
user: UserProfile.isRequired,
};

View File

@ -1,7 +1,7 @@
import React from "react";
import renderer from "react-test-renderer";
import Group from "@/services/group";
import UserShow from "./UserShow";
import ReadOnlyUserProfile from "./ReadOnlyUserProfile";
beforeEach(() => {
Group.query = jest.fn().mockResolvedValue([]);
@ -16,7 +16,7 @@ test("renders correctly", () => {
profileImageUrl: "http://www.images.com/llama.jpg",
};
const component = renderer.create(<UserShow user={user} />);
const component = renderer.create(<ReadOnlyUserProfile user={user} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});

View File

@ -0,0 +1,53 @@
import React, { useState, useRef, useCallback } from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import DynamicComponent from "@/components/DynamicComponent";
import { UserProfile } from "@/components/proptypes";
import { currentUser } from "@/services/auth";
import User from "@/services/user";
export default function ToggleUserForm(props) {
const { user, onChange } = props;
const [loading, setLoading] = useState(false);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const toggleUser = useCallback(() => {
const action = user.isDisabled ? User.enableUser : User.disableUser;
setLoading(true);
action(user)
.then(data => {
if (data) {
onChangeRef.current(User.convertUserInfo(data));
}
})
.finally(() => {
setLoading(false);
});
}, [user]);
if (!currentUser.isAdmin || user.id === currentUser.id) {
return null;
}
const buttonProps = {
type: user.isDisabled ? "primary" : "danger",
children: user.isDisabled ? "Enable User" : "Disable User",
};
return (
<DynamicComponent name="UserProfile.ToggleUserForm">
<Button className="w-100 m-t-10" onClick={toggleUser} loading={loading} {...buttonProps} />
</DynamicComponent>
);
}
ToggleUserForm.propTypes = {
user: UserProfile.isRequired,
onChange: PropTypes.func,
};
ToggleUserForm.defaultProps = {
onChange: () => {},
};

View File

@ -0,0 +1,29 @@
import { map } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Tag from "antd/lib/tag";
export default function UserGroups({ groups, ...props }) {
return (
<div {...props}>
{map(groups, group => (
<Tag className="m-b-5 m-r-5" key={group.id}>
<a href={`groups/${group.id}`}>{group.name}</a>
</Tag>
))}
</div>
);
}
UserGroups.propTypes = {
groups: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string,
})
),
};
UserGroups.defaultProps = {
groups: [],
};

View File

@ -0,0 +1,94 @@
import { get, map } from "lodash";
import React, { useRef, useMemo, useCallback } from "react";
import PropTypes from "prop-types";
import { UserProfile } from "@/components/proptypes";
import DynamicComponent from "@/components/DynamicComponent";
import DynamicForm from "@/components/dynamic-form/DynamicForm";
import User from "@/services/user";
import { currentUser } from "@/services/auth";
import UserGroups from "./UserGroups";
import useUserGroups from "../hooks/useUserGroups";
export default function UserInfoForm(props) {
const { user, onChange } = props;
const { groups, allGroups, isLoading: isLoadingGroups } = useUserGroups(user);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const saveUser = useCallback(
(values, successCallback, errorCallback) => {
const data = {
...values,
id: user.id,
};
User.save(data)
.then(user => {
successCallback("Saved.");
onChangeRef.current(User.convertUserInfo(user));
})
.catch(error => {
errorCallback(get(error, "response.data.message", "Failed saving."));
});
},
[user]
);
const formFields = useMemo(
() =>
map(
[
{
name: "name",
title: "Name",
type: "text",
initialValue: user.name,
},
{
name: "email",
title: "Email",
type: "email",
initialValue: user.email,
},
!user.isDisabled && currentUser.id !== user.id
? {
name: "group_ids",
title: "Groups",
type: "select",
mode: "multiple",
options: map(allGroups, group => ({ name: group.name, value: group.id })),
initialValue: map(groups, group => group.id),
loading: isLoadingGroups,
placeholder: isLoadingGroups ? "Loading..." : "",
}
: {
name: "group_ids",
title: "Groups",
type: "content",
content: isLoadingGroups ? "Loading..." : <UserGroups data-test="Groups" groups={groups} />,
},
],
field => ({ readOnly: user.isDisabled, required: true, ...field })
),
[user, groups, allGroups, isLoadingGroups]
);
return (
<DynamicComponent name="UserProfile.UserInfoForm" {...props}>
<DynamicForm fields={formFields} onSubmit={saveUser} hideSubmitButton={user.isDisabled} />
</DynamicComponent>
);
}
UserInfoForm.propTypes = {
user: UserProfile.isRequired,
onChange: PropTypes.func,
};
UserInfoForm.defaultProps = {
onChange: () => {},
};

View File

@ -31,7 +31,9 @@ exports[`renders correctly 1`] = `
<dd>
john@doe.com
</dd>
<dt>
<dt
className="m-b-5"
>
Groups:
</dt>
<dd>

View File

@ -0,0 +1,22 @@
import { filter, includes, isArray } from "lodash";
import { useEffect, useMemo, useState } from "react";
import Group from "@/services/group";
export default function useUserGroups(user) {
const [allGroups, setAllGroups] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const groups = useMemo(() => filter(allGroups, group => includes(user.groupIds, group.id)), [allGroups, user]);
useEffect(() => {
let isCancelled = false;
Group.query().then(groups => {
if (!isCancelled) {
setAllGroups(isArray(groups) ? groups : []);
setIsLoading(false);
}
});
}, []);
return useMemo(() => ({ groups, allGroups, isLoading }), [groups, allGroups, isLoading]);
}