diff --git a/packages/collaboration-extension/src/collaboration.ts b/packages/collaboration-extension/src/collaboration.ts index c11d353e..79b5e4af 100644 --- a/packages/collaboration-extension/src/collaboration.ts +++ b/packages/collaboration-extension/src/collaboration.ts @@ -151,7 +151,10 @@ export const rtcPanelPlugin: JupyterFrontEndPlugin = { userPanel.addClass('jp-RTCPanel'); app.shell.add(userPanel, 'left', { rank: 300 }); - const currentUserPanel = new UserInfoPanel(user); + const currentUserPanel = new UserInfoPanel({ + userManager: user, + trans + }); currentUserPanel.title.label = trans.__('User info'); currentUserPanel.title.caption = trans.__('User information'); userPanel.addWidget(currentUserPanel); diff --git a/packages/collaboration/package.json b/packages/collaboration/package.json index cc6db0d5..50cd66af 100644 --- a/packages/collaboration/package.json +++ b/packages/collaboration/package.json @@ -44,6 +44,7 @@ "@jupyterlab/apputils": "^4.4.0", "@jupyterlab/coreutils": "^6.4.0", "@jupyterlab/docregistry": "^4.4.0", + "@jupyterlab/rendermime-interfaces": "^3.12.0", "@jupyterlab/services": "^7.4.0", "@jupyterlab/ui-components": "^4.4.0", "@lumino/coreutils": "^2.2.1", diff --git a/packages/collaboration/src/components.tsx b/packages/collaboration/src/components.tsx index 97908667..c311b7bd 100644 --- a/packages/collaboration/src/components.tsx +++ b/packages/collaboration/src/components.tsx @@ -2,11 +2,20 @@ // Distributed under the terms of the Modified BSD License. import { User } from '@jupyterlab/services'; +import { ReactWidget } from '@jupyterlab/ui-components'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; -type Props = { - user: User.IIdentity; +type UserIconProps = { + /** + * The user manager instance. + */ + userManager: User.IManager; + /** + * An optional onclick handler for the icon. + * + */ + onClick?: () => void; }; /** @@ -14,19 +23,118 @@ type Props = { * * @returns The React component */ -export const UserIconComponent: React.FC = props => { - const { user } = props; +export function UserIconComponent(props: UserIconProps): JSX.Element { + const { userManager, onClick } = props; + const [user, setUser] = useState(userManager.identity!); + + useEffect(() => { + const updateUser = () => { + setUser(userManager.identity!); + }; + + userManager.userChanged.connect(updateUser); + + return () => { + userManager.userChanged.disconnect(updateUser); + }; + }, [userManager]); return ( -
-
- {user.initials} -
-

{user.display_name}

+
+ {user.initials}
); +} + +type UserDetailsBodyProps = { + /** + * The user manager instance. + **/ + userManager: User.IManager; +}; + +/** + * React widget for the user details. + **/ +export class UserDetailsBody extends ReactWidget { + /** + * Constructs a new user details widget. + */ + constructor(props: UserDetailsBodyProps) { + super(); + this._userManager = props.userManager; + } + + /** + * Get the user modified fields. + */ + getValue(): UserUpdate { + return this._userUpdate; + } + + /** + * Handle change on a field, by updating the user object. + */ + private _onChange = ( + event: React.ChangeEvent, + field: string + ) => { + const updatableFields = (this._userManager.permissions?.[ + 'updatable_fields' + ] || []) as string[]; + if (!updatableFields?.includes(field)) { + return; + } + + this._userUpdate[field as keyof Omit] = + event.target.value; + }; + + render() { + const identity = this._userManager.identity; + if (!identity) { + return
Error loading user info
; + } + const updatableFields = (this._userManager.permissions?.[ + 'updatable_fields' + ] || []) as string[]; + + return ( +
+ {Object.keys(identity).map((field: string) => { + const id = `jp-UserInfo-Value-${field}`; + return ( +
+ + ) => + this._onChange(event, field) + } + defaultValue={identity[field] as string} + disabled={!updatableFields?.includes(field)} + /> +
+ ); + })} +
+ ); + } + + private _userManager: User.IManager; + private _userUpdate: UserUpdate = {}; +} + +/** + * Type for the user update object. + */ +export type UserUpdate = { + [field in keyof Omit]: string; }; diff --git a/packages/collaboration/src/userinfopanel.tsx b/packages/collaboration/src/userinfopanel.tsx index 6a5c32b5..88b6e8e0 100644 --- a/packages/collaboration/src/userinfopanel.tsx +++ b/packages/collaboration/src/userinfopanel.tsx @@ -1,35 +1,52 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { ReactWidget } from '@jupyterlab/apputils'; +import { Dialog, ReactWidget, showDialog } from '@jupyterlab/apputils'; -import { User } from '@jupyterlab/services'; +import { ServerConnection, User } from '@jupyterlab/services'; + +import { URLExt } from '@jupyterlab/coreutils'; + +import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; import { Panel } from '@lumino/widgets'; import * as React from 'react'; -import { UserIconComponent } from './components'; +import { UserDetailsBody, UserIconComponent } from './components'; + +/** + * The properties for the UserInfoBody. + */ +type UserInfoProps = { + userManager: User.IManager; + trans: IRenderMime.TranslationBundle; +}; export class UserInfoPanel extends Panel { private _profile: User.IManager; private _body: UserInfoBody | null; - constructor(user: User.IManager) { + constructor(options: UserInfoProps) { super({}); this.addClass('jp-UserInfoPanel'); - - this._profile = user; + this._profile = options.userManager; this._body = null; if (this._profile.isReady) { - this._body = new UserInfoBody(this._profile.identity!); + this._body = new UserInfoBody({ + userManager: this._profile, + trans: options.trans + }); this.addWidget(this._body); this.update(); } else { this._profile.ready .then(() => { - this._body = new UserInfoBody(this._profile.identity!); + this._body = new UserInfoBody({ + userManager: this._profile, + trans: options.trans + }); this.addWidget(this._body); this.update(); }) @@ -41,27 +58,79 @@ export class UserInfoPanel extends Panel { /** * A SettingsWidget for the user. */ -export class UserInfoBody extends ReactWidget { - private _user: User.IIdentity; - +export class UserInfoBody + extends ReactWidget + implements Dialog.IBodyWidget +{ + private _userManager: User.IManager; + private _trans: IRenderMime.TranslationBundle; /** * Constructs a new settings widget. */ - constructor(user: User.IIdentity) { + constructor(props: UserInfoProps) { super(); - this._user = user; + this._userManager = props.userManager; + this._trans = props.trans; } - get user(): User.IIdentity { - return this._user; + get user(): User.IManager { + return this._userManager; } - set user(user: User.IIdentity) { - this._user = user; + set user(user: User.IManager) { + this._userManager = user; this.update(); } + private onClick = () => { + if (!this._userManager.identity) { + return; + } + showDialog({ + body: new UserDetailsBody({ + userManager: this._userManager + }), + title: this._trans.__('User Details') + }).then(async result => { + if (result.button.accept) { + // Call the Jupyter Server API to update the user field + try { + const settings = ServerConnection.makeSettings(); + const url = URLExt.join(settings.baseUrl, '/api/me'); + const body = { + method: 'PATCH', + body: JSON.stringify(result.value) + }; + + let response: Response; + try { + response = await ServerConnection.makeRequest(url, body, settings); + } catch (error) { + throw new ServerConnection.NetworkError(error as Error); + } + + if (!response.ok) { + const errorMsg = this._trans.__('Failed to update user data'); + throw new Error(errorMsg); + } + + // Refresh user information + this._userManager.refreshUser(); + } catch (error) { + console.error(error); + } + } + }); + }; + render(): JSX.Element { - return ; + return ( +
+ +
+ ); } } diff --git a/packages/collaboration/style/sidepanel.css b/packages/collaboration/style/sidepanel.css index bbc14561..9522aab0 100644 --- a/packages/collaboration/style/sidepanel.css +++ b/packages/collaboration/style/sidepanel.css @@ -142,3 +142,38 @@ box-shadow: 0 2px 2px -2px rgb(0 0 0 / 24%); } + +/************************************************************ + User Info Details +*************************************************************/ +.jp-UserInfo-Field { + display: flex; + justify-content: space-between; +} + +.jp-UserInfo-Field > label, +.jp-UserInfo-Field > input { + padding: 0.5em 1em; + margin: 0.25em 0; +} + +.jp-UserInfo-Field > label { + font-weight: bold; +} + +.jp-UserInfo-Field > input { + border: none; +} + +.jp-UserInfo-Field > input:not(:disabled) { + cursor: pointer; + background-color: var(--jp-input-background); +} + +.jp-UserInfo-Field > input:focus { + border: solid 1px var(--jp-cell-editor-active-border-color); +} + +.jp-UserInfo-Field > input:focus-visible { + outline: none; +} diff --git a/ui-tests/tests/collaborationpanel.spec.ts b/ui-tests/tests/collaborationpanel.spec.ts index f6d79f26..50183242 100644 --- a/ui-tests/tests/collaborationpanel.spec.ts +++ b/ui-tests/tests/collaborationpanel.spec.ts @@ -47,6 +47,35 @@ test('collaboration panel should contains two items', async ({ page }) => { expect(panel.locator('.jp-CollaboratorsList .jp-Collaborator')).toHaveCount(0); }); +test.describe('User info panel', () => { + test('should contain the user info icon', async ({ page }) => { + const panel = await openPanel(page); + const userInfoIcon = panel.locator('.jp-UserInfo-Icon'); + expect(userInfoIcon).toHaveCount(1); + }); + + test('should open the user info dialog', async ({ page }) => { + const panel = await openPanel(page); + const userInfoIcon = panel.locator('.jp-UserInfo-Icon'); + await userInfoIcon.click(); + + const dialog = page.locator('.jp-Dialog-body'); + expect(dialog).toHaveCount(1); + + const userInfoPanel = page.locator('.jp-UserInfoPanel'); + expect(userInfoPanel).toHaveCount(1); + + const userName = page.locator('input[name="display_name"]'); + expect(userName).toHaveCount(1); + expect(await userName.inputValue()).toBe('jovyan'); + + const cancelButton = page.locator( + '.jp-Dialog-button .jp-Dialog-buttonLabel:has-text("Cancel")' + ); + await cancelButton.click(); + expect(dialog).toHaveCount(0); + }); +}); test.describe('One client', () => { let guestPage: IJupyterLabPageFixture; diff --git a/yarn.lock b/yarn.lock index 2104ee11..99250609 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2112,6 +2112,7 @@ __metadata: "@jupyterlab/apputils": ^4.4.0 "@jupyterlab/coreutils": ^6.4.0 "@jupyterlab/docregistry": ^4.4.0 + "@jupyterlab/rendermime-interfaces": ^3.12.0 "@jupyterlab/services": ^7.4.0 "@jupyterlab/ui-components": ^4.4.0 "@lumino/coreutils": ^2.2.1