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 { ...@@ -8,18 +8,17 @@ import {
username as getUsername, username as getUsername,
} from '../helpers/account-id'; } from '../helpers/account-id';
import { withServices } from '../service-context'; import { withServices } from '../service-context';
import type { DashboardService } from '../services/dashboard';
import type { FrameSyncService } from '../services/frame-sync'; import type { FrameSyncService } from '../services/frame-sync';
import { useSidebarStore } from '../store'; import { useSidebarStore } from '../store';
import Menu from './Menu'; import Menu from './Menu';
import MenuItem from './MenuItem'; import MenuItem from './MenuItem';
import MenuSection from './MenuSection'; import MenuSection from './MenuSection';
import OpenDashboardMenuItem from './OpenDashboardMenuItem';
export type UserMenuProps = { export type UserMenuProps = {
onLogout: () => void; onLogout: () => void;
// Injected // Injected
dashboard: DashboardService;
frameSync: FrameSyncService; frameSync: FrameSyncService;
settings: SidebarSettings; settings: SidebarSettings;
}; };
...@@ -30,7 +29,7 @@ export type UserMenuProps = { ...@@ -30,7 +29,7 @@ export type UserMenuProps = {
* This menu will contain different items depending on service configuration, * This menu will contain different items depending on service configuration,
* context and whether the user is first- or third-party. * 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 store = useSidebarStore();
const defaultAuthority = store.defaultAuthority(); const defaultAuthority = store.defaultAuthority();
const profile = store.profile(); const profile = store.profile();
...@@ -116,7 +115,7 @@ function UserMenu({ frameSync, onLogout, settings, dashboard }: UserMenuProps) { ...@@ -116,7 +115,7 @@ function UserMenu({ frameSync, onLogout, settings, dashboard }: UserMenuProps) {
</MenuSection> </MenuSection>
{settings.dashboard?.showEntryPoint && ( {settings.dashboard?.showEntryPoint && (
<MenuSection> <MenuSection>
<MenuItem label="Open dashboard" onClick={() => dashboard.open()} /> <OpenDashboardMenuItem isMenuOpen={isOpen} />
</MenuSection> </MenuSection>
)} )}
{logoutAvailable && ( {logoutAvailable && (
...@@ -133,4 +132,4 @@ function UserMenu({ frameSync, onLogout, settings, dashboard }: UserMenuProps) { ...@@ -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'; ...@@ -7,7 +7,6 @@ import UserMenu, { $imports } from '../UserMenu';
describe('UserMenu', () => { describe('UserMenu', () => {
let fakeProfile; let fakeProfile;
let fakeFrameSync; let fakeFrameSync;
let fakeDashboard;
let fakeIsThirdPartyUser; let fakeIsThirdPartyUser;
let fakeOnLogout; let fakeOnLogout;
let fakeServiceConfig; let fakeServiceConfig;
...@@ -19,7 +18,6 @@ describe('UserMenu', () => { ...@@ -19,7 +18,6 @@ describe('UserMenu', () => {
return mount( return mount(
<UserMenu <UserMenu
frameSync={fakeFrameSync} frameSync={fakeFrameSync}
dashboard={fakeDashboard}
onLogout={fakeOnLogout} onLogout={fakeOnLogout}
settings={fakeSettings} settings={fakeSettings}
/>, />,
...@@ -40,7 +38,6 @@ describe('UserMenu', () => { ...@@ -40,7 +38,6 @@ describe('UserMenu', () => {
userid: 'acct:eleanorFishtail@hypothes.is', userid: 'acct:eleanorFishtail@hypothes.is',
}; };
fakeFrameSync = { notifyHost: sinon.stub() }; fakeFrameSync = { notifyHost: sinon.stub() };
fakeDashboard = { open: sinon.stub() };
fakeIsThirdPartyUser = sinon.stub(); fakeIsThirdPartyUser = sinon.stub();
fakeOnLogout = sinon.stub(); fakeOnLogout = sinon.stub();
fakeServiceConfig = sinon.stub(); fakeServiceConfig = sinon.stub();
...@@ -69,6 +66,11 @@ describe('UserMenu', () => { ...@@ -69,6 +66,11 @@ describe('UserMenu', () => {
$imports.$restore(); $imports.$restore();
}); });
const openMenu = wrapper => {
act(() => wrapper.find('Menu').props().onOpenChanged(true));
wrapper.update();
};
describe('profile menu item', () => { describe('profile menu item', () => {
context('first-party user', () => { context('first-party user', () => {
beforeEach(() => { beforeEach(() => {
...@@ -248,8 +250,7 @@ describe('UserMenu', () => { ...@@ -248,8 +250,7 @@ describe('UserMenu', () => {
const wrapper = createUserMenu(); const wrapper = createUserMenu();
// Make the menu "open" // Make the menu "open"
act(() => wrapper.find('Menu').props().onOpenChanged(true)); openMenu(wrapper);
wrapper.update();
assert.isTrue(wrapper.find('Menu').props().open); assert.isTrue(wrapper.find('Menu').props().open);
wrapper wrapper
...@@ -283,10 +284,7 @@ describe('UserMenu', () => { ...@@ -283,10 +284,7 @@ describe('UserMenu', () => {
it('opens the notebook and closes itself when `n` is typed', () => { it('opens the notebook and closes itself when `n` is typed', () => {
const wrapper = createUserMenu(); const wrapper = createUserMenu();
// Make the menu "open" // Make the menu "open"
act(() => { openMenu(wrapper);
wrapper.find('Menu').props().onOpenChanged(true);
});
wrapper.update();
assert.isTrue(wrapper.find('Menu').props().open); assert.isTrue(wrapper.find('Menu').props().open);
wrapper wrapper
...@@ -379,20 +377,19 @@ describe('UserMenu', () => { ...@@ -379,20 +377,19 @@ describe('UserMenu', () => {
fakeSettings.dashboard = dashboard; fakeSettings.dashboard = dashboard;
const wrapper = createUserMenu(); const wrapper = createUserMenu();
assert.equal( assert.equal(wrapper.exists('OpenDashboardMenuItem'), menuShouldExist);
wrapper.exists('MenuItem[label="Open dashboard"]'),
menuShouldExist,
);
}); });
}); });
it('opens dashboard when clicked', () => { it('marks menu item as open when parent menu is open', () => {
fakeSettings.dashboard = { showEntryPoint: true }; fakeSettings.dashboard = { showEntryPoint: true };
const wrapper = createUserMenu(); const wrapper = createUserMenu();
const isMenuOpen = () =>
wrapper.find('OpenDashboardMenuItem').prop('isMenuOpen');
wrapper.find('MenuItem[label="Open dashboard"]').props().onClick(); assert.isFalse(isMenuOpen());
openMenu(wrapper);
assert.called(fakeDashboard.open); assert.isTrue(isMenuOpen());
}); });
}); });
}); });
...@@ -13,15 +13,56 @@ export class DashboardService { ...@@ -13,15 +13,56 @@ export class DashboardService {
this._dashboardConfig = settings.dashboard; 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) { if (!this._rpc || !this._dashboardConfig) {
return; return undefined;
} }
postMessageJsonRpc.notify( return postMessageJsonRpc.call<string>(
this._rpc.targetFrame, this._rpc.targetFrame,
this._rpc.origin, 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', () => { ...@@ -13,10 +13,12 @@ describe('DashboardService', () => {
origin: 'https://www.example.com', origin: 'https://www.example.com',
}; };
fakeDashboard = { fakeDashboard = {
entryPointRPCMethod: 'openDashboard', authTokenRPCMethod: 'requestAuthToken',
entryPointURL: '/open/dashboard',
authFieldName: 'authorization',
}; };
fakePostMessageJsonRpc = { fakePostMessageJsonRpc = {
notify: sinon.stub(), call: sinon.stub(),
}; };
$imports.$mock({ $imports.$mock({
...@@ -36,28 +38,76 @@ describe('DashboardService', () => { ...@@ -36,28 +38,76 @@ describe('DashboardService', () => {
}); });
} }
describe('open', () => { describe('getAuthToken', () => {
[ [
{ withRpc: false }, { withRpc: false },
{ withDashboard: false }, { withDashboard: false },
{ withRpc: false, withDashboard: false }, { withRpc: false, withDashboard: false },
].forEach(settings => { ].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); const dashboard = createDashboardService(settings);
dashboard.open(); await dashboard.getAuthToken();
assert.notCalled(fakePostMessageJsonRpc.notify); 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(); const dashboard = createDashboardService();
dashboard.open();
const result = await dashboard.getAuthToken();
assert.equal(result, 'the_token');
assert.calledWith( assert.calledWith(
fakePostMessageJsonRpc.notify, fakePostMessageJsonRpc.call,
window, window,
'https://www.example.com', '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 = { ...@@ -89,10 +89,17 @@ export type DashboardConfig = {
*/ */
showEntryPoint: boolean; 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