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:
parent
8741d6dda4
commit
c01113638b
26
.eslintrc.js
26
.eslintrc.js
|
@ -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']
|
||||
},
|
||||
|
|
|
@ -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']
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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/',
|
||||
|
||||
|
|
14
package.json
14
package.json
|
@ -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)",
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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'))
|
||||
})
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
)
|
|
@ -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>
|
||||
|
||||
<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)
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
)
|
|
@ -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)
|
|
@ -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
|
||||
)
|
|
@ -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)
|
|
@ -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>
|
||||
|
||||
</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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)}
|
||||
})
|
||||
}
|
|
@ -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)}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
})
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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]
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
|
||||
<Button color="primary" onClick={this.props.confirm}>
|
||||
Yes
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default UpdateExistingPlansModal
|
|
@ -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)))
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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]>
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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">
|
||||
|
|
21
yarn.lock
21
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue