Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-analytics-dashboard
Overview
Overview
Details
Activity
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
edx
edx-analytics-dashboard
Commits
e75e489c
Commit
e75e489c
authored
Jul 19, 2016
by
Tyler Hallada
Committed by
GitHub
Jul 19, 2016
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #507 from edx/thallada/deep-linking
Deep linking for the Learner Roster page
parents
1324b80b
0ec62786
Hide whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
385 additions
and
38 deletions
+385
-38
analytics_dashboard/static/apps/learners/app/controller.js
+48
-9
analytics_dashboard/static/apps/learners/app/router.js
+19
-3
analytics_dashboard/static/apps/learners/app/spec/controller-spec.js
+31
-1
analytics_dashboard/static/apps/learners/app/spec/router-spec.js
+29
-13
analytics_dashboard/static/apps/learners/app/views/root.js
+5
-2
analytics_dashboard/static/apps/learners/common/collections/learners.js
+82
-0
analytics_dashboard/static/apps/learners/common/collections/spec/learners-spec.js
+72
-0
analytics_dashboard/static/apps/learners/common/templates/alert.underscore
+13
-6
analytics_dashboard/static/apps/learners/common/views/alert-view.js
+2
-1
analytics_dashboard/static/apps/learners/common/views/spec/alert-view-spec.js
+19
-0
analytics_dashboard/static/apps/learners/detail/templates/return-to-learners.underscore
+1
-1
analytics_dashboard/static/apps/learners/detail/views/learner-return.js
+2
-1
analytics_dashboard/static/apps/learners/roster/templates/roster-loading.underscore
+3
-0
analytics_dashboard/static/apps/learners/roster/views/base-header-cell.js
+5
-0
analytics_dashboard/static/js/test/specs/utils-spec.js
+14
-0
analytics_dashboard/static/js/utils/utils.js
+35
-0
analytics_dashboard/static/sass/_mixins.scss
+5
-1
No files found.
analytics_dashboard/static/apps/learners/app/controller.js
View file @
e75e489c
...
...
@@ -17,8 +17,11 @@ define(function (require) {
LearnerDetailView
=
require
(
'learners/detail/views/learner-detail'
),
LearnerModel
=
require
(
'learners/common/models/learner'
),
LearnerRosterView
=
require
(
'learners/roster/views/roster'
),
LoadingView
=
require
(
'learners/common/views/loading-view'
),
ReturnLinkView
=
require
(
'learners/detail/views/learner-return'
),
rosterLoadingTemplate
=
require
(
'text!learners/roster/templates/roster-loading.underscore'
),
LearnersController
;
LearnersController
=
Marionette
.
Object
.
extend
({
...
...
@@ -31,7 +34,7 @@ define(function (require) {
/**
* Event handler for the 'showPage' event. Called by the
* router whenever a route method beginning with "show" has
* been triggered.
* been triggered.
Executes before the route method does.
*/
onShowPage
:
function
()
{
// Show a loading bar
...
...
@@ -48,18 +51,53 @@ define(function (require) {
}
},
showLearnerRosterPage
:
function
()
{
showLearnerRosterPage
:
function
(
queryString
)
{
var
rosterView
=
new
LearnerRosterView
({
collection
:
this
.
options
.
learnerCollection
,
courseMetadata
:
this
.
options
.
courseMetadata
,
trackingModel
:
this
.
options
.
trackingModel
});
collection
:
this
.
options
.
learnerCollection
,
courseMetadata
:
this
.
options
.
courseMetadata
,
trackingModel
:
this
.
options
.
trackingModel
,
}),
loadingView
=
new
LoadingView
({
model
:
this
.
options
.
learnerCollection
,
template
:
_
.
template
(
rosterLoadingTemplate
),
successView
:
rosterView
}),
fetch
;
try
{
this
.
options
.
learnerCollection
.
setStateFromQueryString
(
queryString
);
if
(
this
.
options
.
learnerCollection
.
isStale
)
{
// Show a loading spinner while we fetch new collection data
this
.
options
.
rootView
.
showChildView
(
'main'
,
loadingView
);
fetch
=
this
.
options
.
learnerCollection
.
fetch
({
reset
:
true
});
if
(
fetch
)
{
fetch
.
complete
(
function
(
response
)
{
// fetch doesn't empty the collection on 404 by default
if
(
response
&&
response
.
status
===
404
)
{
this
.
options
.
learnerCollection
.
reset
();
}
});
}
}
else
{
// Immediately show the roster with the currently loaded collection data
this
.
options
.
rootView
.
showChildView
(
'main'
,
rosterView
);
}
}
catch
(
e
)
{
// These JS errors occur when trying to parse invalid URL parameters
if
(
e
instanceof
RangeError
||
e
instanceof
TypeError
)
{
this
.
options
.
rootView
.
showAlert
(
'error'
,
gettext
(
'Invalid Parameters'
),
gettext
(
'Sorry, we couldn
\'
t find any learners who matched that query.'
),
{
url
:
'#'
,
text
:
gettext
(
'Return to the Learners page.'
)});
}
else
{
throw
e
;
}
}
this
.
options
.
rootView
.
getRegion
(
'navigation'
).
empty
();
this
.
options
.
pageModel
.
set
(
'title'
,
gettext
(
'Learners'
));
this
.
onLearnerCollectionUpdated
(
this
.
options
.
learnerCollection
);
this
.
options
.
rootView
.
showChildView
(
'main'
,
rosterView
);
// track the "page" view
this
.
options
.
trackingModel
.
set
(
'page'
,
'learner_roster'
);
...
...
@@ -73,7 +111,9 @@ define(function (require) {
* succeeds.
*/
showLearnerDetailPage
:
function
(
username
)
{
this
.
options
.
rootView
.
showChildView
(
'navigation'
,
new
ReturnLinkView
({}));
this
.
options
.
rootView
.
showChildView
(
'navigation'
,
new
ReturnLinkView
({
queryString
:
this
.
options
.
learnerCollection
.
getQueryString
()
}));
var
engagementTimelineModel
=
new
EngagementTimelineModel
({},
{
url
:
this
.
options
.
learnerEngagementTimelineUrl
.
replace
(
'temporary_username'
,
username
),
courseId
:
this
.
options
.
courseId
...
...
@@ -129,7 +169,6 @@ define(function (require) {
// track the "page" view
this
.
options
.
trackingModel
.
set
(
'page'
,
'learner_not_found'
);
this
.
options
.
trackingModel
.
trigger
(
'segment:page'
);
}
});
...
...
analytics_dashboard/static/apps/learners/app/router.js
View file @
e75e489c
...
...
@@ -9,16 +9,32 @@ define(function (require) {
// Routes intended to show a page in the app should map to method names
// beginning with "show", e.g. 'showLearnerRosterPage'.
appRoutes
:
{
// TODO: handle 'queryString' arguments in https://openedx.atlassian.net/browse/AN-6668
'(/)(?*queryString)'
:
'showLearnerRosterPage'
,
':username(/)(?*queryString)'
:
'showLearnerDetailPage'
,
'*notFound'
:
'showNotFoundPage'
},
onRoute
:
function
(
routeName
)
{
if
(
routeName
.
indexOf
(
'show'
)
===
0
)
{
// This method is run before the route methods are run.
execute
:
function
(
callback
,
args
,
name
)
{
if
(
name
.
indexOf
(
'show'
)
===
0
)
{
this
.
options
.
controller
.
triggerMethod
(
'showPage'
);
}
if
(
callback
)
{
callback
.
apply
(
this
,
args
);
}
},
initialize
:
function
(
options
)
{
this
.
options
=
options
||
{};
this
.
learnerCollection
=
options
.
controller
.
options
.
learnerCollection
;
this
.
listenTo
(
this
.
learnerCollection
,
'sync'
,
this
.
updateUrl
);
Marionette
.
AppRouter
.
prototype
.
initialize
.
call
(
this
,
options
);
},
// Called on LearnerCollection update. Converts the state of the collection (including any filters, searchers,
// sorts, or page numbers) into a url and then navigates the router to that url.
updateUrl
:
function
()
{
this
.
navigate
(
this
.
learnerCollection
.
getQueryString
(),
{
replace
:
true
});
}
});
...
...
analytics_dashboard/static/apps/learners/app/spec/controller-spec.js
View file @
e75e489c
...
...
@@ -69,7 +69,7 @@ define(function (require) {
last_updated
:
new
Date
(
2016
,
1
,
28
),
course_id
:
courseId
};
this
.
collection
=
new
LearnerCollection
([
this
.
user
],
{
parse
:
true
});
this
.
collection
=
new
LearnerCollection
([
this
.
user
],
{
parse
:
true
,
url
:
'http://example.com'
});
this
.
controller
=
new
LearnersController
({
rootView
:
this
.
rootView
,
learnerCollection
:
this
.
collection
,
...
...
@@ -92,6 +92,26 @@ define(function (require) {
expectRosterPage
(
this
.
controller
);
});
it
(
'should show the filtered learner roster page'
,
function
(
done
)
{
this
.
controller
.
showLearnerRosterPage
(
'text_search=foo'
);
expect
(
this
.
controller
.
options
.
learnerCollection
.
getSearchString
()).
toEqual
(
'foo'
);
this
.
controller
.
options
.
learnerCollection
.
once
(
'sync'
,
function
()
{
expectRosterPage
(
this
.
controller
);
expect
(
this
.
controller
.
options
.
rootView
.
$
(
'.learners-active-filters'
).
html
()).
toContainText
(
'foo'
);
done
();
},
this
,
done
);
expect
(
this
.
controller
.
options
.
rootView
.
$
(
'.learners-main-region'
).
html
()).
toContainText
(
'Loading...'
);
server
.
requests
[
server
.
requests
.
length
-
1
].
respond
(
200
,
{},
'{}'
);
});
it
(
'should show invalid parameters alert with invalid URL parameters'
,
function
()
{
this
.
controller
.
showLearnerRosterPage
(
'text_search=foo='
);
expect
(
this
.
controller
.
options
.
rootView
.
$
(
'.learners-alert-region'
).
html
()).
toContainText
(
'Invalid Parameters'
);
expect
(
this
.
controller
.
options
.
rootView
.
$
(
'.learners-main-region'
).
html
()).
toBe
(
''
);
});
describe
(
'navigating to the Learner Detail page'
,
function
()
{
it
(
'should show the learner detail page'
,
function
()
{
var
engagementTimelineResponse
;
...
...
@@ -120,6 +140,16 @@ define(function (require) {
server
.
requests
[
server
.
requests
.
length
-
1
].
respond
(
500
,
{},
''
);
});
it
(
'should have query string in return to learners navigation link'
,
function
()
{
this
.
collection
.
state
.
currentPage
=
2
;
this
.
collection
.
setSearchString
(
'foobar'
);
this
.
controller
.
showLearnerDetailPage
(
'learner'
);
server
.
requests
[
0
].
respond
(
200
,
{},
JSON
.
stringify
(
this
.
user
));
server
.
requests
[
server
.
requests
.
length
-
1
].
respond
(
200
,
{},
JSON
.
stringify
({}));
expect
(
this
.
controller
.
options
.
rootView
.
$
(
'.learners-navigation-region a'
).
attr
(
'href'
))
.
toEqual
(
'#?text_search=foobar&page=2'
);
});
});
// The 'showPage' event gets fired by the router on the
...
...
analytics_dashboard/static/apps/learners/app/spec/router-spec.js
View file @
e75e489c
...
...
@@ -2,23 +2,27 @@ define(function (require) {
'use strict'
;
var
Backbone
=
require
(
'backbone'
),
Marionette
=
require
(
'marionette'
),
LearnersRouter
=
require
(
'learners/app/router'
);
LearnerCollection
=
require
(
'learners/common/collections/learners'
),
LearnersController
=
require
(
'learners/app/controller'
),
LearnersRouter
=
require
(
'learners/app/router'
),
PageModel
=
require
(
'learners/common/models/page'
);
describe
(
'LearnersRouter'
,
function
()
{
beforeEach
(
function
()
{
Backbone
.
history
.
start
({
silent
:
true
});
this
.
controller
=
new
(
Marionette
.
Object
.
extend
({
showLearnerRosterPage
:
function
()
{},
showLearnerDetailPage
:
function
()
{},
showNotFoundPage
:
function
()
{},
onShowPage
:
function
()
{}
}))();
spyOn
(
this
.
controller
,
'showLearnerRosterPage'
);
spyOn
(
this
.
controller
,
'showLearnerDetailPage'
);
spyOn
(
this
.
controller
,
'showNotFoundPage'
);
spyOn
(
this
.
controller
,
'onShowPage'
);
this
.
server
=
sinon
.
fakeServer
.
create
();
this
.
user
=
{
last_updated
:
new
Date
(
2016
,
1
,
28
)
};
this
.
collection
=
new
LearnerCollection
([
this
.
user
],
{
url
:
'http://example.com'
});
this
.
controller
=
new
LearnersController
({
learnerCollection
:
this
.
collection
,
pageModel
:
new
PageModel
()
});
spyOn
(
this
.
controller
,
'showLearnerRosterPage'
).
and
.
stub
();
spyOn
(
this
.
controller
,
'showLearnerDetailPage'
).
and
.
stub
();
spyOn
(
this
.
controller
,
'showNotFoundPage'
).
and
.
stub
();
spyOn
(
this
.
controller
,
'onShowPage'
).
and
.
stub
();
this
.
router
=
new
LearnersRouter
({
controller
:
this
.
controller
});
...
...
@@ -28,6 +32,7 @@ define(function (require) {
// Clear previous route
this
.
router
.
navigate
(
''
);
Backbone
.
history
.
stop
();
this
.
server
.
restore
();
});
it
(
'triggers a showPage event for pages beginning with "show"'
,
function
()
{
...
...
@@ -86,5 +91,16 @@ define(function (require) {
expect
(
this
.
controller
.
showNotFoundPage
).
toHaveBeenCalledWith
(
'this/does/not/match'
,
null
);
});
});
it
(
'URL fragment is updated on LearnerCollection sync'
,
function
(
done
)
{
this
.
collection
.
state
.
currentPage
=
2
;
this
.
collection
.
setFilterField
(
'text_search'
,
'foo'
);
this
.
collection
.
fetch
({
reset
:
true
});
this
.
collection
.
once
(
'sync'
,
function
()
{
expect
(
Backbone
.
history
.
getFragment
()).
toBe
(
'?text_search=foo&page=2'
);
done
();
});
this
.
server
.
requests
[
0
].
respond
(
200
,
{},
JSON
.
stringify
([
this
.
user
]));
});
});
});
analytics_dashboard/static/apps/learners/app/views/root.js
View file @
e75e489c
...
...
@@ -63,12 +63,15 @@ define(function (require) {
* types are defined in the AlertView module.
* @param {string} title - the title of the alert.
* @param {string} description - the description of the alert.
* @param {object} link - the link for the alert. Has key "url"
* (the href) and key "text" (the display text for the link).
*/
showAlert
:
function
(
type
,
title
,
description
)
{
showAlert
:
function
(
type
,
title
,
description
,
link
)
{
this
.
showChildView
(
'alert'
,
new
AlertView
({
alertType
:
type
,
title
:
title
,
body
:
description
body
:
description
,
link
:
link
}));
},
...
...
analytics_dashboard/static/apps/learners/common/collections/learners.js
View file @
e75e489c
...
...
@@ -5,6 +5,7 @@ define(function (require) {
LearnerModel
=
require
(
'learners/common/models/learner'
),
LearnerUtils
=
require
(
'learners/common/utils'
),
Utils
=
require
(
'utils/utils'
),
LearnerCollection
;
...
...
@@ -52,6 +53,87 @@ define(function (require) {
hasNext
:
function
()
{
return
this
.
hasNextPage
();
},
/**
* The following two methods encode and decode the state of the collection to a query string. This query string
* is different than queryParams, which we send to the API server during a fetch. Here, the string encodes the
* current user view on the collection including page number, filters applied, search query, and sorting. The
* string is then appended on to the fragment identifier portion of the URL.
*
* e.g. http://.../learners/#?text_search=foo&sortKey=username&order=desc&page=1
*/
// Encodes the state of the collection into a query string that can be appended onto the URL.
getQueryString
:
function
()
{
var
params
=
this
.
getActiveFilterFields
(
true
),
orderedParams
=
[],
fragment
;
// Order the parameters: filters & search, sortKey, order, and then page.
// Because the active filter fields object is not ordered, these are the only params of orderedParams that
// don't have a defined order besides being before sortKey, order, and page.
_
.
mapObject
(
params
,
function
(
val
,
key
)
{
orderedParams
.
push
({
key
:
key
,
val
:
val
});
});
if
(
this
.
state
.
sortKey
!==
null
)
{
orderedParams
.
push
({
key
:
'sortKey'
,
val
:
this
.
state
.
sortKey
});
orderedParams
.
push
({
key
:
'order'
,
val
:
this
.
state
.
order
===
1
?
'desc'
:
'asc'
});
}
orderedParams
.
push
({
key
:
'page'
,
val
:
this
.
state
.
currentPage
});
fragment
=
orderedParams
.
map
(
function
(
param
)
{
// Note: this assumes that filter keys will never have URI special characters. We should encode the key
// too if that assumption is wrong.
return
param
.
key
+
'='
+
encodeURIComponent
(
param
.
val
);
}).
join
(
'&'
);
// Prefix query string params with '?', but return an empty string if there are no params.
return
fragment
!==
''
?
(
'?'
+
fragment
)
:
fragment
;
},
/**
* Decodes a query string into arguments and sets the state of the collection to what the arguments describe.
* The query string argument should have already had the prefix '?' stripped (the AppRouter does this).
*
* Will set the collection's isStale boolean to whether the new state differs from the old state (so the caller
* knows that the collection is stale and needs to do a fetch).
*/
setStateFromQueryString
:
function
(
queryString
)
{
var
params
=
Utils
.
parseQueryString
(
queryString
),
order
=
-
1
,
page
,
sortKey
;
_
.
mapObject
(
params
,
function
(
val
,
key
)
{
if
(
key
===
'page'
)
{
page
=
parseInt
(
val
,
10
);
if
(
page
!==
this
.
state
.
currentPage
)
{
this
.
isStale
=
true
;
}
this
.
state
.
currentPage
=
page
;
}
else
if
(
key
===
'sortKey'
)
{
sortKey
=
val
;
}
else
if
(
key
===
'order'
)
{
order
=
val
===
'desc'
?
1
:
-
1
;
}
else
{
if
(
key
in
this
.
filterableFields
||
key
===
'text_search'
)
{
if
(
val
!==
this
.
getFilterFieldValue
(
key
))
{
this
.
isStale
=
true
;
}
this
.
setFilterField
(
key
,
val
);
}
}
},
this
);
// Set the sort state if sortKey or order from the queryString are different from the current state
if
(
sortKey
&&
sortKey
in
this
.
sortableFields
)
{
if
(
sortKey
!==
this
.
state
.
sortKey
||
order
!==
this
.
state
.
order
)
{
this
.
isStale
=
true
;
this
.
setSorting
(
sortKey
,
order
);
}
}
}
});
...
...
analytics_dashboard/static/apps/learners/common/collections/spec/learners-spec.js
View file @
e75e489c
...
...
@@ -189,5 +189,77 @@ define(function (require) {
expect
(
learners
.
hasNext
()).
toBe
(
false
);
});
});
describe
(
'Encoding State to a Query String'
,
function
()
{
it
(
'encodes an empty state'
,
function
()
{
expect
(
learners
.
getQueryString
()).
toBe
(
'?page=1'
);
});
it
(
'encodes the page number'
,
function
()
{
learners
.
state
.
currentPage
=
2
;
expect
(
learners
.
getQueryString
()).
toBe
(
'?page=2'
);
});
it
(
'encodes the sort state'
,
function
()
{
learners
.
state
.
sortKey
=
'username'
;
learners
.
state
.
order
=
1
;
expect
(
learners
.
getQueryString
()).
toBe
(
'?sortKey=username&order=desc&page=1'
);
});
it
(
'encodes the text search'
,
function
()
{
learners
.
setFilterField
(
'text_search'
,
'foo'
);
expect
(
learners
.
getQueryString
()).
toBe
(
'?text_search=foo&page=1'
);
});
it
(
'encodes the filters'
,
function
()
{
learners
.
setFilterField
(
'enrollment_mode'
,
'audit'
);
learners
.
setFilterField
(
'cohort'
,
'group1'
);
// order of filter fields in query string is not defined
var
qstring
=
learners
.
getQueryString
(),
pageAfterFilters
=
(
qstring
===
'?enrollment_mode=audit&cohort=group1&page=1'
||
qstring
===
'?cohort=group1&enrollment_mode=audit&page=1'
);
expect
(
pageAfterFilters
).
toBe
(
true
);
});
});
describe
(
'Decoding Query String to a State'
,
function
()
{
var
state
=
{};
beforeEach
(
function
()
{
state
=
{
firstPage
:
1
,
lastPage
:
null
,
currentPage
:
1
,
pageSize
:
25
,
totalPages
:
null
,
totalRecords
:
null
,
sortKey
:
null
,
order
:
-
1
};
});
it
(
'decodes an empty query string'
,
function
()
{
learners
.
setStateFromQueryString
(
''
);
expect
(
learners
.
state
).
toEqual
(
state
);
expect
(
learners
.
getActiveFilterFields
()).
toEqual
({});
});
it
(
'decodes the page number'
,
function
()
{
state
.
currentPage
=
2
;
learners
.
setStateFromQueryString
(
'page=2'
);
expect
(
learners
.
state
).
toEqual
(
state
);
expect
(
learners
.
getActiveFilterFields
()).
toEqual
({});
});
it
(
'decodes the sort'
,
function
()
{
state
.
sortKey
=
'username'
;
state
.
order
=
1
;
learners
.
setStateFromQueryString
(
'sortKey=username&order=desc'
);
expect
(
learners
.
state
).
toEqual
(
state
);
expect
(
learners
.
getActiveFilterFields
()).
toEqual
({});
});
it
(
'decodes the text search'
,
function
()
{
learners
.
setStateFromQueryString
(
'text_search=foo'
);
expect
(
learners
.
state
).
toEqual
(
state
);
expect
(
learners
.
getSearchString
()).
toEqual
(
'foo'
);
});
it
(
'decodes the filters'
,
function
()
{
learners
.
setStateFromQueryString
(
'enrollment_mode=audit&cohort=group1'
);
expect
(
learners
.
state
).
toEqual
(
state
);
expect
(
learners
.
getActiveFilterFields
()).
toEqual
({
enrollment_mode
:
'audit'
,
cohort
:
'group1'
});
});
});
});
});
analytics_dashboard/static/apps/learners/common/templates/alert.underscore
View file @
e75e489c
...
...
@@ -9,16 +9,23 @@
<%- title %>
</div>
<% if (body) { %>
<div>
<div
class="alert-body"
>
<%- body %>
</div>
<% } %>
<% if (suggestions.length) { %>
<ul class="suggestions">
<% suggestions.map(function (suggestion) { %>
<li><%- suggestion %></li>
<% }); %>
</ul>
<div>
<ul class="suggestions">
<% suggestions.map(function (suggestion) { %>
<li><%- suggestion %></li>
<% }); %>
</ul>
</div>
<% } %>
<% if (link) { %>
<div class="link">
<a href="<%- link.url %>"><%- link.text %></a>
</div>
<% } %>
</div>
</div>
...
...
analytics_dashboard/static/apps/learners/common/views/alert-view.js
View file @
e75e489c
...
...
@@ -31,7 +31,8 @@ define(function (require) {
alertType
:
'info'
,
// default alert type
title
:
undefined
,
// string title of alert
body
:
undefined
,
// string body of alert
suggestions
:
[]
// list of strings to display after the body
suggestions
:
[],
// list of strings to display after the body
link
:
undefined
,
// string to display and url of link on alert
},
/**
...
...
analytics_dashboard/static/apps/learners/common/views/spec/alert-view-spec.js
View file @
e75e489c
...
...
@@ -41,5 +41,24 @@ define(function (require) {
expect
(
fixture
).
toContainElement
(
'li'
);
});
it
(
'renders an alert with a link'
,
function
()
{
var
fixture
=
setFixtures
(
'<div class="info-alert"></div>'
);
new
AlertView
({
el
:
'.info-alert'
,
alertType
:
'info'
,
title
:
'edX Info Alert'
,
body
:
'More description about an information alert.'
,
suggestions
:
[
'Display alerts.'
,
'Alerts are helpful messages!'
],
link
:
{
url
:
'http://example.com'
,
text
:
'A helpful link.'
}
}).
render
();
expect
(
fixture
).
toContainElement
(
'i.fa-bullhorn'
);
expect
(
fixture
).
toContainElement
(
'.alert-info-container'
);
expect
(
fixture
).
toContainElement
(
'li'
);
expect
(
fixture
).
toContainElement
(
'.link'
);
expect
(
fixture
).
toContainElement
(
'a'
);
});
});
});
analytics_dashboard/static/apps/learners/detail/templates/return-to-learners.underscore
View file @
e75e489c
<a href="">
<a href="
#<%- queryString %>
">
<i class="fa fa-arrow-left" aria-hidden="true"></i>
<%- returnText %>
</a>
analytics_dashboard/static/apps/learners/detail/views/learner-return.js
View file @
e75e489c
...
...
@@ -9,7 +9,8 @@ define(function (require) {
templateHelpers
:
function
()
{
return
{
returnText
:
gettext
(
'Return to Learners'
)
returnText
:
gettext
(
'Return to Learners'
),
queryString
:
this
.
options
.
queryString
};
},
});
...
...
analytics_dashboard/static/apps/learners/roster/templates/roster-loading.underscore
0 → 100644
View file @
e75e489c
<div class="loading">
<p class="text-center"><i class="fa fa-spinner fa-spin fa-lg" aria-hidden="true"></i> <%- loadingText %></p>
</div>
analytics_dashboard/static/apps/learners/roster/views/base-header-cell.js
View file @
e75e489c
...
...
@@ -37,6 +37,11 @@ define(function (require) {
},
render
:
function
(
column
,
direction
)
{
if
(
this
.
collection
.
state
.
sortKey
&&
this
.
collection
.
state
.
sortKey
===
this
.
column
.
attributes
.
name
)
{
direction
=
this
.
collection
.
state
.
order
?
'ascending'
:
'descending'
;
this
.
column
.
attributes
.
direction
=
direction
;
column
=
this
.
column
;
}
Backgrid
.
HeaderCell
.
prototype
.
render
.
apply
(
this
,
arguments
);
this
.
$el
.
html
(
this
.
template
({
label
:
this
.
column
.
get
(
'label'
)
...
...
analytics_dashboard/static/js/test/specs/utils-spec.js
View file @
e75e489c
...
...
@@ -86,4 +86,18 @@ define(['utils/utils'], function (Utils) {
});
});
describe
(
'parseQueryString'
,
function
()
{
it
(
'should parse query string'
,
function
()
{
expect
(
Utils
.
parseQueryString
(
'foo=bar&baz=quux'
)).
toEqual
({
foo
:
'bar'
,
baz
:
'quux'
});
expect
(
Utils
.
parseQueryString
(
'foo=bar&'
)).
toEqual
({
foo
:
'bar'
});
expect
(
Utils
.
parseQueryString
(
'foo=bar&baz'
)).
toEqual
({
foo
:
'bar'
,
baz
:
''
});
expect
(
Utils
.
parseQueryString
(
'foo=bar&baz='
)).
toEqual
({
foo
:
'bar'
,
baz
:
''
});
expect
(
Utils
.
parseQueryString
(
''
)).
toEqual
({});
expect
(
Utils
.
parseQueryString
(
null
)).
toEqual
({});
expect
(
function
()
{
Utils
.
parseQueryString
(
'foo=bar='
);
}).
toThrow
(
new
Error
(
'Each "&"-separated substring must either be a key or a key-value pair'
)
);
});
});
});
analytics_dashboard/static/js/utils/utils.js
View file @
e75e489c
...
...
@@ -103,6 +103,41 @@ define(['moment', 'underscore', 'utils/globalization'], function (moment, _, Glo
}
return
time
;
}).
join
(
':'
);
},
/**
* Converts the querystring portion of the URL into an object
* mapping keys to argument values.
*
* Examples:
* - 'foo=bar&baz=quux' -> {foo: 'bar', baz: 'quux'}
* - 'foo=bar&' -> {foo: 'bar'}
* - 'foo=bar&baz' -> {foo: 'bar', baz: ''}
* - 'foo=bar&baz=' -> {foo: 'bar', baz: ''}
* - '' -> {}
* - null -> {}
*
* @param queryString {string}
* @returns {object}
*/
parseQueryString
:
function
(
queryString
)
{
if
(
queryString
)
{
return
_
(
decodeURI
(
queryString
).
split
(
'&'
)).
map
(
function
(
namedVal
)
{
var
keyValPair
=
namedVal
.
split
(
'='
),
params
=
{};
if
(
keyValPair
.
length
===
1
&&
keyValPair
[
0
])
{
// No value
params
[
keyValPair
[
0
]]
=
''
;
}
else
if
(
keyValPair
.
length
===
2
){
params
[
keyValPair
[
0
]]
=
keyValPair
[
1
];
}
else
if
(
keyValPair
.
length
>
2
)
{
// Have something like foo=bar=...
throw
new
TypeError
(
'Each "&"-separated substring must either be a key or a key-value pair'
);
}
return
params
;
}).
reduce
(
function
(
memo
,
keyValPair
)
{
return
_
.
extend
(
memo
,
keyValPair
);
});
}
else
{
return
{};
}
}
};
...
...
analytics_dashboard/static/sass/_mixins.scss
View file @
e75e489c
...
...
@@ -38,7 +38,7 @@
@mixin
alert-container
(
$alert-color
)
{
padding
:
$padding-large-horizontal
$padding-large-vertical
;
padding-bottom
:
$padding-large-horizontal
*
2
;
padding-bottom
:
(
$padding-large-horizontal
*
2
)
-
$padding-small-horizontal
;
margin-bottom
:
$padding-large-horizontal
*
2
;
background-color
:
$alert-background-color
;
border-top
:
4px
solid
$alert-color
;
...
...
@@ -55,6 +55,10 @@
padding-bottom
:
$padding-small-horizontal
;
}
.alert-body
,
.link
{
padding-bottom
:
$padding-small-horizontal
;
}
// this keeps the gray borders from touching the top band at a 45 degree angle
&
:before
{
border
:
$description-border
;
...
...
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