Commit b4eac256 authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Implement logic to open the instructor dashboard directly

parent 52f5e3d8
import { useEffect, useState } from 'preact/hooks';
import { withServices } from '../service-context';
import type { DashboardService } from '../services/dashboard';
import MenuItem from './MenuItem';
export type OpenDashboardMenuItemProps = {
isMenuOpen: boolean;
// Injected
dashboard: DashboardService;
};
function OpenDashboardMenuItem({
dashboard,
isMenuOpen,
}: OpenDashboardMenuItemProps) {
const [authToken, setAuthToken] = useState<string>();
useEffect(() => {
// Fetch a new auth token every time the menu containing this item is open,
// to make sure we always have an up-to-date one
if (isMenuOpen) {
dashboard
.getAuthToken()
.then(setAuthToken)
.catch(error =>
console.warn('An error occurred while getting auth token', error),
);
}
// Discard previous token just before trying to fetch a new one
return () => setAuthToken(undefined);
}, [dashboard, isMenuOpen]);
return (
<MenuItem
label="Open dashboard"
isDisabled={!authToken}
onClick={() => authToken && dashboard.open(authToken)}
/>
);
}
export default withServices(OpenDashboardMenuItem, ['dashboard']);
......@@ -8,18 +8,17 @@ import {
username as getUsername,
} from '../helpers/account-id';
import { withServices } from '../service-context';
import type { DashboardService } from '../services/dashboard';
import type { FrameSyncService } from '../services/frame-sync';
import { useSidebarStore } from '../store';
import Menu from './Menu';
import MenuItem from './MenuItem';
import MenuSection from './MenuSection';
import OpenDashboardMenuItem from './OpenDashboardMenuItem';
export type UserMenuProps = {
onLogout: () => void;
// Injected
dashboard: DashboardService;
frameSync: FrameSyncService;
settings: SidebarSettings;
};
......@@ -30,7 +29,7 @@ export type UserMenuProps = {
* This menu will contain different items depending on service configuration,
* context and whether the user is first- or third-party.
*/
function UserMenu({ frameSync, onLogout, settings, dashboard }: UserMenuProps) {
function UserMenu({ frameSync, onLogout, settings }: UserMenuProps) {
const store = useSidebarStore();
const defaultAuthority = store.defaultAuthority();
const profile = store.profile();
......@@ -116,7 +115,7 @@ function UserMenu({ frameSync, onLogout, settings, dashboard }: UserMenuProps) {
</MenuSection>
{settings.dashboard?.showEntryPoint && (
<MenuSection>
<MenuItem label="Open dashboard" onClick={() => dashboard.open()} />
<OpenDashboardMenuItem isMenuOpen={isOpen} />
</MenuSection>
)}
{logoutAvailable && (
......@@ -133,4 +132,4 @@ function UserMenu({ frameSync, onLogout, settings, dashboard }: UserMenuProps) {
);
}
export default withServices(UserMenu, ['dashboard', 'frameSync', 'settings']);
export default withServices(UserMenu, ['frameSync', 'settings']);
import { waitFor } from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';
import sinon from 'sinon';
import OpenDashboardMenuItem from '../OpenDashboardMenuItem';
describe('OpenDashboardMenuItem', () => {
let fakeDashboard;
beforeEach(() => {
fakeDashboard = {
getAuthToken: sinon.stub().resolves('auth_token'),
open: sinon.stub(),
};
});
function createComponent({ isMenuOpen = false } = {}) {
return mount(
<OpenDashboardMenuItem
isMenuOpen={isMenuOpen}
dashboard={fakeDashboard}
/>,
);
}
context('when menu is closed', () => {
it('does not try to load auth token', () => {
createComponent();
assert.notCalled(fakeDashboard.getAuthToken);
});
it('has disabled menu item', () => {
const wrapper = createComponent();
assert.isTrue(wrapper.find('MenuItem').prop('isDisabled'));
});
it('does not open dashboard when item is clicked', () => {
const wrapper = createComponent();
wrapper.find('MenuItem').props().onClick();
assert.notCalled(fakeDashboard.open);
});
});
context('when menu is open', () => {
async function createOpenComponent() {
const wrapper = createComponent({ isMenuOpen: true });
// Wait for an enabled menu item, which means the auth token was loaded
await waitFor(() => wrapper.find('MenuItem[isDisabled=false]'));
wrapper.update();
return wrapper;
}
it('loads auth token', async () => {
await createOpenComponent();
assert.called(fakeDashboard.getAuthToken);
});
it('has enabled menu item', async () => {
const wrapper = await createOpenComponent();
assert.isFalse(wrapper.find('MenuItem').prop('isDisabled'));
});
it('opens dashboard when item is clicked', async () => {
const wrapper = await createOpenComponent();
wrapper.find('MenuItem').props().onClick();
assert.calledWith(fakeDashboard.open, 'auth_token');
});
it('logs error if getting auth token fails', async () => {
const error = new Error('Error loading auth token');
fakeDashboard.getAuthToken.rejects(error);
sinon.stub(console, 'warn');
try {
createOpenComponent();
assert.called(fakeDashboard.getAuthToken);
await waitFor(() => {
const { lastCall } = console.warn;
if (!lastCall) {
return false;
}
const { args } = lastCall;
return (
args[0] === 'An error occurred while getting auth token' &&
args[1] === error
);
});
} finally {
console.warn.restore();
}
});
});
});
......@@ -7,7 +7,6 @@ import UserMenu, { $imports } from '../UserMenu';
describe('UserMenu', () => {
let fakeProfile;
let fakeFrameSync;
let fakeDashboard;
let fakeIsThirdPartyUser;
let fakeOnLogout;
let fakeServiceConfig;
......@@ -19,7 +18,6 @@ describe('UserMenu', () => {
return mount(
<UserMenu
frameSync={fakeFrameSync}
dashboard={fakeDashboard}
onLogout={fakeOnLogout}
settings={fakeSettings}
/>,
......@@ -40,7 +38,6 @@ describe('UserMenu', () => {
userid: 'acct:eleanorFishtail@hypothes.is',
};
fakeFrameSync = { notifyHost: sinon.stub() };
fakeDashboard = { open: sinon.stub() };
fakeIsThirdPartyUser = sinon.stub();
fakeOnLogout = sinon.stub();
fakeServiceConfig = sinon.stub();
......@@ -69,6 +66,11 @@ describe('UserMenu', () => {
$imports.$restore();
});
const openMenu = wrapper => {
act(() => wrapper.find('Menu').props().onOpenChanged(true));
wrapper.update();
};
describe('profile menu item', () => {
context('first-party user', () => {
beforeEach(() => {
......@@ -248,8 +250,7 @@ describe('UserMenu', () => {
const wrapper = createUserMenu();
// Make the menu "open"
act(() => wrapper.find('Menu').props().onOpenChanged(true));
wrapper.update();
openMenu(wrapper);
assert.isTrue(wrapper.find('Menu').props().open);
wrapper
......@@ -283,10 +284,7 @@ describe('UserMenu', () => {
it('opens the notebook and closes itself when `n` is typed', () => {
const wrapper = createUserMenu();
// Make the menu "open"
act(() => {
wrapper.find('Menu').props().onOpenChanged(true);
});
wrapper.update();
openMenu(wrapper);
assert.isTrue(wrapper.find('Menu').props().open);
wrapper
......@@ -379,20 +377,19 @@ describe('UserMenu', () => {
fakeSettings.dashboard = dashboard;
const wrapper = createUserMenu();
assert.equal(
wrapper.exists('MenuItem[label="Open dashboard"]'),
menuShouldExist,
);
assert.equal(wrapper.exists('OpenDashboardMenuItem'), menuShouldExist);
});
});
it('opens dashboard when clicked', () => {
it('marks menu item as open when parent menu is open', () => {
fakeSettings.dashboard = { showEntryPoint: true };
const wrapper = createUserMenu();
const isMenuOpen = () =>
wrapper.find('OpenDashboardMenuItem').prop('isMenuOpen');
wrapper.find('MenuItem[label="Open dashboard"]').props().onClick();
assert.called(fakeDashboard.open);
assert.isFalse(isMenuOpen());
openMenu(wrapper);
assert.isTrue(isMenuOpen());
});
});
});
......@@ -13,15 +13,56 @@ export class DashboardService {
this._dashboardConfig = settings.dashboard;
}
open() {
/**
* Get the auth token via JSON RPC.
* This method should be called before `open`, to get the authToken that needs
* to be passed there.
*/
async getAuthToken(): Promise<string | undefined> {
if (!this._rpc || !this._dashboardConfig) {
return;
return undefined;
}
postMessageJsonRpc.notify(
return postMessageJsonRpc.call<string>(
this._rpc.targetFrame,
this._rpc.origin,
this._dashboardConfig.entryPointRPCMethod,
this._dashboardConfig.authTokenRPCMethod,
);
}
/**
* Open the dashboard with provided auth token.
*
* The auth token should be fetched separately, by calling `getAuthToken`
* first.
* It is not done here transparently, so that we can invoke this method as
* part of a user gesture, and browsers don't end up blocking the new tab
* opened by the form being submitted later.
*
* Related Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1469422
*
* @see {getAuthToken}
*/
open(authToken: string, document_ = document) {
if (!this._rpc || !this._dashboardConfig) {
throw new Error(
'Dashboard cannot be opened due to missing configuration',
);
}
const form = document_.createElement('form');
form.action = this._dashboardConfig.entryPointURL;
form.target = '_blank';
form.method = 'POST';
const authInput = document_.createElement('input');
authInput.type = 'hidden';
authInput.name = this._dashboardConfig.authFieldName;
authInput.value = authToken;
form.append(authInput);
document_.body.append(form);
form.submit();
form.remove();
}
}
......@@ -13,10 +13,12 @@ describe('DashboardService', () => {
origin: 'https://www.example.com',
};
fakeDashboard = {
entryPointRPCMethod: 'openDashboard',
authTokenRPCMethod: 'requestAuthToken',
entryPointURL: '/open/dashboard',
authFieldName: 'authorization',
};
fakePostMessageJsonRpc = {
notify: sinon.stub(),
call: sinon.stub(),
};
$imports.$mock({
......@@ -36,28 +38,76 @@ describe('DashboardService', () => {
});
}
describe('open', () => {
describe('getAuthToken', () => {
[
{ withRpc: false },
{ withDashboard: false },
{ withRpc: false, withDashboard: false },
].forEach(settings => {
it('does not notify frame if there is any missing config', () => {
it('does not call frame if there is any missing config', async () => {
const dashboard = createDashboardService(settings);
dashboard.open();
assert.notCalled(fakePostMessageJsonRpc.notify);
await dashboard.getAuthToken();
assert.notCalled(fakePostMessageJsonRpc.call);
});
});
it('notifies frame to open the dashboard', () => {
it('calls frame to get the authToken', async () => {
fakePostMessageJsonRpc.call.resolves('the_token');
const dashboard = createDashboardService();
dashboard.open();
const result = await dashboard.getAuthToken();
assert.equal(result, 'the_token');
assert.calledWith(
fakePostMessageJsonRpc.notify,
fakePostMessageJsonRpc.call,
window,
'https://www.example.com',
'openDashboard',
'requestAuthToken',
);
});
});
describe('open', () => {
[
{ withRpc: false },
{ withDashboard: false },
{ withRpc: false, withDashboard: false },
].forEach(settings => {
it('throws error if there is any missing config', () => {
const dashboard = createDashboardService(settings);
assert.throws(
() => dashboard.open('auth_token'),
'Dashboard cannot be opened due to missing configuration',
);
});
});
it('submits form with auth token', () => {
const fakeForm = {
append: sinon.stub(),
submit: sinon.stub(),
remove: sinon.stub(),
};
const fakeInput = {};
const fakeDocument = {
createElement: tagName => (tagName === 'form' ? fakeForm : fakeInput),
body: {
append: sinon.stub(),
},
};
const dashboard = createDashboardService();
dashboard.open('auth_token', fakeDocument);
assert.equal(fakeForm.action, fakeDashboard.entryPointURL);
assert.equal(fakeInput.name, fakeDashboard.authFieldName);
assert.equal(fakeInput.value, 'auth_token');
assert.calledWith(fakeDocument.body.append, fakeForm);
assert.calledWith(fakeForm.append, fakeInput);
assert.called(fakeForm.submit);
assert.called(fakeForm.remove);
});
});
});
......@@ -89,10 +89,17 @@ export type DashboardConfig = {
*/
showEntryPoint: boolean;
/** Name of the RPC method to get a valid auth token */
authTokenRPCMethod: string;
/**
* Name of the RPC method to call in embedded frame on entry point activation.
* Entry point for the dashboard, where the first request needs to happen to
* get authenticated.
*/
entryPointRPCMethod: string;
entryPointURL: string;
/** The name of the form field containing the auth token */
authFieldName: string;
};
/**
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment