From 53bfa8ac03cf26c6d9ba67e838fc40a47a88ba0f Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 6 May 2025 13:47:03 +0200 Subject: [PATCH 1/7] Add dialog to update user info --- packages/collaboration/src/components.tsx | 128 +++++++++++++++++-- packages/collaboration/src/userinfopanel.tsx | 86 +++++++++++-- packages/collaboration/style/sidepanel.css | 31 +++++ 3 files changed, 217 insertions(+), 28 deletions(-) diff --git a/packages/collaboration/src/components.tsx b/packages/collaboration/src/components.tsx index 97908667..ec0bd119 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,110 @@ 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: keyof User.IIdentity + ) => { + this._userUpdate[field as keyof Omit] = + event.target.value; + }; + + render() { + if (!this._userManager.identity) { + return
Error loading user info
; + } + const updatableFields = (this._userManager.permissions?.[ + 'updatable_fields' + ] || []) as string[]; + + return ( +
+ {Object.keys(this._userManager.identity).map((field: string) => { + return ( +
+ {field} + {updatableFields?.includes(field) ? ( + ) => + this._onChange(event, field) + } + defaultValue={this._userManager.identity![field] as string} + /> + ) : ( + {this._userManager.identity![field] as string} + )} +
+ ); + })} +
+ ); + } + + 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..e3a41a60 100644 --- a/packages/collaboration/src/userinfopanel.tsx +++ b/packages/collaboration/src/userinfopanel.tsx @@ -1,15 +1,16 @@ // 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 { Panel } from '@lumino/widgets'; import * as React from 'react'; -import { UserIconComponent } from './components'; +import { UserDetailsBody, UserIconComponent } from './components'; +import { URLExt } from '@jupyterlab/coreutils'; export class UserInfoPanel extends Panel { private _profile: User.IManager; @@ -23,13 +24,13 @@ export class UserInfoPanel extends Panel { this._body = null; if (this._profile.isReady) { - this._body = new UserInfoBody(this._profile.identity!); + this._body = new UserInfoBody({ userManager: this._profile }); 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 }); this.addWidget(this._body); this.update(); }) @@ -38,30 +39,87 @@ export class UserInfoPanel extends Panel { } } +/** + * The properties for the UserInfoBody. + */ +type UserInfoBodyProps = { + userManager: User.IManager; +}; + /** * 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; /** * Constructs a new settings widget. */ - constructor(user: User.IIdentity) { + constructor(props: UserInfoBodyProps) { super(); - this._user = user; + this._userManager = props.userManager; } - 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: '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) { + throw new Error('Failed to update user data'); + } + + // 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..caa65b28 100644 --- a/packages/collaboration/style/sidepanel.css +++ b/packages/collaboration/style/sidepanel.css @@ -142,3 +142,34 @@ 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 > * { + padding: 0.5em 1em; + margin: 0.25em 0; +} + +.jp-UserInfo-Field > .jp-UserInfo-Field-title { + font-weight: bold; +} + +.jp-UserInfo-Field > input { + border: none; + 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; +} From a6f42f1654ca864d4bc8362d99fc0978f8e7df29 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 6 May 2025 22:11:10 +0200 Subject: [PATCH 2/7] Use labelled inputs for all the fields --- packages/collaboration/src/components.tsx | 33 +++++++++++++--------- packages/collaboration/style/sidepanel.css | 5 +++- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/collaboration/src/components.tsx b/packages/collaboration/src/components.tsx index ec0bd119..ae2d92b5 100644 --- a/packages/collaboration/src/components.tsx +++ b/packages/collaboration/src/components.tsx @@ -82,8 +82,15 @@ export class UserDetailsBody extends ReactWidget { */ private _onChange = ( event: React.ChangeEvent, - field: keyof User.IIdentity + 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; }; @@ -99,20 +106,20 @@ export class UserDetailsBody extends ReactWidget { return (
{Object.keys(this._userManager.identity).map((field: string) => { + const id = `jp-UserInfo-Value-${field}`; return (
- {field} - {updatableFields?.includes(field) ? ( - ) => - this._onChange(event, field) - } - defaultValue={this._userManager.identity![field] as string} - /> - ) : ( - {this._userManager.identity![field] as string} - )} + + ) => + this._onChange(event, field) + } + defaultValue={this._userManager.identity![field] as string} + disabled={!updatableFields?.includes(field)} + />
); })} diff --git a/packages/collaboration/style/sidepanel.css b/packages/collaboration/style/sidepanel.css index caa65b28..2cba2df1 100644 --- a/packages/collaboration/style/sidepanel.css +++ b/packages/collaboration/style/sidepanel.css @@ -156,12 +156,15 @@ margin: 0.25em 0; } -.jp-UserInfo-Field > .jp-UserInfo-Field-title { +.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); } From 9f23d979e69dcf5a8b1d1cdaca12bec1d3b97e18 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 6 May 2025 22:19:44 +0200 Subject: [PATCH 3/7] Add test on dialog --- ui-tests/tests/collaborationpanel.spec.ts | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) 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; From 2dae4f30f47c3c5d2cde2fc8bc0ac3aa19e9467c Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Wed, 7 May 2025 09:59:51 +0200 Subject: [PATCH 4/7] Apply PR suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michał Krassowski <5832902+krassowski@users.noreply.github.com> --- packages/collaboration/src/components.tsx | 7 ++++--- packages/collaboration/style/sidepanel.css | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/collaboration/src/components.tsx b/packages/collaboration/src/components.tsx index ae2d92b5..c311b7bd 100644 --- a/packages/collaboration/src/components.tsx +++ b/packages/collaboration/src/components.tsx @@ -96,7 +96,8 @@ export class UserDetailsBody extends ReactWidget { }; render() { - if (!this._userManager.identity) { + const identity = this._userManager.identity; + if (!identity) { return
Error loading user info
; } const updatableFields = (this._userManager.permissions?.[ @@ -105,7 +106,7 @@ export class UserDetailsBody extends ReactWidget { return (
- {Object.keys(this._userManager.identity).map((field: string) => { + {Object.keys(identity).map((field: string) => { const id = `jp-UserInfo-Value-${field}`; return (
@@ -117,7 +118,7 @@ export class UserDetailsBody extends ReactWidget { onInput={(event: React.ChangeEvent) => this._onChange(event, field) } - defaultValue={this._userManager.identity![field] as string} + defaultValue={identity[field] as string} disabled={!updatableFields?.includes(field)} />
diff --git a/packages/collaboration/style/sidepanel.css b/packages/collaboration/style/sidepanel.css index 2cba2df1..9522aab0 100644 --- a/packages/collaboration/style/sidepanel.css +++ b/packages/collaboration/style/sidepanel.css @@ -151,7 +151,8 @@ justify-content: space-between; } -.jp-UserInfo-Field > * { +.jp-UserInfo-Field > label, +.jp-UserInfo-Field > input { padding: 0.5em 1em; margin: 0.25em 0; } From 4b05a255731b5717c20914b1a47b046b24c60700 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Mon, 26 May 2025 16:01:35 +0200 Subject: [PATCH 5/7] Add translation --- .../src/collaboration.ts | 5 ++- packages/collaboration/package.json | 1 + packages/collaboration/src/userinfopanel.tsx | 45 ++++++++++++------- yarn.lock | 1 + 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/packages/collaboration-extension/src/collaboration.ts b/packages/collaboration-extension/src/collaboration.ts index c11d353e..93a3d99d 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, + translation: 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/userinfopanel.tsx b/packages/collaboration/src/userinfopanel.tsx index e3a41a60..e2a6c508 100644 --- a/packages/collaboration/src/userinfopanel.tsx +++ b/packages/collaboration/src/userinfopanel.tsx @@ -5,32 +5,48 @@ import { Dialog, ReactWidget, showDialog } from '@jupyterlab/apputils'; 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 { UserDetailsBody, UserIconComponent } from './components'; -import { URLExt } from '@jupyterlab/coreutils'; + +/** + * The properties for the UserInfoBody. + */ +type UserInfoProps = { + userManager: User.IManager; + translation: 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({ userManager: this._profile }); + this._body = new UserInfoBody({ + userManager: this._profile, + translation: options.translation + }); this.addWidget(this._body); this.update(); } else { this._profile.ready .then(() => { - this._body = new UserInfoBody({ userManager: this._profile }); + this._body = new UserInfoBody({ + userManager: this._profile, + translation: options.translation + }); this.addWidget(this._body); this.update(); }) @@ -39,13 +55,6 @@ export class UserInfoPanel extends Panel { } } -/** - * The properties for the UserInfoBody. - */ -type UserInfoBodyProps = { - userManager: User.IManager; -}; - /** * A SettingsWidget for the user. */ @@ -54,13 +63,14 @@ export class UserInfoBody implements Dialog.IBodyWidget { private _userManager: User.IManager; - + private _translation: IRenderMime.TranslationBundle; /** * Constructs a new settings widget. */ - constructor(props: UserInfoBodyProps) { + constructor(props: UserInfoProps) { super(); this._userManager = props.userManager; + this._translation = props.translation; } get user(): User.IManager { @@ -80,7 +90,7 @@ export class UserInfoBody body: new UserDetailsBody({ userManager: this._userManager }), - title: 'User Details' + title: this._translation.__('User Details') }).then(async result => { if (result.button.accept) { // Call the Jupyter Server API to update the user field @@ -100,7 +110,8 @@ export class UserInfoBody } if (!response.ok) { - throw new Error('Failed to update user data'); + const errorMsg = this._translation.__('Failed to update user data'); + throw new Error(errorMsg); } // Refresh user information 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 From 82ef111231c1955eeb3478933ff41b1eb481fc07 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Mon, 26 May 2025 17:26:41 +0200 Subject: [PATCH 6/7] Rename the translation variable to __trans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michał Krassowski <5832902+krassowski@users.noreply.github.com> --- packages/collaboration/src/userinfopanel.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/collaboration/src/userinfopanel.tsx b/packages/collaboration/src/userinfopanel.tsx index e2a6c508..9296743f 100644 --- a/packages/collaboration/src/userinfopanel.tsx +++ b/packages/collaboration/src/userinfopanel.tsx @@ -63,14 +63,14 @@ export class UserInfoBody implements Dialog.IBodyWidget { private _userManager: User.IManager; - private _translation: IRenderMime.TranslationBundle; + private _trans: IRenderMime.TranslationBundle; /** * Constructs a new settings widget. */ constructor(props: UserInfoProps) { super(); this._userManager = props.userManager; - this._translation = props.translation; + this._trans = props.translation; } get user(): User.IManager { @@ -90,7 +90,7 @@ export class UserInfoBody body: new UserDetailsBody({ userManager: this._userManager }), - title: this._translation.__('User Details') + title: this._trans.__('User Details') }).then(async result => { if (result.button.accept) { // Call the Jupyter Server API to update the user field @@ -110,7 +110,7 @@ export class UserInfoBody } if (!response.ok) { - const errorMsg = this._translation.__('Failed to update user data'); + const errorMsg = this._trans.__('Failed to update user data'); throw new Error(errorMsg); } From a17ffcd2735187a7abeee8ca1070c9b37c0df179 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Mon, 26 May 2025 21:51:46 +0200 Subject: [PATCH 7/7] Rename the property name 'translator' to 'trans' --- packages/collaboration-extension/src/collaboration.ts | 2 +- packages/collaboration/src/userinfopanel.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/collaboration-extension/src/collaboration.ts b/packages/collaboration-extension/src/collaboration.ts index 93a3d99d..79b5e4af 100644 --- a/packages/collaboration-extension/src/collaboration.ts +++ b/packages/collaboration-extension/src/collaboration.ts @@ -153,7 +153,7 @@ export const rtcPanelPlugin: JupyterFrontEndPlugin = { const currentUserPanel = new UserInfoPanel({ userManager: user, - translation: trans + trans }); currentUserPanel.title.label = trans.__('User info'); currentUserPanel.title.caption = trans.__('User information'); diff --git a/packages/collaboration/src/userinfopanel.tsx b/packages/collaboration/src/userinfopanel.tsx index 9296743f..88b6e8e0 100644 --- a/packages/collaboration/src/userinfopanel.tsx +++ b/packages/collaboration/src/userinfopanel.tsx @@ -20,7 +20,7 @@ import { UserDetailsBody, UserIconComponent } from './components'; */ type UserInfoProps = { userManager: User.IManager; - translation: IRenderMime.TranslationBundle; + trans: IRenderMime.TranslationBundle; }; export class UserInfoPanel extends Panel { @@ -36,7 +36,7 @@ export class UserInfoPanel extends Panel { if (this._profile.isReady) { this._body = new UserInfoBody({ userManager: this._profile, - translation: options.translation + trans: options.trans }); this.addWidget(this._body); this.update(); @@ -45,7 +45,7 @@ export class UserInfoPanel extends Panel { .then(() => { this._body = new UserInfoBody({ userManager: this._profile, - translation: options.translation + trans: options.trans }); this.addWidget(this._body); this.update(); @@ -70,7 +70,7 @@ export class UserInfoBody constructor(props: UserInfoProps) { super(); this._userManager = props.userManager; - this._trans = props.translation; + this._trans = props.trans; } get user(): User.IManager {