Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
b4eac256
Commit
b4eac256
authored
Apr 11, 2024
by
Alejandro Celaya
Committed by
Alejandro Celaya
Apr 12, 2024
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Implement logic to open the instructor dashboard directly
parent
52f5e3d8
Changes
7
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
276 additions
and
38 deletions
+276
-38
OpenDashboardMenuItem.tsx
src/sidebar/components/OpenDashboardMenuItem.tsx
+44
-0
UserMenu.tsx
src/sidebar/components/UserMenu.tsx
+4
-5
OpenDashboardMenuItem-test.js
src/sidebar/components/test/OpenDashboardMenuItem-test.js
+100
-0
UserMenu-test.js
src/sidebar/components/test/UserMenu-test.js
+14
-17
dashboard.ts
src/sidebar/services/dashboard.ts
+45
-4
dashboard-test.js
src/sidebar/services/test/dashboard-test.js
+60
-10
config.ts
src/types/config.ts
+9
-2
No files found.
src/sidebar/components/OpenDashboardMenuItem.tsx
0 → 100644
View file @
b4eac256
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'
]);
src/sidebar/components/UserMenu.tsx
View file @
b4eac256
...
...
@@ -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'
]);
src/sidebar/components/test/OpenDashboardMenuItem-test.js
0 → 100644
View file @
b4eac256
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
();
}
});
});
});
src/sidebar/components/test/UserMenu-test.js
View file @
b4eac256
...
...
@@ -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
()
);
});
});
});
src/sidebar/services/dashboard.ts
View file @
b4eac256
...
...
@@ -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
();
}
}
src/sidebar/services/test/dashboard-test.js
View file @
b4eac256
...
...
@@ -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
(
'
op
en'
,
()
=>
{
describe
(
'
getAuthTok
en'
,
()
=>
{
[
{
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
.
op
en
();
assert
.
notCalled
(
fakePostMessageJsonRpc
.
notify
);
await
dashboard
.
getAuthTok
en
();
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
);
});
});
});
src/types/config.ts
View file @
b4eac256
...
...
@@ -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
;
};
/**
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment