Skip to content

Commit 602746c

Browse files
brichetkrassowski
andauthored
Add dialog to update user info (#482)
* Add dialog to update user info * Use labelled inputs for all the fields * Add test on dialog * Apply PR suggestions Co-authored-by: Michał Krassowski <[email protected]> * Add translation * Rename the translation variable to __trans Co-authored-by: Michał Krassowski <[email protected]> * Rename the property name 'translator' to 'trans' --------- Co-authored-by: Michał Krassowski <[email protected]>
1 parent ca8c21a commit 602746c

File tree

7 files changed

+279
-33
lines changed

7 files changed

+279
-33
lines changed

packages/collaboration-extension/src/collaboration.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,10 @@ export const rtcPanelPlugin: JupyterFrontEndPlugin<void> = {
151151
userPanel.addClass('jp-RTCPanel');
152152
app.shell.add(userPanel, 'left', { rank: 300 });
153153

154-
const currentUserPanel = new UserInfoPanel(user);
154+
const currentUserPanel = new UserInfoPanel({
155+
userManager: user,
156+
trans
157+
});
155158
currentUserPanel.title.label = trans.__('User info');
156159
currentUserPanel.title.caption = trans.__('User information');
157160
userPanel.addWidget(currentUserPanel);

packages/collaboration/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@jupyterlab/apputils": "^4.4.0",
4545
"@jupyterlab/coreutils": "^6.4.0",
4646
"@jupyterlab/docregistry": "^4.4.0",
47+
"@jupyterlab/rendermime-interfaces": "^3.12.0",
4748
"@jupyterlab/services": "^7.4.0",
4849
"@jupyterlab/ui-components": "^4.4.0",
4950
"@lumino/coreutils": "^2.2.1",

packages/collaboration/src/components.tsx

Lines changed: 122 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,139 @@
22
// Distributed under the terms of the Modified BSD License.
33

44
import { User } from '@jupyterlab/services';
5+
import { ReactWidget } from '@jupyterlab/ui-components';
56

6-
import * as React from 'react';
7+
import React, { useEffect, useState } from 'react';
78

8-
type Props = {
9-
user: User.IIdentity;
9+
type UserIconProps = {
10+
/**
11+
* The user manager instance.
12+
*/
13+
userManager: User.IManager;
14+
/**
15+
* An optional onclick handler for the icon.
16+
*
17+
*/
18+
onClick?: () => void;
1019
};
1120

1221
/**
1322
* React component for the user icon.
1423
*
1524
* @returns The React component
1625
*/
17-
export const UserIconComponent: React.FC<Props> = props => {
18-
const { user } = props;
26+
export function UserIconComponent(props: UserIconProps): JSX.Element {
27+
const { userManager, onClick } = props;
28+
const [user, setUser] = useState(userManager.identity!);
29+
30+
useEffect(() => {
31+
const updateUser = () => {
32+
setUser(userManager.identity!);
33+
};
34+
35+
userManager.userChanged.connect(updateUser);
36+
37+
return () => {
38+
userManager.userChanged.disconnect(updateUser);
39+
};
40+
}, [userManager]);
1941

2042
return (
21-
<div className="jp-UserInfo-Container">
22-
<div
23-
title={user.display_name}
24-
className="jp-UserInfo-Icon"
25-
style={{ backgroundColor: user.color }}
26-
>
27-
<span>{user.initials}</span>
28-
</div>
29-
<h3>{user.display_name}</h3>
43+
<div
44+
title={user.display_name}
45+
className="jp-UserInfo-Icon"
46+
style={{ backgroundColor: user.color }}
47+
onClick={onClick}
48+
>
49+
<span>{user.initials}</span>
3050
</div>
3151
);
52+
}
53+
54+
type UserDetailsBodyProps = {
55+
/**
56+
* The user manager instance.
57+
**/
58+
userManager: User.IManager;
59+
};
60+
61+
/**
62+
* React widget for the user details.
63+
**/
64+
export class UserDetailsBody extends ReactWidget {
65+
/**
66+
* Constructs a new user details widget.
67+
*/
68+
constructor(props: UserDetailsBodyProps) {
69+
super();
70+
this._userManager = props.userManager;
71+
}
72+
73+
/**
74+
* Get the user modified fields.
75+
*/
76+
getValue(): UserUpdate {
77+
return this._userUpdate;
78+
}
79+
80+
/**
81+
* Handle change on a field, by updating the user object.
82+
*/
83+
private _onChange = (
84+
event: React.ChangeEvent<HTMLInputElement>,
85+
field: string
86+
) => {
87+
const updatableFields = (this._userManager.permissions?.[
88+
'updatable_fields'
89+
] || []) as string[];
90+
if (!updatableFields?.includes(field)) {
91+
return;
92+
}
93+
94+
this._userUpdate[field as keyof Omit<User.IIdentity, 'username'>] =
95+
event.target.value;
96+
};
97+
98+
render() {
99+
const identity = this._userManager.identity;
100+
if (!identity) {
101+
return <div className="jp-UserInfo-Details">Error loading user info</div>;
102+
}
103+
const updatableFields = (this._userManager.permissions?.[
104+
'updatable_fields'
105+
] || []) as string[];
106+
107+
return (
108+
<div className="jp-UserInfo-Details">
109+
{Object.keys(identity).map((field: string) => {
110+
const id = `jp-UserInfo-Value-${field}`;
111+
return (
112+
<div key={field} className="jp-UserInfo-Field">
113+
<label htmlFor={id}>{field}</label>
114+
<input
115+
type={'text'}
116+
name={field}
117+
id={id}
118+
onInput={(event: React.ChangeEvent<HTMLInputElement>) =>
119+
this._onChange(event, field)
120+
}
121+
defaultValue={identity[field] as string}
122+
disabled={!updatableFields?.includes(field)}
123+
/>
124+
</div>
125+
);
126+
})}
127+
</div>
128+
);
129+
}
130+
131+
private _userManager: User.IManager;
132+
private _userUpdate: UserUpdate = {};
133+
}
134+
135+
/**
136+
* Type for the user update object.
137+
*/
138+
export type UserUpdate = {
139+
[field in keyof Omit<User.IIdentity, 'username'>]: string;
32140
};
Lines changed: 87 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,52 @@
11
// Copyright (c) Jupyter Development Team.
22
// Distributed under the terms of the Modified BSD License.
33

4-
import { ReactWidget } from '@jupyterlab/apputils';
4+
import { Dialog, ReactWidget, showDialog } from '@jupyterlab/apputils';
55

6-
import { User } from '@jupyterlab/services';
6+
import { ServerConnection, User } from '@jupyterlab/services';
7+
8+
import { URLExt } from '@jupyterlab/coreutils';
9+
10+
import { IRenderMime } from '@jupyterlab/rendermime-interfaces';
711

812
import { Panel } from '@lumino/widgets';
913

1014
import * as React from 'react';
1115

12-
import { UserIconComponent } from './components';
16+
import { UserDetailsBody, UserIconComponent } from './components';
17+
18+
/**
19+
* The properties for the UserInfoBody.
20+
*/
21+
type UserInfoProps = {
22+
userManager: User.IManager;
23+
trans: IRenderMime.TranslationBundle;
24+
};
1325

1426
export class UserInfoPanel extends Panel {
1527
private _profile: User.IManager;
1628
private _body: UserInfoBody | null;
1729

18-
constructor(user: User.IManager) {
30+
constructor(options: UserInfoProps) {
1931
super({});
2032
this.addClass('jp-UserInfoPanel');
21-
22-
this._profile = user;
33+
this._profile = options.userManager;
2334
this._body = null;
2435

2536
if (this._profile.isReady) {
26-
this._body = new UserInfoBody(this._profile.identity!);
37+
this._body = new UserInfoBody({
38+
userManager: this._profile,
39+
trans: options.trans
40+
});
2741
this.addWidget(this._body);
2842
this.update();
2943
} else {
3044
this._profile.ready
3145
.then(() => {
32-
this._body = new UserInfoBody(this._profile.identity!);
46+
this._body = new UserInfoBody({
47+
userManager: this._profile,
48+
trans: options.trans
49+
});
3350
this.addWidget(this._body);
3451
this.update();
3552
})
@@ -41,27 +58,79 @@ export class UserInfoPanel extends Panel {
4158
/**
4259
* A SettingsWidget for the user.
4360
*/
44-
export class UserInfoBody extends ReactWidget {
45-
private _user: User.IIdentity;
46-
61+
export class UserInfoBody
62+
extends ReactWidget
63+
implements Dialog.IBodyWidget<User.IManager>
64+
{
65+
private _userManager: User.IManager;
66+
private _trans: IRenderMime.TranslationBundle;
4767
/**
4868
* Constructs a new settings widget.
4969
*/
50-
constructor(user: User.IIdentity) {
70+
constructor(props: UserInfoProps) {
5171
super();
52-
this._user = user;
72+
this._userManager = props.userManager;
73+
this._trans = props.trans;
5374
}
5475

55-
get user(): User.IIdentity {
56-
return this._user;
76+
get user(): User.IManager {
77+
return this._userManager;
5778
}
5879

59-
set user(user: User.IIdentity) {
60-
this._user = user;
80+
set user(user: User.IManager) {
81+
this._userManager = user;
6182
this.update();
6283
}
6384

85+
private onClick = () => {
86+
if (!this._userManager.identity) {
87+
return;
88+
}
89+
showDialog({
90+
body: new UserDetailsBody({
91+
userManager: this._userManager
92+
}),
93+
title: this._trans.__('User Details')
94+
}).then(async result => {
95+
if (result.button.accept) {
96+
// Call the Jupyter Server API to update the user field
97+
try {
98+
const settings = ServerConnection.makeSettings();
99+
const url = URLExt.join(settings.baseUrl, '/api/me');
100+
const body = {
101+
method: 'PATCH',
102+
body: JSON.stringify(result.value)
103+
};
104+
105+
let response: Response;
106+
try {
107+
response = await ServerConnection.makeRequest(url, body, settings);
108+
} catch (error) {
109+
throw new ServerConnection.NetworkError(error as Error);
110+
}
111+
112+
if (!response.ok) {
113+
const errorMsg = this._trans.__('Failed to update user data');
114+
throw new Error(errorMsg);
115+
}
116+
117+
// Refresh user information
118+
this._userManager.refreshUser();
119+
} catch (error) {
120+
console.error(error);
121+
}
122+
}
123+
});
124+
};
125+
64126
render(): JSX.Element {
65-
return <UserIconComponent user={this._user} />;
127+
return (
128+
<div className="jp-UserInfo-Container">
129+
<UserIconComponent
130+
userManager={this._userManager}
131+
onClick={this.onClick}
132+
/>
133+
</div>
134+
);
66135
}
67136
}

packages/collaboration/style/sidepanel.css

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,38 @@
142142
box-shadow: 0 2px 2px -2px rgb(0 0 0 / 24%);
143143

144144
}
145+
146+
/************************************************************
147+
User Info Details
148+
*************************************************************/
149+
.jp-UserInfo-Field {
150+
display: flex;
151+
justify-content: space-between;
152+
}
153+
154+
.jp-UserInfo-Field > label,
155+
.jp-UserInfo-Field > input {
156+
padding: 0.5em 1em;
157+
margin: 0.25em 0;
158+
}
159+
160+
.jp-UserInfo-Field > label {
161+
font-weight: bold;
162+
}
163+
164+
.jp-UserInfo-Field > input {
165+
border: none;
166+
}
167+
168+
.jp-UserInfo-Field > input:not(:disabled) {
169+
cursor: pointer;
170+
background-color: var(--jp-input-background);
171+
}
172+
173+
.jp-UserInfo-Field > input:focus {
174+
border: solid 1px var(--jp-cell-editor-active-border-color);
175+
}
176+
177+
.jp-UserInfo-Field > input:focus-visible {
178+
outline: none;
179+
}

ui-tests/tests/collaborationpanel.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,35 @@ test('collaboration panel should contains two items', async ({ page }) => {
4747
expect(panel.locator('.jp-CollaboratorsList .jp-Collaborator')).toHaveCount(0);
4848
});
4949

50+
test.describe('User info panel', () => {
51+
test('should contain the user info icon', async ({ page }) => {
52+
const panel = await openPanel(page);
53+
const userInfoIcon = panel.locator('.jp-UserInfo-Icon');
54+
expect(userInfoIcon).toHaveCount(1);
55+
});
56+
57+
test('should open the user info dialog', async ({ page }) => {
58+
const panel = await openPanel(page);
59+
const userInfoIcon = panel.locator('.jp-UserInfo-Icon');
60+
await userInfoIcon.click();
61+
62+
const dialog = page.locator('.jp-Dialog-body');
63+
expect(dialog).toHaveCount(1);
64+
65+
const userInfoPanel = page.locator('.jp-UserInfoPanel');
66+
expect(userInfoPanel).toHaveCount(1);
67+
68+
const userName = page.locator('input[name="display_name"]');
69+
expect(userName).toHaveCount(1);
70+
expect(await userName.inputValue()).toBe('jovyan');
71+
72+
const cancelButton = page.locator(
73+
'.jp-Dialog-button .jp-Dialog-buttonLabel:has-text("Cancel")'
74+
);
75+
await cancelButton.click();
76+
expect(dialog).toHaveCount(0);
77+
});
78+
});
5079

5180
test.describe('One client', () => {
5281
let guestPage: IJupyterLabPageFixture;

0 commit comments

Comments
 (0)