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..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..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;