Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/collaboration-extension/src/collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ export const rtcPanelPlugin: JupyterFrontEndPlugin<void> = {
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);
Expand Down
1 change: 1 addition & 0 deletions packages/collaboration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
136 changes: 122 additions & 14 deletions packages/collaboration/src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,139 @@
// 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;
};

/**
* React component for the user icon.
*
* @returns The React component
*/
export const UserIconComponent: React.FC<Props> = props => {
const { user } = props;
export function UserIconComponent(props: UserIconProps): JSX.Element {
const { userManager, onClick } = props;
const [user, setUser] = useState(userManager.identity!);

Check warning on line 28 in packages/collaboration/src/components.tsx

View workflow job for this annotation

GitHub Actions / Run pre-commit hook

Forbidden non-null assertion

useEffect(() => {
const updateUser = () => {
setUser(userManager.identity!);

Check warning on line 32 in packages/collaboration/src/components.tsx

View workflow job for this annotation

GitHub Actions / Run pre-commit hook

Forbidden non-null assertion
};

userManager.userChanged.connect(updateUser);

return () => {
userManager.userChanged.disconnect(updateUser);
};
}, [userManager]);

return (
<div className="jp-UserInfo-Container">
<div
title={user.display_name}
className="jp-UserInfo-Icon"
style={{ backgroundColor: user.color }}
>
<span>{user.initials}</span>
</div>
<h3>{user.display_name}</h3>
<div
title={user.display_name}
className="jp-UserInfo-Icon"
style={{ backgroundColor: user.color }}
onClick={onClick}
>
<span>{user.initials}</span>
</div>
);
}

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<HTMLInputElement>,
field: string
) => {
const updatableFields = (this._userManager.permissions?.[
'updatable_fields'
] || []) as string[];
if (!updatableFields?.includes(field)) {
return;
}

this._userUpdate[field as keyof Omit<User.IIdentity, 'username'>] =
event.target.value;
};

render() {
const identity = this._userManager.identity;
if (!identity) {
return <div className="jp-UserInfo-Details">Error loading user info</div>;
}
const updatableFields = (this._userManager.permissions?.[
'updatable_fields'
] || []) as string[];

return (
<div className="jp-UserInfo-Details">
{Object.keys(identity).map((field: string) => {
const id = `jp-UserInfo-Value-${field}`;
return (
<div key={field} className="jp-UserInfo-Field">
<label htmlFor={id}>{field}</label>
<input
type={'text'}
name={field}
id={id}
onInput={(event: React.ChangeEvent<HTMLInputElement>) =>
this._onChange(event, field)
}
defaultValue={identity[field] as string}
disabled={!updatableFields?.includes(field)}
/>
</div>
);
})}
</div>
);
}

private _userManager: User.IManager;
private _userUpdate: UserUpdate = {};
}

/**
* Type for the user update object.
*/
export type UserUpdate = {
[field in keyof Omit<User.IIdentity, 'username'>]: string;
};
105 changes: 87 additions & 18 deletions packages/collaboration/src/userinfopanel.tsx
Original file line number Diff line number Diff line change
@@ -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;
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(this._profile.identity!);
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(this._profile.identity!);
this._body = new UserInfoBody({
userManager: this._profile,
translation: options.translation
});
this.addWidget(this._body);
this.update();
})
Expand All @@ -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<User.IManager>
{
private _userManager: User.IManager;
private _translation: IRenderMime.TranslationBundle;
/**
* Constructs a new settings widget.
*/
constructor(user: User.IIdentity) {
constructor(props: UserInfoProps) {
super();
this._user = user;
this._userManager = props.userManager;
this._translation = props.translation;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be named _trans:

Suggested change
this._translation = props.translation;
this._trans = props.translation;

Because it gets extracted using very simple static analysis, see https://jupyterlab.readthedocs.io/en/latest/extension/internationalization.html#rules

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I didn't know about that, I'll try to remember it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I just gave an example suggestion, it needs to be renamed in a few more places.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean renaming also the property name in the options ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed it anyway for better readability, but I thought the name was required when using it only.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I thought the name was required when using it only.

Yes, that's correct. My bad I misread the extend of changes in your previous commit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😄 took me 5 min reading again, to be sure I was not missing something again.

}

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._translation.__('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._translation.__('Failed to update user data');
throw new Error(errorMsg);
}

// Refresh user information
this._userManager.refreshUser();
} catch (error) {
console.error(error);
}
}
});
};

render(): JSX.Element {
return <UserIconComponent user={this._user} />;
return (
<div className="jp-UserInfo-Container">
<UserIconComponent
userManager={this._userManager}
onClick={this.onClick}
/>
</div>
);
}
}
35 changes: 35 additions & 0 deletions packages/collaboration/style/sidepanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
29 changes: 29 additions & 0 deletions ui-tests/tests/collaborationpanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading