Show observee data on the student planner

the planner used to rely on ENV.STUDENT_PLANNER_COURSES
it now makes a request to get dashboard_cards to
acquire the info it needs about the user's list of
courses.

closes LS-2919
flag=observer_picker

test plan:
  AS A NOT K5 STUDENT
  > expect the planner to load assignments
  - click the + and add a note to the user
  - click the + and add a note to a course
  > expect both notes to be in the planner,
    even after you refresh
  - click the grades button
  > expect grades to be loaded in the tray
  - have a missing assignment, and click the
    opportunities button
  > expect the missing assignment(s) to show up
  - dismiss one
  > expect it to move to the dismissed tab

  AS AN OBSERVER OF THIS STUDENT
  - and another student
  > expect the planner to load assignments
  > expect the planner to show grades
  > expect the planner to show opportunities
  - Don't bother adding notes or dismissing
    opportunities, those will eventually get
    disabled.
  - switch observed students
  > expect the planner to update with new data
  - switch to the card dashboar and expect it
    to be correct
  - refresh so the cards are the default, then
    switch to the planner
  - do ^that, but switch observees before switching
    dashboard views

  AS A K5 STUDENT
  > expect the Homeroom to show the right data
    - e.g. stuff due today and stuff missing
  > expect the Schedule tab to show the right data
   - e.g. assignments and missing items

  AS AN OBSERVER OF A K5 STUDENT
  - and another k5 student
  > expect it to work as it always has
  - switch observed student
  > expect the dashboard to update with new data
  - start from different default dashboards
    and switch dashboard views or switch observees
    then try the other way around.

  AS AN ENROLLED STUDENT OBSERVING A STUDENT
  > expect the dashboards to default to observing
    the current user
  - switch to observing your student
  > expect the dashboards to reflect the new observee
  - refresh
  > expect to be observing the student by default
  - switch to observing the observer
  > expect the right stuff to happen

  TURN OFF THE observer_picker FEATURE
  > expect all dashboards to still work correctly

  *** BONUS *****
  > expect the initial queries for dashboard_cards,
    missing_submissions, and planner/items
    to be the result of the prefetch.  On loading
    the planner page, you should see only 1 copy
    of the various requests until you start
    interacting with the page

Change-Id: I5a2b1eb2fbc812bbf6ee0bb9c037f21ab99cb007
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/284754
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jackson Howe <jackson.howe@instructure.com>
QA-Review: Jackson Howe <jackson.howe@instructure.com>
Product-Review: Ed Schiebel <eschiebel@instructure.com>
This commit is contained in:
Ed Schiebel 2022-02-07 10:26:08 -05:00
parent 6a08fb0158
commit c6df32c76f
38 changed files with 1028 additions and 555 deletions

View File

@ -350,6 +350,9 @@ class PlannerController < ApplicationController
# needed for all_ungraded_todo_items, but otherwise we don't need to load the actual
# objects
@contexts = Context.find_all_by_asset_string(context_ids) if public_access?
# so we get user notes too if a superobserver
@user_ids = [params[:observed_user_id]] if params.key?(:observed_user_id) && @user.grants_right?(@current_user, session, :read_as_parent)
end
# make IDs relative to the user's shard

View File

@ -532,7 +532,7 @@ class UsersController < ApplicationController
# prefetch dashboard cards with the right observer url param
if @current_user.roles(@domain_root_account).include?("observer") && (Account.site_admin.feature_enabled?(:k5_parent_support) || Account.site_admin.feature_enabled?(:observer_picker))
@cards_prefetch_observer_param = @selected_observed_user&.id
@cards_prefetch_observed_param = @selected_observed_user&.id
end
if k5_user?

View File

@ -2399,7 +2399,12 @@ class User < ActiveRecord::Base
def cached_course_ids_for_observed_user(observed_user)
Rails.cache.fetch_with_batched_keys(["course_ids_for_observed_user", self, observed_user].cache_key, batch_object: self, batched_keys: :enrollments, expires_in: 1.day) do
enrollments.shard(in_region_associated_shards).active_or_pending.of_observer_type.where(associated_user_id: observed_user).pluck(:course_id)
enrollments
.shard(in_region_associated_shards)
.active_by_date
.of_observer_type
.where(associated_user_id: observed_user)
.pluck(:course_id)
end
end

View File

@ -35,9 +35,9 @@ render_on_pageload = user_dashboard_view == 'cards'
%>
<% if render_on_pageload %>
<%# fire off these `fetch` requests now so they are ready sooner %>
<% if Account.site_admin.feature_enabled?(:observer_picker) && @current_user.roles(@domain_root_account).include?("observer") && @cards_prefetch_observer_param.present? %>
<%= prefetch_xhr("/api/v1/dashboard/dashboard_cards?observed_user=#{@cards_prefetch_observer_param}") %>
<%= prefetch_xhr("/dashboard-sidebar?observed_user=#{@cards_prefetch_observer_param}") %>
<% if Account.site_admin.feature_enabled?(:observer_picker) && @current_user.roles(@domain_root_account).include?("observer") && @cards_prefetch_observed_param.present? %>
<%= prefetch_xhr("/api/v1/dashboard/dashboard_cards?observed_user=#{@cards_prefetch_observed_param}") %>
<%= prefetch_xhr("/dashboard-sidebar?observed_user=#{@cards_prefetch_observed_param}") %>
<% else %>
<%= prefetch_xhr("/api/v1/dashboard/dashboard_cards") %>
<%= prefetch_xhr("/dashboard-sidebar") %>

View File

@ -27,31 +27,52 @@
<% provide :page_title do %><%= dashboard_title %><% end %>
<%
is_observer = Account.site_admin.feature_enabled?(:k5_parent_support) && @current_user&.roles(@domain_root_account)&.include?('observer')
if show_planner? && !is_observer
if show_planner?
# fire off these `fetch` requests now so they are ready sooner
six_months_ago = Time.now.utc.at_beginning_of_day.months_ago(6).iso8601(3)
beginning_of_day = Time.zone.now.at_beginning_of_day.utc.iso8601(3)
beginning_of_week = Time.zone.now.beginning_of_week(:sunday).utc.iso8601(3)
end_of_week = Time.zone.now.end_of_week(:sunday).utc.iso8601(3)
next_year = Time.zone.now.utc.at_beginning_of_day.years_since(1).iso8601
last_year = Time.zone.now.utc.at_beginning_of_day.years_ago(1).iso8601
if k5_user?
six_months_ago = CGI.escape Time.now.utc.at_beginning_of_day.months_ago(6).iso8601(3)
beginning_of_day = CGI.escape Time.zone.now.at_beginning_of_day.utc.iso8601(3)
beginning_of_week = CGI.escape Time.zone.now.beginning_of_week(:sunday).utc.iso8601(3)
end_of_week = CGI.escape Time.zone.now.end_of_week(:sunday).utc.iso8601(3)
next_year = CGI.escape Time.zone.now.utc.at_beginning_of_day.years_since(1).iso8601(3)
last_year = CGI.escape Time.zone.now.utc.at_beginning_of_day.years_ago(1).iso8601(3)
if !is_observer || @cards_prefetch_observed_param == @current_user.id
if k5_user?
%>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{last_year}&order=asc&per_page=1") %>
<%= prefetch_xhr("/api/v1/planner/items?end_date=#{next_year}&order=desc&per_page=1") %>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{beginning_of_week}&end_date=#{end_of_week}&per_page=100") %>
<%= prefetch_xhr('/api/v1/users/self/missing_submissions?include[]=planner_overrides&filter[]=submittable&filter[]=current_grading_period&per_page=100') %>
<% else %>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{six_months_ago}&filter=new_activity&order=asc") %>
<%= prefetch_xhr("/api/v1/planner/items?end_date=#{beginning_of_day}&order=desc&per_page=1") %>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{beginning_of_day}") %>
<%= prefetch_xhr('/api/v1/users/self/missing_submissions?include[]=planner_overrides&filter[]=submittable') %>
<% end %>
<% end %>
<%= prefetch_xhr("/api/v1/dashboard/dashboard_cards") %>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{last_year}&order=asc&per_page=1") %>
<%= prefetch_xhr("/api/v1/planner/items?end_date=#{next_year}&order=desc&per_page=1") %>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{beginning_of_week}&end_date=#{end_of_week}&per_page=100") %>
<%= prefetch_xhr('/api/v1/users/self/missing_submissions?include%5B%5D=planner_overrides&filter%5B%5D=current_grading_period&filter%5B%5D=submittable&per_page=100') %>
<% else %>
<%= prefetch_xhr("/api/v1/dashboard/dashboard_cards") %>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{six_months_ago}&filter=new_activity&order=asc") %>
<%= prefetch_xhr("/api/v1/planner/items?end_date=#{beginning_of_day}&order=desc&per_page=1") %>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{beginning_of_day}") %>
<%= prefetch_xhr('/api/v1/users/self/missing_submissions?include%5B%5D=planner_overrides&filter%5B%5D=current_grading_period&filter%5B%5D=submittable') %>
<% end %>
<% else #is_observer %>
<% observed_course_ids = @current_user.cached_course_ids_for_observed_user(@selected_observed_user)
.sort {|a, b| a <=> b }
.map {|cid| "course_ids%5B%5D=#{cid}" }
.join('&')
observed_context_codes = observed_course_ids.gsub('course_ids%5B%5D=', 'context_codes%5B%5D=course_')
<% if show_planner? %>
<%= prefetch_xhr("/api/v1/dashboard/dashboard_cards#{@cards_prefetch_observer_param.present? ? "?observed_user=#{@cards_prefetch_observer_param}" : ""}") %>
<% end %>
if k5_user?
%>
<%= prefetch_xhr("/api/v1/dashboard/dashboard_cards?observed_user=#{@cards_prefetch_observed_param}") %>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{last_year}&order=asc&per_page=1&observed_user_id=#{@cards_prefetch_observed_param}&#{observed_context_codes}") %>
<%= prefetch_xhr("/api/v1/planner/items?end_date=#{next_year}&order=desc&per_page=1&observed_user_id=#{@cards_prefetch_observed_param}&#{observed_context_codes}") %>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{beginning_of_week}&end_date=#{end_of_week}&per_page=100&observed_user_id=#{@cards_prefetch_observed_param}&#{observed_context_codes}") %>
<%= prefetch_xhr("/api/v1/users/self/missing_submissions?include%5B%5D=planner_overrides&filter%5B%5D=current_grading_period&filter%5B%5D=submittable&per_page=100&observed_user_id=#{@cards_prefetch_observed_param}&#{observed_course_ids}") %>
<% else %>
<%= prefetch_xhr("/api/v1/dashboard/dashboard_cards?observed_user=#{@cards_prefetch_observed_param}") %>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{six_months_ago}&filter=new_activity&order=asc&observed_user_id=#{@cards_prefetch_observed_param}&#{observed_context_codes}") %>
<%= prefetch_xhr("/api/v1/planner/items?end_date=#{beginning_of_day}&order=desc&per_page=1&observed_user_id=#{@cards_prefetch_observed_param}&#{observed_context_codes}") %>
<%= prefetch_xhr("/api/v1/planner/items?start_date=#{beginning_of_day}&observed_user_id=#{@cards_prefetch_observed_param}&#{observed_context_codes}") %>
<%= prefetch_xhr("/api/v1/users/self/missing_submissions?include%5B%5D=planner_overrides&filter%5B%5D=current_grading_period&filter%5B%5D=submittable&observed_user_id=#{@cards_prefetch_observed_param}&#{observed_course_ids}") %>
<% end %>
<% end%>
<% end %>
<% provide :right_side do %>
<% if !show_planner? and !k5_user? %>

View File

@ -19,11 +19,13 @@ import moxios from 'moxios'
import {findByTestId, render} from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import {
store,
initializePlanner,
loadPlannerDashboard,
resetPlanner,
renderToDoSidebar,
renderWeeklyPlannerHeader
renderWeeklyPlannerHeader,
reloadPlannerForObserver
} from '../index'
import {initialize as alertInitialize} from '../utilities/alertUtils'
@ -50,6 +52,11 @@ function defaultPlannerOptions() {
}
}
const defaultState = {
courses: [],
currentUser: {id: 13}
}
afterEach(() => {
resetPlanner()
})
@ -153,4 +160,64 @@ describe('with mock api', () => {
expect(wph).toBeInTheDocument()
})
})
describe('reloadPlannerForObserver', () => {
beforeEach(() => {
window.ENV ||= {}
ENV.FEATURES = {observer_picker: false}
store.dispatch = jest.fn()
store.getState = () => defaultState
})
afterEach(() => {
jest.resetAllMocks()
})
it('throws an exception unless the planner is initialized', () => {
expect(() => reloadPlannerForObserver('1')).toThrow()
})
it('does nothing if not passed an observee id', () => {
return initializePlanner(defaultPlannerOptions()).then(() => {
store.dispatch.mockClear()
reloadPlannerForObserver(null)
expect(store.dispatch).not.toHaveBeenCalled()
})
})
it('does nothing if given the existing selectedObservee id', () => {
store.getState = () => ({
...defaultState,
selectedObservee: '17'
})
return initializePlanner(defaultPlannerOptions()).then(() => {
store.dispatch.mockClear()
reloadPlannerForObserver('17')
expect(store.dispatch).not.toHaveBeenCalled()
})
})
it('does nothing if not the weekly planner', () => {
return initializePlanner(defaultPlannerOptions()).then(() => {
store.dispatch.mockClear()
reloadPlannerForObserver('17')
expect(store.dispatch).not.toHaveBeenCalled()
})
})
it('dispatches reloadWithObservee when all conditions are met', () => {
store.getState = () => ({
...defaultState,
selectedObservee: '1',
weeklyDashboard: {}
})
return initializePlanner(defaultPlannerOptions()).then(() => {
store.dispatch.mockClear()
reloadPlannerForObserver('17')
expect(store.dispatch).toHaveBeenCalled()
})
})
})
})

View File

@ -23,6 +23,7 @@ import * as Actions from '../index'
import {initialize as alertInitialize} from '../../utilities/alertUtils'
jest.mock('../../utilities/apiUtils', () => ({
...jest.requireActual('../../utilities/apiUtils'),
transformApiToInternalItem: jest.fn(response => ({...response, transformedToInternal: true})),
transformInternalToApiItem: jest.fn(internal => ({...internal, transformedToApi: true})),
transformInternalToApiOverride: jest.fn(internal => ({
@ -598,54 +599,60 @@ describe('api actions', () => {
describe('clearItems', () => {
it('dispatches clearWeeklyItems and clearOpportunities actions', () => {
const mockDispatch = jest.fn()
Actions.clearItems()(mockDispatch, getBasicState)
expect(mockDispatch).toHaveBeenCalledTimes(3)
Actions.clearItems()(mockDispatch, () => ({
weeklyDashboard: {}
}))
expect(mockDispatch).toHaveBeenCalledTimes(4)
expect(mockDispatch).toHaveBeenCalledWith({type: 'CLEAR_WEEKLY_ITEMS'})
expect(mockDispatch).toHaveBeenCalledWith({type: 'CLEAR_OPPORTUNITIES'})
expect(mockDispatch).toHaveBeenCalledWith({type: 'CLEAR_DAYS'})
expect(mockDispatch).toHaveBeenCalledWith({type: 'CLEAR_COURSES'})
})
it('does not dispatch clearWeeklyItems if not a weekly dashboard', () => {
const mockDispatch = jest.fn()
Actions.clearItems()(mockDispatch, () => ({}))
expect(mockDispatch).toHaveBeenCalledTimes(3)
expect(mockDispatch).toHaveBeenCalledWith({type: 'CLEAR_OPPORTUNITIES'})
expect(mockDispatch).toHaveBeenCalledWith({type: 'CLEAR_DAYS'})
expect(mockDispatch).toHaveBeenCalledWith({type: 'CLEAR_COURSES'})
})
})
describe('reloadWithObservee', () => {
it('dispatches actions to reload planner if observeeId/contextCodes are present and have changed', () => {
const mockDispatch = jest.fn()
const getState = () => ({
...getBasicState(),
selectedObservee: {id: '5', contextCodes: undefined}
})
Actions.reloadWithObservee('5', ['course_1'])(mockDispatch, getState)
expect(mockDispatch).toHaveBeenCalledTimes(5)
expect(mockDispatch).toHaveBeenCalledWith({
payload: {id: '5', contextCodes: ['course_1']},
type: 'SELECTED_OBSERVEE'
})
let mockDispatch, store, getState
beforeEach(() => {
mockDispatch = jest.fn(() => Promise.resolve({data: []}))
store = {...getBasicState()}
getState = () => store
})
it('dispatches startLoadingItems if contextCodes are not present but observee id changed', () => {
const mockDispatch = jest.fn()
const getState = () => ({
...getBasicState(),
selectedObservee: {id: '5', contextCodes: undefined}
})
Actions.reloadWithObservee('6', undefined)(mockDispatch, getState)
expect(mockDispatch).toHaveBeenCalledTimes(3)
expect(mockDispatch).toHaveBeenCalledWith({
payload: {id: '6', contextCodes: undefined},
type: 'SELECTED_OBSERVEE'
})
expect(mockDispatch).toHaveBeenCalledWith({
type: 'START_LOADING_ITEMS'
})
afterEach(() => {
jest.resetAllMocks()
})
it('does not dispatch anything if contextCodes and id have not changed', () => {
const mockDispatch = jest.fn()
const getState = () => ({
...getBasicState(),
selectedObservee: {id: '5', contextCodes: ['course_5']}
it('does nothing if no observee id', () => {
Actions.reloadWithObservee(undefined)(mockDispatch, getState)
expect(mockDispatch).not.toHaveBeenCalled()
})
it('does nothing if the observee id did not change', () => {
store.selectedObservee = '5'
Actions.reloadWithObservee('5')(mockDispatch, getState)
expect(mockDispatch).not.toHaveBeenCalled()
})
it('dispatches startLoadingItems if contextCodes are not present but observee id changed', async () => {
store.selectedObservee = '5'
await Actions.reloadWithObservee('6')(mockDispatch, getState)
expect(mockDispatch).toHaveBeenCalledTimes(4)
expect(mockDispatch).toHaveBeenCalledWith({
payload: '6',
type: 'SELECTED_OBSERVEE'
})
Actions.reloadWithObservee('5', ['course_5'])(mockDispatch, getState)
expect(mockDispatch).toHaveBeenCalledTimes(0)
expect(mockDispatch).toHaveBeenCalledWith({type: 'START_LOADING_ALL_OPPORTUNITIES'})
})
})
})

View File

@ -24,6 +24,7 @@ import {initialize as alertInitialize} from '../../utilities/alertUtils'
import configureStore from '../../store/configureStore'
jest.mock('../../utilities/apiUtils', () => ({
...jest.requireActual('../../utilities/apiUtils'),
getContextCodesFromState: jest.requireActual('../../utilities/apiUtils').getContextCodesFromState,
findNextLink: jest.fn(),
transformApiToInternalItem: jest.fn(response => ({
@ -82,7 +83,7 @@ describe('api actions', () => {
})
return moxiosWait(request => {
expect(request.config.url).toBe(
`/api/v1/planner/items?start_date=${fromMoment.toISOString()}`
`/api/v1/planner/items?start_date=${encodeURIComponent(fromMoment.toISOString())}`
)
})
})
@ -91,10 +92,10 @@ describe('api actions', () => {
const fromMoment = moment.tz('Asia/Tokyo')
Actions.sendFetchRequest({
fromMoment,
getState: () => ({loading: {futureNextUrl: 'next url'}})
getState: () => ({loading: {futureNextUrl: '/next/url'}})
})
return moxiosWait(request => {
expect(request.config.url).toBe('next url')
expect(request.config.url).toBe('/next/url')
})
})
@ -107,7 +108,9 @@ describe('api actions', () => {
})
return moxiosWait(request => {
expect(request.config.url).toBe(
`/api/v1/planner/items?end_date=${fromMoment.toISOString()}&order=desc`
`/api/v1/planner/items?end_date=${encodeURIComponent(
fromMoment.toISOString()
)}&order=desc`
)
})
})
@ -117,10 +120,10 @@ describe('api actions', () => {
Actions.sendFetchRequest({
fromMoment,
mode: 'past',
getState: () => ({loading: {pastNextUrl: 'past next url'}})
getState: () => ({loading: {pastNextUrl: '/past/next/url'}})
})
return moxiosWait(request => {
expect(request.config.url).toBe('past next url')
expect(request.config.url).toBe('/past/next/url')
})
})
@ -137,16 +140,19 @@ describe('api actions', () => {
})
describe('getPlannerItems', () => {
it('dispatches START_LOADING_ITEMS, getFirstNewActivityDate, and starts the saga', () => {
const mockDispatch = jest.fn()
Actions.getPlannerItems(moment('2017-12-18'))(mockDispatch, getBasicState)
it('dispatches START_LOADING_ITEMS, getFirstNewActivityDate, and starts the saga', async () => {
const mockDispatch = jest.fn(() => Promise.resolve({data: []}))
const mockMoment = moment()
moxios.stubRequest(/.*/, {status: 200, response: [{dateBucketMoment: mockMoment}]})
await Actions.getPlannerItems(moment('2017-12-18'))(mockDispatch, getBasicState)
expect(mockDispatch).toHaveBeenCalledWith(Actions.startLoadingItems())
expect(mockDispatch).toHaveBeenCalledWith(Actions.continueLoadingInitialItems())
expect(mockDispatch).toHaveBeenCalledWith(Actions.peekIntoPastSaga())
expect(mockDispatch).toHaveBeenCalledWith(Actions.startLoadingFutureSaga())
const getFirstNewActivityDateThunk = mockDispatch.mock.calls[2][0]
const getFirstNewActivityDateThunk = mockDispatch.mock.calls[4][0]
expect(typeof getFirstNewActivityDateThunk).toBe('function')
const mockMoment = moment()
const newActivityPromise = getFirstNewActivityDateThunk(mockDispatch, getBasicState)
return moxiosRespond([{dateBucketMoment: mockMoment}], newActivityPromise).then(() => {
expect(mockDispatch).toHaveBeenCalledWith(
@ -159,17 +165,18 @@ describe('api actions', () => {
})
describe('getFirstNewActivityDate', () => {
it('sends deep past, filter, and order parameters', () => {
const mockDispatch = jest.fn()
it('sends deep past, filter, and order parameters', async () => {
const mockDispatch = jest.fn(() => Promise.resolve({data: []}))
const mockMoment = moment.tz('Asia/Tokyo').startOf('day')
Actions.getFirstNewActivityDate(mockMoment)(mockDispatch, getBasicState)
return moxiosWait(request => {
expect(request.url).toBe(
`/api/v1/planner/items?start_date=${mockMoment
.subtract(6, 'months')
.toISOString()}&filter=new_activity&order=asc`
)
})
moxios.stubRequest(/\/planner\/items/, {response: {data: []}})
const p = Actions.getFirstNewActivityDate(mockMoment)(mockDispatch, getBasicState)
await p
const request = moxios.requests.mostRecent()
expect(request.url).toBe(
`/api/v1/planner/items?start_date=${encodeURIComponent(
mockMoment.subtract(6, 'months').toISOString()
)}&filter=new_activity&order=asc`
)
})
it('calls the alert method when it fails to get new activity', () => {
@ -259,7 +266,7 @@ describe('api actions', () => {
beforeEach(() => {
moxios.install()
mockDispatch = jest.fn()
mockDispatch = jest.fn(() => Promise.resolve({data: []}))
weeklyState = getBasicState().weeklyDashboard
})
@ -281,9 +288,9 @@ describe('api actions', () => {
status: 200,
response: [{plannable_date: '2017-01-01T:00:00:00Z'}]
})
moxios.stubRequest(/dashboard_cards/, {response: {data: []}})
Actions.getWeeklyPlannerItems(today)(mockDispatch, getBasicState)
await Actions.getWeeklyPlannerItems(today)(mockDispatch, getBasicState)
expect(mockDispatch).toHaveBeenCalledWith(Actions.startLoadingItems())
expect(mockDispatch).toHaveBeenCalledWith(Actions.gettingInitWeekItems(weeklyState))
expect(mockDispatch).toHaveBeenCalledWith(
@ -293,15 +300,16 @@ describe('api actions', () => {
isPreload: false
})
)
const getWayFutureItemThunk = mockDispatch.mock.calls[2][0] // the function returned by getWayFutureItem()
const getWayFutureItemThunk = mockDispatch.mock.calls[4][0] // the function returned by getWayFutureItem()
expect(typeof getWayFutureItemThunk).toBe('function')
/* eslint-disable jest/valid-expect-in-promise */
const futurePromise = getWayFutureItemThunk(mockDispatch, getBasicState).then(() => {
expect(mockDispatch).toHaveBeenCalledWith({
type: 'GOT_WAY_FUTURE_ITEM_DATE',
payload: '2017-05-01T:00:00:00Z'
})
})
const getWayPastItemThunk = mockDispatch.mock.calls[3][0]
const getWayPastItemThunk = mockDispatch.mock.calls[5][0]
expect(typeof getWayPastItemThunk).toBe('function')
const pastPromise = getWayPastItemThunk(mockDispatch, getBasicState).then(() => {
expect(mockDispatch).toHaveBeenCalledWith({
@ -309,6 +317,7 @@ describe('api actions', () => {
payload: '2017-01-01T:00:00:00Z'
})
})
/* eslint-enable jest/valid-expect-in-promise */
return Promise.all([futurePromise, pastPromise])
})
})
@ -488,7 +497,6 @@ describe('api actions', () => {
response: [{plannable_date: '2017-05-01T:00:00:00Z'}]
})
const mockCourses = [{id: 7}]
const mockUiManager = {
setStore: jest.fn(),
handleAction: jest.fn(),
@ -497,20 +505,18 @@ describe('api actions', () => {
const store = configureStore(mockUiManager, {
...getBasicState(),
courses: mockCourses,
courses: [{id: '7', assetString: 'course_7'}],
singleCourse: true
})
store.dispatch(Actions.getWeeklyPlannerItems(today))
await store.dispatch(Actions.getWeeklyPlannerItems(today))
const expectedContextCodes = /context_codes\[]=course_7/
moxios.wait(() => {
// Fetching current week, far future date, and far past date should all be filtered by context_codes
expect(moxios.requests.count()).toBe(3)
expect(moxios.requests.at(0).url).toMatch(expectedContextCodes)
expect(moxios.requests.at(1).url).toMatch(expectedContextCodes)
expect(moxios.requests.at(2).url).toMatch(expectedContextCodes)
done()
})
const expectedContextCodes = /context_codes%5B%5D=course_7/
// Fetching current week, far future date, and far past date should all be filtered by context_codes
expect(moxios.requests.count()).toBe(3)
expect(moxios.requests.at(0).url).toMatch(expectedContextCodes)
expect(moxios.requests.at(1).url).toMatch(expectedContextCodes)
expect(moxios.requests.at(2).url).toMatch(expectedContextCodes)
done()
})
it('adds observee id and context codes to request if state contains selected observee', async done => {
@ -519,8 +525,13 @@ describe('api actions', () => {
status: 200,
response: [{plannable_date: '2017-05-01T:00:00:00Z'}]
})
moxios.stubRequest(/dashboard_cards/, {
response: [
{id: '11', assetString: 'course_11'},
{id: '12', assetString: 'course_12'}
]
})
const mockCourses = [{id: 7}]
const mockUiManager = {
setStore: jest.fn(),
handleAction: jest.fn(),
@ -529,21 +540,20 @@ describe('api actions', () => {
const store = configureStore(mockUiManager, {
...getBasicState(),
courses: mockCourses,
selectedObservee: {id: '35', contextCodes: ['course_11', 'course_12']}
selectedObservee: '35'
})
store.dispatch(Actions.getWeeklyPlannerItems(today))
await store.dispatch(Actions.getWeeklyPlannerItems(today))
const expectedParams =
/observed_user_id=35&context_codes\[]=course_11&context_codes\[]=course_12/
moxios.wait(() => {
// Fetching current week, far future date, and far past date should all have observee id and context codes
expect(moxios.requests.count()).toBe(3)
expect(moxios.requests.at(0).url).toMatch(expectedParams)
expect(moxios.requests.at(1).url).toMatch(expectedParams)
expect(moxios.requests.at(2).url).toMatch(expectedParams)
done()
})
/observed_user_id=35&context_codes%5B%5D=course_11&context_codes%5B%5D=course_12/
// Fetching current week, far future date, and far past date should all have observee id and context codes
expect(moxios.requests.count()).toBe(4)
expect(moxios.requests.at(0).url).toMatch(/dashboard_cards/)
expect(moxios.requests.at(1).url).toMatch(expectedParams)
expect(moxios.requests.at(2).url).toMatch(expectedParams)
expect(moxios.requests.at(3).url).toMatch(expectedParams)
done()
})
it('does not add observee id if observee id is the current user id', async done => {
@ -552,8 +562,10 @@ describe('api actions', () => {
status: 200,
response: [{plannable_date: '2017-05-01T:00:00:00Z'}]
})
moxios.stubRequest(/dashboard_cards/, {
response: [{id: '7', assetString: 'course_7'}]
})
const mockCourses = [{id: 7}]
const mockUiManager = {
setStore: jest.fn(),
handleAction: jest.fn(),
@ -562,18 +574,18 @@ describe('api actions', () => {
const store = configureStore(mockUiManager, {
...getBasicState(),
courses: mockCourses,
selectedObservee: {id: '1', contextCodes: ['course_11', 'course_12']}
selectedObservee: '1'
})
store.dispatch(Actions.getWeeklyPlannerItems(today))
moxios.wait(() => {
expect(moxios.requests.count()).toBe(3)
expect(moxios.requests.at(0).url).not.toContain('observed_user_id')
expect(moxios.requests.at(1).url).not.toContain('observed_user_id')
expect(moxios.requests.at(2).url).not.toContain('observed_user_id')
done()
})
await store.dispatch(Actions.getWeeklyPlannerItems(today))
expect(moxios.requests.count()).toBe(4)
expect(moxios.requests.at(0).url).toMatch(/dashboard_cards/)
expect(moxios.requests.at(0).url).not.toContain('observed_user')
expect(moxios.requests.at(1).url).not.toContain('observed_user_id')
expect(moxios.requests.at(2).url).not.toContain('observed_user_id')
expect(moxios.requests.at(3).url).not.toContain('observed_user_id')
done()
})
})
})

View File

@ -278,7 +278,8 @@ describe('loadAllOpportunitiesSaga', () => {
course_ids: undefined,
include: ['planner_overrides'],
filter: ['submittable', 'current_grading_period'],
per_page: 100
per_page: 100,
observed_user_id: null
})
)
})
@ -341,13 +342,14 @@ describe('loadAllOpportunitiesSaga', () => {
it('passes observed_user_id and course_ids to API call if observing', () => {
const overrides = {
courses: [
{id: '1', assetString: 'course_1'},
{id: '569', assetString: 'course_569'}
],
currentUser: {
id: '3'
},
selectedObservee: {
id: '12',
contextCodes: ['course_1', 'course_569']
}
selectedObservee: '12'
}
const generator = loadAllOpportunitiesSaga()
generator.next() // select state

View File

@ -17,20 +17,19 @@
*/
import {createActions, createAction} from 'redux-actions'
import axios from 'axios'
import moment from 'moment-timezone'
import {asAxios, getPrefetchedXHR} from '@instructure/js-utils'
import {deepEqual} from '@instructure/ui-utils'
import parseLinkHeader from 'parse-link-header'
import configureAxios from '../utilities/configureAxios'
import {alert} from '../utilities/alertUtils'
import formatMessage from '../format-message'
import {maybeUpdateTodoSidebar} from './sidebar-actions'
import {getWeeklyPlannerItems, preloadSurroundingWeeks, startLoadingItems} from './loading-actions'
import {getPlannerItems, getWeeklyPlannerItems} from './loading-actions'
import {
transformInternalToApiItem,
transformInternalToApiOverride,
transformPlannerNoteApiToInternalItem
transformPlannerNoteApiToInternalItem,
getResponseHeader,
buildURL
} from '../utilities/apiUtils'
configureAxios(axios)
@ -57,7 +56,8 @@ export const {
selectedObservee,
clearWeeklyItems,
clearOpportunities,
clearDays
clearDays,
clearCourses
} = createActions(
'INITIAL_OPTIONS',
'ADD_OPPORTUNITIES',
@ -80,7 +80,8 @@ export const {
'SELECTED_OBSERVEE',
'CLEAR_WEEKLY_ITEMS',
'CLEAR_OPPORTUNITIES',
'CLEAR_DAYS'
'CLEAR_DAYS',
'CLEAR_COURSES'
)
export * from './loading-actions'
@ -111,18 +112,20 @@ export const getNextOpportunities = () => {
url: getState().opportunities.nextUrl
})
.then(response => {
if (parseLinkHeader(response.headers.link).next) {
if (parseLinkHeader(getResponseHeader(response, 'link')).next) {
dispatch(
addOpportunities({
items: response.data,
nextUrl: parseLinkHeader(response.headers.link).next.url
nextUrl: parseLinkHeader(getResponseHeader(response, 'link')).next.url
})
)
} else {
dispatch(addOpportunities({items: response.data, nextUrl: null}))
}
})
.catch(() => alert(formatMessage('Failed to load opportunities'), true))
.catch(_ex => {
alert(formatMessage('Failed to load opportunities'), true)
})
} else {
dispatch(allOpportunitiesLoaded())
}
@ -133,17 +136,28 @@ export const getInitialOpportunities = () => {
return (dispatch, getState) => {
dispatch(startLoadingOpportunities())
// eslint-disable-next-line @typescript-eslint/no-shadow
const {courses, selectedObservee} = getState()
const url =
getState().opportunities.nextUrl ||
'/api/v1/users/self/missing_submissions?include[]=planner_overrides&filter[]=submittable'
buildURL('/api/v1/users/self/missing_submissions', {
include: ['planner_overrides'],
filter: ['submittable', 'current_grading_period'],
observed_user_id: selectedObservee,
course_ids: selectedObservee
? courses.map(c => c.id).sort((a, b) => a.localeCompare(b, 'en', {numeric: true}))
: undefined
})
const request = asAxios(getPrefetchedXHR(url)) || axios({method: 'get', url})
request
.then(response => {
const next = parseLinkHeader(response.headers.link).next
const next = parseLinkHeader(getResponseHeader(response, 'link')).next
dispatch(addOpportunities({items: response.data, nextUrl: next ? next.url : null}))
})
.catch(() => alert(formatMessage('Failed to load opportunities'), true))
.catch(_ex => {
alert(formatMessage('Failed to load opportunities'), true)
})
}
}
@ -286,27 +300,29 @@ function getOverrideDataOnItem(plannerItem) {
}
export const clearItems = () => {
return dispatch => {
dispatch(clearWeeklyItems())
return (dispatch, getState) => {
if (getState().weeklyDashboard) {
dispatch(clearWeeklyItems())
}
dispatch(clearCourses(getState().singleCourse))
dispatch(clearOpportunities())
dispatch(clearDays())
}
}
export const reloadWithObservee = (observeeId, contextCodes) => {
export const reloadWithObservee = observeeId => {
return (dispatch, getState) => {
if (
getState().selectedObservee?.id !== observeeId ||
(observeeId && !deepEqual(getState().selectedObservee?.contextCodes, contextCodes))
) {
dispatch(selectedObservee({id: observeeId, contextCodes}))
if (getState().selectedObservee !== observeeId) {
dispatch(selectedObservee(observeeId))
dispatch(clearItems())
if (observeeId && !contextCodes) {
dispatch(startLoadingItems())
if (getState().weeklyDashboard) {
return dispatch(getWeeklyPlannerItems(getState().today)).then(() => {
dispatch(startLoadingAllOpportunities())
})
} else {
dispatch(getWeeklyPlannerItems(moment.tz(getState().timeZone).startOf('day')))
dispatch(preloadSurroundingWeeks())
dispatch(startLoadingAllOpportunities())
return dispatch(getPlannerItems(getState().today)).then(() => {
dispatch(startLoadingAllOpportunities())
})
}
}
}

View File

@ -18,12 +18,13 @@
import {createActions, createAction} from 'redux-actions'
import axios from 'axios'
import buildURL from 'axios/lib/helpers/buildURL'
import {asAxios, getPrefetchedXHR} from '@instructure/js-utils'
import {
getContextCodesFromState,
transformApiToInternalItem,
observedUserId
observedUserId,
observedUserContextCodes,
buildURL
} from '../utilities/apiUtils'
import {alert} from '../utilities/alertUtils'
import formatMessage from '../format-message'
@ -54,7 +55,8 @@ export const {
jumpToWeek,
jumpToThisWeek,
gotWayPastItemDate,
gotWayFutureItemDate
gotWayFutureItemDate,
gotCourseList
} = createActions(
'START_LOADING_ITEMS',
'CONTINUE_LOADING_INITIAL_ITEMS',
@ -80,7 +82,8 @@ export const {
'JUMP_TO_WEEK',
'JUMP_TO_THIS_WEEK',
'GOT_WAY_FUTURE_ITEM_DATE',
'GOT_WAY_PAST_ITEM_DATE'
'GOT_WAY_PAST_ITEM_DATE',
'GOT_COURSE_LIST'
)
export const gettingPastItems = createAction(
@ -111,8 +114,17 @@ export function getFirstNewActivityDate(fromMoment) {
// specifically so we know what the very oldest new activity is
return (dispatch, getState) => {
fromMoment = fromMoment.clone().subtract(6, 'months')
const observed_user_id = observedUserId(getState())
const context_codes = observedUserContextCodes(getState())
const url = buildURL('/api/v1/planner/items', {
start_date: fromMoment.toISOString(),
filter: 'new_activity',
order: 'asc',
observed_user_id,
context_codes
})
const url = `/api/v1/planner/items?start_date=${fromMoment.toISOString()}&filter=new_activity&order=asc`
const request = asAxios(getPrefetchedXHR(url)) || axios.get(url)
return request
@ -127,7 +139,7 @@ export function getFirstNewActivityDate(fromMoment) {
dispatch(foundFirstNewActivityDate(first.dateBucketMoment))
}
})
.catch(() => alert(formatMessage('Failed to get new activity'), true))
.catch(_ex => alert(formatMessage('Failed to get new activity'), true))
}
}
@ -135,10 +147,30 @@ export function getFirstNewActivityDate(fromMoment) {
export function getPlannerItems(fromMoment) {
return dispatch => {
dispatch(startLoadingItems())
dispatch(continueLoadingInitialItems()) // a start counts as a continue for the ContinueInitialLoad animation
dispatch(getFirstNewActivityDate(fromMoment))
dispatch(peekIntoPastSaga())
dispatch(startLoadingFutureSaga())
return dispatch(getCourseList()) // classic planner never does singleCourse
.then(res => {
dispatch(gotCourseList(res.data))
dispatch(continueLoadingInitialItems()) // a start counts as a continue for the ContinueInitialLoad animation
dispatch(getFirstNewActivityDate(fromMoment))
dispatch(peekIntoPastSaga())
dispatch(startLoadingFutureSaga())
})
.catch(_ex => {
alert(formatMessage('Failed getting course list'))
})
}
}
export function getCourseList() {
return (dispatch, getState) => {
if (getState().singleCourse) {
return Promise.resolve({data: getState().courses}) // set via INITIAL_OPTIONS
}
const observeeId = observedUserId(getState())
const ovserveeParam = observeeId ? `?observed_user=${observeeId}` : ''
const url = `/api/v1/dashboard/dashboard_cards${ovserveeParam}`
const request = asAxios(getPrefetchedXHR(url)) || axios.get(url)
return request
}
}
@ -198,11 +230,21 @@ export const loadPastUntilToday = () => dispatch => {
export function getWeeklyPlannerItems(fromMoment) {
return (dispatch, getState) => {
dispatch(startLoadingItems())
const weeklyState = getState().weeklyDashboard
dispatch(gettingInitWeekItems(weeklyState))
dispatch(getWayFutureItem(fromMoment))
dispatch(getWayPastItem(fromMoment))
loadWeekItems(dispatch, getState)
const coursesPromise = getState().singleCourse
? Promise.resolve({data: getState().courses}) // set in INITIAL_OPTIONS for the singleCourse case
: dispatch(getCourseList())
return coursesPromise
.then(res => {
dispatch(gotCourseList(res.data))
const weeklyState = getState().weeklyDashboard
dispatch(gettingInitWeekItems(weeklyState))
dispatch(getWayFutureItem(fromMoment))
dispatch(getWayPastItem(fromMoment))
loadWeekItems(dispatch, getState)
})
.catch(_ex => {
alert(formatMessage('Failed getting course list'))
})
}
}
@ -285,15 +327,15 @@ function getWayFutureItem(fromMoment) {
const observed_user_id = observedUserId(state)
let context_codes
if (observed_user_id) {
context_codes = state.selectedObservee.contextCodes
context_codes = getContextCodesFromState(state)
} else {
context_codes = state.singleCourse ? getContextCodesFromState(state) : undefined
}
const futureMoment = fromMoment.clone().add(1, 'year')
const futureMoment = fromMoment.clone().tz('UTC').startOf('day').add(1, 'year')
const url = buildURL('/api/v1/planner/items', {
observed_user_id,
context_codes,
end_date: futureMoment.format(),
end_date: futureMoment.toISOString(),
order: 'desc',
per_page: 1
})
@ -306,7 +348,7 @@ function getWayFutureItem(fromMoment) {
dispatch(gotWayFutureItemDate(wayFutureItemDate))
}
})
.catch(() => alert(formatMessage('Failed peeking into your future'), true))
.catch(_ex => alert(formatMessage('Failed peeking into your future'), true))
}
}
@ -316,15 +358,15 @@ function getWayPastItem(fromMoment) {
const observed_user_id = observedUserId(state)
let context_codes
if (observed_user_id) {
context_codes = state.selectedObservee.contextCodes
context_codes = getContextCodesFromState(state)
} else {
context_codes = state.singleCourse ? getContextCodesFromState(state) : undefined
}
const pastMoment = fromMoment.clone().add(-1, 'year')
const pastMoment = fromMoment.clone().tz('UTC').startOf('day').add(-1, 'year')
const url = buildURL('/api/v1/planner/items', {
observed_user_id,
context_codes,
start_date: pastMoment.format(),
start_date: pastMoment.toISOString(),
order: 'asc',
per_page: 1
})
@ -337,7 +379,7 @@ function getWayPastItem(fromMoment) {
dispatch(gotWayPastItemDate(wayPastItemDate))
}
})
.catch(() => {
.catch(_ex => {
alert(formatMessage('Failed peeking into your past'), true)
})
}
@ -385,7 +427,7 @@ function fetchParams(loadingOptions) {
const observeeId = observedUserId(loadingOptions.getState())
if (observeeId) {
params.observed_user_id = observeeId
params.context_codes = loadingOptions.getState().selectedObservee.contextCodes
params.context_codes = observedUserContextCodes(loadingOptions.getState())
}
return ['/api/v1/planner/items', {params}]

View File

@ -23,7 +23,8 @@ import {getFirstLoadedMoment, getLastLoadedMoment} from '../utilities/dateUtils'
import {
getContextCodesFromState,
transformApiToInternalGrade,
observedUserId
observedUserId,
getResponseHeader
} from '../utilities/apiUtils'
import {alert} from '../utilities/alertUtils'
import formatMessage from '../format-message'
@ -142,7 +143,7 @@ export function* loadGradesSaga() {
gradesData[internalGrade.courseId] = internalGrade
})
const links = parseLinkHeader(response.headers.link)
const links = parseLinkHeader(getResponseHeader(response, 'link'))
loadingUrl = links && links.next ? links.next.url : null
}
yield put(gotGradesSuccess(gradesData))
@ -160,9 +161,9 @@ export function* loadAllOpportunitiesSaga() {
const observed_user_id = observedUserId({selectedObservee, currentUser})
let course_ids
if (observed_user_id) {
course_ids = selectedObservee.contextCodes?.map(c => c.split('_')[1])
course_ids = courses.map(c => c.id)
} else {
course_ids = singleCourse ? courses.map(({id}) => id) : undefined
course_ids = singleCourse ? courses.map(c => c.id) : undefined
}
while (loadingUrl != null) {
const filter = ['submittable']
@ -178,7 +179,7 @@ export function* loadAllOpportunitiesSaga() {
})
items.push(...response.data)
const links = parseLinkHeader(response.headers.link)
const links = parseLinkHeader(getResponseHeader(response, 'link'))
loadingUrl = links?.next ? links.next.url : null
}
yield put(addOpportunities({items, nextUrl: null}))

View File

@ -37,14 +37,15 @@ import {
loadPastButtonClicked,
togglePlannerItemCompletion,
updateTodo,
scrollToToday
scrollToToday,
reloadWithObservee
} from '../../actions'
import {notifier} from '../../dynamic-ui'
import {daysToDaysHash} from '../../utilities/daysUtils'
import {formatDayKey, isThisWeek} from '../../utilities/dateUtils'
import {Animator} from '../../dynamic-ui/animator'
import responsiviser from '../responsiviser'
import {observedUserId} from '../../utilities/apiUtils'
import {observedUserId, observedUserContextCodes} from '../../utilities/apiUtils'
export class PlannerApp extends Component {
static propTypes = {
@ -84,7 +85,9 @@ export class PlannerApp extends Component {
loadingOpportunities: bool,
opportunityCount: number,
singleCourseView: bool,
isObserving: bool
isObserving: bool,
observedUserId: string,
observedUserContextCodes: arrayOf(string)
}
static defaultProps = {
@ -125,6 +128,11 @@ export class PlannerApp extends Component {
}
componentDidUpdate(prevProps) {
if (prevProps.observedUserId !== this.props.observedUserId) {
reloadWithObservee(this.props.observedUserId, this.props.observedUserContextCodes)
return
}
this.props.triggerDynamicUiUpdates()
if (this.props.responsiveSize !== prevProps.responsiveSize) {
this.afterLayoutChange()
@ -523,7 +531,9 @@ export const mapStateToProps = state => {
loadingOpportunities: !!state.loading.loadingOpportunities,
opportunityCount: state.opportunities?.items?.length || 0,
singleCourseView: state.singleCourse,
isObserving: !!observedUserId(state)
isObserving: !!observedUserId(state),
observeduserId: observedUserId(state),
observedUserContextCodes: observedUserContextCodes(state)
}
}

View File

@ -249,22 +249,23 @@ function loading() {
export function createPlannerApp() {
if (!store.getState().weeklyDashboard) {
// disable load on scroll for weekly dashboard
registerScrollEvents({
scrollIntoPast: handleScrollIntoPastAttempt,
scrollIntoFuture: handleScrollIntoFutureAttempt,
scrollPositionChange: pos => dynamicUiManager.handleScrollPositionChange(pos)
})
if (!createPlannerApp.scrollEventsRegistered) {
// register events only once
registerScrollEvents({
scrollIntoPast: handleScrollIntoPastAttempt,
scrollIntoFuture: handleScrollIntoFutureAttempt,
scrollPositionChange: pos => dynamicUiManager.handleScrollPositionChange(pos)
})
createPlannerApp.scrollEventsRegistered = true
}
store.dispatch(getPlannerItems(moment.tz(initializedOptions.env.timeZone).startOf('day')))
} else {
const waitingOnObserveeContextCodes =
store.getState().selectedObservee?.id && !store.getState().selectedObservee?.contextCodes
if (!waitingOnObserveeContextCodes) {
store.dispatch(
getWeeklyPlannerItems(moment.tz(initializedOptions.env.timeZone).startOf('day'))
)
store.dispatch(startLoadingAllOpportunities())
}
store
.dispatch(getWeeklyPlannerItems(moment.tz(initializedOptions.env.timeZone).startOf('day')))
.then(() => {
store.dispatch(startLoadingAllOpportunities())
})
}
return (
@ -285,6 +286,7 @@ export function createPlannerApp() {
</DynamicUiProvider>
)
}
createPlannerApp.scrollEventsRegistered = false
function renderApp(element) {
ReactDOM.render(createPlannerApp(), element)
@ -325,7 +327,7 @@ export function renderToDoSidebar(element) {
<Provider store={store}>
<Suspense fallback={loading()}>
<ToDoSidebar
courses={env.STUDENT_PLANNER_COURSES}
courses={store.getState().courses}
timeZone={env.TIMEZONE}
locale={env.MOMENT_LOCALE}
changeDashboardView={initializedOptions.changeDashboardView}
@ -377,13 +379,20 @@ export function preloadInitialItems() {
}
}
// Call with student id and student's context codes to load planner scoped to
// Call with student id to load planner scoped to
// one of an observer's students
export function reloadPlannerForObserver(observeeId, contextCodes) {
export function reloadPlannerForObserver(newObserveeId) {
if (!initializedOptions)
throw new Error('initializePlanner must be called before reloadPlannerForObserver')
if (!store.getState().weeklyDashboard)
throw new Error('reloadPlannerForObserver is only supported in weekly dashboard mode')
store.dispatch(reloadWithObservee(observeeId, contextCodes))
// if observer is observing themselves, then we're not really observing
const observeeId =
!newObserveeId || newObserveeId === store.getState().currentUser.id ? null : newObserveeId
if (
observeeId !== store.getState().selectedObservee &&
(store.getState().weeklyDashboard || ENV.FEATURES.observer_picker)
) {
store.dispatch(reloadWithObservee(observeeId))
}
}

View File

@ -17,7 +17,7 @@
*/
import reducer from '../courses-reducer'
import {gotGradesSuccess} from '../../actions'
import {gotGradesSuccess, clearCourses} from '../../actions'
it('merges grades into courses', () => {
const courses = [
@ -25,10 +25,31 @@ it('merges grades into courses', () => {
{id: '2', otherData: 'second-other-fields'}
]
const grades = {
'1': {courseId: '1', hasGradingPeriods: true, grade: '34.42%'},
'2': {courseId: '2', hasGradingPeriods: false, grade: '42.34%'}
1: {courseId: '1', hasGradingPeriods: true, grade: '34.42%'},
2: {courseId: '2', hasGradingPeriods: false, grade: '42.34%'}
}
const action = gotGradesSuccess(grades)
const nextState = reducer(courses, action)
expect(nextState).toMatchSnapshot()
})
describe('CLEAR_COURSES', () => {
it('clears courses', () => {
const courses = [
{id: '1', otherData: 'first-other-fields'},
{id: '2', otherData: 'second-other-fields'}
]
const action = clearCourses()
const nextState = reducer(courses, action)
expect(nextState).toEqual([])
})
it('does not clear courses in singleCourse mode', () => {
const courses = [{id: '1', otherData: 'just-one-course'}]
const action = clearCourses(true)
const nextState = reducer(courses, action)
expect(nextState).toBe(courses)
})
})

View File

@ -17,6 +17,8 @@
*/
import {handleActions} from 'redux-actions'
const defaultState = []
function mergeGradesIntoCourses(courses, action) {
const grades = action.payload
return courses.map(course => {
@ -26,21 +28,22 @@ function mergeGradesIntoCourses(courses, action) {
})
}
const getPlannerCourses = ({
payload: {
env: {COURSE, STUDENT_PLANNER_COURSES}
}
}) => {
if (!STUDENT_PLANNER_COURSES.length && COURSE) {
return [COURSE]
}
return STUDENT_PLANNER_COURSES
}
export default handleActions(
{
INITIAL_OPTIONS: (state, action) => getPlannerCourses(action),
GOT_GRADES_SUCCESS: mergeGradesIntoCourses
INITIAL_OPTIONS: (state, action) => {
if (action.payload.singleCourse) {
return [action.payload.env.COURSE]
}
return []
},
GOT_COURSE_LIST: (state, action) => {
return action.payload
},
GOT_GRADES_SUCCESS: mergeGradesIntoCourses,
CLEAR_COURSES: (state, action) => {
if (action.payload) return state
return defaultState
}
},
[]
defaultState
)

View File

@ -18,7 +18,7 @@
import moment from 'moment-timezone'
import {combineReducers} from 'redux'
import {handleAction, handleActions} from 'redux-actions'
import {handleAction} from 'redux-actions'
import days from './days-reducer'
import loading from './loading-reducer'
import courses from './courses-reducer'
@ -29,6 +29,7 @@ import ui from './ui-reducer'
import savePlannerItem from './save-item-reducer'
import sidebar from './sidebar-reducer'
import weeklyDashboard from './weekly-reducer'
import selectedObservee from './selected-observee-reducer'
const locale = handleAction(
'INITIAL_OPTIONS',
@ -87,19 +88,6 @@ const firstNewActivityDate = handleAction(
null
)
const selectedObservee = handleActions(
{
SELECTED_OBSERVEE: (state, action) => ({
id: action.payload?.id,
contextCodes: action.payload?.contextCodes
}),
INITIAL_OPTIONS: (state, action) => ({
id: action.payload.observedUserId
})
},
null
)
const combinedReducers = combineReducers({
courses,
groups,

View File

@ -0,0 +1,31 @@
/*
* Copyright (C) 2022 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {handleActions} from 'redux-actions'
export default handleActions(
{
INITIAL_OPTIONS: (state, action) => {
return action.payload.observedUserId || action.payload.observedUser?.id || null
},
SELECTED_OBSERVEE: (state, action) => {
return action.payload || null
}
},
null
)

View File

@ -22,7 +22,10 @@ import {
transformInternalToApiOverride,
transformPlannerNoteApiToInternalItem,
transformApiToInternalGrade,
observedUserId
observedUserId,
observedUserContextCodes,
buildURL,
getContextCodesFromState
} from '../apiUtils'
const courses = [
@ -646,17 +649,95 @@ describe('observedUserId', () => {
selectedObservee: null
}
it('returns undefined if selectedObservee.id does not exist', () => {
expect(observedUserId(defaultState)).toBeUndefined()
it('returns null if selectedObservee does not exist', () => {
expect(observedUserId(defaultState)).toBeNull()
})
it('returns undefined if the selectedObservee is the same as the current user', () => {
const state = {...defaultState, selectedObservee: {id: '3'}}
expect(observedUserId(state)).toBeUndefined()
it('returns null if the selectedObservee is the same as the current user', () => {
const state = {...defaultState, selectedObservee: '3'}
expect(observedUserId(state)).toBeNull()
})
it('returns the observee id if present and not the current user', () => {
const state = {...defaultState, selectedObservee: {id: '2'}}
const state = {...defaultState, selectedObservee: '2'}
expect(observedUserId(state)).toBe('2')
})
})
describe('observedUserContextCodes', () => {
it('returns undefined if selectedObservee is the current user', () => {
expect(
observedUserContextCodes({
currentUser: {id: '3'},
selectedObservee: '3'
})
).toBeUndefined()
})
it('returns the course context codes for an observee', () => {
expect(
observedUserContextCodes({
currentUser: {id: '3'},
selectedObservee: '17',
courses: [{id: '20'}, {id: '4'}]
})
).toStrictEqual(['course_4', 'course_20'])
})
})
describe('buildURL', () => {
it('returns a url with params in the expected order', () => {
const url = buildURL('/here/there', {
course_ids: ['50', '7'],
context_codes: ['g_30', 'g_5'],
observed_user_id: 'f',
per_page: 'e',
order: 'd',
filter: 'c',
include: 'i',
end_date: 'b',
start_date: 'a'
})
expect(url).toStrictEqual(
'/here/there?start_date=a&end_date=b&include=i&filter=c&order=d&per_page=e&observed_user_id=f&context_codes%5B%5D=g_5&context_codes%5B%5D=g_30&course_ids%5B%5D=7&course_ids%5B%5D=50'
)
})
it('appends unordered params to the end', () => {
const url1 = buildURL('/here/there', {
filter: 'c',
start_date: 'a',
foo: 'bar'
})
expect(url1).toStrictEqual('/here/there?start_date=a&filter=c&foo=bar')
const url2 = buildURL('/here/there', {
filter: 'c',
start_date: 'a',
foo: ['bar', 'baz']
})
expect(url2).toStrictEqual('/here/there?start_date=a&filter=c&foo%5B%5D=bar&foo%5B%5D=baz')
})
it('omits undefined and null params', () => {
const url1 = buildURL('/here/there', {
filter: undefined,
start_date: 'a'
})
expect(url1).toStrictEqual('/here/there?start_date=a')
const url2 = buildURL('/here/there', {
foo: null,
start_date: 'a'
})
expect(url2).toStrictEqual('/here/there?start_date=a')
})
})
describe('getContextCodesFromState', () => {
it('returns context codes in sorted order', () => {
expect(
getContextCodesFromState({
courses: [{id: '20'}, {id: '4'}]
})
).toStrictEqual(['course_4', 'course_20'])
})
})

View File

@ -80,7 +80,7 @@ const getApiItemType = overrideType => {
}
export function findNextLink(response) {
const linkHeader = response.headers.link
const linkHeader = getResponseHeader(response, 'link')
if (linkHeader == null) return null
const parsedLinks = parseLinkHeader(linkHeader)
@ -214,7 +214,11 @@ export function transformApiToInternalGrade(apiResult) {
}
export function getContextCodesFromState({courses = []}) {
return courses?.length ? courses.map(({id}) => `course_${id}`) : undefined
return courses?.length
? courses
.map(({id}) => `course_${id}`)
.sort((a, b) => a.localeCompare(b, 'en', {numeric: true}))
: undefined
}
function getCourseContext(course) {
@ -260,7 +264,68 @@ function isComplete(apiResponse) {
}
export function observedUserId(state) {
if (state.selectedObservee?.id && state.selectedObservee.id !== state.currentUser.id) {
return state.selectedObservee.id
if (state.selectedObservee && state.selectedObservee !== state.currentUser.id) {
return state.selectedObservee
}
return null
}
export function observedUserContextCodes(state) {
if (state.selectedObservee && state.selectedObservee !== state.currentUser.id) {
return getContextCodesFromState(state)
}
return undefined
}
export function getResponseHeader(response, name) {
return response.headers.get?.(name) || response.headers[name]
}
// take a base url and object of params and generate
// a url with query_string parameters for the params
//
// To build a URL that matches the one build for the prefetch
// params are in the following order
const paramOrder = [
'start_date',
'end_date',
'include',
'filter',
'order',
'per_page',
'observed_user_id',
'context_codes',
'course_ids'
]
export function buildURL(url, params = {}) {
const result = new URL(url, 'http://localhost/')
const params2 = {...params}
// first the order-dependent params
paramOrder.forEach(key => {
if (key in params2) {
serializeParamIntoURL(key, params2[key], result)
delete params2[key]
}
})
// then any left over
Object.keys(params2).forEach(key => {
serializeParamIntoURL(key, params2[key], result)
})
return `${result.pathname}${result.search}`
}
function serializeParamIntoURL(key, val, url) {
if (val === null || typeof val === 'undefined') return
if (Array.isArray(val)) {
// assumes values are strings
val
.sort((a, b) => a.localeCompare(b, 'en', {numeric: true}))
.forEach(arrayVal => {
if (arrayVal === null || typeof arrayVal === 'undefined') return
url.searchParams.append(`${key}[]`, arrayVal)
})
} else {
url.searchParams.append(key, val)
}
}

View File

@ -6,6 +6,9 @@
"main": "lib/index.js",
"module": "es/index.js",
"sideEffects": false,
"dependencies": {
"@instructure/get-cookie": "*"
},
"scripts": {
"test": "echo 'no tests yet...'",
"build:canvas": "babel --root-mode upward src -d es & JEST_WORKER_ID=true babel --root-mode upward src -d lib"

View File

@ -15,6 +15,7 @@
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import getCookie from '@instructure/get-cookie'
// These are helpful methods you can use along side the ruby ApplicationHelper::prefetch_xhr helper method in canvas
@ -80,11 +81,16 @@ export function clearPrefetchedXHRs() {
* @param {Promise<Response>} fetchRequest
* @returns {Promise<import("axios").AxiosResponse>}
*/
export function asAxios(fetchRequest) {
export function asAxios(fetchRequest, type = 'json') {
if (!fetchRequest) return
return fetchRequest
.then(checkStatus)
.then(res => res.json().then(data => ({data, headers: {link: res.headers.get('Link')}})))
return fetchRequest.then(checkStatus).then(res =>
res
.clone()
[type]()
.then(data => {
return {data, headers: {link: res.headers.get('Link')}}
})
)
}
/**
@ -95,7 +101,7 @@ export function asAxios(fetchRequest) {
*/
export function asJson(fetchRequest) {
if (!fetchRequest) return
return fetchRequest.then(checkStatus).then(res => res.json())
return fetchRequest.then(checkStatus).then(res => res.clone().json())
}
/**
@ -106,7 +112,7 @@ export function asJson(fetchRequest) {
*/
export function asText(fetchRequest) {
if (!fetchRequest) return
return fetchRequest.then(checkStatus).then(res => res.text())
return fetchRequest.then(checkStatus).then(res => res.clone().text())
}
/**
@ -133,6 +139,7 @@ export const defaultFetchOptions = {
credentials: 'same-origin',
headers: {
Accept: 'application/json+canvas-string-ids, application/json',
'X-Requested-With': 'XMLHttpRequest'
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': getCookie('_csrf_token')
}
}

View File

@ -16,6 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import fetchMock from 'fetch-mock'
import DashboardCardBackgroundStore from '@canvas/dashboard-card/react/DashboardCardBackgroundStore'
import fakeENV from 'helpers/fakeENV'
@ -24,7 +25,7 @@ const TEST_COLORS = {
'#91349B': '#91349B',
'#E1185C': '#E1185C'
}
DashboardCardBackgroundStore.reset = function() {
DashboardCardBackgroundStore.reset = function () {
return this.setState({
courseColors: TEST_COLORS,
usedDefaults: []
@ -37,26 +38,24 @@ QUnit.module('DashboardCardBackgroundStore', {
fakeENV.setup()
ENV.PREFERENCES = {custom_colors: TEST_COLORS}
ENV.current_user_id = 22
this.server = sinon.fakeServer.create()
this.response = []
this.server.respondWith('POST', '/api/v1/users/22/colors/course_1', [
200,
{'Content-Type': 'application/json'},
''
])
this.server.respondWith('POST', '/api/v1/users/22/colors/course_2', [
200,
{'Content-Type': 'application/json'},
''
])
this.server.respondWith('POST', '/api/v1/users/22/colors/course_3', [
200,
{'Content-Type': 'application/json'},
''
])
fetchMock.put('path:/api/v1/users/22/colors/course_1', {
status: 200,
headers: {'Content-Type': 'application/json'},
body: ''
})
fetchMock.put('path:/api/v1/users/22/colors/course_2', {
status: 200,
headers: {'Content-Type': 'application/json'},
body: ''
})
fetchMock.put('path:/api/v1/users/22/colors/course_3', {
status: 200,
headers: {'Content-Type': 'application/json'},
body: ''
})
},
teardown() {
this.server.restore()
fetchMock.restore()
DashboardCardBackgroundStore.reset()
fakeENV.teardown()
}
@ -84,19 +83,19 @@ test('maintains list of used defaults', () => {
ok(DashboardCardBackgroundStore.getUsedDefaults().includes('#91349B'))
})
test('posts to the server when a default is set', function() {
test('PUTs to the server when a default is set', async function () {
DashboardCardBackgroundStore.setDefaultColor('course_1')
ok(this.server.requests[0].url.match(/course_1/))
equal(this.server.requests.length, 1)
this.server.respond()
await fetchMock.flush()
ok(fetchMock.lastUrl().match(/course_1/))
equal(fetchMock.calls().length, 1)
})
test('sets multiple defaults properly', function() {
test('sets multiple defaults properly', async function () {
DashboardCardBackgroundStore.setDefaultColors(['course_2', 'course_3'])
ok(this.server.requests[0].url.match(/course_2/))
ok(this.server.requests[1].url.match(/course_3/))
equal(this.server.requests.length, 2)
return this.server.respond()
await fetchMock.flush()
ok(fetchMock.calls()[0][0].match(/course_2/))
ok(fetchMock.calls()[1][0].match(/course_3/))
equal(fetchMock.calls().length, 2)
})
// ==========================

View File

@ -22,10 +22,12 @@ import {DragDropContext} from 'react-dnd'
import ReactDndTestBackend from 'react-dnd-test-backend'
import sinon from 'sinon'
import {waitFor} from '@testing-library/react'
import fetchMock from 'fetch-mock'
import DashboardCard from '@canvas/dashboard-card/react/DashboardCard'
import getDroppableDashboardCardBox from '@canvas/dashboard-card/react/getDroppableDashboardCardBox'
import CourseActivitySummaryStore from '@canvas/dashboard-card/react/CourseActivitySummaryStore'
import fakeENV from 'helpers/fakeENV'
QUnit.module('DashboardCardBox', suiteHooks => {
let $container
@ -33,6 +35,7 @@ QUnit.module('DashboardCardBox', suiteHooks => {
let server
suiteHooks.beforeEach(() => {
fakeENV.setup()
$container = document.createElement('div')
document.body.appendChild($container)
@ -42,16 +45,24 @@ QUnit.module('DashboardCardBox', suiteHooks => {
{
id: '1',
isFavorited: true,
courseName: 'Bio 101'
courseName: 'Bio 101',
assetString: 'course_1'
},
{
id: '2',
isFavorited: true,
courseName: 'Philosophy 201'
courseName: 'Philosophy 201',
assetString: 'course_1'
}
]
}
fetchMock.put(/\/api\/v1\/users\/1\/colors\/.*/, {
status: 200,
headers: {'Content-Type': 'application/json'},
body: ''
})
server = sinon.fakeServer.create({respondImmediately: true})
return sandbox.stub(CourseActivitySummaryStore, 'getStateForCourse').returns({})
})
@ -60,6 +71,7 @@ QUnit.module('DashboardCardBox', suiteHooks => {
ReactDOM.unmountComponentAtNode($container)
$container.remove()
server.restore()
fakeENV.teardown()
})
function mountComponent() {
@ -99,9 +111,9 @@ QUnit.module('DashboardCardBox', suiteHooks => {
}
function getUnfavoriteButton() {
return [
...getDashboardMenu().querySelectorAll('.DashboardCardMenu__MovementItem')
].find($button => $button.textContent.includes('Unfavorite'))
return [...getDashboardMenu().querySelectorAll('.DashboardCardMenu__MovementItem')].find(
$button => $button.textContent.includes('Unfavorite')
)
}
function getModal() {

View File

@ -2728,7 +2728,7 @@ describe UsersController do
end
end
context "@cards_prefetch_observer_param" do
context "@cards_prefetch_observed_param" do
before :once do
Account.site_admin.enable_feature!(:k5_parent_support)
@user1 = user_factory(active_all: true, account: @account)
@ -2744,13 +2744,13 @@ describe UsersController do
@course.enroll_student(student)
@course.enroll_user(@user1, "ObserverEnrollment", { associated_user_id: student.id })
get "user_dashboard"
expect(controller.instance_variable_get(:@cards_prefetch_observer_param)).to eq student.id
expect(controller.instance_variable_get(:@cards_prefetch_observed_param)).to eq student.id
end
it "is undefined when user is not an observer" do
@course.enroll_student(@user1)
get "user_dashboard"
expect(controller.instance_variable_get(:@cards_prefetch_observer_param)).to be_nil
expect(controller.instance_variable_get(:@cards_prefetch_observed_param)).to be_nil
end
end
end

View File

@ -240,9 +240,9 @@ QUnit.module('Dashboard Header', hooks => {
dashboardHeader.changeDashboard('cards')
moxios.wait(() => {
const request = moxios.requests.mostRecent()
equal(request.url, '/dashboard/view')
equal(request.config.data, '{"dashboard_view":"cards"}')
const requests = moxios.requests
equal(requests.at(1).url, '/dashboard/view')
equal(requests.at(1).config.data, '{"dashboard_view":"cards"}')
done()
})
ok(plannerStub.notCalled)

View File

@ -30,6 +30,7 @@ let fakeServer
QUnit.module('DashboardCard Reordering', {
setup() {
fakeENV.setup()
cards = [
{
id: '1',
@ -63,6 +64,10 @@ QUnit.module('DashboardCard Reordering', {
sandbox.fetch.mock('path:/api/v1/courses/1/activity_stream/summary', 200)
sandbox.fetch.mock('path:/api/v1/courses/2/activity_stream/summary', 200)
sandbox.fetch.mock('path:/api/v1/courses/3/activity_stream/summary', 200)
sandbox.fetch.mock(new RegExp(`\/api\/v1\/users\/${ENV.current_user_id}\/colors.*`), {
status: 200,
body: '{}'
})
},
teardown() {
fakeENV.teardown()

View File

@ -25,18 +25,20 @@ import {bool, func, string, object, oneOf} from 'prop-types'
import {
initializePlanner,
loadPlannerDashboard,
reloadPlannerForObserver,
renderToDoSidebar,
responsiviser
} from '@instructure/canvas-planner'
import {asAxios, getPrefetchedXHR} from '@instructure/js-utils'
import {showFlashAlert, showFlashError} from '@canvas/alerts/react/FlashAlert'
import apiUserContent from '@canvas/util/jquery/apiUserContent'
import DashboardOptionsMenu from './DashboardOptionsMenu'
import loadCardDashboard, {resetDashboardCards} from '@canvas/dashboard-card'
import {CardDashboardLoader} from '@canvas/dashboard-card'
import $ from 'jquery'
import {asText, getPrefetchedXHR} from '@instructure/js-utils'
import '@canvas/jquery/jquery.disableWhileLoading'
import {CreateCourseModal} from '@canvas/create-course-modal/react/CreateCourseModal'
import ObserverOptions from '@canvas/observer-picker'
import {savedObservedId} from '@canvas/observer-picker/ObserverGetObservee'
import {View} from '@instructure/ui-view'
const [show, hide] = ['block', 'none'].map(displayVal => id => {
@ -72,8 +74,19 @@ class DashboardHeader extends React.Component {
constructor(...args) {
super(...args)
this.cardDashboardLoader = new CardDashboardLoader()
this.planner_init_promise = undefined
if (this.props.planner_enabled) {
// setup observing another user?
let observedUser
if (observerMode() && ENV.OBSERVED_USERS_LIST.length > 0) {
const storedObservedUserId = savedObservedId(ENV.current_user.id)
const {id, name, avatar_url} =
ENV.OBSERVED_USERS_LIST.find(u => u.id === storedObservedUserId) ||
ENV.OBSERVED_USERS_LIST[0]
observedUser = id === ENV.current_user_id ? null : {id, name, avatarUrl: avatar_url}
}
this.planner_init_promise = initializePlanner({
changeDashboardView: this.changeDashboard,
getActiveApp: this.getActiveApp,
@ -87,6 +100,7 @@ class DashboardHeader extends React.Component {
datetimeString: $.datetimeString
},
externalFallbackFocusable: this.menuButtonFocusable,
observedUser,
env: this.props.env
})
}
@ -130,7 +144,7 @@ class DashboardHeader extends React.Component {
loadCardDashboard(observedUserId) {
// I put this in so I can spy on the imported function in a spec :'(
loadCardDashboard(undefined, observedUserId)
this.cardDashboardLoader.loadCardDashboard(undefined, observedUserId)
}
loadStreamItemDashboard(observedUserId) {
@ -174,9 +188,9 @@ class DashboardHeader extends React.Component {
.then(() => {
this.loadPlannerComponent()
})
.catch(() =>
.catch(_ex => {
showFlashAlert({message: I18n.t('Failed initializing dashboard'), type: 'error'})
)
})
} else if (newView === 'cards') {
this.loadCardDashboard(this.state.selectedObserveeId)
} else if (newView === 'activity') {
@ -214,6 +228,19 @@ class DashboardHeader extends React.Component {
.catch(showFlashError(I18n.t('Failed to save dashboard selection')))
}
handleChangeObservedUser(id) {
this.reloadDashboardForObserver(id)
if (this.props.planner_enabled) {
this.planner_init_promise
.then(() => {
reloadPlannerForObserver(id)
})
.catch(() => {
// ignore. handled elsewhere
})
}
}
changeDashboard = newView => {
if (newView === 'elementary') {
this.switchToElementary()
@ -253,8 +280,8 @@ class DashboardHeader extends React.Component {
reloadDashboardForObserver = userId => {
this.sidebarHasLoaded = false
resetDashboardCards()
this.setState({selectedObserveeId: userId, loadedViews: []}, () => {
this.cardDashboardLoader = new CardDashboardLoader()
this.loadDashboard(this.state.currentDashboard)
})
}
@ -273,7 +300,7 @@ class DashboardHeader extends React.Component {
currentUserRoles={ENV.current_user_roles}
observedUsersList={ENV.OBSERVED_USERS_LIST}
canAddObservee={ENV.CAN_ADD_OBSERVEE}
handleChangeObservedUser={this.reloadDashboardForObserver}
handleChangeObservedUser={id => this.handleChangeObservedUser(id)}
/>
</View>
)}
@ -305,7 +332,6 @@ class DashboardHeader extends React.Component {
export {DashboardHeader}
export default responsiviser()(DashboardHeader)
let readSidebarPrefetch = false
// extract this out to a property so tests can override it and not have to mock
// out the timers in every single test.
function loadDashboardSidebar(observedUserId) {
@ -316,44 +342,40 @@ function loadDashboardSidebar(observedUserId) {
const rightSide = $('#right-side')
const promiseToGetNewCourseForm = import('../jquery/util/newCourseForm')
const prefetchedXhr = getPrefetchedXHR(dashboardSidebarUrl)
const promiseToGetHtml =
!readSidebarPrefetch && prefetchedXhr !== undefined
? asText(prefetchedXhr)
: $.get(dashboardSidebarUrl)
readSidebarPrefetch = true
asAxios(getPrefetchedXHR(dashboardSidebarUrl), 'text') || axios.get(dashboardSidebarUrl)
rightSide.disableWhileLoading(
Promise.all([promiseToGetNewCourseForm, promiseToGetHtml]).then(
([{default: newCourseForm}, html]) => {
// inject the erb html we got from the server
rightSide.html(html)
newCourseForm()
Promise.all([promiseToGetNewCourseForm, promiseToGetHtml]).then(response => {
const newCourseForm = response[0].default
const html = response[1].data
// inject the erb html we got from the server
rightSide.html(html)
newCourseForm()
// the injected html has a .Sidebar__TodoListContainer element in it,
// render the canvas-planner ToDo list into it
const container = document.querySelector('.Sidebar__TodoListContainer')
if (container) renderToDoSidebar(container)
// the injected html has a .Sidebar__TodoListContainer element in it,
// render the canvas-planner ToDo list into it
const container = document.querySelector('.Sidebar__TodoListContainer')
if (container) renderToDoSidebar(container)
const startButton = document.getElementById('start_new_course')
const modalContainer = document.getElementById('create_course_modal_container')
if (startButton && modalContainer && ENV.FEATURES?.create_course_subaccount_picker) {
startButton.addEventListener('click', () => {
ReactDOM.render(
<CreateCourseModal
isModalOpen
setModalOpen={isOpen => {
if (!isOpen) ReactDOM.unmountComponentAtNode(modalContainer)
}}
permissions={ENV.CREATE_COURSES_PERMISSIONS.PERMISSION}
restrictToMCCAccount={ENV.CREATE_COURSES_PERMISSIONS.RESTRICT_TO_MCC_ACCOUNT}
isK5User={false} // can't be k5 user if classic dashboard is showing
/>,
modalContainer
)
})
}
const startButton = document.getElementById('start_new_course')
const modalContainer = document.getElementById('create_course_modal_container')
if (startButton && modalContainer && ENV.FEATURES?.create_course_subaccount_picker) {
startButton.addEventListener('click', () => {
ReactDOM.render(
<CreateCourseModal
isModalOpen
setModalOpen={isOpen => {
if (!isOpen) ReactDOM.unmountComponentAtNode(modalContainer)
}}
permissions={ENV.CREATE_COURSES_PERMISSIONS.PERMISSION}
restrictToMCCAccount={ENV.CREATE_COURSES_PERMISSIONS.RESTRICT_TO_MCC_ACCOUNT}
isK5User={false} // can't be k5 user if classic dashboard is showing
/>,
modalContainer
)
})
}
)
})
)
}

View File

@ -470,7 +470,7 @@ export function K5Course({
}) {
const initialObservedId = observedUsersList.find(o => o.id === savedObservedId(currentUser.id))
? savedObservedId(currentUser.id)
: undefined
: null
const renderTabs = toRenderTabs(tabs, hasSyllabusBody)
const {activeTab, currentTab, handleTabChange} = useTabState(defaultTab, renderTabs)

View File

@ -44,7 +44,7 @@ import GradesPage from './GradesPage'
import HomeroomPage from './HomeroomPage'
import TodosPage from './TodosPage'
import K5DashboardContext from '@canvas/k5/react/K5DashboardContext'
import loadCardDashboard, {resetDashboardCards} from '@canvas/dashboard-card'
import {CardDashboardLoader} from '@canvas/dashboard-card'
import {mapStateToProps} from '@canvas/k5/redux/redux-helpers'
import SchedulePage from '@canvas/k5/react/SchedulePage'
import ResourcesPage from '@canvas/k5/react/ResourcesPage'
@ -158,6 +158,7 @@ export const K5Dashboard = ({
const [tabsRef, setTabsRef] = useState(null)
const [trayOpen, setTrayOpen] = useState(false)
const [observedUserId, setObservedUserId] = useState(initialObservedId)
const [cardDashboardLoader, setCardDashboardLoader] = useState(null)
const plannerInitialized = usePlanner({
plannerEnabled,
isPlannerActive: () => activeTab.current === TAB_IDS.SCHEDULE,
@ -189,7 +190,7 @@ export const K5Dashboard = ({
const handleChangeObservedUser = id => {
if (id !== observedUserId) {
resetDashboardCards()
setCardDashboardLoader(null)
setCardsSettled(false)
setObservedUserId(id)
}
@ -198,8 +199,11 @@ export const K5Dashboard = ({
useEffect(() => {
// don't call on the initial load when we know we're in observer mode but don't have the ID yet
if (!observerMode || (observerMode && observedUserId)) {
loadCardDashboard(loadCardDashboardCallBack, observerMode ? observedUserId : undefined)
const dcl = new CardDashboardLoader()
dcl.loadCardDashboard(loadCardDashboardCallBack, observerMode ? observedUserId : undefined)
setCardDashboardLoader(dcl)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [observedUserId, observerMode])
useFetchApi({
@ -349,7 +353,6 @@ export const K5Dashboard = ({
visible={currentTab === TAB_IDS.SCHEDULE}
singleCourse={false}
observedUserId={observedUserId}
contextCodes={cardsSettled ? cards?.map(c => c.assetString) : undefined}
/>
<GradesPage
visible={currentTab === TAB_IDS.GRADES}

View File

@ -15,17 +15,15 @@
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import moment from 'moment-timezone'
import moxios from 'moxios'
import {act, render, screen, waitFor} from '@testing-library/react'
import {resetDashboardCards, resetCardCache} from '@canvas/dashboard-card'
import {resetCardCache} from '@canvas/dashboard-card'
import {resetPlanner} from '@instructure/canvas-planner'
import fetchMock from 'fetch-mock'
import {OBSERVER_COOKIE_PREFIX} from '@canvas/observer-picker/ObserverGetObservee'
import {cloneDeep} from 'lodash'
import {MOCK_TODOS} from './mocks'
import {
MOCK_ASSIGNMENTS,
@ -39,13 +37,12 @@ import K5Dashboard from '../K5Dashboard'
import {destroyContainer} from '@canvas/alerts/react/FlashAlert'
const ASSIGNMENTS_URL = /\/api\/v1\/calendar_events\?type=assignment&important_dates=true&.*/
const currentUserId = '1'
const observedUserCookieName = `${OBSERVER_COOKIE_PREFIX}${currentUserId}`
const currentUser = {
id: currentUserId,
display_name: 'Geoffrey Jellineck',
name: 'Geoffrey Jellineck',
avatar_image_url: 'http://avatar'
}
const cardSummary = [
@ -196,18 +193,8 @@ const defaultProps = {
observedUsersList: MOCK_OBSERVED_USERS_LIST,
openTodosInNewTab: true
}
beforeAll(() => {
jest.setTimeout(15000)
})
afterAll(() => {
jest.setTimeout(5000)
})
beforeEach(() => {
moxios.install()
moxios.stubRequest('/api/v1/dashboard/dashboard_cards', {
function createPlannerMocks() {
moxios.stubRequest(/\/api\/v1\/dashboard\/dashboard_cards$/, {
status: 200,
response: MOCK_CARDS
})
@ -269,6 +256,16 @@ beforeEach(() => {
headers: {link: 'url; rel="current"'},
response: opportunities
})
}
beforeAll(() => {
jest.setTimeout(15000)
})
afterAll(() => {
jest.setTimeout(5000)
})
beforeEach(() => {
moxios.install()
createPlannerMocks()
fetchMock.get('/api/v1/courses/1/activity_stream/summary', JSON.stringify(cardSummary))
fetchMock.get(/\/api\/v1\/announcements.*/, announcements)
fetchMock.get(/\/api\/v1\/users\/self\/courses.*/, JSON.stringify(gradeCourses))
@ -283,23 +280,20 @@ beforeEach(() => {
/\/api\/v1\/calendar_events\/save_selected_contexts.*/,
JSON.stringify({status: 'ok'})
)
fetchMock.put(/\/api\/v1\/users\/\d+\/colors\.*/, {status: 200, body: []})
global.ENV = defaultEnv
})
afterEach(() => {
moxios.uninstall()
fetchMock.restore()
global.ENV = {}
resetDashboardCards()
resetPlanner()
resetCardCache()
sessionStorage.clear()
window.location.hash = ''
destroyContainer()
document.cookie = `${observedUserCookieName}=`
resetCardCache()
})
describe('K-5 Dashboard', () => {
it('displays a welcome message to the logged-in user', () => {
const {getByText} = render(<K5Dashboard {...defaultProps} />)
@ -310,21 +304,18 @@ describe('K-5 Dashboard', () => {
const {getByRole} = render(<K5Dashboard {...defaultProps} canDisableElementaryDashboard />)
const optionsButton = getByRole('button', {name: 'Dashboard Options'})
act(() => optionsButton.click())
// There should be an Homeroom View menu option already checked
const elementaryViewOption = screen.getByRole('menuitemradio', {
name: 'Homeroom View',
checked: true
})
expect(elementaryViewOption).toBeInTheDocument()
// There should be a Classic View menu option initially un-checked
const classicViewOption = screen.getByRole('menuitemradio', {
name: 'Classic View',
checked: false
})
expect(classicViewOption).toBeInTheDocument()
// Clicking the Classic View option should update the user's dashboard setting
act(() => classicViewOption.click())
await waitFor(() => {
@ -336,7 +327,6 @@ describe('K-5 Dashboard', () => {
)
})
})
describe('Tabs', () => {
it('show Homeroom, Schedule, Grades, and Resources options', async () => {
const {getByText} = render(<K5Dashboard {...defaultProps} />)
@ -351,7 +341,6 @@ describe('K-5 Dashboard', () => {
const {findByRole} = render(<K5Dashboard {...defaultProps} />)
expect(await findByRole('tab', {name: 'Homeroom', selected: true})).toBeInTheDocument()
})
describe('store current tab ID to URL', () => {
afterEach(() => {
window.location.hash = ''
@ -371,18 +360,15 @@ describe('K-5 Dashboard', () => {
it('and update the current tab as tabs are changed', async () => {
const {findByRole, getByRole, queryByRole} = render(<K5Dashboard {...defaultProps} />)
const gradesTab = await findByRole('tab', {name: 'Grades'})
act(() => gradesTab.click())
expect(await findByRole('tab', {name: 'Grades', selected: true})).toBeInTheDocument()
act(() => getByRole('tab', {name: 'Resources'}).click())
expect(await findByRole('tab', {name: 'Resources', selected: true})).toBeInTheDocument()
expect(queryByRole('tab', {name: 'Grades', selected: true})).not.toBeInTheDocument()
})
})
})
describe('Homeroom Section', () => {
it('displays "My Subjects" heading', async () => {
const {findByText} = render(<K5Dashboard {...defaultProps} />)
@ -411,7 +397,7 @@ describe('K-5 Dashboard', () => {
expect(getByText('Your homeroom is currently unpublished.')).toBeInTheDocument()
})
it('shows a due today link pointing to the schedule tab of the course', async () => {
it('shows due today and missing items links pointing to the schedule tab of the course', async () => {
const {findByRole} = render(<K5Dashboard {...defaultProps} plannerEnabled />)
const dueTodayLink = await findByRole('link', {
name: 'View 1 items due today for course Economics 101',
@ -419,16 +405,13 @@ describe('K-5 Dashboard', () => {
})
expect(dueTodayLink).toBeInTheDocument()
expect(dueTodayLink.getAttribute('href')).toMatch('/courses/1?focusTarget=today#schedule')
})
it('shows a missing items link pointing to the schedule tab of the course', async () => {
const {findByRole} = render(<K5Dashboard {...defaultProps} plannerEnabled />)
const dueTodayLink = await findByRole('link', {
const misingItemsLink = await findByRole('link', {
name: 'View 2 missing items for course Economics 101',
timeout: 5000
})
expect(dueTodayLink).toBeInTheDocument()
expect(dueTodayLink.getAttribute('href')).toMatch(
expect(misingItemsLink).toBeInTheDocument()
expect(misingItemsLink.getAttribute('href')).toMatch(
'/courses/1?focusTarget=missing-items#schedule'
)
})
@ -458,10 +441,8 @@ describe('K-5 Dashboard', () => {
expect(getByText("You don't have any active courses yet.")).toBeInTheDocument()
)
expect(getByTestId('empty-dash-panda')).toBeInTheDocument()
const scheduleTab = getByRole('tab', {name: 'Schedule'})
act(() => scheduleTab.click())
expect(getByText("You don't have any active courses yet.")).toBeInTheDocument()
expect(getByTestId('empty-dash-panda')).toBeInTheDocument()
})
@ -470,7 +451,6 @@ describe('K-5 Dashboard', () => {
sessionStorage.setItem('dashcards_for_user_1', JSON.stringify(MOCK_CARDS))
moxios.withMock(() => {
render(<K5Dashboard {...defaultProps} />)
// Don't respond immediately, let the cards from sessionStorage return first
moxios.wait(() =>
moxios.requests
@ -492,7 +472,6 @@ describe('K-5 Dashboard', () => {
sessionStorage.setItem('dashcards_for_user_1', JSON.stringify([]))
moxios.withMock(() => {
render(<K5Dashboard {...defaultProps} />)
moxios.wait(() =>
moxios.requests
.mostRecent()
@ -511,7 +490,6 @@ describe('K-5 Dashboard', () => {
})
})
})
describe('Schedule Section', () => {
it('displays the planner with a planned item', async () => {
const {findByText} = render(
@ -523,19 +501,16 @@ describe('K-5 Dashboard', () => {
// expect(await findByText("Looks like there isn't anything here")).toBeInTheDocument()
// expect(await findByText('Nothing More To Do')).toBeInTheDocument()
})
// Skipping for flakiness. See https://instructure.atlassian.net/browse/LS-2243.
it.skip('displays a list of missing assignments if there are any', async () => {
const {findByRole, getByRole, getByText} = render(
<K5Dashboard {...defaultProps} defaultTab="tab-schedule" plannerEnabled />
)
const missingAssignments = await findByRole('button', {
name: 'Show 2 missing items',
timeout: 5000
})
expect(missingAssignments).toBeInTheDocument()
act(() => missingAssignments.click())
expect(getByRole('button', {name: 'Hide 2 missing items'})).toBeInTheDocument()
expect(getByText('Assignment 1')).toBeInTheDocument()
@ -548,7 +523,6 @@ describe('K-5 Dashboard', () => {
)
const planner = await findByTestId('PlannerApp', {timeout: 4000}) // give it some more time
expect(planner).toBeInTheDocument()
const header = await findByTestId('WeeklyPlannerHeader')
expect(header).toBeInTheDocument()
})
@ -557,13 +531,10 @@ describe('K-5 Dashboard', () => {
const {findByRole} = render(
<K5Dashboard {...defaultProps} defaultTab="tab-schedule" plannerEnabled />
)
const jumpToNavButton = await findByRole('button', {name: 'Jump to navigation toolbar'})
expect(jumpToNavButton).not.toBeVisible()
act(() => jumpToNavButton.focus())
expect(jumpToNavButton).toBeVisible()
act(() => jumpToNavButton.click())
expect(document.activeElement.id).toBe('weekly-header-active-button')
expect(jumpToNavButton).not.toBeVisible()
@ -585,7 +556,6 @@ describe('K-5 Dashboard', () => {
const {findByTestId, getByText} = render(
<K5Dashboard {...defaultProps} defaultTab="tab-schedule" plannerEnabled={false} />
)
expect(await findByTestId('kinder-panda')).toBeInTheDocument()
expect(getByText('Schedule Preview')).toBeInTheDocument()
expect(
@ -598,12 +568,14 @@ describe('K-5 Dashboard', () => {
})
it('preloads surrounding weeks only once schedule tab is visible', async done => {
const {findByText, getByRole} = render(<K5Dashboard {...defaultProps} plannerEnabled />)
const {findByText, getByRole} = render(
<K5Dashboard {...defaultProps} currentUserRoles={['user', 'student']} plannerEnabled />
)
expect(await findByText('Assignment 15')).toBeInTheDocument()
expect(moxios.requests.count()).toBe(5)
expect(moxios.requests.count()).toBe(6)
act(() => getByRole('tab', {name: 'Schedule'}).click())
moxios.wait(() => {
expect(moxios.requests.count()).toBe(7) // 2 more requests for prev and next week preloads
expect(moxios.requests.count()).toBe(8) // 2 more requests for prev and next week preloads
done()
})
})
@ -613,7 +585,6 @@ describe('K-5 Dashboard', () => {
status: 200,
response: MOCK_CARDS
})
const observerPlannerItem = cloneDeep(MOCK_PLANNER_ITEM)
observerPlannerItem[0].plannable.title = 'Assignment for Observee'
const observedUsersList = [
@ -626,7 +597,6 @@ describe('K-5 Dashboard', () => {
name: 'Student 2'
}
]
const {findByText, findByRole, getByRole, getByText} = render(
<K5Dashboard
{...defaultProps}
@ -645,7 +615,6 @@ describe('K-5 Dashboard', () => {
timeout: 5000
})
).toBeInTheDocument()
moxios.stubs.reset()
moxios.stubRequest('/api/v1/dashboard/dashboard_cards?observed_user=2', {
status: 200,
@ -661,7 +630,6 @@ describe('K-5 Dashboard', () => {
headers: {link: 'url; rel="current"'},
response: [opportunities[0]]
})
const observerSelect = getByRole('combobox', {name: 'Select a student to view'})
act(() => observerSelect.click())
act(() => getByText('Student 2').click())
@ -679,7 +647,6 @@ describe('K-5 Dashboard', () => {
})
})
})
describe('Grades Section', () => {
it('does not show the grades tab to students if hideGradesTabForStudents is set', async () => {
const {queryByRole} = render(
@ -697,7 +664,6 @@ describe('K-5 Dashboard', () => {
expect(queryByText('Homeroom Class')).not.toBeInTheDocument()
})
})
describe('Resources Section', () => {
it('displays syllabus content for homeroom under important info section', async () => {
const {getByText, findByText} = render(
@ -724,15 +690,12 @@ describe('K-5 Dashboard', () => {
expect(wrapper.getByText('Teaching Assistant')).toBeInTheDocument()
})
})
describe('Todos Section', () => {
it('displays todo tab to teachers', async () => {
const {findByRole} = render(<K5Dashboard {...defaultProps} currentUserRoles={['teacher']} />)
const todoTab = await findByRole('tab', {name: 'To Do'})
expect(todoTab).toBeInTheDocument()
act(() => todoTab.click())
expect(await findByRole('link', {name: 'Grade Plant a plant'})).toBeInTheDocument()
})
@ -744,7 +707,6 @@ describe('K-5 Dashboard', () => {
expect(queryByRole('tab', {name: 'To Do'})).not.toBeInTheDocument()
})
})
describe('Important Dates', () => {
it('renders a sidebar with important dates and no tray buttons on large screens', async () => {
const {getByText, queryByText} = render(<K5Dashboard {...defaultProps} />)
@ -764,7 +726,6 @@ describe('K-5 Dashboard', () => {
})
// Only return assignments associated with course_1 on next call
fetchMock.get(ASSIGNMENTS_URL, MOCK_ASSIGNMENTS.slice(0, 1), {overwriteRoutes: true})
const {getByRole, getByText, queryByText} = render(
<K5Dashboard
{...defaultProps}
@ -779,17 +740,14 @@ describe('K-5 Dashboard', () => {
})
expect(fetchMock.lastUrl(ASSIGNMENTS_URL)).toMatch('context_codes%5B%5D=course_1')
expect(fetchMock.lastUrl(ASSIGNMENTS_URL)).not.toMatch('context_codes%5B%5D=course_3')
// Only return assignments associated with course_3 on next call
fetchMock.get(ASSIGNMENTS_URL, MOCK_ASSIGNMENTS.slice(1, 3), {overwriteRoutes: true})
act(() =>
getByRole('button', {name: 'Select calendars to retrieve important dates from'}).click()
)
act(() => getByRole('checkbox', {name: 'Economics 101', checked: true}).click())
act(() => getByRole('checkbox', {name: 'The Maths', checked: false}).click())
act(() => getByRole('button', {name: 'Submit'}).click())
await waitFor(() => {
expect(queryByText('Algebra 2')).not.toBeInTheDocument()
expect(getByText('History Discussion')).toBeInTheDocument()
@ -804,12 +762,10 @@ describe('K-5 Dashboard', () => {
await waitFor(() => expect(getByText('History Discussion')).toBeInTheDocument())
})
})
describe('Parent Support', () => {
beforeEach(() => {
document.cookie = `${observedUserCookieName}=4;path=/`
})
const opportunities2 = [
{
id: '3',
@ -836,27 +792,34 @@ describe('K-5 Dashboard', () => {
expect(select.value).toBe('Student 4')
})
it('prefetches dashboard cards with the correct url param', done => {
moxios.withMock(async () => {
render(
<K5Dashboard
{...defaultProps}
currentUserRoles={['user', 'observer', 'teacher']}
canAddObservee
parentSupportEnabled
/>
)
moxios.wait(() => {
const preFetchedRequest = moxios.requests.mostRecent()
expect(preFetchedRequest.url).toBe('/api/v1/dashboard/dashboard_cards?observed_user=4')
expect(moxios.requests.count()).toBe(1)
done()
})
it('prefetches dashboard cards with the correct url param', async done => {
moxios.stubRequest('/api/v1/dashboard/dashboard_cards?observed_user=4', {
status: 200,
response: MOCK_CARDS
})
render(
<K5Dashboard
{...defaultProps}
currentUserRoles={['user', 'observer', 'teacher']}
canAddObservee
parentSupportEnabled
/>
)
// let the dashboard execute all its queries and render
await waitFor(
() => {
expect(moxios.requests.mostRecent()).not.toBeNull()
},
{timeout: 5000}
)
const preFetchedRequest = moxios.requests.mostRecent()
expect(preFetchedRequest.url).toBe('/api/v1/dashboard/dashboard_cards?observed_user=4')
expect(moxios.requests.count()).toBe(1)
done()
})
it('does not make a request if the user has been already requested', async () => {
moxios.stubs.reset()
moxios.stubRequest('/api/v1/dashboard/dashboard_cards?observed_user=4', {
status: 200,
response: MOCK_CARDS
@ -865,7 +828,6 @@ describe('K-5 Dashboard', () => {
status: 200,
response: MOCK_CARDS_2
})
const {findByText, getByRole, getByText, queryByText} = render(
<K5Dashboard
{...defaultProps}
@ -874,7 +836,6 @@ describe('K-5 Dashboard', () => {
canAddObservee
/>
)
expect(await findByText('Economics 101')).toBeInTheDocument()
expect(queryByText('Economics 203')).not.toBeInTheDocument()
const select = getByRole('combobox', {name: 'Select a student to view'})
@ -882,7 +843,6 @@ describe('K-5 Dashboard', () => {
expect(moxios.requests.mostRecent().url).toBe(
'/api/v1/dashboard/dashboard_cards?observed_user=4'
)
act(() => select.click())
act(() => getByText('Student 2').click())
expect(await findByText('Economics 203')).toBeInTheDocument()
@ -890,7 +850,6 @@ describe('K-5 Dashboard', () => {
expect(moxios.requests.mostRecent().url).toBe(
'/api/v1/dashboard/dashboard_cards?observed_user=2'
)
act(() => select.click())
act(() => getByText('Student 4').click())
expect(await findByText('Economics 101')).toBeInTheDocument()
@ -904,6 +863,28 @@ describe('K-5 Dashboard', () => {
})
it('shows the observee missing items on dashboard cards', async () => {
moxios.stubs.reset()
moxios.requests.reset()
moxios.stubRequest('/api/v1/dashboard/dashboard_cards?observed_user=4', {
status: 200,
response: MOCK_CARDS
})
moxios.stubRequest('/api/v1/dashboard/dashboard_cards?observed_user=2', {
status: 200,
response: MOCK_CARDS_2
})
moxios.stubRequest(/\/api\/v1\/users\/self\/missing_submissions\?.*observed_user_id=4.*/, {
status: 200,
headers: {link: 'url; rel="current"'},
response: opportunities
})
moxios.stubRequest(/\/api\/v1\/users\/self\/missing_submissions\?.*observed_user_id=2.*/, {
status: 200,
headers: {link: 'url; rel="current"'},
response: opportunities2
})
createPlannerMocks()
const {getByText, findByRole, getByRole} = render(
<K5Dashboard
{...defaultProps}
@ -913,43 +894,28 @@ describe('K-5 Dashboard', () => {
plannerEnabled
/>
)
moxios.stubs.reset()
moxios.stubRequest('/api/v1/dashboard/dashboard_cards?observed_user=4', {
status: 200,
response: MOCK_CARDS
})
moxios.stubRequest('/api/v1/dashboard/dashboard_cards?observed_user=2', {
status: 200,
response: MOCK_CARDS_2
})
moxios.stubRequest(/\/api\/v1\/users\/self\/missing_submissions\?.*observed_user_id=4.*/, {
status: 200,
headers: {link: 'url; rel="current"'},
response: opportunities
})
moxios.stubRequest(/\/api\/v1\/users\/self\/missing_submissions\?.*observed_user_id=2.*/, {
status: 200,
headers: {link: 'url; rel="current"'},
response: opportunities2
})
// let the dashboard execute all its queries and render
await waitFor(
() => {
expect(document.querySelectorAll('.ic-DashboardCard').length).toBeGreaterThan(0)
},
{timeout: 5000}
)
expect(
await findByRole('link', {
name: 'View 2 missing items for course Economics 101',
timeout: 5000
timeout: 5000,
exact: false
})
).toBeInTheDocument()
const observerSelect = getByRole('combobox', {name: 'Select a student to view'})
act(() => observerSelect.click())
act(() => getByText('Student 2').click())
expect(
await findByRole('link', {
name: 'View 1 missing items for course Economics 203',
timeout: 5000
timeout: 5000,
exact: false
})
).toBeInTheDocument()
})

View File

@ -1,3 +1,4 @@
/* eslint-disable promise/no-callback-in-promise */
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
@ -17,23 +18,27 @@
*/
import moxios from 'moxios'
import {showFlashAlert} from '@canvas/alerts/react/FlashAlert'
import {CardDashboardLoader, resetCardCache} from '../loadCardDashboard'
import loadCardDashboard, {resetDashboardCards} from '../loadCardDashboard'
jest.mock('@canvas/alerts/react/FlashAlert')
describe('loadCardDashboard', () => {
let cardDashboardLoader
beforeEach(() => {
moxios.install()
cardDashboardLoader = new CardDashboardLoader()
})
afterEach(() => {
moxios.uninstall()
resetDashboardCards()
resetCardCache()
})
describe('with observer', () => {
it('loads student cards asynchronously and calls back renderFn', done => {
const callback = jest.fn()
loadCardDashboard(callback, 2)
cardDashboardLoader.loadCardDashboard(callback, 2)
moxios.wait(() => {
expect(callback).not.toHaveBeenCalled()
moxios.requests
@ -46,12 +51,15 @@ describe('loadCardDashboard', () => {
expect(callback).toHaveBeenCalledWith(['card'], true)
done()
})
.catch(e => {
throw e
})
})
})
it('saves student cards and calls back renderFn immediately if requested again', done => {
const callback = jest.fn()
loadCardDashboard(callback, 5)
cardDashboardLoader.loadCardDashboard(callback, 5)
moxios.wait(() => {
moxios.requests
.mostRecent()
@ -60,14 +68,34 @@ describe('loadCardDashboard', () => {
response: ['card']
})
.then(() => {
resetDashboardCards()
loadCardDashboard(callback, 5)
resetCardCache()
cardDashboardLoader.loadCardDashboard(callback, 5)
moxios.wait(() => {
expect(callback).toHaveBeenCalledWith(['card'], true)
expect(moxios.requests.count()).toBe(1)
done()
})
})
.catch(e => {
throw e
})
})
})
it('fails gracefully', done => {
const callback = jest.fn()
cardDashboardLoader.loadCardDashboard(callback, 2)
moxios.wait(() => {
// eslint-disable-next-line promise/catch-or-return
moxios.requests
.mostRecent()
.respondWith({
status: 500
})
.then(() => {
expect(showFlashAlert).toHaveBeenCalledTimes(1)
done()
})
})
})
})

View File

@ -21,11 +21,10 @@ import ReactDOM from 'react-dom'
import getDroppableDashboardCardBox from './react/getDroppableDashboardCardBox'
import DashboardCard from './react/DashboardCard'
import axios from '@canvas/axios'
import {asJson, getPrefetchedXHR} from '@instructure/js-utils'
import {showFlashAlert} from '@canvas/alerts/react/FlashAlert'
import {asJson, checkStatus, getPrefetchedXHR} from '@instructure/js-utils'
import buildURL from 'axios/lib/helpers/buildURL'
let promiseToGetDashboardCards
let observedUsersDashboardCards = {}
import I18n from 'i18n!load_card_dashboard'
export function createDashboardCards(dashboardCards, cardComponent = DashboardCard, extraProps) {
const Box = getDroppableDashboardCardBox()
@ -43,75 +42,110 @@ export function createDashboardCards(dashboardCards, cardComponent = DashboardCa
/>
)
}
export class CardDashboardLoader {
static observedUsersDashboardCards = {}
function renderIntoDOM(dashboardCards) {
const dashboardContainer = document.getElementById('DashboardCard_Container')
ReactDOM.render(createDashboardCards(dashboardCards), dashboardContainer)
}
export default function loadCardDashboard(renderFn = renderIntoDOM, observedUserId) {
if (observedUserId && observedUsersDashboardCards[observedUserId]) {
renderFn(observedUsersDashboardCards[observedUserId], true)
} else if (promiseToGetDashboardCards) {
promiseToGetDashboardCards.then(cards => renderFn(cards, true))
} else {
let xhrHasReturned = false
let sessionStorageTimeout
const sessionStorageKey = `dashcards_for_user_${ENV && ENV.current_user_id}`
const urlPrefix = '/api/v1/dashboard/dashboard_cards'
const url = buildURL(urlPrefix, {observed_user: observedUserId})
promiseToGetDashboardCards =
asJson(getPrefetchedXHR(url)) || axios.get(url).then(({data}) => data)
promiseToGetDashboardCards.then(() => (xhrHasReturned = true))
// Because we use prefetch_xhr to prefetch this xhr request from our rails erb, there is a
// chance that the XHR to get the latest dashcard data has already come back before we get
// to this point. So if the XHR is ready, there's no need to render twice, just render
// once with the newest data.
// Otherwise, render with the cached stuff from session storage now, then render again
// when the xhr comes back with the latest data.
const promiseToGetCardsFromSessionStorage = new Promise(resolve => {
sessionStorageTimeout = setTimeout(() => {
const cachedCards = sessionStorage.getItem(sessionStorageKey)
if (cachedCards) resolve(JSON.parse(cachedCards))
}, 1)
})
Promise.race([promiseToGetDashboardCards, promiseToGetCardsFromSessionStorage]).then(
dashboardCards => {
clearTimeout(sessionStorageTimeout)
// calling the renderFn with `false` indicates to consumers that we're still waiting
// on the follow-up xhr request to complete.
renderFn(dashboardCards, xhrHasReturned)
// calling it with `true` indicates that all outstanding card promises have settled.
if (!xhrHasReturned) return promiseToGetDashboardCards.then(cards => renderFn(cards, true))
}
)
// Cache the fetched dashcards in sessionStorage so we can render instantly next
// time they come to their dashboard (while still fetching the most current data)
// Also save the observed user's cards if observing so observer can switch between students
// without any delay
promiseToGetDashboardCards.then(dashboardCards => {
try {
sessionStorage.setItem(sessionStorageKey, JSON.stringify(dashboardCards))
} catch (_e) {
// If saving the cards to session storage fails, we can just ignore the exception; the cards
// will still be fetched and displayed on the next load. Telling the user probably doesn't
// make sense since it doesn't change the way the app works, nor does it make sense to log
// the error since it could happen in normal circumstances (like using Safari in private mode).
}
if (observedUserId) {
observedUsersDashboardCards[observedUserId] = dashboardCards
}
})
constructor() {
this.promiseToGetDashboardCards = undefined
this.errorShown = false
}
}
export function resetDashboardCards() {
promiseToGetDashboardCards = undefined
renderIntoDOM = dashboardCards => {
const dashboardContainer = document.getElementById('DashboardCard_Container')
ReactDOM.render(createDashboardCards(dashboardCards), dashboardContainer)
}
loadCardDashboard(renderFn = this.renderIntoDOM, observedUserId) {
if (observedUserId && CardDashboardLoader.observedUsersDashboardCards[observedUserId]) {
renderFn(CardDashboardLoader.observedUsersDashboardCards[observedUserId], true)
} else if (this.promiseToGetDashboardCards) {
this.promiseToGetDashboardCards
.then(cards => {
renderFn(cards, true)
})
.catch(e => {
this.showError(e)
})
} else {
let xhrHasReturned = false
let sessionStorageTimeout
const sessionStorageKey = `dashcards_for_user_${ENV && ENV.current_user_id}`
const urlPrefix = '/api/v1/dashboard/dashboard_cards'
const url = buildURL(urlPrefix, {observed_user: observedUserId})
this.promiseToGetDashboardCards =
asJson(getPrefetchedXHR(url)) ||
axios
.get(url)
.then(checkStatus)
.then(({data}) => data)
.catch(e => {
this.showError(e)
})
this.promiseToGetDashboardCards
.then(() => (xhrHasReturned = true))
.catch(e => {
this.showError(e)
})
// Because we use prefetch_xhr to prefetch this xhr request from our rails erb, there is a
// chance that the XHR to get the latest dashcard data has already come back before we get
// to this point. So if the XHR is ready, there's no need to render twice, just render
// once with the newest data.
// Otherwise, render with the cached stuff from session storage now, then render again
// when the xhr comes back with the latest data.
const promiseToGetCardsFromSessionStorage = new Promise(resolve => {
sessionStorageTimeout = setTimeout(() => {
const cachedCards = sessionStorage.getItem(sessionStorageKey)
if (cachedCards) resolve(JSON.parse(cachedCards))
}, 1)
})
Promise.race([this.promiseToGetDashboardCards, promiseToGetCardsFromSessionStorage])
.then(dashboardCards => {
clearTimeout(sessionStorageTimeout)
// calling the renderFn with `false` indicates to consumers that we're still waiting
// on the follow-up xhr request to complete.
renderFn(dashboardCards, xhrHasReturned)
// calling it with `true` indicates that all outstanding card promises have settled.
if (!xhrHasReturned)
return this.promiseToGetDashboardCards.then(cards => renderFn(cards, true))
})
.catch(e => {
this.showError(e)
})
// Cache the fetched dashcards in sessionStorage so we can render instantly next
// time they come to their dashboard (while still fetching the most current data)
// Also save the observed user's cards if observing so observer can switch between students
// without any delay
this.promiseToGetDashboardCards
.then(dashboardCards => {
try {
sessionStorage.setItem(sessionStorageKey, JSON.stringify(dashboardCards))
} catch (_e) {
// If saving the cards to session storage fails, we can just ignore the exception; the cards
// will still be fetched and displayed on the next load. Telling the user probably doesn't
// make sense since it doesn't change the way the app works, nor does it make sense to log
// the error since it could happen in normal circumstances (like using Safari in private mode).
}
if (observedUserId) {
CardDashboardLoader.observedUsersDashboardCards[observedUserId] = dashboardCards
}
})
.catch(e => {
this.showError(e)
})
}
}
showError(e) {
if (!this.errorShown) {
this.errorShown = true
showFlashAlert({message: I18n.t('Failed loading course cards'), err: e, type: 'error'})
}
}
}
// Clears the cache for use in test suites
export function resetCardCache() {
observedUsersDashboardCards = {}
CardDashboardLoader.observedUsersDashboardCards = {}
}

View File

@ -59,7 +59,9 @@ describe('CourseActivitySummaryStore', () => {
const spy = jest.spyOn(window, 'fetch').mockImplementation(() =>
Promise.resolve().then(() => ({
status: 200,
json: () => Promise.resolve().then(() => stream)
clone: () => ({
json: () => Promise.resolve().then(() => stream)
})
}))
)
CourseActivitySummaryStore._fetchForCourse(1)

View File

@ -39,8 +39,7 @@ const SchedulePage = ({
userHasEnrollments,
visible,
singleCourse,
observedUserId,
contextCodes
observedUserId
}) => {
const [isPlannerCreated, setPlannerCreated] = useState(false)
const [hasPreloadedItems, setHasPreloadedItems] = useState(false)
@ -67,16 +66,9 @@ const SchedulePage = ({
useEffect(() => {
if (plannerReady) {
reloadPlannerForObserver(observedUserId, contextCodes)
reloadPlannerForObserver(observedUserId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
plannerReady,
observedUserId,
// contextCodes is included in the dependency array in its stringified form
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(contextCodes)
])
}, [plannerReady, observedUserId])
let content = <></>
if (plannerInitialized && isPlannerCreated) {
@ -116,8 +108,7 @@ SchedulePage.propTypes = {
userHasEnrollments: PropTypes.bool.isRequired,
visible: PropTypes.bool.isRequired,
singleCourse: PropTypes.bool.isRequired,
observedUserId: PropTypes.string,
contextCodes: PropTypes.arrayOf(PropTypes.string)
observedUserId: PropTypes.string
}
export default SchedulePage

View File

@ -52,10 +52,17 @@ export default function usePlanner({
observedUserId,
isObserver = false
}) {
const [plannerInitializing, setPlannerInitializing] = useState(false)
const [plannerInitialized, setPlannerInitialized] = useState(false)
useEffect(() => {
if (plannerEnabled && !plannerInitialized && (!isObserver || !!observedUserId)) {
if (
plannerEnabled &&
!plannerInitializing &&
!plannerInitialized &&
(!isObserver || !!observedUserId)
) {
setPlannerInitializing(true)
initializePlanner({
getActiveApp: () => (isPlannerActive() ? 'planner' : ''),
flashError: message => showFlashAlert({message, type: 'error'}),
@ -68,8 +75,12 @@ export default function usePlanner({
singleCourse,
observedUserId
})
.then(setPlannerInitialized)
.catch(showFlashError(I18n.t('Failed to load the schedule tab')))
.then(val => {
setPlannerInitialized(val)
})
.catch(_ex => {
showFlashError(I18n.t('Failed to load the schedule tab'))()
})
}
// The rest of the dependencies don't change
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -297,7 +297,7 @@ export const transformAnnouncement = announcement => {
if (!announcement) return undefined
let attachment
if (announcement.attachments[0]) {
if (announcement?.attachments[0]) {
attachment = {
display_name: announcement.attachments[0].display_name,
url: announcement.attachments[0].url,
@ -345,6 +345,7 @@ export const ignoreTodo = ignoreUrl =>
})
export const groupImportantDates = (assignments, events, timeZone) => {
if (!assignments) return []
const groups = assignments.concat(events).reduce((acc, item) => {
const parsedItem = {
id: item.id,

View File

@ -15,17 +15,22 @@
// You should have received a copy of the GNU Affero General Public License along
// with this program. If not, see <http://www.gnu.org/licenses/>.
import $ from 'jquery'
import _ from 'underscore'
import rgb2hex from './rgb2hex'
import {defaultFetchOptions} from '@instructure/js-utils'
export default {
persistContextColors(colorsByContext, userId) {
_.each(colorsByContext, (color, contextCode) => {
const hexcode = color.match(/rgb/) ? rgb2hex(color) : color
const hexcode = (color.match(/rgb/) ? rgb2hex(color) : color).replace(/^#/, '')
const url = `/api/v1/users/${userId}/colors/${contextCode}`
$.ajax({url, type: 'PUT', data: {hexcode}})
// I don't know why, but when the hexcode was in the body, it failed to
// work from selenium
const url = `/api/v1/users/${userId}/colors/${contextCode}?hexcode=${hexcode}`
fetch(url, {
method: 'PUT',
...defaultFetchOptions
})
})
}
}