Initial pace plans import

This change brings in the basic pace plans frontend with
styled-components and several other smaller libraries replaced with
InstUI 7 components. It also adds the 'reselect' library as a direct
dependency (which we already had through @instructure/outcomes-ui) and
'tsc-files' for type-checking of staged TS files on commit. There were
also some tweaks to typescript and eslint configs, mostly to get both
up to speed with the typescript code.

Finally, this also adds a `pace_plans` endpoint to
`courses_controller` to bootstrap the frontend-- this will get moved
to `pace_plans_controller` once it has been merged.

It's also worth noting that no frontend tests are included with this
change-- the existing tests were written with enzyme and are heavily
snapshot-based, so we will be replacing those with
@testing-library/react tests in later updates (in keeping with current
testing best practices at Instructure).

closes LS-2431, LS-2432, LS-2433, LS-2434, LS-2452
flag = pace_plans

Test plan:
  - Set up a course with at least one module and several module items
  - Turn on the pace_plans feature flag in the account associated with
    that course
  - Turn on the "Enable pace plans" setting in course settings
  - Create a pace plan for the course via the Rails console:
    c = Course.find<id>
    pp = c.pace_plans.create! workflow_state: 'active'
    c.context_module_tags.each_with_index do |t, i|
      pp.pace_plan_module_items.create! module_item: t, duration: i*2
    end

  - Go to the course as a teacher or admin
  - Expect to see a "Pace Plans" link in the course navigation
  - Click it, expect the pace plan you created earlier to load and
    render
  - Expect to be able to pick dates, change durations, and toggle
    checkboxes (although saves will fail, since there is no API yet).

  - Expect to not see the "Pace Plans" course nav link when the feature
    flag or course setting is off
  - Expect /courses/<id>/pace_plans to return a 404 when the feature
    flag or course setting is off
  - Expect to not see the "Pace Plans" course nav link as a student
  - Expect /courses/<id>/pace_plans to display an "Unauthorized" page
    as a student

Change-Id: If4dc5d17f2c6a2109d4b4cb652c9e9ef00d7cc33
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/271650
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Ed Schiebel <eschiebel@instructure.com>
QA-Review: Ed Schiebel <eschiebel@instructure.com>
Product-Review: Jeff Largent <jeff.largent@instructure.com>
This commit is contained in:
Jeff Largent 2021-08-17 17:14:24 -04:00
parent 8741d6dda4
commit c01113638b
59 changed files with 4667 additions and 60 deletions

View File

@ -88,7 +88,6 @@ module.exports = {
'no-plusplus': 'off',
'no-prototype-builtins': 'off',
'no-return-assign': 'off',
'no-shadow': 'warn', // AirBnB says 'error', we downgrade to just 'warn'
'no-underscore-dangle': 'off',
'no-use-before-define': 'off',
'no-useless-escape': 'off',
@ -189,15 +188,6 @@ module.exports = {
// These are things we care about
'react/jsx-filename-extension': ['error', {extensions: ['.js', 'ts', 'tsx']}],
'no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
// allows `const {propIUse, propIDontUseButDontWantToPassOn, ...propsToPassOn} = this.props`
ignoreRestSiblings: true
}
],
'eslint-comments/no-unused-disable': 'error',
'import/extensions': ['error', 'ignorePackages', {js: 'never', ts: 'never', tsx: 'never'}],
'import/no-commonjs': 'off', // This is overridden where it counts
@ -219,7 +209,21 @@ module.exports = {
'no-unused-expressions': 'off', // the babel version allows optional chaining a?.b
'babel/no-unused-expressions': ['error', {allowShortCircuit: true, allowTernary: true}],
// These are for typescript
// Some rules need to be replaced with typescript versions to work with TS
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': 'error',
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'warn', // AirBnB says 'error', we downgrade to just 'warn'
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
// allows `const {propIUse, propIDontUseButDontWantToPassOn, ...propsToPassOn} = this.props`
ignoreRestSiblings: true
}
],
semi: 'off',
'@typescript-eslint/semi': ['error', 'never']
},

View File

@ -1,3 +1,4 @@
module.exports = {
'*.js': ['eslint --fix', 'git add']
'*.{js,ts,tsx}': ['eslint --fix', 'git add'],
'*.{ts,tsx}': ['tsc-files -p tsconfig.json --noEmit']
}

View File

@ -21,9 +21,26 @@ class PacePlansController < ApplicationController
before_action :require_context
before_action :require_feature_flag
before_action :authorize_action
before_action :load_pace_plan, only: [:update, :show]
before_action :load_pace_plan, only: [:api_show, :update]
include Api::V1::Course
def show
not_found unless @context.account.feature_enabled?(:pace_plans) && @context.settings[:enable_pace_plans]
return unless authorized_action(@context, @current_user, :manage_content)
@pace_plan = @context.pace_plans.primary.first
js_env({
BLACKOUT_DATES: [],
COURSE: course_json(@context, @current_user, session, [], nil),
ENROLLMENTS: enrollments_json(@context),
SECTIONS: sections_json(@context),
PACE_PLAN: PacePlanPresenter.new(@pace_plan).as_json
})
js_bundle :pace_plans
end
def api_show
plans_json = PacePlanPresenter.new(@pace_plan).as_json
render json: { pace_plan: plans_json }
end
@ -52,15 +69,40 @@ class PacePlansController < ApplicationController
private
def enrollments_json(course)
json = course.all_real_student_enrollments.map do |enrollment|
{
id: enrollment.id,
user_id: enrollment.user_id,
course_id: enrollment.course_id,
section_id: enrollment.course_section_id,
full_name: enrollment.user.name,
sortable_name: enrollment.user.sortable_name,
start_at: enrollment.start_at
}
end
json.index_by {|h| h[:id]}
end
def sections_json(course)
json = course.course_sections.map do |section|
{
id: section.id,
course_id: section.course_id,
name: section.name,
start_date: section.start_at,
end_date: section.end_at
}
end
json.index_by {|h| h[:id]}
end
def authorize_action
authorized_action(@context, @current_user, :manage_content)
end
def require_feature_flag
unless @context.account.feature_enabled?(:pace_plans) && @context.enable_pace_plans
render json: { message: 'Pace Plans feature flag is not enabled' },
status: :forbidden
end
not_found unless @context.account.feature_enabled?(:pace_plans) && @context.enable_pace_plans
end
def load_pace_plan

View File

@ -2878,6 +2878,7 @@ class Course < ActiveRecord::Base
TAB_COLLABORATIONS_NEW = 17
TAB_RUBRICS = 18
TAB_SCHEDULE = 19
TAB_PACE_PLANS = 20
CANVAS_K6_TAB_IDS = [TAB_HOME, TAB_ANNOUNCEMENTS, TAB_GRADES, TAB_MODULES].freeze
COURSE_SUBJECT_TAB_IDS = [TAB_HOME, TAB_SCHEDULE, TAB_MODULES, TAB_GRADES].freeze
@ -3032,17 +3033,30 @@ class Course < ActiveRecord::Base
def uncached_tabs_available(user, opts)
# make sure t() is called before we switch to the secondary, in case we update the user's selected locale in the process
course_subject_tabs = elementary_subject_course? && opts[:course_subject_tabs]
pace_plans_allowed = false
default_tabs = if elementary_homeroom_course?
Course.default_homeroom_tabs
elsif course_subject_tabs
Course.course_subject_tabs
elsif elementary_subject_course?
pace_plans_allowed = true
Course.elementary_course_nav_tabs
else
pace_plans_allowed = true
Course.default_tabs
end
# can't manage people in template courses
default_tabs.delete_if { |t| t[:id] == TAB_PEOPLE } if template?
# only show pace plans if enabled
if pace_plans_allowed && account.feature_enabled?(:pace_plans) && enable_pace_plans
default_tabs.insert(default_tabs.index { |t| t[:id] == TAB_MODULES } + 1, {
:id => TAB_PACE_PLANS,
:label => t('#tabs.pace_plans', "Pace Plans"),
:css_class => 'pace_plans',
:href => :course_pace_plans_path,
:visibility => 'admins'
})
end
opts[:include_external] = false if elementary_homeroom_course?
GuardRail.activate(:secondary) do
@ -3149,7 +3163,7 @@ class Course < ActiveRecord::Base
# remove tabs that the user doesn't have access to
unless opts[:for_reordering]
delete_unless.call([TAB_HOME, TAB_ANNOUNCEMENTS, TAB_PAGES, TAB_OUTCOMES, TAB_CONFERENCES, TAB_COLLABORATIONS, TAB_MODULES], :read, :manage_content)
delete_unless.call([TAB_HOME, TAB_ANNOUNCEMENTS, TAB_PAGES, TAB_OUTCOMES, TAB_CONFERENCES, TAB_COLLABORATIONS, TAB_MODULES, TAB_PACE_PLANS], :read, :manage_content)
member_only_tabs = tabs.select{ |t| t[:visibility] == 'members' }
tabs -= member_only_tabs if member_only_tabs.present? && !check_for_permission.call(:participate_as_student, :read_as_admin)

View File

@ -40,6 +40,7 @@ class PacePlanPresenter
published_at: pace_plan.published_at,
root_account_id: pace_plan.root_account_id,
modules: modules_json,
context_id: context_id,
context_type: context_type
}
end
@ -75,6 +76,10 @@ class PacePlanPresenter
end
end
def context_id
pace_plan.user_id || pace_plan.course_section_id || pace_plan.course_id
end
def context_type
if pace_plan.user_id
'User'

View File

@ -0,0 +1,25 @@
<%
# Copyright (C) 2021 - 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/>.
%>
<%
provide :page_title, t("Pace Plans")
set_active_tab "pace_plans"
js_bundle :pace_plans
%>
<div id="pace_plans"></div>

View File

@ -456,6 +456,8 @@ CanvasRails::Application.routes.draw do
end
end
get 'pace_plans' => 'pace_plans#show'
post 'collapse_all_modules' => 'context_modules#toggle_collapse_all'
resources :content_exports, only: [:create, :index, :destroy, :show]
get 'offline_web_exports' => 'courses#offline_web_exports'
@ -2353,7 +2355,7 @@ CanvasRails::Application.routes.draw do
scope(controller: :pace_plans) do
post 'courses/:course_id/pace_plans', action: :create
get 'courses/:course_id/pace_plans/:id', action: :show
get 'courses/:course_id/pace_plans/:id', action: :api_show
put 'courses/:course_id/pace_plans/:id', action: :update
end
end

View File

@ -52,7 +52,7 @@ module.exports = {
'@testing-library/jest-dom/extend-expect',
'./packages/validated-apollo/src/ValidatedApolloCleanup.js'
],
testMatch: ['**/__tests__/**/?(*.)(spec|test).js'],
testMatch: ['**/__tests__/**/?(*.)(spec|test).[jt]s?(x)'],
coverageDirectory: '<rootDir>/coverage-jest/',

View File

@ -159,6 +159,7 @@
"redux": "^4.0.1",
"redux-actions": "^2.6.4",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"shallow-equal": "^1.2.0",
"spin.js": "2.3.2",
"swfobject": "^2.2.1",
@ -193,7 +194,8 @@
"@testing-library/react": "^11",
"@testing-library/react-hooks": "^5",
"@testing-library/user-event": "^12",
"@types/react": ">=16.9.0",
"@types/react": "^17.0.19",
"@types/react-dom": "^17.0.9",
"@typescript-eslint/eslint-plugin": "^4.29.1",
"@typescript-eslint/parser": "^4.29.1",
"@yarnpkg/lockfile": "^1.0.2",
@ -300,6 +302,7 @@
"terser-webpack-plugin": "^1.4.3",
"through2": "^3",
"tinymce": "^5",
"tsc-files": "^1.1.2",
"typescript": "^4.3.5",
"waait": "^1",
"webpack": "^4",
@ -346,13 +349,14 @@
"build:js": "yarn run webpack-development",
"build:js:watch": "yarn run webpack",
"build:packages": "yarn workspace-run build:canvas",
"check:js": "tsc --noEmit --checkJs -p tsconfig.json",
"check:ts": "tsc --noEmit -p tsconfig.json",
"check:js": "tsc --checkJs -p tsconfig.json",
"check:ts": "tsc -p tsconfig.json",
"check:ts:watch": "tsc --watch -p tsconfig.json",
"lint:browser-code": "es-check es9 ./public/dist/**/*.js",
"lint:staged": "lint-staged",
"lint:js:coffeescripts": "echo 'STOP CALLING ME IM NO LONGER NEEDED'",
"lint:js:jsx": "eslint './ui/**/*.js'",
"lint:js:packages": "eslint ./packages/**/*.js",
"lint:js:jsx": "eslint ui --ext '.js,.ts,.tsx'",
"lint:js:packages": "eslint packages --ext '.js,.ts,.tsx'",
"lint:style": "stylelint './app/**/*.{css,scss}' './packages/**/*.{css,scss}'",
"lint:xss": "node ./script/xsslint.js",
"postinstall": "yarn dedupe-yarn; test -n \"$DISABLE_POSTINSTALL\" || (yarn build:packages && ./script/install_hooks && ./script/fix_inst_esm.js)",

View File

@ -1729,7 +1729,7 @@ describe CoursesController do
end
describe "update" do
it "syncs enrollments if setting is set" do
progress = double('Progress').as_null_object
allow(Progress).to receive(:new).and_return(progress)
@ -1757,9 +1757,9 @@ describe CoursesController do
@course.save!
get 'update', params: {
:id => @course.id,
:id => @course.id,
:course => {
homeroom_course_id: '17',
homeroom_course_id: '17',
sync_enrollments_from_homeroom: '1'
}
}

View File

@ -55,8 +55,8 @@ describe PacePlansController, type: :controller do
@a3 = @course.assignments.create! name: 'A3', workflow_state: 'unpublished'
@mod2.add_item id: @a3.id, type: 'assignment'
@course.context_module_tags.each do |tag|
@pace_plan.pace_plan_module_items.create! module_item: tag
@course.context_module_tags.each_with_index do |tag, i|
@pace_plan.pace_plan_module_items.create! module_item: tag, duration: i * 2
end
@course.enable_pace_plans = true
@ -87,8 +87,82 @@ describe PacePlansController, type: :controller do
end
describe "GET #show" do
it "populates js_env with course, enrollment, sections, and pace_plan details" do
@section = @course.course_sections.first
@student_enrollment = @course.enrollments.find_by(user_id: @student.id)
get :show, {params: {course_id: @course.id}}
expect(response).to be_successful
expect(assigns[:js_bundles].flatten).to include(:pace_plans)
expect(controller.js_env[:BLACKOUT_DATES]).to eq([])
expect(controller.js_env[:COURSE]).to match(hash_including({
id: @course.id,
name: @course.name,
start_at: @course.start_at,
end_at: @course.end_at
}))
expect(controller.js_env[:ENROLLMENTS].length).to be(1)
expect(controller.js_env[:ENROLLMENTS][@student_enrollment.id]).to match(hash_including({
id: @student_enrollment.id,
user_id: @student.id,
course_id: @course.id,
full_name: @student.name,
sortable_name: @student.sortable_name
}))
expect(controller.js_env[:SECTIONS].length).to be(1)
expect(controller.js_env[:SECTIONS][@section.id]).to match(hash_including({
id: @section.id,
course_id: @course.id,
name: @section.name,
start_date: @section.start_at,
end_date: @section.end_at
}))
expect(controller.js_env[:PACE_PLAN]).to match(hash_including({
id: @pace_plan.id,
course_id: @course.id,
course_section_id: nil,
user_id: nil,
workflow_state: "active",
exclude_weekends: true,
hard_end_dates: true,
context_id: @course.id,
context_type: "Course"
}))
expect(controller.js_env[:PACE_PLAN][:modules].length).to be(2)
expect(controller.js_env[:PACE_PLAN][:modules][0][:items].length).to be(1)
expect(controller.js_env[:PACE_PLAN][:modules][1][:items].length).to be(2)
expect(controller.js_env[:PACE_PLAN][:modules][1][:items][1]).to match(hash_including({
assignment_title: @a3.title,
module_item_type: 'Assignment',
duration: 4
}))
end
it "responds with not found if the pace_plans feature is disabled" do
@course.account.disable_feature!(:pace_plans)
assert_page_not_found do
get :show, params: {course_id: @course.id}
end
end
it "responds with not found if the enable_pace_plans setting is disabled" do
@course.enable_pace_plans = false
@course.save!
assert_page_not_found do
get :show, params: {course_id: @course.id}
end
end
it "responds with forbidden if the user doesn't have authorization" do
user_session(@student)
get :show, params: {course_id: @course.id}
assert_unauthorized
end
end
describe "GET #api_show" do
it "renders the specified pace plan" do
get :show, params: { course_id: @course.id, id: @pace_plan.id }
get :api_show, params: { course_id: @course.id, id: @pace_plan.id }
expect(response).to be_successful
expect(JSON.parse(response.body)["pace_plan"]["id"]).to eq(@pace_plan.id)
end

View File

@ -2623,6 +2623,7 @@ describe Course, "tabs_available" do
before :once do
course_with_teacher(:active_all => true)
end
let_once(:default_tab_ids) { Course.default_tabs.pluck(:id) }
describe 'TAB_CONFERENCES' do
context 'when WebConferences are enabled' do
@ -2651,10 +2652,9 @@ describe Course, "tabs_available" do
end
it "should return the defaults if nothing specified" do
length = Course.default_tabs.length
tab_ids = @course.tabs_available(@user).map{|t| t[:id] }
expect(tab_ids).to eql(Course.default_tabs.map{|t| t[:id] })
expect(tab_ids.length).to eql(length)
expect(tab_ids).to eql(default_tab_ids)
expect(tab_ids.length).to eql(default_tab_ids.length)
end
it "should return K-6 tabs if feature flag is enabled for teachers" do
@ -2676,15 +2676,14 @@ describe Course, "tabs_available" do
it "should overwrite the order of tabs if configured" do
@course.tab_configuration = [{ id: Course::TAB_COLLABORATIONS }]
available_tabs = @course.tabs_available(@user).map { |tab| tab[:id] }
default_tabs = Course.default_tabs.map { |tab| tab[:id] }
custom_tabs = @course.tab_configuration.map { |tab| tab[:id] }
expected_tabs = (custom_tabs + default_tabs).uniq
expected_tabs = (custom_tabs + default_tab_ids).uniq
# Home tab always comes first
home_tab = default_tabs[0]
home_tab = default_tab_ids[0]
expected_tabs = expected_tabs.insert(0, expected_tabs.delete(home_tab))
expect(available_tabs).to eq expected_tabs
expect(available_tabs.length).to eq default_tabs.length
expect(available_tabs.length).to eq default_tab_ids.length
end
it "should not blow up if somehow nils got in there" do
@ -2714,7 +2713,7 @@ describe Course, "tabs_available" do
@course.tab_configuration = [{'id' => 912}]
expect(@course.tabs_available(@user).map{|t| t[:id] }).not_to be_include(912)
tab_ids = @course.tabs_available(@user).map{|t| t[:id] }
expect(tab_ids).to eql(Course.default_tabs.map{|t| t[:id] })
expect(tab_ids).to eql(default_tab_ids)
expect(tab_ids.length).to be > 0
expect(@course.tabs_available(@user).map{|t| t[:label] }.compact.length).to eql(tab_ids.length)
end
@ -2739,7 +2738,7 @@ describe Course, "tabs_available" do
it "should not hide tabs for completed teacher enrollments" do
@user.enrollments.where(:course_id => @course).first.complete!
tab_ids = @course.tabs_available(@user).map{|t| t[:id] }
expect(tab_ids).to eql(Course.default_tabs.map{|t| t[:id] })
expect(tab_ids).to eql(default_tab_ids)
end
it "should not include Announcements without read_announcements rights" do
@ -2842,10 +2841,10 @@ describe Course, "tabs_available" do
end
it "returns default course tabs without home if course_subject_tabs option is not passed" do
course_elementary_nav_tabs = Course.default_tabs.reject{|tab| tab[:id] == Course::TAB_HOME}
course_elementary_nav_tabs = default_tab_ids.reject{|id| id == Course::TAB_HOME}
length = course_elementary_nav_tabs.length
tab_ids = @course.tabs_available(@user).map{|t| t[:id] }
expect(tab_ids).to eql(course_elementary_nav_tabs.map{|t| t[:id] })
expect(tab_ids).to eql(course_elementary_nav_tabs)
expect(tab_ids.length).to eql(length)
end
@ -2911,6 +2910,26 @@ describe Course, "tabs_available" do
end
end
end
context "pace plans" do
before :once do
@course.account.enable_feature!(:pace_plans)
@course.enable_pace_plans = true
@course.save!
end
it "should be included when pace plans is enabled" do
tabs = @course.tabs_available(@teacher).pluck(:id)
expect(tabs).to include(Course::TAB_PACE_PLANS)
end
it "should not be included for students" do
tabs = @course.tabs_available(@student).pluck(:id)
expect(tabs).not_to include(Course::TAB_PACE_PLANS)
end
end
end
context "students" do

View File

@ -45,6 +45,7 @@ describe PacePlanPresenter do
formatted_plan = PacePlanPresenter.new(@pace_plan).as_json
expect(formatted_plan[:id]).to eq(@pace_plan.id)
expect(formatted_plan[:context_id]).to eq(@pace_plan.course_id)
expect(formatted_plan[:context_type]).to eq('Course')
expect(formatted_plan[:course_id]).to eq(@pace_plan.course_id)
expect(formatted_plan[:course_section_id]).to eq(@pace_plan.course_section_id)

View File

@ -4,11 +4,15 @@
"esModuleInterop": true, // more accurately transpiles to the ES6 module spec (maybe not needed w/ babel transpilation)
"isolatedModules": true, // required to adhere to babel's single-file transpilation process
"jsx": "react", // transpiles jsx to React.createElement calls (maybe not needed w/ babel transpilation)
"lib": ["dom", "es7"], // include types for DOM APIs and standard JS up to ES2016 / ES7
"module": "ES2020", // support the most modern ES6-style module syntax
"moduleResolution": "node", // required for non-commonjs imports
"noEmit": true, // don't generate transpiled JS files, source-maps, or .d.ts files for Canvas source code
"resolveJsonModule": true, // enables importing json files
"skipLibCheck": true, // don't do type-checking on dependencies' internal types for improved performance
"strict": true, // enables a bunch of strict mode family options. See https://www.typescriptlang.org/tsconfig#strict
"target": "ES2020" // support the most modern JS features (let babel handle transpilation)
"target": "ES2020", // support the most modern JS features (let babel handle transpilation)
"noImplicitAny": false // remove once fixed in pace plans
},
"include": ["ui/**/*"]
}

View File

@ -0,0 +1,36 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import ReactDOM from 'react-dom'
import {Provider} from 'react-redux'
import ready from '@instructure/ready'
import App from './react/app'
import reducers from './react/reducers/reducers'
import createStore from './react/shared/create_store'
const CoursePage: React.FC = () => (
<Provider store={createStore(reducers)}>
<App />
</Provider>
)
ready(() => {
ReactDOM.render(<CoursePage />, document.getElementById('pace_plans'))
})

View File

@ -0,0 +1,120 @@
/*
* Copyright (C) 2021 - 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 _ from 'lodash'
import {Action, Dispatch} from 'redux'
import {ThunkAction} from 'redux-thunk'
import equal from 'fast-deep-equal'
import {getPacePlan} from '../reducers/pace_plans'
import {StoreState, PacePlan} from '../types'
import * as pacePlanAPI from '../api/pace_plan_api'
import {actions as uiActions} from './ui'
import {pacePlanActions} from './pace_plans'
const updatePacePlan = async (
dispatch: Dispatch<Action>,
getState: () => StoreState,
planBefore: PacePlan,
shouldBlock: boolean,
extraSaveParams = {}
) => {
await pacePlanAPI.waitForActionCompletion(() => getState().ui.planPublishing)
const plan = getPacePlan(getState())
if (planBefore.id && plan.id !== planBefore.id) {
dispatch(uiActions.autoSaveCompleted())
return
}
const persisted = !!plan.id
const method = persisted ? pacePlanAPI.update : pacePlanAPI.create
// Whether we should update the plan to match the state presented by the backend.
// We don't do this all the time, because it results in race conditions that cause
// the ui to get out of sync.
const updateAfterRequest =
!persisted || shouldBlock || (plan.hard_end_dates && plan.context_type === 'Enrollment')
return method(plan, extraSaveParams) // Hit the API to update
.then(response => {
const updatedPlan: PacePlan = response.data
if (updateAfterRequest) {
dispatch(pacePlanActions.planCreated(updatedPlan))
}
if (shouldBlock) {
dispatch(uiActions.hideLoadingOverlay())
}
dispatch(uiActions.autoSaveCompleted()) // Update the UI state
dispatch(uiActions.setErrorMessage(''))
})
.catch(error => {
if (shouldBlock) {
dispatch(uiActions.hideLoadingOverlay())
}
dispatch(uiActions.autoSaveCompleted())
dispatch(uiActions.setErrorMessage('There was an error saving your changes'))
console.error(error) // eslint-disable-line no-console
})
}
const debouncedUpdatePacePlan = _.debounce(updatePacePlan, 1000, {trailing: true, maxWait: 2000})
/*
Given any action, returns a new thunked action that applies the action and
then initiates the autosave (including updating the UI)
action - pass any redux action that should initiate an auto save
debounce - whether the action should be immediately autosaved, or debounced
shouldBlock - whether you want the plan updated after the autosave and for a loading icon to block user interaction
until that is complete
extraSaveParams - params that should be passed to the backend during the API call
*/
export const createAutoSavingAction = (
action: any,
debounce = true,
shouldBlock = false,
extraSaveParams = {}
): ThunkAction<void, StoreState, void, Action> => {
return (dispatch, getState) => {
if (shouldBlock) {
dispatch(uiActions.showLoadingOverlay('Updating...'))
}
const planBefore = getPacePlan(getState())
dispatch(action) // Dispatch the original action
// Don't autosave if no changes have occured
if (equal(planBefore, getPacePlan(getState()))) {
return
}
dispatch(pacePlanActions.setUnpublishedChanges(true))
dispatch(uiActions.startAutoSave())
dispatch(pacePlanActions.setLinkedToParent(false))
if (debounce) {
return debouncedUpdatePacePlan(dispatch, getState, planBefore, shouldBlock, extraSaveParams)
} else {
return updatePacePlan(dispatch, getState, planBefore, shouldBlock, extraSaveParams)
}
}
}

View File

@ -0,0 +1,44 @@
/*
* Copyright (C) 2021 - 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 {createAutoSavingAction} from './autosaving'
import {createAction, ActionsUnion} from '../shared/types'
export enum Constants {
SET_PLAN_ITEM_DURATION = 'PACE_PLAN_ITEMS/SET_PLAN_ITEM_DURATION'
}
/* Action creators */
export const actions = {
setPlanItemDuration: (planItemId: number, duration: number) =>
createAction(Constants.SET_PLAN_ITEM_DURATION, {planItemId, duration})
}
export const autoSavingActions = {
setPlanItemDuration: (planItemId: number, duration: number, extraSaveParams = {}) => {
return createAutoSavingAction(
actions.setPlanItemDuration(planItemId, duration),
true,
false,
extraSaveParams
)
}
}
export type PacePlanItemAction = ActionsUnion<typeof actions>

View File

@ -0,0 +1,210 @@
/*
* Copyright (C) 2021 - 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 {Action} from 'redux'
import {ThunkAction} from 'redux-thunk'
import moment from 'moment-timezone'
import {PacePlan, PlanContextTypes, StoreState, PublishOptions} from '../types'
import {createAction, ActionsUnion, BlackoutDate} from '../shared/types'
import {actions as uiActions} from './ui'
import {createAutoSavingAction} from './autosaving'
import * as DateHelpers from '../utils/date_stuff/date_helpers'
import * as Api from '../api/pace_plan_api'
import {getPacePlan} from '../reducers/pace_plans'
export enum Constants {
SET_END_DATE = 'PACE_PLAN/SET_END_DATE',
SET_START_DATE = 'PACE_PLAN/SET_START_DATE',
PUBLISH_PLAN = 'PACE_PLAN/PUBLISH_PLAN',
SET_PLAN_DAYS = 'PACE_PLAN/SET_PLAN_DAYS',
TOGGLE_EXCLUDE_WEEKENDS = 'PACE_PLAN/TOGGLE_EXCLUDE_WEEKENDS',
SET_PACE_PLAN = 'PACE_PLAN/SET_PACE_PLAN',
PLAN_CREATED = 'PACE_PLAN/PLAN_CREATED',
SET_UNPUBLISHED_CHANGES = 'PACE_PLAN/SET_UNPUBLISHED_CHANGES',
TOGGLE_HARD_END_DATES = 'PACE_PLAN/TOGGLE_HARD_END_DATES',
SET_LINKED_TO_PARENT = 'PACE_PLAN/SET_LINKED_TO_PARENT'
}
/* Action creators */
type LoadingAfterAction = (plan: PacePlan) => any
// Without this, we lose the ReturnType through our mapped ActionsUnion (because of setPlanDays), and the type just becomes any.
type SetEndDate = {type: Constants.SET_END_DATE; payload: string}
const regularActions = {
setPacePlan: (plan: PacePlan) => createAction(Constants.SET_PACE_PLAN, plan),
setStartDate: (date: string) => createAction(Constants.SET_START_DATE, date),
setEndDate: (date: string): SetEndDate => createAction(Constants.SET_END_DATE, date),
planCreated: (plan: PacePlan) => createAction(Constants.PLAN_CREATED, plan),
toggleExcludeWeekends: () => createAction(Constants.TOGGLE_EXCLUDE_WEEKENDS),
toggleHardEndDates: () => createAction(Constants.TOGGLE_HARD_END_DATES),
setLinkedToParent: (linked: boolean) => createAction(Constants.SET_LINKED_TO_PARENT, linked),
setUnpublishedChanges: (unpublishedChanges: boolean) =>
createAction(Constants.SET_UNPUBLISHED_CHANGES, unpublishedChanges),
setPlanDays: (planDays: number, plan: PacePlan, blackoutDates: BlackoutDate[]): SetEndDate => {
// This calculates the new end date based off of the number of plan days specified and invokes
// a setEndDate action. This would potentially be better with a thunked action, but I'm not
// sure how to get that to work with a typesafe-actions createAction call.
let newEndDate: string
if (planDays === 0) {
newEndDate = plan.start_date
} else {
// Subtract one day from planDays to make it include the start date
const start = moment(plan.start_date).subtract(1, 'day')
newEndDate = DateHelpers.addDays(start, planDays, plan.exclude_weekends, blackoutDates)
}
return pacePlanActions.setEndDate(newEndDate)
}
}
const thunkActions = {
publishPlan: (
publishForOption: PublishOptions,
publishForSectionIds: Array<string | number>,
publishForEnrollmentIds: Array<string | number>
): ThunkAction<void, StoreState, void, Action> => {
return (dispatch, getState) => {
dispatch(uiActions.showLoadingOverlay('Publishing...'))
dispatch(uiActions.publishPlanStarted())
Api.waitForActionCompletion(() => getState().ui.autoSaving)
return Api.publish(
getState().pacePlan,
publishForOption,
publishForSectionIds,
publishForEnrollmentIds
)
.then(response => {
const newDraft: PacePlan = response.data.new_draft_plan
dispatch(pacePlanActions.setPacePlan(newDraft))
dispatch(uiActions.hideLoadingOverlay())
dispatch(uiActions.publishPlanFinished())
})
.catch(error => {
dispatch(uiActions.hideLoadingOverlay())
dispatch(uiActions.publishPlanFinished())
dispatch(uiActions.setErrorMessage('There was an error publishing your plan.'))
console.error(error) // eslint-disable-line no-console
})
}
},
resetToLastPublished: (
contextType: PlanContextTypes,
contextId: string | number
): ThunkAction<void, StoreState, void, Action> => {
return async (dispatch, getState) => {
dispatch(uiActions.showLoadingOverlay('Loading...'))
await Api.waitForActionCompletion(
() => getState().ui.autoSaving || getState().ui.planPublishing
)
return Api.resetToLastPublished(contextType, contextId)
.then(response => {
const plan: PacePlan = response.data.pace_plan
dispatch(pacePlanActions.setPacePlan(plan))
dispatch(uiActions.hideLoadingOverlay())
})
.catch(error => {
dispatch(uiActions.hideLoadingOverlay())
dispatch(uiActions.setErrorMessage('There was an error resetting to the previous plan.'))
console.error(error) // eslint-disable-line no-console
})
}
},
loadLatestPlanByContext: (
contextType: PlanContextTypes,
contextId: number | string,
afterAction: LoadingAfterAction = pacePlanActions.setPacePlan
): ThunkAction<void, StoreState, void, Action> => {
return async (dispatch, getState) => {
dispatch(uiActions.showLoadingOverlay('Loading...'))
await Api.waitForActionCompletion(() => getState().ui.autoSaving)
return Api.getLatestDraftFor(contextType, contextId)
.then(response => {
const plan: PacePlan = response.data.pace_plan
dispatch(afterAction(plan))
dispatch(uiActions.hideLoadingOverlay())
})
.catch(error => {
dispatch(uiActions.hideLoadingOverlay())
dispatch(uiActions.setErrorMessage('There was an error loading the plan.'))
console.error(error) // eslint-disable-line no-console
})
}
},
relinkToParentPlan: (): ThunkAction<void, StoreState, void, Action> => {
return async (dispatch, getState) => {
dispatch(uiActions.showLoadingOverlay('Relinking plans...'))
await Api.waitForActionCompletion(() => getState().ui.autoSaving)
return Api.relinkToParentPlan(getState().pacePlan.id as number)
.then(response => {
const plan: PacePlan = response.data.pace_plan
dispatch(pacePlanActions.setPacePlan(plan))
dispatch(uiActions.hideLoadingOverlay())
})
.catch(error => {
dispatch(uiActions.hideLoadingOverlay())
dispatch(uiActions.setErrorMessage('There was an error linking plan.'))
console.error(error) // eslint-disable-line no-console
})
}
}
}
export const autoSavingActions = {
setStartDate: (date: string) => {
return createAutoSavingAction(pacePlanActions.setStartDate(date))
},
setEndDate: (date: string) => {
return createAutoSavingAction(pacePlanActions.setEndDate(date))
},
setPlanDays: (planDays: number, plan: PacePlan, blackoutDates: BlackoutDate[]) => {
return createAutoSavingAction(pacePlanActions.setPlanDays(planDays, plan, blackoutDates))
},
toggleExcludeWeekends: (extraSaveParams = {}) => {
return createAutoSavingAction(
pacePlanActions.toggleExcludeWeekends(),
true,
false,
extraSaveParams
)
},
toggleHardEndDates: (): ThunkAction<void, StoreState, void, Action> => {
return (dispatch, getState) => {
const plan = getPacePlan(getState())
// If we're an enrollment plan and we're setting hard end dates, we should block the UI until the backend
// re-adjusts the assignment due dates
const shouldWaitForSave = plan.context_type === 'Enrollment' && !plan.hard_end_dates
dispatch(
createAutoSavingAction(pacePlanActions.toggleHardEndDates(), true, shouldWaitForSave)
)
}
}
}
export const pacePlanActions = {...regularActions, ...thunkActions}
export type PacePlanAction = ActionsUnion<typeof regularActions>

View File

@ -0,0 +1,91 @@
/*
* Copyright (C) 2021 - 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/>.
*/
/*
* The actions in this file should encapsulate state variables that only effect the UI.
*/
import {Action} from 'redux'
import {ThunkAction} from 'redux-thunk'
import {PlanTypes, StoreState, PacePlan, Enrollment} from '../types'
import {Course, createAction, ActionsUnion} from '../shared/types'
import {pacePlanActions} from './pace_plans'
export enum Constants {
START_AUTO_SAVING = 'UI/START_AUTO_SAVING',
AUTO_SAVE_COMPLETED = 'UI/AUTO_SAVE_COMPLETED',
SET_ERROR_MESSAGE = 'UI/SET_ERROR_MESSAGE',
TOGGLE_DIVIDE_INTO_WEEKS = 'UI/TOGGLE_DIVIDE_INTO_WEEKS',
PUBLISH_PLAN_STARTED = 'UI/PUBLISH_PLAN_STARTED',
PUBLISH_PLAN_FINISHED = 'UI/PUBLISH_PLAN_FINISHED',
SET_SELECTED_PLAN_TYPE = 'UI/SET_SELECTED_PLAN_TYPE',
SHOW_LOADING_OVERLAY = 'UI/SHOW_LOADING_OVERLAY',
HIDE_LOADING_OVERLAY = 'UI/HIDE_LOADING_OVERLAY',
SET_EDITING_BLACKOUT_DATES = 'UI/SET_EDITING_BLACKOUT_DATES',
SET_ADJUSTING_HARD_END_DATES_AFTER = 'UI/SET_ADJUSTING_HARD_END_DATES_AFTER'
}
/* Action creators */
export const regularActions = {
startAutoSave: () => createAction(Constants.START_AUTO_SAVING),
autoSaveCompleted: () => createAction(Constants.AUTO_SAVE_COMPLETED),
setErrorMessage: (message: string) => createAction(Constants.SET_ERROR_MESSAGE, message),
toggleDivideIntoWeeks: () => createAction(Constants.TOGGLE_DIVIDE_INTO_WEEKS),
publishPlanStarted: () => createAction(Constants.PUBLISH_PLAN_STARTED),
publishPlanFinished: () => createAction(Constants.PUBLISH_PLAN_FINISHED),
showLoadingOverlay: (message: string) => createAction(Constants.SHOW_LOADING_OVERLAY, message),
hideLoadingOverlay: () => createAction(Constants.HIDE_LOADING_OVERLAY),
setEditingBlackoutDates: (editing: boolean) =>
createAction(Constants.SET_EDITING_BLACKOUT_DATES, editing),
setSelectedPlanType: (planType: PlanTypes, newSelectedPlan: PacePlan) =>
createAction(Constants.SET_SELECTED_PLAN_TYPE, {planType, newSelectedPlan}),
setAdjustingHardEndDatesAfter: (position: number | undefined) =>
createAction(Constants.SET_ADJUSTING_HARD_END_DATES_AFTER, position)
}
export const thunkActions = {
setSelectedPlanType: (
planType: PlanTypes,
enrollmentId: number | string = 0
): ThunkAction<void, StoreState, void, Action> => {
// Switch to the other plan type, and load the exact plan we should switch to
return (dispatch, getState) => {
const state = getState()
const newSelectPlanContext: Course | Enrollment =
planType === 'template' ? state.course : state.enrollments[enrollmentId]
const contextType = planType === 'template' ? 'Course' : 'Enrollment'
const afterLoadActionCreator = (newSelectedPlan: PacePlan): SetSelectedPlanType => {
return {type: Constants.SET_SELECTED_PLAN_TYPE, payload: {planType, newSelectedPlan}}
}
dispatch(
pacePlanActions.loadLatestPlanByContext(
contextType,
newSelectPlanContext.id,
afterLoadActionCreator
)
)
}
}
}
export const actions = {...regularActions, ...thunkActions}
export type UIAction = ActionsUnion<typeof regularActions>
export type SetSelectedPlanType = ReturnType<typeof regularActions.setSelectedPlanType>

View File

@ -0,0 +1,59 @@
/*
* Copyright (C) 2021 - 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 axios, {AxiosPromise} from '@canvas/axios'
import {BlackoutDate} from '../shared/types'
import * as DateHelpers from '../utils/date_stuff/date_helpers'
/* API methods */
export const create = (blackoutDate: BlackoutDate): AxiosPromise => {
return axios.post(`/api/v1/blackout_dates`, {
blackout_date: transformBlackoutDateForApi(blackoutDate)
})
}
export const deleteBlackoutDate = (id: number | string): AxiosPromise => {
return axios.delete(`/api/v1/blackout_dates/${id}`)
}
/* API transformers */
interface ApiFormattedBlackoutDate {
course_id?: number | string
event_title: string
start_date: string
end_date: string
admin_level: boolean
}
const transformBlackoutDateForApi = (blackoutDate: BlackoutDate): ApiFormattedBlackoutDate => {
const formattedBlackoutDate: ApiFormattedBlackoutDate = {
event_title: blackoutDate.event_title,
start_date: DateHelpers.formatDate(blackoutDate.start_date),
end_date: DateHelpers.formatDate(blackoutDate.end_date),
admin_level: !!blackoutDate.admin_level
}
if (blackoutDate.course_id) {
formattedBlackoutDate.course_id = blackoutDate.course_id
}
return formattedBlackoutDate
}

View File

@ -0,0 +1,165 @@
/*
* Copyright (C) 2021 - 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 axios, {AxiosPromise} from '@canvas/axios'
import {PacePlan, PlanContextTypes, WorkflowStates, PublishOptions} from '../types'
/* API helpers */
/*
This helper is useful if you've got an async action that you don't want to execute until another
is complete to avoid race consitions.
Example: changing anything on the page will autosave, but the user might also hit the publish
at the same time. If they publish while the autosave is still happening you can get race condition
bugs. So when publishing we can this to wait until the autosave completes before we allow a publish.
*/
export const waitForActionCompletion = (actionInProgress: () => boolean, waitTime = 1000) => {
return new Promise((resolve, reject) => {
const staller = (
actionInProgress: () => boolean,
waitTime: number,
innerResolve,
innerReject
) => {
if (actionInProgress()) {
setTimeout(() => staller(actionInProgress, waitTime, innerResolve, innerReject), waitTime)
} else {
innerResolve('done')
}
}
staller(actionInProgress, waitTime, resolve, reject)
})
}
/* API methods */
export const update = (pacePlan: PacePlan, extraSaveParams = {}): AxiosPromise => {
return axios.put(`/api/v1/pace_plans/${pacePlan.id}`, {
...extraSaveParams,
pace_plan: transformPacePlanForApi(pacePlan)
})
}
export const create = (pacePlan: PacePlan, extraSaveParams = {}): AxiosPromise => {
return axios.post(`/api/v1/pace_plans`, {
...extraSaveParams,
pace_plan: transformPacePlanForApi(pacePlan)
})
}
export const publish = (
plan: PacePlan,
publishForOption: PublishOptions,
publishForSectionIds: Array<number | string>,
publishForEnrollmentIds: Array<number | string>
): AxiosPromise => {
return axios.post(`/api/v1/pace_plans/publish`, {
context_type: plan.context_type,
context_id: plan.context_id,
publish_for_option: publishForOption,
publish_for_section_ids: publishForSectionIds,
publish_for_enrollment_ids: publishForEnrollmentIds
})
}
export const resetToLastPublished = (
contextType: PlanContextTypes,
contextId: number | string
): AxiosPromise => {
return axios.post(`/api/v1/pace_plans/reset_to_last_published`, {
context_type: contextType,
context_id: contextId
})
}
export const load = (pacePlanId: number | string) => {
return axios.get(`/api/v1/pace_plans/${pacePlanId}`)
}
export const getLatestDraftFor = (context: PlanContextTypes, contextId: number | string) => {
return axios.get(
`/api/v1/pace_plans/latest_draft_for?context_type=${context}&context_id=${contextId}`
)
}
export const republishAllPlansForCourse = (courseId: string | number) => {
return axios.post(`/api/v1/pace_plans/republish_all_plans`, {course_id: courseId})
}
export const republishAllPlans = () => {
return axios.post(`/api/v1/pace_plans/republish_all_plans`)
}
export const relinkToParentPlan = (planId: string | number) => {
return axios.post(`/api/v1/pace_plans/${planId}/relink_to_parent_plan`)
}
/* API transformers
* functions and interfaces to transform the frontend formatted objects
* to the format required for backend consumption
*
* TODO: potential technical debt - having to transform between the frontend
* and backend data structures like this seems a bit messy. Could use a *REFACTOR*
* if more models are saved using the same pattern.
*/
interface ApiPacePlanModuleItemsAttributes {
readonly id: number
readonly duration: number
readonly module_item_id: number
}
interface ApiFormattedPacePlan {
readonly start_date: string
readonly end_date: string
readonly workflow_state: WorkflowStates
readonly exclude_weekends: boolean
readonly context_type: PlanContextTypes
readonly context_id: string | number
readonly hard_end_dates: boolean
readonly pace_plan_module_items_attributes: ApiPacePlanModuleItemsAttributes[]
}
const transformPacePlanForApi = (pacePlan: PacePlan): ApiFormattedPacePlan => {
const pacePlanItems: ApiPacePlanModuleItemsAttributes[] = []
pacePlan.modules.forEach(module => {
module.items.forEach(item => {
pacePlanItems.push({
id: item.id,
duration: item.duration,
module_item_id: item.module_item_id
})
})
})
const apiFormattedPacePlan: ApiFormattedPacePlan = {
start_date: pacePlan.start_date,
end_date: pacePlan.end_date,
workflow_state: pacePlan.workflow_state,
exclude_weekends: pacePlan.exclude_weekends,
context_type: pacePlan.context_type,
context_id: pacePlan.context_id,
hard_end_dates: !!pacePlan.hard_end_dates,
pace_plan_module_items_attributes: pacePlanItems
}
return apiFormattedPacePlan
}

View File

@ -0,0 +1,86 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {connect} from 'react-redux'
import {Alert} from '@instructure/ui-alerts'
import {Flex} from '@instructure/ui-flex'
import {Mask, Overlay} from '@instructure/ui-overlays'
import {Spinner} from '@instructure/ui-spinner'
import {View} from '@instructure/ui-view'
import Header from './components/header/header'
import Body from './components/body'
import {StoreState, PacePlan} from './types'
import {getErrorMessage, getLoadingMessage, getShowLoadingOverlay} from './reducers/ui'
import {getPacePlan} from './reducers/pace_plans'
interface StoreProps {
readonly errorMessage: string
readonly loadingMessage: string
readonly showLoadingOverlay: boolean
readonly pacePlan: PacePlan
}
export class App extends React.Component<StoreProps> {
renderErrorAlert() {
if (this.props.errorMessage) {
return (
<Alert variant="error" closeButtonLabel="Close" margin="small">
{this.props.errorMessage}
</Alert>
)
}
}
render() {
return (
<View>
<Overlay
open={this.props.showLoadingOverlay}
transition="fade"
label={this.props.loadingMessage}
>
<Mask>
<Spinner title="Loading" size="large" margin="0 0 0 medium" />
</Mask>
</Overlay>
<View overflowX="auto" width="100%">
<Flex as="div" direction="column">
<View>
{this.renderErrorAlert()}
<Header />
</View>
<Body />
</Flex>
</View>
</View>
)
}
}
const mapStateToProps = (state: StoreState): StoreProps => {
return {
errorMessage: getErrorMessage(state),
loadingMessage: getLoadingMessage(state),
showLoadingOverlay: getShowLoadingOverlay(state),
pacePlan: getPacePlan(state)
}
}
export default connect(mapStateToProps)(App)

View File

@ -0,0 +1,24 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import PacePlanTable from './pace_plan_table/pace_plan_table'
const Body: React.FC = () => <PacePlanTable />
export default Body

View File

@ -0,0 +1,49 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {Flex} from '@instructure/ui-flex'
import {View} from '@instructure/ui-view'
import PlanLengthPicker from './plan_length_picker/plan_length_picker'
import PlanPicker from './plan_picker'
import PlanTypePicker from './plan_type_picker'
import Settings from './settings/settings'
const Header: React.FC = () => (
<Flex wrap="wrap" alignItems="center" justifyItems="space-between">
<Flex
as="div"
height="9rem"
direction="column"
justifyItems="space-between"
margin="0 x-small medium 0"
>
<Flex alignItems="center" margin="0 0 x-small 0">
<PlanTypePicker />
<View margin="0 0 0 small">
<Settings />
</View>
</Flex>
<PlanPicker />
</Flex>
<PlanLengthPicker />
</Flex>
)
export default Header

View File

@ -0,0 +1,163 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {connect} from 'react-redux'
import {NumberInput} from '@instructure/ui-number-input'
import {StoreState, PacePlan} from '../../../types'
import {BlackoutDate} from '../../../shared/types'
import {
getPlanDays,
getPlanWeeks,
getPacePlan,
getWeekLength,
isPlanCompleted
} from '../../../reducers/pace_plans'
import {getDivideIntoWeeks, getEditingBlackoutDates} from '../../../reducers/ui'
import {autoSavingActions as actions} from '../../../actions/pace_plans'
import {getBlackoutDates} from '../../../shared/reducers/blackout_dates'
interface StoreProps {
readonly planDays: number
readonly planWeeks: number
readonly divideIntoWeeks: boolean
readonly pacePlan: PacePlan
readonly weekLength: number
readonly blackoutDates: BlackoutDate[]
readonly editingBlackoutDates: boolean
readonly planCompleted: boolean
}
interface DispatchProps {
readonly setPlanDays: typeof actions.setPlanDays
}
type ComponentProps = StoreProps & DispatchProps
interface LocalState {
readonly planDays: number | string
}
// This component has to keep a local and Redux version of planDays.
// The local version is needed so that we don't commit to redux (and recalculate)
// until there's a valid value. The WeeksSelector and AssignmentRow components
// are following a similar pattern and could maybe be *REFACTOR*ed to use the same
// component wrapping around NumberInput.
export class DaysSelector extends React.Component<ComponentProps, LocalState> {
/* Lifecycle */
constructor(props: ComponentProps) {
super(props)
this.state = {planDays: props.planDays}
}
// If blackout dates are being edited, we don't want to try and re-calculate the days
// until they've all been saved to the backend, and the plan has been re-loaded.
shouldComponentUpdate(nextProps: ComponentProps) {
return !nextProps.editingBlackoutDates
}
/* Callbacks */
commitChanges = () => {
const days =
typeof this.state.planDays === 'string'
? parseInt(this.state.planDays, 10)
: this.state.planDays
if (!Number.isNaN(days) && days !== this.props.planDays) {
this.props.setPlanDays(days, this.props.pacePlan, this.props.blackoutDates)
}
}
onChangeNumDays = (_e: React.FormEvent<HTMLInputElement>, days: string | number) => {
let daysFormatted: number | string
const daysAsInt = typeof days === 'string' ? parseInt(days, 10) : days
if (Number.isNaN(daysAsInt)) {
daysFormatted = ''
} else if (daysAsInt < 0 || (daysAsInt > this.props.weekLength && this.props.divideIntoWeeks)) {
return
} else {
daysFormatted = this.props.divideIntoWeeks
? this.props.planWeeks * this.props.weekLength + daysAsInt
: daysAsInt
}
this.setState({planDays: daysFormatted}, this.commitChanges)
}
onDecrementOrIncrement = (e: React.FormEvent<HTMLInputElement>, direction: number) => {
let days = this.displayDays()
days = typeof days === 'string' ? parseInt(days, 10) : days
this.onChangeNumDays(e, days + direction)
}
/* Helpers */
disabled() {
return (
this.props.planCompleted ||
(this.props.pacePlan.context_type === 'Enrollment' && this.props.pacePlan.hard_end_dates)
)
}
displayDays() {
if (!this.props.divideIntoWeeks || typeof this.state.planDays === 'string') {
return this.state.planDays
} else {
return this.state.planDays % this.props.weekLength
}
}
/* Renderers */
render() {
return (
<NumberInput
renderLabel="Days"
width="90px"
value={this.displayDays().toString()}
onDecrement={e => this.onDecrementOrIncrement(e, -1)}
onIncrement={e => this.onDecrementOrIncrement(e, 1)}
onChange={this.onChangeNumDays}
interaction={this.disabled() ? 'disabled' : 'enabled'}
/>
)
}
}
const mapStateToProps = (state: StoreState): StoreProps => {
const planDays = getPlanDays(state)
return {
planDays,
planWeeks: getPlanWeeks(state),
divideIntoWeeks: getDivideIntoWeeks(state),
pacePlan: getPacePlan(state),
weekLength: getWeekLength(state),
blackoutDates: getBlackoutDates(state),
editingBlackoutDates: getEditingBlackoutDates(state),
planCompleted: isPlanCompleted(state),
// Used to reset the selector to days coming from the Redux store when they change there
key: `days-selector-${planDays}`
}
}
export default connect(mapStateToProps, {setPlanDays: actions.setPlanDays} as DispatchProps)(
DaysSelector
)

View File

@ -0,0 +1,93 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {connect} from 'react-redux'
import moment from 'moment-timezone'
import {autoSavingActions as actions} from '../../../actions/pace_plans'
import PacePlanDateInput from '../../../shared/components/pace_plan_date_input'
import {StoreState, PacePlan} from '../../../types'
import {BlackoutDate} from '../../../shared/types'
import {getPacePlan, getDisabledDaysOfWeek, isPlanCompleted} from '../../../reducers/pace_plans'
import {getBlackoutDates} from '../../../shared/reducers/blackout_dates'
import * as DateHelpers from '../../../utils/date_stuff/date_helpers'
interface StoreProps {
readonly pacePlan: PacePlan
readonly disabledDaysOfWeek: number[]
readonly blackoutDates: BlackoutDate[]
readonly planCompleted: boolean
}
interface DispatchProps {
readonly setEndDate: typeof actions.setEndDate
}
type ComponentProps = StoreProps & DispatchProps
export class EndDateSelector extends React.Component<ComponentProps> {
/* Callbacks */
onDateChange = (rawValue: string) => {
this.props.setEndDate(rawValue)
}
isDayDisabled = (date: moment.Moment) => {
return (
date < moment(this.props.pacePlan.start_date) ||
DateHelpers.inBlackoutDate(date, this.props.blackoutDates)
)
}
disabled() {
return (
this.props.planCompleted ||
(this.props.pacePlan.context_type === 'Enrollment' && this.props.pacePlan.hard_end_dates)
)
}
/* Renderers */
render() {
return (
<PacePlanDateInput
id="end-date"
disabled={this.disabled()}
dateValue={this.props.pacePlan.end_date}
onDateChange={this.onDateChange}
disabledDaysOfWeek={this.props.disabledDaysOfWeek}
disabledDays={this.isDayDisabled}
label="End Date"
width="149px"
/>
)
}
}
const mapStateToProps = (state: StoreState): StoreProps => {
return {
pacePlan: getPacePlan(state),
disabledDaysOfWeek: getDisabledDaysOfWeek(state),
blackoutDates: getBlackoutDates(state),
planCompleted: isPlanCompleted(state)
}
}
export default connect(mapStateToProps, {setEndDate: actions.setEndDate} as DispatchProps)(
EndDateSelector
)

View File

@ -0,0 +1,212 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {connect} from 'react-redux'
import {Button, CloseButton} from '@instructure/ui-buttons'
import {Checkbox} from '@instructure/ui-checkbox'
import {IconWarningSolid} from '@instructure/ui-icons'
import {Flex} from '@instructure/ui-flex'
import {Modal} from '@instructure/ui-modal'
import {Text} from '@instructure/ui-text'
import {View} from '@instructure/ui-view'
import {StoreState, PacePlan} from '../../../types'
import {getPacePlan, getExcludeWeekends, isPlanCompleted} from '../../../reducers/pace_plans'
import {getDivideIntoWeeks} from '../../../reducers/ui'
import {actions as uiActions} from '../../../actions/ui'
import {autoSavingActions as pacePlanActions} from '../../../actions/pace_plans'
import DaysSelector from './days_selector'
import WeeksSelector from './weeks_selector'
import StartDateSelector from './start_date_selector'
import EndDateSelector from './end_date_selector'
import {Heading} from '@instructure/ui-heading'
const CheckboxWrapper = ({children}) => <View maxWidth="10.5rem">{children}</View>
interface StoreProps {
readonly pacePlan: PacePlan
readonly ignoreWeeks: boolean
readonly excludeWeekends: boolean
readonly planCompleted: boolean
readonly enrollmentHardEndDatePlan: boolean
}
interface DispatchProps {
readonly toggleDivideIntoWeeks: typeof uiActions.toggleDivideIntoWeeks
readonly toggleExcludeWeekends: typeof pacePlanActions.toggleExcludeWeekends
readonly toggleHardEndDates: typeof pacePlanActions.toggleHardEndDates
readonly setAdjustingHardEndDatesAfter: typeof uiActions.setAdjustingHardEndDatesAfter
}
type ComponentProps = StoreProps & DispatchProps
interface LocalState {
readonly showHardEndDateModal: boolean
}
export class PlanLengthPicker extends React.Component<ComponentProps, LocalState> {
/* Lifecycle */
constructor(props: ComponentProps) {
super(props)
this.state = {showHardEndDateModal: false}
}
/* Callbacks */
toggleExcludeWeekends = () => {
let saveParams = {}
if (this.props.enrollmentHardEndDatePlan) {
saveParams = {compress_items_after: 0}
this.props.setAdjustingHardEndDatesAfter(-1) // Set this to -1 so that all date inputs are disabled
}
this.props.toggleExcludeWeekends(saveParams)
}
toggleHardEndDates = () => {
this.props.toggleHardEndDates()
this.setState({showHardEndDateModal: false})
}
/* Renderers */
hardEndDatesModalBodyText = () => {
if (this.props.pacePlan.hard_end_dates) {
return `
Are you sure you want to remove the requirement that students complete the course by a specified
end date? Due dates will be calculated from the student plan start dates.
`
} else {
return `
Are you sure you want to require that students complete the course by a specified end date?
Due dates will be weighted towards the end of the course.
`
}
}
render() {
return (
<Flex width="32.5rem" alignItems="center">
<View width="100%">
<Flex justifyItems="space-between">
<div>
<StartDateSelector />
</div>
<div>
<EndDateSelector />
</div>
<div>
<WeeksSelector />
</div>
<div>
<DaysSelector />
</div>
</Flex>
<Flex alignItems="start" justifyItems="space-between" margin="medium 0 0">
<CheckboxWrapper>
<Checkbox
label="Skip Weekends"
checked={this.props.excludeWeekends}
onChange={this.toggleExcludeWeekends}
disabled={this.props.planCompleted}
/>
</CheckboxWrapper>
<CheckboxWrapper>
<Checkbox
label="Require Completion by Specified End Date"
checked={this.props.pacePlan.hard_end_dates}
onChange={() => this.setState({showHardEndDateModal: true})}
disabled={this.props.planCompleted || !this.props.pacePlan.end_date}
/>
</CheckboxWrapper>
<CheckboxWrapper>
<Checkbox
label="Divide into Weeks"
checked={this.props.ignoreWeeks}
onChange={this.props.toggleDivideIntoWeeks}
disabled={this.props.planCompleted}
/>
</CheckboxWrapper>
</Flex>
</View>
<Modal
open={this.state.showHardEndDateModal}
onDismiss={() => this.setState({showHardEndDateModal: false})}
label="Are you sure?"
shouldCloseOnDocumentClick
>
<Modal.Header>
<CloseButton
placement="end"
offset="medium"
variant="icon"
onClick={() => this.setState({showHardEndDateModal: false})}
>
Close
</CloseButton>
<Heading>Are you sure?</Heading>
</Modal.Header>
<Modal.Body>
<View as="div" width="28rem" margin="0 0 medium">
<Text>{this.hardEndDatesModalBodyText()}</Text>
</View>
<View as="div">
<View margin="0 small 0 0">
<IconWarningSolid color="warning" margin="0 small 0 0" />
</View>
<Text>Previously entered due dates may be impacted.</Text>
</View>
</Modal.Body>
<Modal.Footer>
<Button color="secondary" onClick={() => this.setState({showHardEndDateModal: false})}>
Cancel
</Button>
&nbsp;
<Button color="primary" onClick={this.toggleHardEndDates}>
Yes, I confirm.
</Button>
</Modal.Footer>
</Modal>
</Flex>
)
}
}
const mapStateToProps = (state: StoreState): StoreProps => {
const pacePlan = getPacePlan(state)
return {
pacePlan,
ignoreWeeks: getDivideIntoWeeks(state),
excludeWeekends: getExcludeWeekends(state),
planCompleted: isPlanCompleted(state),
enrollmentHardEndDatePlan: !!(pacePlan.hard_end_dates && pacePlan.context_type === 'Enrollment')
}
}
export default connect(mapStateToProps, {
toggleDivideIntoWeeks: uiActions.toggleDivideIntoWeeks,
toggleExcludeWeekends: pacePlanActions.toggleExcludeWeekends,
toggleHardEndDates: pacePlanActions.toggleHardEndDates,
setAdjustingHardEndDatesAfter: uiActions.setAdjustingHardEndDatesAfter
})(PlanLengthPicker)

View File

@ -0,0 +1,90 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {connect} from 'react-redux'
import moment from 'moment-timezone'
import {autoSavingActions as actions} from '../../../actions/pace_plans'
import PacePlanDateInput from '../../../shared/components/pace_plan_date_input'
import {StoreState, PacePlan} from '../../../types'
import {BlackoutDate} from '../../../shared/types'
import {getPacePlan, getDisabledDaysOfWeek, isPlanCompleted} from '../../../reducers/pace_plans'
import {getBlackoutDates} from '../../../shared/reducers/blackout_dates'
import * as DateHelpers from '../../../utils/date_stuff/date_helpers'
interface StoreProps {
readonly pacePlan: PacePlan
readonly disabledDaysOfWeek: number[]
readonly blackoutDates: BlackoutDate[]
readonly planCompleted: boolean
}
interface DispatchProps {
readonly setStartDate: typeof actions.setStartDate
}
type ComponentProps = StoreProps & DispatchProps
export class StartDateSelector extends React.Component<ComponentProps> {
onDateChange = (rawValue: string) => {
this.props.setStartDate(rawValue)
}
isDayDisabled = (date: moment.Moment) => {
return (
date > moment(this.props.pacePlan.end_date) ||
DateHelpers.inBlackoutDate(date, this.props.blackoutDates)
)
}
disabled() {
return (
this.props.planCompleted ||
(this.props.pacePlan.context_type === 'Enrollment' && this.props.pacePlan.hard_end_dates)
)
}
render() {
return (
<PacePlanDateInput
id="start-date"
disabled={this.disabled()}
dateValue={this.props.pacePlan.start_date}
onDateChange={this.onDateChange}
disabledDaysOfWeek={this.props.disabledDaysOfWeek}
disabledDays={this.isDayDisabled}
label="Start Date"
width="149px"
/>
)
}
}
const mapStateToProps = (state: StoreState): StoreProps => {
return {
pacePlan: getPacePlan(state),
disabledDaysOfWeek: getDisabledDaysOfWeek(state),
blackoutDates: getBlackoutDates(state),
planCompleted: isPlanCompleted(state)
}
}
export default connect(mapStateToProps, {setStartDate: actions.setStartDate} as DispatchProps)(
StartDateSelector
)

View File

@ -0,0 +1,156 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {connect} from 'react-redux'
import {NumberInput} from '@instructure/ui-number-input'
import {StoreState, PacePlan} from '../../../types'
import {BlackoutDate} from '../../../shared/types'
import {
getPlanDays,
getPlanWeeks,
getPacePlan,
getWeekLength,
isPlanCompleted
} from '../../../reducers/pace_plans'
import {autoSavingActions as actions} from '../../../actions/pace_plans'
import {getDivideIntoWeeks, getEditingBlackoutDates} from '../../../reducers/ui'
import {getBlackoutDates} from '../../../shared/reducers/blackout_dates'
interface StoreProps {
readonly planDays: number
readonly planWeeks: number
readonly divideIntoWeeks: boolean
readonly pacePlan: PacePlan
readonly weekLength: number
readonly blackoutDates: BlackoutDate[]
readonly editingBlackoutDates: boolean
readonly planCompleted: boolean
}
interface DispatchProps {
readonly setPlanDays: typeof actions.setPlanDays
}
type ComponentProps = StoreProps & DispatchProps
interface LocalState {
readonly planWeeks: number | string
}
// This component has to keep a local and Redux version of planDays.
// The local version is needed so that we don't commit to redux (and recalculate)
// until there's a valid value. The DaysSelector and AssignmentRow components
// are following a similar pattern and could maybe be refactored to use the same
// component wrapping around NumberInput.
export class WeeksSelector extends React.Component<ComponentProps, LocalState> {
/* Lifecycle */
constructor(props: ComponentProps) {
super(props)
this.state = {planWeeks: props.planWeeks}
this.commitChanges = this.commitChanges.bind(this)
}
// If blackout dates are being edited, we don't want to try and re-calculate the weeks
// until they've all been saved to the backend, and the plan has been re-loaded.
shouldComponentUpdate(nextProps: ComponentProps) {
return !nextProps.editingBlackoutDates
}
/* Callbacks */
commitChanges = () => {
const weeks =
typeof this.state.planWeeks === 'string'
? parseInt(this.state.planWeeks, 10)
: this.state.planWeeks
if (!Number.isNaN(weeks)) {
const totalNumberOfDays =
weeks * this.props.weekLength + (this.props.planDays % this.props.weekLength)
if (totalNumberOfDays !== this.props.planDays) {
this.props.setPlanDays(totalNumberOfDays, this.props.pacePlan, this.props.blackoutDates)
}
}
}
onChangeNumWeeks = (_e: React.FormEvent<HTMLInputElement>, weeks: string | number) => {
weeks = typeof weeks !== 'number' ? parseInt(weeks, 10) : weeks
if (weeks < 0) return // don't allow negative weeks
const weeksFormatted = Number.isNaN(weeks) ? '' : weeks
this.setState({planWeeks: weeksFormatted}, this.commitChanges)
}
onDecrementOrIncrement = (e: React.FormEvent<HTMLInputElement>, direction: number) => {
const currentWeeks =
typeof this.state.planWeeks === 'string'
? parseInt(this.state.planWeeks, 10)
: this.state.planWeeks
this.onChangeNumWeeks(e, currentWeeks + direction)
}
/* Helpers */
disabled() {
return (
this.props.planCompleted ||
!this.props.divideIntoWeeks ||
(this.props.pacePlan.context_type === 'Enrollment' && this.props.pacePlan.hard_end_dates)
)
}
/* Renderers */
render() {
const weeks = this.props.divideIntoWeeks ? this.state.planWeeks : ''
return (
<NumberInput
renderLabel="Weeks"
width="90px"
value={weeks.toString()}
onDecrement={e => this.onDecrementOrIncrement(e, -1)}
onIncrement={e => this.onDecrementOrIncrement(e, 1)}
onChange={this.onChangeNumWeeks}
interaction={this.disabled() ? 'disabled' : 'enabled'}
/>
)
}
}
const mapStateToProps = (state: StoreState): StoreProps => {
const planWeeks = getPlanWeeks(state)
return {
planDays: getPlanDays(state),
planWeeks,
divideIntoWeeks: getDivideIntoWeeks(state),
pacePlan: getPacePlan(state),
weekLength: getWeekLength(state),
blackoutDates: getBlackoutDates(state),
editingBlackoutDates: getEditingBlackoutDates(state),
planCompleted: isPlanCompleted(state),
// Used to reset the selector to weeks coming from the Redux store when they change there
key: `weeks-selector-${planWeeks}`
}
}
export default connect(mapStateToProps, {setPlanDays: actions.setPlanDays} as DispatchProps)(
WeeksSelector
)

View File

@ -0,0 +1,192 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {connect} from 'react-redux'
import {Flex} from '@instructure/ui-flex'
import {SimpleSelect} from '@instructure/ui-simple-select'
import {
StoreState,
Enrollment,
Sections,
Section,
PacePlan,
PlanContextTypes,
PlanTypes
} from '../../types'
import {Course} from '../../shared/types'
import {getSortedEnrollments} from '../../reducers/enrollments'
import {getSections} from '../../reducers/sections'
import {getCourse} from '../../reducers/course'
import {getPacePlan, getActivePlanContext} from '../../reducers/pace_plans'
import {pacePlanActions} from '../../actions/pace_plans'
import {getSelectedPlanType} from '../../reducers/ui'
// Doing this to avoid TS2339 errors-- remove once we're on InstUI 8
const {Option} = SimpleSelect as any
interface StoreProps {
readonly enrollments: Enrollment[]
readonly sections: Sections
readonly pacePlan: PacePlan
readonly course: Course
readonly selectedPlanType: PlanTypes
readonly activePlanContext: Course | Section | Enrollment
}
interface DispatchProps {
readonly loadLatestPlanByContext: typeof pacePlanActions.loadLatestPlanByContext
}
interface PassedProps {
readonly inline?: boolean
}
type ComponentProps = StoreProps & DispatchProps & PassedProps
interface OptionValue {
readonly id: string
}
export class PlanPicker extends React.Component<ComponentProps> {
/* Helpers */
formatOptionValue = (contextType: PlanContextTypes, contextId: string | number): string => {
return [contextType, contextId].join(':')
}
getSelectedOption = (planType: PlanTypes): string | null => {
const option = this.formatOptionValue(
this.props.pacePlan.context_type,
this.props.pacePlan.context_id
)
return planType === this.props.selectedPlanType ? option : null
}
sortedSectionIds = (): string[] => {
return Object.keys(this.props.sections).sort((a, b) => {
const sectionA: Section = this.props.sections[a]
const sectionB: Section = this.props.sections[b]
if (sectionA.name > sectionB.name) {
return 1
} else if (sectionA.name < sectionB.name) {
return -1
} else {
return 0
}
})
}
/* Callbacks */
onChangePlan = (e: any, value: OptionValue) => {
const valueSplit = value.id.split(':')
const contextType = valueSplit[0] as PlanContextTypes
const contextId = valueSplit[1]
if (
String(this.props.pacePlan.context_id) === contextId &&
this.props.pacePlan.context_type === contextType
) {
return
}
this.props.loadLatestPlanByContext(contextType, contextId)
}
/* Renderers */
renderSectionOptions = () => {
const options = this.sortedSectionIds().map(sectionId => {
const section: Section = this.props.sections[sectionId]
const value = this.formatOptionValue('Section', section.id)
return (
<Option id={`plan-section-${sectionId}`} key={`plan-section-${sectionId}`} value={value}>
{section.name}
</Option>
)
})
options.unshift(
<Option
id="plan-primary"
key="plan-primary"
value={this.formatOptionValue('Course', this.props.course.id)}
>
Master Plan
</Option>
)
return options
}
renderEnrollmentOptions = () => {
return this.props.enrollments.map((enrollment: Enrollment) => {
const value = this.formatOptionValue('Enrollment', enrollment.id)
return (
<Option
id={`plan-enrollment-${enrollment.id}`}
key={`plan-enrollment-${enrollment.id}`}
value={value}
>
{enrollment.full_name}
</Option>
)
})
}
renderPlanSelector = () => {
const options =
this.props.selectedPlanType === 'student'
? this.renderEnrollmentOptions()
: this.renderSectionOptions()
return (
<SimpleSelect
isInline={this.props.inline}
renderLabel="Plan"
width="300px"
value={this.getSelectedOption(this.props.selectedPlanType)}
onChange={this.onChangePlan}
>
{options}
</SimpleSelect>
)
}
render() {
return <Flex margin="0 0 small 0">{this.renderPlanSelector()}</Flex>
}
}
const mapStateToProps = (state: StoreState): StoreProps => {
return {
enrollments: getSortedEnrollments(state),
sections: getSections(state),
pacePlan: getPacePlan(state),
course: getCourse(state),
selectedPlanType: getSelectedPlanType(state),
activePlanContext: getActivePlanContext(state)
}
}
export default connect(mapStateToProps, {
loadLatestPlanByContext: pacePlanActions.loadLatestPlanByContext
})(PlanPicker)

View File

@ -0,0 +1,106 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {connect} from 'react-redux'
import {Flex} from '@instructure/ui-flex'
import {actions as uiActions} from '../../actions/ui'
import {StoreState, PlanTypes, Enrollment} from '../../types'
import {getSelectedPlanType} from '../../reducers/ui'
import {getSortedEnrollments} from '../../reducers/enrollments'
interface StoreProps {
readonly selectedPlanType: PlanTypes
readonly enrollments: Enrollment[]
}
interface DispatchProps {
readonly setSelectedPlanType: typeof uiActions.setSelectedPlanType
}
type ComponentProps = StoreProps & DispatchProps
interface ButtonProps {
readonly selected: boolean
readonly disabled?: boolean
}
const buttonStyles = ({selected, disabled}: ButtonProps) => {
return {
width: '107px',
height: '36px',
border: '1px solid #c7cdd1',
fontSize: '14px',
fontWeight: 600,
padding: '0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: selected ? '#73818C' : '#F5F5F5',
color: selected ? '#FFFFFF' : '#394B58',
cursor: disabled ? 'not-allowed' : selected ? 'normal' : 'pointer',
opacity: disabled ? '0.4' : undefined
}
}
export class PlanTypePicker extends React.PureComponent<ComponentProps> {
render() {
return (
<Flex>
<div
role="button"
style={{
...buttonStyles({selected: this.props.selectedPlanType === 'template'}),
borderRadius: '4px 0px 0px 4px'
}}
onClick={() => this.props.setSelectedPlanType('template')}
>
Template Plans
</div>
<div
style={{
...buttonStyles({
selected: this.props.selectedPlanType === 'student',
disabled: this.props.enrollments.length === 0
}),
borderLeft: '0',
borderRadius: '0px 4px 4px 0px'
}}
onClick={() =>
this.props.enrollments.length > 0 &&
this.props.setSelectedPlanType('student', this.props.enrollments[0].id)
}
>
Student Plans
</div>
</Flex>
)
}
}
const mapDispatchToProps = (state: StoreState): StoreProps => {
return {
selectedPlanType: getSelectedPlanType(state),
enrollments: getSortedEnrollments(state)
}
}
export default connect(mapDispatchToProps, {setSelectedPlanType: uiActions.setSelectedPlanType})(
PlanTypePicker
)

View File

@ -0,0 +1,96 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {connect} from 'react-redux'
import {StoreState} from '../../../types'
import {BlackoutDate} from '../../../shared/types'
import {getBlackoutDates} from '../../../shared/reducers/blackout_dates'
import {actions} from '../../../shared/actions/blackout_dates'
import {getCourse} from '../../../reducers/course'
import {BlackoutDatesTable} from '../../../shared/components/blackout_dates_table'
import NewBlackoutDatesForm from '../../../shared/components/new_blackout_dates_form'
interface PassedProps {
readonly onChange?: () => any
}
interface StoreProps {
readonly blackoutDates: BlackoutDate[]
readonly courseId: number | string
}
interface DispatchProps {
readonly addBlackoutDate: typeof actions.addBlackoutDate
readonly deleteBlackoutDate: typeof actions.deleteBlackoutDate
}
type ComponentProps = PassedProps & StoreProps & DispatchProps
export class BlackoutDates extends React.Component<ComponentProps> {
/* Callbacks */
addBlackoutDate = (blackoutDate: BlackoutDate) => {
this.props.addBlackoutDate({
...blackoutDate,
course_id: this.props.courseId
})
this.bubbleChangeUp()
}
deleteBlackoutDate = (blackoutDate: BlackoutDate) => {
if (blackoutDate.id) {
this.bubbleChangeUp()
this.props.deleteBlackoutDate(blackoutDate.id)
}
}
bubbleChangeUp = () => {
if (this.props.onChange) {
this.props.onChange()
}
}
/* Renderers */
render() {
return (
<div>
<NewBlackoutDatesForm addBlackoutDate={this.addBlackoutDate} />
<BlackoutDatesTable
displayType="course"
blackoutDates={this.props.blackoutDates}
deleteBlackoutDate={this.deleteBlackoutDate}
/>
</div>
)
}
}
const mapStateToProps = (state: StoreState): StoreProps => {
return {
blackoutDates: getBlackoutDates(state),
courseId: getCourse(state).id
}
}
export default connect(mapStateToProps, {
addBlackoutDate: actions.addBlackoutDate,
deleteBlackoutDate: actions.deleteBlackoutDate
})(BlackoutDates)

View File

@ -0,0 +1,167 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {connect} from 'react-redux'
import {Button, CloseButton, IconButton} from '@instructure/ui-buttons'
import {Heading} from '@instructure/ui-heading'
import {IconSettingsLine} from '@instructure/ui-icons'
import {Modal} from '@instructure/ui-modal'
import {View} from '@instructure/ui-view'
import BlackoutDates from './blackout_dates'
import * as PacePlanApi from '../../../api/pace_plan_api'
import {StoreState, PacePlan} from '../../../types'
import {getCourse} from '../../../reducers/course'
import {getPacePlan} from '../../../reducers/pace_plans'
import {pacePlanActions} from '../../../actions/pace_plans'
import {actions as uiActions} from '../../../actions/ui'
import UpdateExistingPlansModal from '../../../shared/components/update_existing_plans_modal'
interface StoreProps {
readonly courseId: number | string
readonly pacePlan: PacePlan
}
interface DispatchProps {
readonly loadLatestPlanByContext: typeof pacePlanActions.loadLatestPlanByContext
readonly setEditingBlackoutDates: typeof uiActions.setEditingBlackoutDates
readonly showLoadingOverlay: typeof uiActions.showLoadingOverlay
}
type ComponentProps = StoreProps & DispatchProps
interface LocalState {
readonly showBlackoutDatesModal: boolean
readonly showUpdateExistingPlansModal: boolean
readonly changeMadeToBlackoutDates: boolean
}
export class Settings extends React.Component<ComponentProps, LocalState> {
/* Lifecycle */
constructor(props: ComponentProps) {
super(props)
this.state = {
showBlackoutDatesModal: false,
showUpdateExistingPlansModal: false,
changeMadeToBlackoutDates: false
}
}
/* Callbacks */
showBlackoutDatesModal = () => {
this.setState({showBlackoutDatesModal: true})
this.props.setEditingBlackoutDates(true)
}
republishAllPlans = () => {
this.props.showLoadingOverlay('Publishing...')
PacePlanApi.republishAllPlansForCourse(this.props.courseId).then(
this.onCloseUpdateExistingPlansModal
)
}
onCloseBlackoutDatesModal = () => {
this.setState({
showBlackoutDatesModal: false,
showUpdateExistingPlansModal: this.state.changeMadeToBlackoutDates,
changeMadeToBlackoutDates: false
})
if (!this.state.changeMadeToBlackoutDates) {
this.props.setEditingBlackoutDates(false)
}
}
onCloseUpdateExistingPlansModal = async () => {
this.setState({showUpdateExistingPlansModal: false})
await this.props.loadLatestPlanByContext(
this.props.pacePlan.context_type,
this.props.pacePlan.context_id
)
this.props.setEditingBlackoutDates(false)
}
/* Renderers */
renderBlackoutDatesModal() {
return (
<Modal
open={this.state.showBlackoutDatesModal}
onDismiss={() => this.setState({showBlackoutDatesModal: false})}
label="Blackout Dates"
shouldCloseOnDocumentClick
>
<Modal.Header>
<CloseButton
placement="end"
offset="medium"
variant="icon"
onClick={this.onCloseBlackoutDatesModal}
>
Close
</CloseButton>
<Heading>Blackout Dates</Heading>
</Modal.Header>
<Modal.Body>
<View as="div" width="36rem">
<BlackoutDates onChange={() => this.setState({changeMadeToBlackoutDates: true})} />
</View>
</Modal.Body>
<Modal.Footer>
<Button color="secondary" onClick={this.onCloseBlackoutDatesModal}>
Close
</Button>
&nbsp;
</Modal.Footer>
</Modal>
)
}
render() {
return (
<div>
{this.renderBlackoutDatesModal()}
<UpdateExistingPlansModal
open={this.state.showUpdateExistingPlansModal}
onDismiss={this.onCloseUpdateExistingPlansModal}
confirm={this.republishAllPlans}
/>
<IconButton onClick={this.showBlackoutDatesModal} screenReaderLabel="Settings">
<IconSettingsLine />
</IconButton>
</div>
)
}
}
const mapStateToProps = (state: StoreState): StoreProps => {
return {
courseId: getCourse(state).id,
pacePlan: getPacePlan(state)
}
}
export default connect(mapStateToProps, {
loadLatestPlanByContext: pacePlanActions.loadLatestPlanByContext,
setEditingBlackoutDates: uiActions.setEditingBlackoutDates,
showLoadingOverlay: uiActions.showLoadingOverlay
})(Settings)

View File

@ -0,0 +1,399 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {connect} from 'react-redux'
import {debounce} from 'lodash'
import {Flex} from '@instructure/ui-flex'
import {
IconAssignmentLine,
IconDiscussionLine,
IconPublishSolid,
IconQuizLine,
IconUnpublishedLine
} from '@instructure/ui-icons'
import {NumberInput} from '@instructure/ui-number-input'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {Text} from '@instructure/ui-text'
import {TruncateText} from '@instructure/ui-truncate-text'
import {View} from '@instructure/ui-view'
import moment from 'moment-timezone'
import {PacePlanItem, PacePlan, StoreState, Enrollment, Section} from '../../types'
import {BlackoutDate, Course} from '../../shared/types'
import {
getPacePlan,
getDueDate,
getExcludeWeekends,
getPacePlanItems,
getPacePlanItemPosition,
isPlanCompleted,
getActivePlanContext,
getDisabledDaysOfWeek
} from '../../reducers/pace_plans'
import {autoSavingActions as actions} from '../../actions/pace_plan_items'
import {actions as uiActions} from '../../actions/ui'
import PacePlanDateInput from '../../shared/components/pace_plan_date_input'
import * as DateHelpers from '../../utils/date_stuff/date_helpers'
import {getAutoSaving, getAdjustingHardEndDatesAfter} from '../../reducers/ui'
import {getBlackoutDates} from '../../shared/reducers/blackout_dates'
interface PassedProps {
readonly pacePlanItem: PacePlanItem
}
interface StoreProps {
readonly pacePlan: PacePlan
readonly dueDate: string
readonly excludeWeekends: boolean
readonly pacePlanItems: PacePlanItem[]
readonly pacePlanItemPosition: number
readonly blackoutDates: BlackoutDate[]
readonly planCompleted: boolean
readonly autosaving: boolean
readonly enrollmentHardEndDatePlan: boolean
readonly adjustingHardEndDatesAfter?: number
readonly activePlanContext: Course | Enrollment | Section
readonly disabledDaysOfWeek: number[]
}
interface DispatchProps {
readonly setPlanItemDuration: typeof actions.setPlanItemDuration
readonly setAdjustingHardEndDatesAfter: typeof uiActions.setAdjustingHardEndDatesAfter
}
interface LocalState {
readonly duration: string
readonly hovering: boolean
}
type ComponentProps = PassedProps & StoreProps & DispatchProps
export const ColumnWrapper = ({children, center = false, ...props}) => {
const alignment = center ? 'center' : 'start'
return (
<Flex alignItems={alignment} justifyItems={alignment} margin="0 small" {...props}>
{children}
</Flex>
)
}
export const COLUMN_WIDTHS = {
DURATION: 90,
DATE: 150,
STATUS: 45
}
export class AssignmentRow extends React.Component<ComponentProps, LocalState> {
state: LocalState = {
duration: String(this.props.pacePlanItem.duration),
hovering: false
}
private debouncedCommitChanges: any
/* Component lifecycle */
constructor(props: ComponentProps) {
super(props)
this.debouncedCommitChanges = debounce(this.commitChanges, 300, {
leading: false,
trailing: true
})
}
shouldComponentUpdate(nextProps: ComponentProps, nextState: LocalState) {
return (
nextProps.dueDate !== this.props.dueDate ||
nextProps.adjustingHardEndDatesAfter !== this.props.adjustingHardEndDatesAfter ||
nextState.duration !== this.state.duration ||
nextState.hovering !== this.state.hovering ||
nextProps.pacePlan.exclude_weekends !== this.props.pacePlan.exclude_weekends ||
nextProps.pacePlan.context_type !== this.props.pacePlan.context_type ||
(nextProps.pacePlan.context_type === this.props.pacePlan.context_type &&
nextProps.pacePlan.context_id !== this.props.pacePlan.context_id)
)
}
/* Helpers */
newDuration = (newDueDate: string | moment.Moment) => {
const daysDiff = DateHelpers.daysBetween(
this.props.dueDate,
newDueDate,
this.props.excludeWeekends,
this.props.blackoutDates,
false
)
return parseInt(this.state.duration, 10) + daysDiff
}
dateInputIsDisabled = (): boolean => {
return (
this.props.planCompleted ||
(this.props.enrollmentHardEndDatePlan && !!this.props.adjustingHardEndDatesAfter)
)
}
parsePositiveNumber = (value?: string): number | false => {
if (typeof value !== 'string') return false
try {
const parsedInt = parseInt(value, 10)
if (parsedInt >= 0) return parsedInt
} catch (err) {
return false
}
return false
}
/* Callbacks */
onChangeItemDuration = (_e: React.FormEvent<HTMLInputElement>, value: string) => {
if (value === '') {
this.setState({duration: ''})
return
}
const duration = this.parsePositiveNumber(value)
if (duration !== false) {
this.setState({duration: duration.toString()})
}
}
onDecrementOrIncrement = (_e: React.FormEvent<HTMLInputElement>, direction: number) => {
const newValue = (this.parsePositiveNumber(this.state.duration) || 0) + direction
if (newValue < 0) return
this.setState({duration: newValue.toString()})
this.debouncedCommitChanges()
}
onBlur = (e: React.FormEvent<HTMLInputElement>) => {
const value = (e.currentTarget?.value || '') === '' ? '0' : e.currentTarget.value
const duration = this.parsePositiveNumber(value)
if (duration !== false) {
this.setState({duration: duration.toString()}, () => {
this.commitChanges()
})
}
}
onDateChange = (isoValue: string) => {
// Get rid of the timezone because we're only dealing with exact days and not timezones currently
const newDuration = this.newDuration(DateHelpers.stripTimezone(isoValue))
// If the date hasn't changed, we should force an update, which will reset the DateInput if they
// user entered invalid data.
const shouldForceUpdate = String(newDuration) === this.state.duration
this.setState({duration: newDuration.toString()}, () => {
this.commitChanges()
if (shouldForceUpdate) {
this.forceUpdate()
}
})
}
// Values are stored in local state while editing, and is then debounced
// to commit the change to redux.
commitChanges = () => {
const duration = parseInt(this.state.duration, 10)
if (!Number.isNaN(duration)) {
let saveParams = {}
// If this is a student Hard End Date plan then we should recompress
// all items AFTER the modified item, so that we still hit the specified
// end date.
if (this.props.enrollmentHardEndDatePlan && duration !== this.props.pacePlanItem.duration) {
saveParams = {compress_items_after: this.props.pacePlanItemPosition}
this.props.setAdjustingHardEndDatesAfter(this.props.pacePlanItemPosition)
}
this.props.setPlanItemDuration(this.props.pacePlanItem.id, duration, saveParams)
}
}
isDayDisabled = (date: moment.Moment): boolean => {
return (
this.newDuration(date) < 0 ||
DateHelpers.inBlackoutDate(date, this.props.blackoutDates) ||
(this.props.enrollmentHardEndDatePlan && date > moment(this.props.pacePlan.end_date))
)
}
/* Renderers */
renderAssignmentIcon = () => {
const size = '20px'
const color = this.props.pacePlanItem.published ? '#4AA937' : '#75808B'
switch (this.props.pacePlanItem.module_item_type) {
case 'Assignment':
return <IconAssignmentLine width={size} height={size} style={{color}} />
case 'Quizzes::Quiz':
return <IconQuizLine width={size} height={size} style={{color}} />
case 'Quiz':
return <IconQuizLine width={size} height={size} style={{color}} />
case 'DiscussionTopic':
return <IconDiscussionLine width={size} height={size} style={{color}} />
case 'Discussion':
return <IconDiscussionLine width={size} height={size} style={{color}} />
default:
return <IconAssignmentLine width={size} height={size} style={{color}} />
}
}
renderPublishStatusBadge = () => {
return this.props.pacePlanItem.published ? (
<IconPublishSolid color="success" size="x-small" />
) : (
<IconUnpublishedLine size="x-small" />
)
}
renderDurationInput = () => {
if (this.props.enrollmentHardEndDatePlan) {
return null
} else {
const value = this.state.duration
return (
<ColumnWrapper center>
<NumberInput
interaction={this.props.planCompleted ? 'disabled' : 'enabled'}
renderLabel={
<ScreenReaderContent>
Duration for module {this.props.pacePlanItem.assignment_title}
</ScreenReaderContent>
}
display="inline-block"
width={`${COLUMN_WIDTHS.DURATION}px`}
value={value}
onChange={this.onChangeItemDuration}
onBlur={this.onBlur}
onDecrement={e => this.onDecrementOrIncrement(e, -1)}
onIncrement={e => this.onDecrementOrIncrement(e, 1)}
/>
</ColumnWrapper>
)
}
}
renderDateInput = () => {
if (
this.props.adjustingHardEndDatesAfter !== undefined &&
this.props.pacePlanItemPosition > this.props.adjustingHardEndDatesAfter
) {
return <View width={`${COLUMN_WIDTHS.DATE}px`}>Adjusting due date...</View>
} else {
return (
<PacePlanDateInput
id={String(this.props.pacePlanItem.id)}
disabled={this.dateInputIsDisabled()}
dateValue={this.props.dueDate}
onDateChange={this.onDateChange}
disabledDaysOfWeek={this.props.disabledDaysOfWeek}
disabledDays={this.isDayDisabled}
width={`${COLUMN_WIDTHS.DATE}px`}
label={
<ScreenReaderContent>
Due Date for module {this.props.pacePlanItem.assignment_title}
</ScreenReaderContent>
}
/>
)
}
}
renderBody() {
return (
<Flex height="100%" width="100%" alignItems="center" justifyItems="space-between">
<Flex alignItems="center" justifyItems="center">
<View margin="0 x-small 0 0">{this.renderAssignmentIcon()}</View>
<Text weight="bold">
<TruncateText>{this.props.pacePlanItem.assignment_title}</TruncateText>
</Text>
</Flex>
<Flex alignItems="center" justifyItems="space-between">
{this.renderDurationInput()}
<ColumnWrapper center>{this.renderDateInput()}</ColumnWrapper>
<ColumnWrapper center width={`${COLUMN_WIDTHS.STATUS}px`}>
{this.renderPublishStatusBadge()}
</ColumnWrapper>
</Flex>
</Flex>
)
}
render() {
const hoverProps = this.state.hovering
? {
background: 'secondary',
borderColor: 'brand',
borderWidth: '0 0 0 large',
padding: 'x-small small'
}
: {padding: 'x-small small x-small medium'}
return (
<View
as="div"
borderWidth="0 small small"
borderRadius="none"
onMouseEnter={() => this.setState({hovering: true})}
onMouseLeave={() => this.setState({hovering: false})}
>
<View
as="div"
{...hoverProps}
theme={{
backgroundSecondary: '#eef7ff',
paddingMedium: '1rem'
}}
>
{this.renderBody()}
</View>
</View>
)
}
}
const mapStateToProps = (state: StoreState, props: PassedProps): StoreProps => {
const pacePlan = getPacePlan(state)
return {
pacePlan,
dueDate: getDueDate(state, props),
excludeWeekends: getExcludeWeekends(state),
pacePlanItems: getPacePlanItems(state),
pacePlanItemPosition: getPacePlanItemPosition(state, props),
blackoutDates: getBlackoutDates(state),
planCompleted: isPlanCompleted(state),
autosaving: getAutoSaving(state),
enrollmentHardEndDatePlan: !!(
pacePlan.hard_end_dates && pacePlan.context_type === 'Enrollment'
),
adjustingHardEndDatesAfter: getAdjustingHardEndDatesAfter(state),
activePlanContext: getActivePlanContext(state),
disabledDaysOfWeek: getDisabledDaysOfWeek(state)
}
}
export default connect(mapStateToProps, {
setPlanItemDuration: actions.setPlanItemDuration,
setAdjustingHardEndDatesAfter: uiActions.setAdjustingHardEndDatesAfter
})(AssignmentRow)

View File

@ -0,0 +1,128 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {ApplyTheme} from '@instructure/ui-themeable'
import {Button} from '@instructure/ui-buttons'
import {Flex} from '@instructure/ui-flex'
import {Heading} from '@instructure/ui-heading'
import {IconMiniArrowDownLine, IconMiniArrowRightLine} from '@instructure/ui-icons'
import {Text} from '@instructure/ui-text'
import {ToggleDetails} from '@instructure/ui-toggle-details'
import {View} from '@instructure/ui-view'
import AssignmentRow, {ColumnWrapper, COLUMN_WIDTHS} from './assignment_row'
import {Module as IModule, PacePlan} from '../../types'
interface PassedProps {
readonly module: IModule
readonly index: number
readonly pacePlan: PacePlan
}
interface LocalState {
readonly visible: boolean
}
export class Module extends React.Component<PassedProps, LocalState> {
state: LocalState = {visible: true}
renderArrow = () => {
return this.state.visible ? <IconMiniArrowDownLine /> : <IconMiniArrowRightLine />
}
renderDaysText = () => {
if (this.props.pacePlan.hard_end_dates && this.props.pacePlan.context_type === 'Enrollment') {
return null
} else {
return <Text weight="bold">Days</Text>
}
}
renderModuleDetails = () => {
if (this.state.visible) {
return (
<Flex alignItems="end">
<ColumnWrapper width={COLUMN_WIDTHS.DURATION}>{this.renderDaysText()}</ColumnWrapper>
<ColumnWrapper width={COLUMN_WIDTHS.DATE}>
<Text weight="bold">Due Date</Text>
</ColumnWrapper>
<ColumnWrapper width={COLUMN_WIDTHS.STATUS}>
<Text weight="bold">Status</Text>
</ColumnWrapper>
</Flex>
)
}
}
renderModuleHeader = () => {
return (
<Flex alignItems="center" justifyItems="space-between">
<Heading level="h4" as="h2">
{`${this.props.index}. ${this.props.module.name}`}
</Heading>
{this.renderModuleDetails()}
</Flex>
)
}
render() {
const assignmentRows: JSX.Element[] = this.props.module.items.map(item => {
// Scoping the key to the state of hard_end_dates and the pacePlan id ensures a full re-render of the row if either the hard_end_date
// status changes or the pace plan changes. This is necessary because the AssignmentRow maintains the duration in local state,
// and applying updates with componentWillReceiveProps makes it buggy (because the Redux updates can be slow, causing changes to
// get reverted as you type).
const key = `${item.id}|${this.props.pacePlan.id}|${this.props.pacePlan.hard_end_dates}`
return <AssignmentRow key={key} pacePlanItem={item} />
})
return (
<View margin="medium 0 0">
<ApplyTheme
theme={{
[(Button as any).theme]: {
borderRadius: '0',
mediumPaddingTop: '1rem',
mediumPaddingBottom: '1rem'
}
}}
>
<ToggleDetails
summary={this.renderModuleHeader()}
icon={() => <IconMiniArrowRightLine />}
iconExpanded={() => <IconMiniArrowDownLine />}
onToggle={(_, expanded) => this.setState({visible: expanded})}
variant="filled"
defaultExpanded
size="large"
theme={{
iconMargin: '0.5rem',
filledBorderRadius: '0',
filledPadding: '2rem',
togglePadding: '0'
}}
>
<View as="div">{assignmentRows}</View>
</ToggleDetails>
</ApplyTheme>
</View>
)
}
}
export default Module

View File

@ -0,0 +1,55 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {Text} from '@instructure/ui-text'
import Module from './module'
import {StoreState, PacePlan} from '../../types'
import {connect} from 'react-redux'
import {getPacePlan} from '../../reducers/pace_plans'
interface StoreProps {
readonly pacePlan: PacePlan
}
export const PacePlanTable: React.FC<StoreProps> = props => {
const modules: JSX.Element[] = props.pacePlan.modules.map((module, index) => (
<Module
key={`module-${module.id}`}
index={index + 1}
module={module}
pacePlan={props.pacePlan}
/>
))
return (
<>
<Text weight="bold">Modules</Text>
{modules}
</>
)
}
const mapStateToProps = (state: StoreState) => {
return {
pacePlan: getPacePlan(state)
}
}
export default connect(mapStateToProps)(PacePlanTable)

View File

@ -0,0 +1,35 @@
/*
* Copyright (C) 2021 - 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 {StoreState} from '../types'
import {Course} from '../shared/types'
export const courseInitialState: Course = (window.ENV.COURSE || {}) as Course
/* Selectors */
export const getCourse = (state: StoreState): Course => state.course
/* Reducers */
export const courseReducer = (state = courseInitialState, action: any): Course => {
switch (action.type) {
default:
return state
}
}

View File

@ -0,0 +1,55 @@
/*
* Copyright (C) 2021 - 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 {Enrollments, Enrollment, StoreState} from '../types'
import {createSelector} from 'reselect'
export const enrollmentsInitialState: Enrollments = (window.ENV.ENROLLMENTS || []) as Enrollments
/* Selectors */
export const getEnrollments = (state: StoreState): Enrollments => state.enrollments
export const getEnrollment = (state: StoreState, id: number): Enrollment => state.enrollments[id]
export const getSortedEnrollments = createSelector(
getEnrollments,
(enrollments: Enrollments): Enrollment[] => {
const sortedIds = Object.keys(enrollments).sort((a, b) => {
const enrollmentA: Enrollment = enrollments[a]
const enrollmentB: Enrollment = enrollments[b]
if (enrollmentA.sortable_name > enrollmentB.sortable_name) {
return 1
} else if (enrollmentA.sortable_name < enrollmentB.sortable_name) {
return -1
} else {
return 0
}
})
return sortedIds.map(id => enrollments[id])
}
)
/* Reducers */
export const enrollmentsReducer = (state = enrollmentsInitialState, action: any): Enrollments => {
switch (action.type) {
default:
return state
}
}

View File

@ -0,0 +1,43 @@
/*
* Copyright (C) 2021 - 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 {PacePlanItem, Module} from '../types'
import {Constants as PacePlanItemConstants, PacePlanItemAction} from '../actions/pace_plan_items'
/* Reducers */
const itemsReducer = (state: PacePlanItem[], action: PacePlanItemAction): PacePlanItem[] => {
switch (action.type) {
case PacePlanItemConstants.SET_PLAN_ITEM_DURATION:
return state.map(item => {
return item.id === action.payload.planItemId
? {...item, duration: action.payload.duration}
: item
})
default:
return state
}
}
// Modules are read-only currently, so this is just deferring to the itemsReducer for
// each module's item.
export default (state: Module[], action: PacePlanItemAction): Module[] => {
return state.map(module => {
return {...module, items: itemsReducer(module.items, action)}
})
}

View File

@ -0,0 +1,231 @@
/*
* Copyright (C) 2021 - 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 {createSelector, createSelectorCreator, defaultMemoize} from 'reselect'
import equal from 'fast-deep-equal'
import {Constants as PacePlanConstants, PacePlanAction} from '../actions/pace_plans'
import pacePlanItemsReducer from './pace_plan_items'
import * as DateHelpers from '../utils/date_stuff/date_helpers'
import * as PlanDueDatesCalculator from '../utils/date_stuff/plan_due_dates_calculator'
import {weekendIntegers} from '../shared/api/backend_serializer'
import {
PacePlansState,
PacePlan,
StoreState,
PacePlanItem,
PacePlanItemDueDates,
Enrollment,
Sections,
Enrollments,
Section
} from '../types'
import {BlackoutDate, Course} from '../shared/types'
import {Constants as UIConstants, SetSelectedPlanType} from '../actions/ui'
import {getCourse} from './course'
import {getEnrollments} from './enrollments'
import {getSections} from './sections'
import {getBlackoutDates} from '../shared/reducers/blackout_dates'
export const initialState: PacePlansState = (window.ENV.PACE_PLAN || {}) as PacePlansState
/* Selectors */
// Uses the lodash isEqual function to do a deep comparison for selectors created with
// this selector creator. This allows values to still be memoized when one of the arguments
// is some sort of nexted object, where the default memoization function will return a false
// equality check. See: https://github.com/reduxjs/reselect#createselectorinputselectors--inputselectors-resultfunc
// The memoization equality check is potentially slower, but if the selector itself is computing
// some complex data, it will ultimately be better to use this, otherwise you'll get unnecessary
// calculations.
const createDeepEqualSelector = createSelectorCreator(defaultMemoize, equal)
export const getPacePlan = (state: StoreState): PacePlan => state.pacePlan
export const getExcludeWeekends = (state: StoreState): boolean => state.pacePlan.exclude_weekends
export const getStartDate = (state: StoreState): string => state.pacePlan.start_date
export const getPacePlanItems = createSelector(
getPacePlan,
(pacePlan: PacePlan): PacePlanItem[] => {
const pacePlanItems: PacePlanItem[] = []
pacePlan.modules.forEach(module => {
module.items.forEach(item => pacePlanItems.push(item))
})
return pacePlanItems
}
)
export const getPacePlanItemPosition = createDeepEqualSelector(
getPacePlanItems,
(_, props): PacePlanItem => props.pacePlanItem,
(pacePlanItems: PacePlanItem[], pacePlanItem: PacePlanItem): number => {
let position = 0
for (let i = 0; i < pacePlanItems.length; i++) {
position = i
if (pacePlanItems[i].id === pacePlanItem.id) {
break
}
}
return position
}
)
export const getPlanDays = createDeepEqualSelector(
getPacePlan,
getExcludeWeekends,
getBlackoutDates,
(pacePlan: PacePlan, excludeWeekends: boolean, blackoutDates: BlackoutDate[]): number => {
if (!pacePlan.end_date || !pacePlan.start_date) {
return 0
}
return DateHelpers.daysBetween(
pacePlan.start_date,
pacePlan.end_date,
excludeWeekends,
blackoutDates
)
}
)
export const getPlanWeeks = createSelector(
getPlanDays,
getExcludeWeekends,
(planDays: number, excludeWeekends: boolean): number => {
const weekLength = excludeWeekends ? 5 : 7
return Math.floor(planDays / weekLength)
}
)
export const getWeekLength = createSelector(
getExcludeWeekends,
(excludeWeekends: boolean): number => {
return excludeWeekends ? 5 : 7
}
)
// Wrapping this in a selector makes sure the result is memoized
export const getDueDates = createDeepEqualSelector(
getPacePlanItems,
getStartDate,
getExcludeWeekends,
getBlackoutDates,
(
items: PacePlanItem[],
startDate: string,
excludeWeekends: boolean,
blackoutDates: BlackoutDate[]
): PacePlanItemDueDates => {
return PlanDueDatesCalculator.getDueDates(items, startDate, excludeWeekends, blackoutDates)
}
)
export const getDueDate = createSelector(
getDueDates,
(_, props): PacePlanItem => props.pacePlanItem,
(dueDates: PacePlanItemDueDates, pacePlanItem: PacePlanItem): string => {
return dueDates[pacePlanItem.id]
}
)
export const getActivePlanContext = createSelector(
getPacePlan,
getCourse,
getEnrollments,
getSections,
(
activePacePlan: PacePlan,
course: Course,
enrollments: Enrollments,
sections: Sections
): Course | Section | Enrollment => {
switch (activePacePlan.context_type) {
case 'Section':
return sections[activePacePlan.context_id]
case 'Enrollment':
return enrollments[activePacePlan.context_id]
default:
return course
}
}
)
export const isPlanCompleted = createSelector(
getPacePlan,
getActivePlanContext,
(pacePlan: PacePlan, context: Course | Section | Enrollment): boolean => {
if (pacePlan.context_type !== 'Enrollment') {
return false
} else {
return !!(context as Enrollment).completed_pace_plan_at
}
}
)
export const getDisabledDaysOfWeek = createSelector(
getExcludeWeekends,
(excludeWeekends: boolean): number[] => {
return excludeWeekends ? weekendIntegers : []
}
)
/* Reducers */
export default (
state = initialState,
action: PacePlanAction | SetSelectedPlanType
): PacePlansState => {
switch (action.type) {
case PacePlanConstants.SET_PACE_PLAN:
return action.payload
case PacePlanConstants.SET_START_DATE:
return {...state, start_date: DateHelpers.formatDate(action.payload)}
case PacePlanConstants.SET_END_DATE:
return {...state, end_date: DateHelpers.formatDate(action.payload)}
case PacePlanConstants.SET_UNPUBLISHED_CHANGES:
return {...state, unpublished_changes: action.payload}
case PacePlanConstants.PLAN_CREATED:
// Could use a *REFACTOR* to better handle new plans and updating the ui properly
return {
...state,
id: action.payload.id,
modules: action.payload.modules,
published_at: action.payload.published_at
}
case UIConstants.SET_SELECTED_PLAN_TYPE:
return action.payload.newSelectedPlan
case PacePlanConstants.TOGGLE_EXCLUDE_WEEKENDS:
if (state.exclude_weekends) {
return {...state, exclude_weekends: false}
} else {
return {
...state,
exclude_weekends: true,
start_date: DateHelpers.adjustDateOnSkipWeekends(state.start_date),
end_date: DateHelpers.adjustDateOnSkipWeekends(state.end_date)
}
}
case PacePlanConstants.TOGGLE_HARD_END_DATES:
return {...state, hard_end_dates: !state.hard_end_dates}
case PacePlanConstants.SET_LINKED_TO_PARENT:
return {...state, linked_to_parent: action.payload}
default:
return {...state, modules: pacePlanItemsReducer(state.modules, action)}
}
}

View File

@ -0,0 +1,36 @@
/*
* Copyright (C) 2021 - 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 {combineReducers} from 'redux'
import {StoreState} from '../types'
import pacePlansReducer from './pace_plans'
import {courseReducer} from './course'
import {sectionsReducer} from './sections'
import {enrollmentsReducer} from './enrollments'
import {blackoutDatesReducer} from '../shared/reducers/blackout_dates'
import uiReducer from './ui'
export default combineReducers<StoreState>({
pacePlan: pacePlansReducer,
enrollments: enrollmentsReducer,
sections: sectionsReducer,
ui: uiReducer,
course: courseReducer,
blackoutDates: blackoutDatesReducer
})

View File

@ -0,0 +1,35 @@
/*
* Copyright (C) 2021 - 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 {Sections, Section, StoreState} from '../types'
export const sectionsInitialState: Sections = (window.ENV.SECTIONS || []) as Sections
/* Selectors */
export const getSections = (state: StoreState): Sections => state.sections
export const getSection = (state: StoreState, id: number): Section => state.sections[id]
/* Reducers */
export const sectionsReducer = (state = sectionsInitialState, action: any): Sections => {
switch (action.type) {
default:
return state
}
}

View File

@ -0,0 +1,76 @@
/*
* Copyright (C) 2021 - 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 {UIState, StoreState} from '../types'
import {Constants as UIConstants, UIAction} from '../actions/ui'
export const initialState: UIState = {
autoSaving: false,
errorMessage: '',
divideIntoWeeks: true,
planPublishing: false,
selectedPlanType: 'template',
loadingMessage: '',
showLoadingOverlay: false,
editingBlackoutDates: false,
adjustingHardEndDatesAfter: undefined
}
/* Selectors */
export const getAutoSaving = (state: StoreState) => state.ui.autoSaving
export const getErrorMessage = (state: StoreState) => state.ui.errorMessage
export const getDivideIntoWeeks = (state: StoreState) => state.ui.divideIntoWeeks
export const getPlanPublishing = (state: StoreState) => state.ui.planPublishing
export const getSelectedPlanType = (state: StoreState) => state.ui.selectedPlanType
export const getLoadingMessage = (state: StoreState) => state.ui.loadingMessage
export const getShowLoadingOverlay = (state: StoreState) => state.ui.showLoadingOverlay
export const getEditingBlackoutDates = (state: StoreState) => state.ui.editingBlackoutDates
export const getAdjustingHardEndDatesAfter = (state: StoreState) =>
state.ui.adjustingHardEndDatesAfter
/* Reducers */
export default (state = initialState, action: UIAction): UIState => {
switch (action.type) {
case UIConstants.START_AUTO_SAVING:
return {...state, autoSaving: true}
case UIConstants.AUTO_SAVE_COMPLETED:
return {...state, autoSaving: false, adjustingHardEndDatesAfter: undefined}
case UIConstants.SET_ERROR_MESSAGE:
return {...state, errorMessage: action.payload}
case UIConstants.TOGGLE_DIVIDE_INTO_WEEKS:
return {...state, divideIntoWeeks: !state.divideIntoWeeks}
case UIConstants.PUBLISH_PLAN_STARTED:
return {...state, planPublishing: true}
case UIConstants.PUBLISH_PLAN_FINISHED:
return {...state, planPublishing: false}
case UIConstants.SET_SELECTED_PLAN_TYPE:
return {...state, selectedPlanType: action.payload.planType}
case UIConstants.SHOW_LOADING_OVERLAY:
return {...state, showLoadingOverlay: true, loadingMessage: action.payload}
case UIConstants.HIDE_LOADING_OVERLAY:
return {...state, showLoadingOverlay: false, loadingMessage: ''}
case UIConstants.SET_ADJUSTING_HARD_END_DATES_AFTER:
return {...state, adjustingHardEndDatesAfter: action.payload}
case UIConstants.SET_EDITING_BLACKOUT_DATES:
return {...state, editingBlackoutDates: action.payload}
default:
return state
}
}

View File

@ -0,0 +1,77 @@
/*
* Copyright (C) 2021 - 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 {Action} from 'redux'
import {ThunkAction} from 'redux-thunk'
import uuid from 'uuid/v1'
import {StoreState} from '../../types'
import {createAction, ActionsUnion, BlackoutDate} from '../types'
import * as BlackoutDatesApi from '../../api/blackout_dates_api'
export enum Constants {
ADD_BLACKOUT_DATE = 'BLACKOUT_DATES/ADD',
DELETE_BLACKOUT_DATE = 'BLACKOUT_DATES/DELETE',
ADD_BACKEND_ID = 'BLACKOUT_DATES/ADD_BACKEND_ID'
}
/* Action Creators */
const regularActions = {
addBackendId: (tempId: string, id: number | string) =>
createAction(Constants.ADD_BACKEND_ID, {tempId, id}),
deleteBlackoutDate: (id: number | string) => createAction(Constants.DELETE_BLACKOUT_DATE, id),
addBlackoutDate: (blackoutDate: BlackoutDate) =>
createAction(Constants.ADD_BLACKOUT_DATE, blackoutDate)
}
const thunkActions = {
addBlackoutDate: (blackoutDate: BlackoutDate): ThunkAction<void, StoreState, void, Action> => {
return (dispatch, _getState) => {
// Generate a tempId first that will be available to the api create callback closure,
// that will allow us to update the correct blackout date after a save. This lets us
// add the item immediately for the sake of UI, and then tie it to the correct ID laster
// so it can be deleted.
const tempId = uuid()
const blackoutDateWithTempId: BlackoutDate = {
...blackoutDate,
temp_id: tempId
}
dispatch(regularActions.addBlackoutDate(blackoutDateWithTempId))
BlackoutDatesApi.create(blackoutDateWithTempId)
.then(response => {
const savedBlackoutDate: BlackoutDate = response.data.blackout_date
dispatch(actions.addBackendId(tempId, savedBlackoutDate.id as number))
})
.catch(error => {
console.error(error) // eslint-disable-line no-console
})
}
},
deleteBlackoutDate: (id: number | string): ThunkAction<void, StoreState, void, Action> => {
return (dispatch, _getState) => {
dispatch(regularActions.deleteBlackoutDate(id))
BlackoutDatesApi.deleteBlackoutDate(id)
}
}
}
export const actions = {...regularActions, ...thunkActions}
export type BlackoutDatesAction = ActionsUnion<typeof regularActions>

View File

@ -0,0 +1,29 @@
/*
* Copyright (C) 2021 - 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 moment from 'moment-timezone'
moment.locale(window.ENV.MOMENT_LOCALE) // Set the locale globally
// Set the timezone globally
moment.tz.setDefault(window.ENV.TIMEZONE || Intl.DateTimeFormat().resolvedOptions().timeZone)
// 2018-01-06 was a Saturday and 2018-01-07 was a Sunday. This is necessary because different
// locales use different weekday integers, so we need to determine what the current values
// would be so that the DatePicker knows to disable the right weekend days.
export const saturdayWeekdayInteger = moment('2018-01-06').weekday()
export const sundayWeekdayInteger = moment('2018-01-07').weekday()
export const weekendIntegers = [sundayWeekdayInteger, saturdayWeekdayInteger]

View File

@ -0,0 +1,125 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import moment from 'moment-timezone'
import {IconButton} from '@instructure/ui-buttons'
import {IconTrashLine} from '@instructure/ui-icons'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {Table} from '@instructure/ui-table'
import {BlackoutDate} from '../types'
// Doing this to avoid TS2339 errors-- remove once we're on InstUI 8
const {Body, Cell, ColHeader, Head, Row} = Table as any
/* React Components */
interface PassedProps {
readonly blackoutDates: BlackoutDate[]
readonly deleteBlackoutDate: (blackoutDate: BlackoutDate) => any
readonly displayType: 'admin' | 'course'
}
type ComponentProps = PassedProps
interface LocalState {}
export class BlackoutDatesTable extends React.Component<ComponentProps, LocalState> {
/* Lifecycle */
constructor(props: ComponentProps) {
super(props)
this.state = {}
}
/* Helpers */
sortBlackoutDates = (dates: BlackoutDate[]): BlackoutDate[] => {
return dates.sort((a, b) => {
const aStart = moment(a.start_date)
const bStart = moment(b.start_date)
const diff = aStart.diff(bStart, 'days')
if (diff > 0) {
return 1
} else if (diff < 0) {
return -1
} else {
return 0
}
})
}
sortedBlackoutDates = (): BlackoutDate[] => {
if (this.props.displayType === 'course') {
const adminDates = this.props.blackoutDates.filter(blackoutDate => blackoutDate.admin_level)
const courseDates = this.props.blackoutDates.filter(blackoutDate => !blackoutDate.admin_level)
return this.sortBlackoutDates(adminDates).concat(this.sortBlackoutDates(courseDates))
} else {
return this.sortBlackoutDates(this.props.blackoutDates)
}
}
/* Renderers */
// @ts-ignore
renderRows = () =>
this.sortedBlackoutDates().map(bd => (
<Row key={`blackout-date-${bd.id}`}>
<Cell>{bd.event_title}</Cell>
<Cell>{bd.start_date.format('L')}</Cell>
<Cell>{bd.end_date.format('L')}</Cell>
<Cell textAlign="end">{this.renderTrash(bd)}</Cell>
</Row>
))
renderTrash = (blackoutDate: BlackoutDate) => {
if (this.props.displayType === 'course' && blackoutDate.admin_level) {
return null
} else {
return (
<IconButton
onClick={() => this.props.deleteBlackoutDate(blackoutDate)}
screenReaderLabel={`Delete blackout date ${blackoutDate.event_title}`}
>
<IconTrashLine />
</IconButton>
)
}
}
render() {
return (
<Table caption="Blackout Dates">
<Head>
<Row>
<ColHeader id="blackout-dates-title">Event Title</ColHeader>
<ColHeader id="blackout-dates-start-date">Start Date</ColHeader>
<ColHeader id="blackout-dates-end-date">End Date</ColHeader>
<ColHeader id="blackout-dates-actions" width="4rem">
<ScreenReaderContent>Actions</ScreenReaderContent>
</ColHeader>
</Row>
</Head>
<Body>{this.renderRows()}</Body>
</Table>
)
}
}
export default BlackoutDatesTable

View File

@ -0,0 +1,127 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import moment from 'moment-timezone'
import {Button} from '@instructure/ui-buttons'
import {Flex} from '@instructure/ui-flex'
import {TextInput} from '@instructure/ui-text-input'
import PacePlanDateInput from './pace_plan_date_input'
import {BlackoutDate} from '../types'
import * as DateHelpers from '../../utils/date_stuff/date_helpers'
interface PassedProps {
readonly addBlackoutDate: (blackoutDate: BlackoutDate) => any
}
interface LocalState {
readonly eventTitle: string
readonly startDate: string
readonly endDate: string
}
class NewBlackoutDatesForm extends React.Component<PassedProps, LocalState> {
constructor(props: PassedProps) {
super(props)
this.state = {eventTitle: '', startDate: '', endDate: ''}
}
/* Callbacks */
addBlackoutDate = () => {
const blackoutDate: BlackoutDate = {
event_title: this.state.eventTitle,
start_date: moment(this.state.startDate),
end_date: moment(this.state.endDate)
}
this.props.addBlackoutDate(blackoutDate)
this.setState({eventTitle: '', startDate: '', endDate: ''})
}
onChangeEventTitle = (e: React.FormEvent<HTMLInputElement>) => {
if (e.currentTarget.value.length <= 100) {
this.setState({eventTitle: e.currentTarget.value})
}
}
onChangeStartDate = (date: string) => {
const startDate = DateHelpers.formatDate(date)
this.setState(({endDate}) => ({startDate, endDate: endDate || startDate}))
}
onChangeEndDate = (date: string) => {
this.setState({endDate: DateHelpers.formatDate(date)})
}
disabledAdd = () => {
return this.state.eventTitle.length < 1 || !this.state.startDate || !this.state.endDate
}
disabledStartDay = (date: moment.Moment) => {
return date > moment(this.state.endDate)
}
disabledEndDay = (date: moment.Moment) => {
return date < moment(this.state.startDate)
}
/* Renderers */
render() {
return (
<div>
<Flex alignItems="end" justifyItems="space-between" wrap="wrap" margin="0 0 large">
<TextInput
renderLabel="Event Title"
placeholder="e.g., Winter Break"
width="180px"
value={this.state.eventTitle}
onChange={this.onChangeEventTitle}
/>
<PacePlanDateInput
dateValue={this.state.startDate}
label="Start Date"
onDateChange={this.onChangeStartDate}
disabledDays={this.disabledStartDay}
id="blackout_start"
width="140px"
/>
<PacePlanDateInput
dateValue={this.state.endDate}
label="End Date"
onDateChange={this.onChangeEndDate}
disabledDays={this.disabledEndDay}
id="blackout_end"
width="140px"
/>
<Button
color="primary"
interaction={this.disabledAdd() ? 'disabled' : 'enabled'}
onClick={this.addBlackoutDate}
>
Add
</Button>
</Flex>
</div>
)
}
}
export default NewBlackoutDatesForm

View File

@ -0,0 +1,131 @@
/*
* Copyright (C) 2021 - 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/>.
*/
/*
* A wrapper around the instructure-ui DateInput component
*
* This wrapper does the following:
*
* - Renders the DateInput with the passed in props
* - Handles date changes and ensures the user doesn't manually enter a disabled date
* - Includes a hack to make sure the DateInput's TextInput updates (see componentWillReceiveProps)
*/
import React from 'react'
import moment from 'moment-timezone'
import tz from '@canvas/timezone'
import CanvasDateInput from '@canvas/datetime/react/components/DateInput'
import * as DateHelpers from '../../utils/date_stuff/date_helpers'
export enum DateErrorMessages {
INVALID_FORMAT = 'Invalid date entered. Date has been reset.',
DISABLED_WEEKEND = 'Weekends are disabled. Date has been shifted to the nearest weekday.',
DISABLED_OTHER = 'Disabled day entered. Date has been reset.'
}
interface PassedProps {
readonly dateValue?: string
readonly label: string | JSX.Element
readonly onDateChange: (rawDate: string) => any
// array representing the disabled days of the week (e.g., [0,6] for Saturday and Sunday)
readonly disabledDaysOfWeek?: number[]
// callback that takes a date and returns if it should be disabled or not
readonly disabledDays?: (date: moment.Moment) => boolean
readonly width?: string
readonly layout?: 'inline' | 'stacked'
readonly inline?: boolean
readonly id: string
readonly disabled?: boolean
readonly placeholder?: string
readonly locale?: string
}
interface LocalState {
readonly error?: string
}
class PacePlanDateInput extends React.Component<PassedProps, LocalState> {
state: LocalState = {
error: undefined
}
public static defaultProps: Partial<PassedProps> = {
disabledDaysOfWeek: [],
disabledDays: [] as any,
width: '135',
layout: 'stacked',
inline: false,
disabled: false,
placeholder: 'Select a date',
locale: window.ENV.LOCALE
}
/* Callbacks */
onDateChange = newDate => {
let error: string | undefined
let parsedDate = moment(newDate).startOf('day')
const landsOnDisabledWeekend =
this.props.disabledDaysOfWeek && this.props.disabledDaysOfWeek.includes(parsedDate.weekday())
const landsOnDisabledDay = this.props.disabledDays && this.props.disabledDays(parsedDate)
const dateIsDisabled = landsOnDisabledWeekend || landsOnDisabledDay
if (!parsedDate.isValid()) {
parsedDate = moment(this.props.dateValue)
error = DateErrorMessages.INVALID_FORMAT
} else if (dateIsDisabled) {
if (landsOnDisabledDay) {
// If the date was disabled because of the disabledDays function, just reset it and don't try to shift
parsedDate = moment(this.props.dateValue)
error = DateErrorMessages.DISABLED_OTHER
} else if (landsOnDisabledWeekend) {
parsedDate = moment(DateHelpers.adjustDateOnSkipWeekends(newDate))
error = DateErrorMessages.DISABLED_WEEKEND
} else {
parsedDate = moment(this.props.dateValue)
error = DateErrorMessages.DISABLED_OTHER
}
}
// Regardless of the displayed format, we should store it as YYYY-MM-DD
this.props.onDateChange(parsedDate.format('YYYY-MM-DD'))
this.setState({error})
}
formatDate = date => tz.format(date, 'date.formats.medium')
/* Renderers */
render() {
return (
<CanvasDateInput
renderLabel={this.props.label}
formatDate={this.formatDate}
onSelectedDateChange={this.onDateChange}
selectedDate={this.props.dateValue || ''}
width={this.props.width}
messages={this.state.error ? [{type: 'error', text: this.state.error}] : []}
/>
)
}
}
export default PacePlanDateInput

View File

@ -0,0 +1,71 @@
/*
* Copyright (C) 2021 - 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 React from 'react'
import {Modal} from '@instructure/ui-modal'
import {Button, CloseButton} from '@instructure/ui-buttons'
import {Heading} from '@instructure/ui-heading'
import {View} from '@instructure/ui-view'
interface PassedProps {
readonly open: boolean
readonly onDismiss: () => any
readonly confirm: () => any
}
class UpdateExistingPlansModal extends React.PureComponent<PassedProps> {
render() {
return (
<Modal
open={this.props.open}
onDismiss={this.props.onDismiss}
label="Update Existing Plans"
shouldCloseOnDocumentClick
>
<Modal.Header>
<CloseButton
placement="end"
offset="medium"
onClick={this.props.onDismiss}
screenReaderLabel="Close"
/>
<Heading>Update Existing Plans?</Heading>
</Modal.Header>
<Modal.Body>
<View as="div" width="36rem">
Would you like to re-publish plans with the blackout dates you have specified?
Assignment due dates may be changed.
</View>
</Modal.Body>
<Modal.Footer>
<Button color="secondary" onClick={this.props.onDismiss}>
No
</Button>
&nbsp;
<Button color="primary" onClick={this.props.confirm}>
Yes
</Button>
</Modal.Footer>
</Modal>
)
}
}
export default UpdateExistingPlansModal

View File

@ -0,0 +1,36 @@
/*
* Copyright (C) 2021 - 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 {createStore, applyMiddleware} from 'redux'
import {composeWithDevTools} from 'redux-devtools-extension/developmentOnly'
import thunkMiddleware from 'redux-thunk'
export default reducers => {
const middlewares: any[] = [thunkMiddleware]
if (process.env.NODE_ENV === `development`) {
const {createLogger} = require(`redux-logger`) // tslint:disable-line
const logger = createLogger({
diff: true,
duration: true
})
middlewares.push(logger)
}
return createStore(reducers, composeWithDevTools(applyMiddleware(...middlewares)))
}

View File

@ -0,0 +1,66 @@
/*
* Copyright (C) 2021 - 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 moment from 'moment-timezone'
import {StoreState as CoursePageStoreState} from '../../types'
import {BlackoutDate} from '../types'
import {Constants, BlackoutDatesAction} from '../actions/blackout_dates'
const blackoutDates: BlackoutDate[] = (window.ENV.BLACKOUT_DATES || []) as BlackoutDate[]
if (blackoutDates && blackoutDates.forEach) {
blackoutDates.forEach(blackoutDate => {
blackoutDate.start_date = moment(blackoutDate.start_date)
blackoutDate.end_date = moment(blackoutDate.end_date)
})
}
export const blackoutDatesInitialState = blackoutDates
/* Selectors */
export const getBlackoutDates = (state: CoursePageStoreState) => state.blackoutDates
/* Reducers */
export const blackoutDatesReducer = (
state = blackoutDatesInitialState,
action: BlackoutDatesAction
): BlackoutDate[] => {
switch (action.type) {
case Constants.ADD_BLACKOUT_DATE:
return [...state, action.payload]
case Constants.DELETE_BLACKOUT_DATE:
return state.filter(blackoutDate => blackoutDate.id !== action.payload)
case Constants.ADD_BACKEND_ID:
return state.map(blackoutDate => {
if (blackoutDate.temp_id === action.payload.tempId) {
return {
...blackoutDate,
temp_id: undefined,
id: action.payload.id
}
} else {
return blackoutDate
}
})
default:
return state
}
}

View File

@ -0,0 +1,108 @@
/*
* Copyright (C) 2021 - 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 moment from 'moment-timezone'
export interface BlackoutDate {
readonly id?: number | string
readonly temp_id?: string
readonly course_id?: number | string
readonly event_title: string
start_date: moment.Moment
end_date: moment.Moment
readonly admin_level?: boolean
}
export type CourseExternalToolStatus = 'OFF' | 'ON' | 'HIDE'
export interface Course {
// These are the only types we really need
readonly id: number
readonly name: string
readonly start_at: string
readonly end_at: string
}
/* Redux action types */
/*
The following types are intended to make it easy to write typesafe redux actions with minimal boilerplate.
Actions created with createAction will return in the following formats:
Without a payload:
{ type: "Constant" }
With a payload:
{ type: "Constant", payload: payload }
Typescript can then easily infer the type of the action using ReturnType.
ActionsUnion is useful for mapping over the return types of a group of actions collected in an object,
so that the reducer can do typesafe switching based on the action type.
Example:
// Actions
export enum Constants {
DO_THING = "DO_THING",
DO_THING_WITH_PAYLOAD = "DO_THING_WITH_PAYLOAD"
}
export const actions = {
doThing: () => createAction(Constants.DO_THING),
doThingWithPayload: (stuff: string) => createAction(Constants.DO_THING_WITH_PAYLOAD, stuff),
}
export type ThingActions = ActionsUnion<typeof actions>;
// Reducer
const reducer = (state = {}, action: ThingActions) => {
switch(action.type) {
case Constants.DO_THING:
return state;
case Constants.DO_THING_WITH_PAYLOAD:
// Typescript knows the shape of payload at this point, because our actions are typesafe. This means it'll
// warn us if we do some sort of type mismatch, or try to access something off of payload that doesn't exist.
return { ...state, stuff: action.payload };
}
}
This setup was inspired by this blog post, which you can read if you want more background on how this all works:
https://medium.com/@martin_hotell/improved-redux-type-safety-with-typescript-2-8-2c11a8062575
*/
interface Action<T extends string> {
type: T
}
interface ActionWithPayload<T extends string, P> extends Action<T> {
payload: P
}
export function createAction<T extends string>(type: T): Action<T>
export function createAction<T extends string, P>(type: T, payload: P): ActionWithPayload<T, P>
export function createAction<T extends string, P>(type: T, payload?: P) {
return payload === undefined ? {type} : {type, payload}
}
type ActionCreatorsMapObject = {[actionCreator: string]: (...args: any[]) => any}
export type ActionsUnion<A extends ActionCreatorsMapObject> = ReturnType<A[keyof A]>

View File

@ -0,0 +1,138 @@
/*
* Copyright (C) 2021 - 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 {BlackoutDate, Course} from './shared/types'
/* Model types */
export interface Enrollment {
readonly id: number
readonly full_name: string
readonly sortable_name: string
readonly start_at: string
readonly completed_pace_plan_at?: string
}
export interface Enrollments {
[key: number]: Enrollment
}
export interface Section {
readonly id: number
readonly name: string
readonly start_at: string
readonly end_at: string
}
export interface Sections {
[key: number]: Section
}
export interface PacePlanItem {
readonly id: number
readonly duration: number
readonly assignment_title: string
readonly position: number
readonly module_item_id: number
readonly module_item_type: string
readonly published: boolean
}
export interface Module {
readonly id: string
readonly name: string
readonly position: number
readonly items: PacePlanItem[]
}
export type PlanContextTypes = 'Course' | 'Section' | 'Enrollment'
export type WorkflowStates = 'unpublished' | 'published' | 'deleted'
export interface PacePlan {
readonly id?: number | string
readonly start_date: string
readonly end_date: string
readonly workflow_state: WorkflowStates
readonly modules: Module[]
readonly exclude_weekends: boolean
readonly hard_end_dates?: boolean
readonly course_id: string | number
readonly course_section_id?: string | number
readonly user_id?: string | number
readonly context_type: PlanContextTypes
readonly context_id: string | number
readonly published_at?: string
readonly unpublished_changes?: boolean
readonly linked_to_parent: boolean
}
export enum PublishOptions {
FUTURE_ONLY = 'future_only',
ALL = 'all',
SELECTED_SECTIONS = 'selected_sections',
SELECTED_STUDENTS = 'selected_students',
SINGLE_STUDENT = 'single_student'
}
/* Redux state types */
export type EnrollmentsState = Enrollments
export type SectionsState = Sections
export type PacePlansState = PacePlan
export interface UIState {
readonly autoSaving: boolean
readonly errorMessage: string
readonly divideIntoWeeks: boolean
readonly planPublishing: boolean
readonly selectedPlanType: PlanTypes
readonly loadingMessage: string
readonly showLoadingOverlay: boolean
readonly editingBlackoutDates: boolean
readonly adjustingHardEndDatesAfter?: number
}
export interface StoreState {
readonly pacePlan: PacePlansState
readonly enrollments: EnrollmentsState
readonly sections: SectionsState
readonly ui: UIState
readonly course: Course
readonly blackoutDates: BlackoutDate[]
}
/* Random types */
// Key is the pace plan item id and value is the date string
export type PacePlanItemDueDates = {[key: number]: string}
export type PlanTypes = 'template' | 'student'
/*
* Use this when creating a payload that should map to a specific pace plan item,
* to enforce a planId and planItemId in the payload.
*
* Example:
*
* interface SetDurationPayload extends PacePlanItemPayload { readonly duration: number }
* const payload: SetDurationPayload = { planId: 1, planItemId: 2, duration: 10 };
*
*/
export interface PacePlanItemPayload {
readonly planItemId: number
}

View File

@ -0,0 +1,140 @@
/*
* Copyright (C) 2021 - 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 moment from 'moment-timezone'
import {BlackoutDate} from '../../shared/types'
import {
weekendIntegers,
sundayWeekdayInteger,
saturdayWeekdayInteger
} from '../../shared/api/backend_serializer'
/*
* Any date manipulation should be consolidated into helper functions in this file
*/
// Takes a date and shifts it to the next weekday if it lands on a weekend
export const adjustDateOnSkipWeekends = (rawDate: string): string => {
const date = moment(rawDate)
switch (date.weekday()) {
case sundayWeekdayInteger:
date.add(1, 'day')
break
case saturdayWeekdayInteger:
date.subtract(1, 'day')
break
}
return formatDate(date)
}
// Takes a date string and formats it in YYYY-MM-DD format
export const formatDate = (date: string | moment.Moment): string => {
return moment(date).format('YYYY-MM-DD')
}
// Calculates the days between the start and end dates.
// Skips weekends if excludeWeekends is true, and includes
// the end date if inclusiveEnd is true.
export const daysBetween = (
start: string | moment.Moment,
end: string | moment.Moment,
excludeWeekends: boolean,
blackoutDates: BlackoutDate[] = [],
inclusiveEnd = true
): number => {
const startDate = moment(start).startOf('day')
const endDate = moment(end).startOf('day')
if (inclusiveEnd) {
endDate.endOf('day').add(1, 'day')
}
const fullDiff = endDate.diff(startDate, 'days')
if (fullDiff === 0) {
return fullDiff
}
const smallerDate = fullDiff > 0 ? startDate : endDate
const sign: 'plus' | 'minus' = fullDiff > 0 ? 'plus' : 'minus'
const countingDate = smallerDate.clone()
let count = 0
for (let i = 0; i < Math.abs(fullDiff); i++) {
if (!dayIsDisabled(countingDate, excludeWeekends, blackoutDates)) {
count = sign === 'plus' ? count + 1 : count - 1
}
countingDate.add(1, 'day')
}
return count
}
// Modifies a starting date string by numberOfDays. Doesn't include the start in that calculation.
// e.g., 2018-01-01 + 2 would equal 2018-01-03. (So make sure to subtract a day from start if you want
// the starting day included.) Skips blackout days if they are provided.
export const addDays = (
start: string | moment.Moment,
numberOfDays: number,
excludeWeekends: boolean,
blackoutDates: BlackoutDate[] = []
): string => {
const date = moment(start)
while (numberOfDays > 0) {
date.add(1, 'days')
if (!dayIsDisabled(date, excludeWeekends, blackoutDates)) {
numberOfDays--
}
}
return formatDate(date.startOf('day'))
}
export const stripTimezone = (date: string): string => {
return date.split('T')[0]
}
export const inBlackoutDate = (
date: moment.Moment | string,
blackoutDates: BlackoutDate[]
): boolean => {
date = moment(date)
return blackoutDates.some(blackoutDate => {
const blackoutStart = blackoutDate.start_date
const blackoutEnd = blackoutDate.end_date
return date >= blackoutStart && date <= blackoutEnd
})
}
/* Non exported helper functions */
const dayIsDisabled = (
date: moment.Moment,
excludeWeekends: boolean,
blackoutDates: BlackoutDate[]
) => {
return (
(excludeWeekends && weekendIntegers.includes(date.weekday())) ||
inBlackoutDate(date, blackoutDates)
)
}

View File

@ -0,0 +1,78 @@
/*
* Copyright (C) 2021 - 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 moment from 'moment-timezone'
import {PacePlanItem, PacePlanItemDueDates} from '../../types'
import {BlackoutDate} from '../../shared/types'
import * as DateHelpers from './date_helpers'
/*
WARNING: Read this before modifying this file!
The logic for calculating due dates is currently duplicated on both the front and backend.
(Gross, I know). If the due date calculation logic is updated, you should also modify it in
pace_plan_due_dates_calculator.rb, so the backend will also reflect those changes.
Ideally this should be *REFACTOR*ed at some point so the logic isn't duplicated. It's a bit
challenging for the following reasons:
- We want the user to get real time feedback on the frontend when they modify their plan.
Doing an API call for every change introduces enough latency to negatively impact the user experience.
- The frontend just POSTs the duration of each module item, and not their actual due date. We can't
just have the frontend send the due dates, because due dates are going to be based off of an enrollment's
start date. A published "master plan" is really just a template, and if they've never specifically published
for an enrollment then the frontend would never have the chance to calculate and send that student's due dates.
- Even if we got around that issue, if a new enrollment is created through a live event we are going to
automatically publish their plan. Which means the teacher may have never opened the tool before we create a plan
for that student.
For now, the logic is simple enough that the duplication isn't a big deal. But if it gets much
more complex, we may want to schedule the necessary time to consolidate it somehow. The solutions I can think of are:
1 - Write the date calculation logic in JavaScript and execute it on the backend using something like therubyracer
2 - Write the date calculation logic in Ruby and compile it to JavaScript using Opal
3 - Execute an API call on every change and just deal with the latency
I ran into various technical difficulties trying to get #1 and #2 working, but they might be feasible if somebody
can spend 3 or 4 days on them.
*/
export const getDueDates = (
pacePlanItems: PacePlanItem[],
startDate: string,
excludeWeekends: boolean,
blackoutDates: BlackoutDate[]
): PacePlanItemDueDates => {
const dueDates = {}
// Subtract one day, because the starting date is not inclusive in DateHelpers.addDays
let currentStart = DateHelpers.formatDate(moment(startDate).subtract(1, 'day'))
for (let i = 0, keys = Object.keys(pacePlanItems); i < keys.length; i++) {
const key = keys[i]
const item = pacePlanItems[key]
// We treat the first assignment as at least 1 day, even if it has a duration of 0
const duration = i === 0 && item.duration === 0 ? 1 : item.duration
currentStart = DateHelpers.addDays(currentStart, duration, excludeWeekends, blackoutDates)
dueDates[item.id] = currentStart
}
return dueDates
}

34
ui/imports.d.ts vendored
View File

@ -16,6 +16,8 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {HTMLButtonElement, HTMLElement, MouseEventHandler} from 'react'
// These are special webpack-processed imports that Typescript doesn't understand
// by default. Declaring them as wildcard modules allows TS to recognize them as
// bare-bones interfaces with the `any` type.
@ -25,3 +27,35 @@ declare module '*.coffee'
declare module '*.graphql'
declare module '*.handlebars'
declare module '*.svg'
// InstUI v7 is missing type information for a lot of its props, so these suppress
// TS errors on valid props until we upgrade to v8.
interface MissingButtonProps {
onClick?: MouseEventHandler<HTMLButtonElement>
}
interface MissingElementProps {
onMouseEnter?: MouseEventHandler<HTMLElement>
onMouseLeave?: MouseEventHandler<HTMLElement>
}
interface MissingThemeableProps {
theme?: object
}
declare module '@instructure/ui-buttons' {
export interface BaseButtonProps extends MissingButtonProps {}
export interface ButtonProps extends MissingButtonProps {}
export interface CloseButtonProps extends MissingButtonProps {}
export interface CondensedButtonProps extends MissingButtonProps {}
export interface IconButtonProps extends MissingButtonProps {}
export interface ToggleButtonProps extends MissingButtonProps {}
}
declare module '@instructure/ui-toggle-details' {
export interface ToggleDetailsProps extends MissingThemeableProps {}
}
declare module '@instructure/ui-view' {
export interface ViewProps extends MissingElementProps, MissingThemeableProps {}
}

View File

@ -18,6 +18,8 @@
import axios from 'axios'
export {AxiosPromise} from 'axios'
// Add CSRF stuffs to make Canvas happy when we are making requests with axios
axios.defaults.xsrfCookieName = '_csrf_token'
axios.defaults.xsrfHeaderName = 'X-CSRF-Token'

View File

@ -142,15 +142,8 @@ export default class ProficiencyRating extends React.Component {
errorMessage = error => (error ? [{text: error, type: 'error'}] : null)
render() {
const {
color,
description,
descriptionError,
disableDelete,
mastery,
points,
pointsError
} = this.props
const {color, description, descriptionError, disableDelete, mastery, points, pointsError} =
this.props
return (
<Table.Row>
<Table.Cell textAlign="center">

View File

@ -5996,10 +5996,10 @@
dependencies:
"@types/react" "*"
"@types/react-dom@>=16.9.0":
version "17.0.7"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.7.tgz#b8ee15ead9e5d6c2c858b44949fdf2ebe5212232"
integrity sha512-Wd5xvZRlccOrCTej8jZkoFZuZRKHzanDDv1xglI33oBNFMWrqOSzrvWFw7ngSiZjrpJAzPKFtX7JvuXpkNmQHA==
"@types/react-dom@>=16.9.0", "@types/react-dom@^17.0.9":
version "17.0.9"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add"
integrity sha512-wIvGxLfgpVDSAMH5utdL9Ngm5Owu0VsGmldro3ORLXV8CShrL8awVj06NuEXFQ5xyaYfdca7Sgbk/50Ri1GdPg==
dependencies:
"@types/react" "*"
@ -6017,10 +6017,10 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@>=16.9.0":
version "17.0.11"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.11.tgz#67fcd0ddbf5a0b083a0f94e926c7d63f3b836451"
integrity sha512-yFRQbD+whVonItSk7ZzP/L+gPTJVBkL/7shLEF+i9GC/1cV3JmUxEQz6+9ylhUpWSDuqo1N9qEvqS6vTj4USUA==
"@types/react@*", "@types/react@>=16.9.0", "@types/react@^17.0.19":
version "17.0.19"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.19.tgz#8f2a85e8180a43b57966b237d26a29481dacc991"
integrity sha512-sX1HisdB1/ZESixMTGnMxH9TDe8Sk709734fEQZzCV/4lSu9kJCPbo2PbTRoZM+53Pp0P10hYVyReUueGwUi4A==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
@ -24805,6 +24805,11 @@ ts-pnp@^1.1.6:
resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92"
integrity sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==
tsc-files@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/tsc-files/-/tsc-files-1.1.2.tgz#acd181949ef53811fc6df455baef41adaf616442"
integrity sha512-biLtl4npoohZ9MBnTFw4NttqYM60RscjzjWxT538UCS8iXaGRZMi+AXj+vEEpDdcjIS2Kx0Acj++1gor5dbbBw==
tsconfig-paths@^3.9.0:
version "3.9.0"
resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b"