Add context modules publish menu
fixes LS-3395 flag=module_publish_menu test plan: - Enable the flag - Go to a modules page - See the publish icon on the module is now a menu - Verify all the actions work as expected - See the publish menu item at the top of the page - Verify the actions open a modal with differing titles based on the action - Verify submitting the form creates a background job that completes and updates the page accordingly - Verify the cancel button stops the job and refreshes the page to reflect the most correct data based on what was run Change-Id: Idbda6fe7a971cb6c9d6a897aaf2bd42fe950ddd4 Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/304526 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: Robin Kuss <rkuss@instructure.com> QA-Review: Robin Kuss <rkuss@instructure.com> Product-Review: Eric Saupe <eric.saupe@instructure.com>
This commit is contained in:
parent
2197a798a8
commit
2adf275017
|
@ -316,7 +316,7 @@ class ApplicationController < ActionController::Base
|
||||||
JS_ENV_SITE_ADMIN_FEATURES = %i[
|
JS_ENV_SITE_ADMIN_FEATURES = %i[
|
||||||
featured_help_links lti_platform_storage scale_equation_images buttons_and_icons_cropper calendar_series
|
featured_help_links lti_platform_storage scale_equation_images buttons_and_icons_cropper calendar_series
|
||||||
account_level_blackout_dates account_calendar_events rce_ux_improvements render_both_to_do_lists
|
account_level_blackout_dates account_calendar_events rce_ux_improvements render_both_to_do_lists
|
||||||
course_paces_redesign course_paces_for_students rce_better_paste
|
course_paces_redesign course_paces_for_students rce_better_paste module_publish_menu
|
||||||
].freeze
|
].freeze
|
||||||
JS_ENV_ROOT_ACCOUNT_FEATURES = %i[
|
JS_ENV_ROOT_ACCOUNT_FEATURES = %i[
|
||||||
product_tours files_dnd usage_rights_discussion_topics
|
product_tours files_dnd usage_rights_discussion_topics
|
||||||
|
|
|
@ -254,6 +254,9 @@ class ContextModulesApiController < ApplicationController
|
||||||
# @argument event [Required, String]
|
# @argument event [Required, String]
|
||||||
# The action to take on each module. Must be 'delete'.
|
# The action to take on each module. Must be 'delete'.
|
||||||
#
|
#
|
||||||
|
# @argument async [Boolean]
|
||||||
|
# . If true, the request will be processed asynchronously and a Progress will be returned.
|
||||||
|
#
|
||||||
# @response_field completed A list of IDs for modules that were updated.
|
# @response_field completed A list of IDs for modules that were updated.
|
||||||
#
|
#
|
||||||
# @example_request
|
# @example_request
|
||||||
|
@ -266,7 +269,8 @@ class ContextModulesApiController < ApplicationController
|
||||||
#
|
#
|
||||||
# @example_response
|
# @example_response
|
||||||
# {
|
# {
|
||||||
# "completed": [1, 2]
|
# "completed": [1, 2],
|
||||||
|
# "progress": null,
|
||||||
# }
|
# }
|
||||||
def batch_update
|
def batch_update
|
||||||
if authorized_action(@context, @current_user, [:manage_content, :manage_course_content_edit])
|
if authorized_action(@context, @current_user, [:manage_content, :manage_course_content_edit])
|
||||||
|
@ -279,23 +283,23 @@ class ContextModulesApiController < ApplicationController
|
||||||
modules = @context.context_modules.not_deleted.where(id: module_ids)
|
modules = @context.context_modules.not_deleted.where(id: module_ids)
|
||||||
return render(json: { message: "no modules found" }, status: :not_found) if modules.empty?
|
return render(json: { message: "no modules found" }, status: :not_found) if modules.empty?
|
||||||
|
|
||||||
completed_ids = []
|
batch_update_params = {
|
||||||
modules.each do |mod|
|
event: event,
|
||||||
case event
|
module_ids: modules.pluck(:id),
|
||||||
when "publish"
|
skip_content_tags: value_to_boolean(params[:skip_content_tags])
|
||||||
unless mod.active?
|
}
|
||||||
mod.publish
|
if value_to_boolean(params[:async]) && Account.site_admin.feature_enabled?(:module_publish_menu)
|
||||||
mod.publish_items!
|
progress = Progress.create!(context: @context, tag: "context_module_batch_update", user: @current_user)
|
||||||
end
|
progress.process_job(
|
||||||
when "unpublish"
|
@context,
|
||||||
mod.unpublish unless mod.unpublished?
|
:batch_update_context_modules,
|
||||||
when "delete"
|
{ run_at: Time.now },
|
||||||
mod.destroy
|
batch_update_params
|
||||||
end
|
)
|
||||||
completed_ids << mod.id
|
else
|
||||||
|
completed_ids = @context.batch_update_context_modules(batch_update_params)
|
||||||
end
|
end
|
||||||
|
render json: { completed: completed_ids, progress: progress }
|
||||||
render json: { completed: completed_ids }
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -414,10 +418,15 @@ class ContextModulesApiController < ApplicationController
|
||||||
if params[:module].key?(:published)
|
if params[:module].key?(:published)
|
||||||
if value_to_boolean(params[:module][:published])
|
if value_to_boolean(params[:module][:published])
|
||||||
@module.publish
|
@module.publish
|
||||||
@module.publish_items!
|
unless Account.site_admin.feature_enabled?(:module_publish_menu) && value_to_boolean(params[:module][:skip_content_tags])
|
||||||
publish_warning = @module.content_tags.any?(&:unpublished?)
|
@module.publish_items!
|
||||||
|
publish_warning = @module.content_tags.any?(&:unpublished?)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
@module.unpublish
|
@module.unpublish
|
||||||
|
if Account.site_admin.feature_enabled?(:module_publish_menu) && !value_to_boolean(params[:module][:skip_content_tags])
|
||||||
|
@module.unpublish_items!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
relock_warning = @module.relock_warning?
|
relock_warning = @module.relock_warning?
|
||||||
|
|
|
@ -182,6 +182,14 @@ class ContextModulesController < ApplicationController
|
||||||
|
|
||||||
set_tutorial_js_env
|
set_tutorial_js_env
|
||||||
|
|
||||||
|
if Account.site_admin.feature_enabled?(:module_publish_menu)
|
||||||
|
@progress = Progress.find_by(
|
||||||
|
context: @context,
|
||||||
|
tag: "context_module_batch_update",
|
||||||
|
workflow_state: ["queued", "running"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
if @is_student
|
if @is_student
|
||||||
return unless tab_enabled?(@context.class::TAB_MODULES)
|
return unless tab_enabled?(@context.class::TAB_MODULES)
|
||||||
|
|
||||||
|
|
|
@ -740,4 +740,31 @@ class ContentTag < ActiveRecord::Base
|
||||||
course_pace.create_publish_progress if deleted? || cpmi.destroyed? || cpmi.saved_change_to_id? || saved_change_to_position?
|
course_pace.create_publish_progress if deleted? || cpmi.destroyed? || cpmi.saved_change_to_id? || saved_change_to_position?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def trigger_publish!
|
||||||
|
enable_publish_at = context.root_account.feature_enabled?(:scheduled_page_publication)
|
||||||
|
if unpublished?
|
||||||
|
if content_type == "Attachment"
|
||||||
|
content.set_publish_state_for_usage_rights
|
||||||
|
content.save!
|
||||||
|
publish if content.published?
|
||||||
|
else
|
||||||
|
publish unless enable_publish_at && content.respond_to?(:publish_at) && content.publish_at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
update_asset_workflow_state!
|
||||||
|
end
|
||||||
|
|
||||||
|
def trigger_unpublish!
|
||||||
|
if published?
|
||||||
|
if content_type == "Attachment"
|
||||||
|
content.locked = true
|
||||||
|
content.save!
|
||||||
|
end
|
||||||
|
unpublish
|
||||||
|
end
|
||||||
|
|
||||||
|
update_asset_workflow_state!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -350,19 +350,19 @@ class ContextModule < ActiveRecord::Base
|
||||||
}
|
}
|
||||||
alias_method :published?, :active?
|
alias_method :published?, :active?
|
||||||
|
|
||||||
def publish_items!
|
def publish_items!(progress: nil)
|
||||||
enable_publish_at = context.root_account.feature_enabled?(:scheduled_page_publication)
|
content_tags.each do |content_tag|
|
||||||
content_tags.each do |tag|
|
break if progress&.reload&.failed?
|
||||||
if tag.unpublished?
|
|
||||||
if tag.content_type == "Attachment"
|
content_tag.trigger_publish!
|
||||||
tag.content.set_publish_state_for_usage_rights
|
end
|
||||||
tag.content.save!
|
end
|
||||||
tag.publish if tag.content.published?
|
|
||||||
else
|
def unpublish_items!(progress: nil)
|
||||||
tag.publish unless enable_publish_at && tag.content.respond_to?(:publish_at) && tag.content.publish_at
|
content_tags.each do |content_tag|
|
||||||
end
|
break if progress&.reload&.failed?
|
||||||
end
|
|
||||||
tag.update_asset_workflow_state!
|
content_tag.trigger_unpublish!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -4155,6 +4155,34 @@ class Course < ActiveRecord::Base
|
||||||
!templated_accounts.exists?
|
!templated_accounts.exists?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def batch_update_context_modules(progress = nil, event:, module_ids:, skip_content_tags: false)
|
||||||
|
completed_ids = []
|
||||||
|
modules = context_modules.not_deleted.where(id: module_ids)
|
||||||
|
progress&.calculate_completion!(0, modules.size)
|
||||||
|
modules.each do |context_module|
|
||||||
|
# Break out of the loop if the progress has been canceled
|
||||||
|
break if progress&.reload&.failed?
|
||||||
|
|
||||||
|
case event.to_s
|
||||||
|
when "publish"
|
||||||
|
context_module.publish unless context_module.active?
|
||||||
|
unless Account.site_admin.feature_enabled?(:module_publish_menu) && skip_content_tags
|
||||||
|
context_module.publish_items!(progress: progress)
|
||||||
|
end
|
||||||
|
when "unpublish"
|
||||||
|
context_module.unpublish unless context_module.unpublished?
|
||||||
|
if Account.site_admin.feature_enabled?(:module_publish_menu) && !skip_content_tags
|
||||||
|
context_module.unpublish_items!(progress: progress)
|
||||||
|
end
|
||||||
|
when "delete"
|
||||||
|
context_module.destroy
|
||||||
|
end
|
||||||
|
progress&.increment_completion!(1) if progress&.total
|
||||||
|
completed_ids << context_module.id
|
||||||
|
end
|
||||||
|
completed_ids
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def effective_due_dates
|
def effective_due_dates
|
||||||
|
|
|
@ -22,9 +22,11 @@
|
||||||
:COLLAPSED_MODULES => @collapsed_modules,
|
:COLLAPSED_MODULES => @collapsed_modules,
|
||||||
:IS_STUDENT => can_do(@context, @current_user, :participate_as_student),
|
:IS_STUDENT => can_do(@context, @current_user, :participate_as_student),
|
||||||
:COURSE_ID => @context.id,
|
:COURSE_ID => @context.id,
|
||||||
|
:PUBLISH_MENU_PROGRESS_ID => @progress&.id,
|
||||||
:NO_MODULE_PROGRESSIONS => @context.large_roster,
|
:NO_MODULE_PROGRESSIONS => @context.large_roster,
|
||||||
})
|
})
|
||||||
%>
|
%>
|
||||||
|
<% js_bundle :context_modules_publish_menu %>
|
||||||
<% js_bundle :module_dnd %>
|
<% js_bundle :module_dnd %>
|
||||||
<% css_bundle :react_files %>
|
<% css_bundle :react_files %>
|
||||||
<% if course_home %>
|
<% if course_home %>
|
||||||
|
@ -56,6 +58,9 @@
|
||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if Account.site_admin.feature_enabled?(:module_publish_menu) && @context.grants_any_right?(@current_user, session, :manage_content, :manage_course_content_edit) %>
|
||||||
|
<div id="context-modules-publish-menu" data-course-id="<%= @context.id %>" data-progress-id="<%= @progress&.id %>" style="display: inline-block;"></div>
|
||||||
|
<% end %>
|
||||||
<% if @can_add %>
|
<% if @can_add %>
|
||||||
<button class="btn btn-primary add_module_link">
|
<button class="btn btn-primary add_module_link">
|
||||||
<i class="icon-plus" role="presentation"></i>
|
<i class="icon-plus" role="presentation"></i>
|
||||||
|
|
|
@ -23,6 +23,7 @@
|
||||||
can_direct_share = can_do(@context, @current_user, :direct_share)
|
can_direct_share = can_do(@context, @current_user, :direct_share)
|
||||||
workflow_state = context_module && context_module.workflow_state
|
workflow_state = context_module && context_module.workflow_state
|
||||||
@modules ||= []
|
@modules ||= []
|
||||||
|
js_bundle :context_modules_publish_icon
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<% cache_if_module(context_module, @can_view, @is_student, @can_view_unpublished, @current_user, @context) do %>
|
<% cache_if_module(context_module, @can_view, @is_student, @can_view_unpublished, @current_user, @context) do %>
|
||||||
|
@ -117,19 +118,28 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if @can_edit%>
|
<% if @can_edit%>
|
||||||
<span
|
<% if Account.site_admin.feature_enabled?(:module_publish_menu) %>
|
||||||
data-module-type="module"
|
<div
|
||||||
data-id="<%= context_module && context_module.id %>"
|
data-course-id="<%= context_module && context_module.context_id %>"
|
||||||
data-course-id="<%= context_module && context_module.context_id %>"
|
data-module-id="<%= context_module && context_module.id %>"
|
||||||
data-published="<%= module_data[:published_status] == 'published' %>"
|
data-published="<%= module_data[:published_status] == 'published' %>"
|
||||||
data-publishable="<%= true %>"
|
class="module-publish-icon">
|
||||||
data-publish-title="<%= context_module ? context_module.name : 'module' %>"
|
</div>
|
||||||
title=""
|
<% else %>
|
||||||
data-tooltip
|
<span
|
||||||
class="publish-icon module <%= module_data[:published_status] %>"
|
data-module-type="module"
|
||||||
>
|
data-id="<%= context_module && context_module.id %>"
|
||||||
<i class="icon-<%= module_data[:published_status] %>" alt="<%= module_data[:published_status] == 'published' ? t('published') : t('unpublished') %>"></i>
|
data-course-id="<%= context_module && context_module.context_id %>"
|
||||||
</span>
|
data-published="<%= module_data[:published_status] == 'published' %>"
|
||||||
|
data-publishable="<%= true %>"
|
||||||
|
data-publish-title="<%= context_module ? context_module.name : 'module' %>"
|
||||||
|
title=""
|
||||||
|
data-tooltip
|
||||||
|
class="publish-icon module <%= module_data[:published_status] %>"
|
||||||
|
>
|
||||||
|
<i class="icon-<%= module_data[:published_status] %>" alt="<%= module_data[:published_status] == 'published' ? t('published') : t('unpublished') %>"></i>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if @can_add %>
|
<% if @can_add %>
|
||||||
|
|
|
@ -110,3 +110,8 @@ deprecate_faculty_journal:
|
||||||
state: allowed_on
|
state: allowed_on
|
||||||
development:
|
development:
|
||||||
state: allowed_on
|
state: allowed_on
|
||||||
|
module_publish_menu:
|
||||||
|
state: hidden
|
||||||
|
display_name: 'Module Publish Menu'
|
||||||
|
description: Enable the module publish menus to make mass publishing and unpublishing easier.
|
||||||
|
applies_to: SiteAdmin
|
||||||
|
|
|
@ -479,6 +479,86 @@ describe "Modules API", type: :request do
|
||||||
expect(@test_modules[1].reload).to be_unpublished
|
expect(@test_modules[1].reload).to be_unpublished
|
||||||
expect(other_module.reload).to be_active
|
expect(other_module.reload).to be_active
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "with module_publish_menu feature flag" do
|
||||||
|
before :once do
|
||||||
|
Account.site_admin.enable_feature!(:module_publish_menu)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "skips publishing module content tags if skip_content_tags is true" do
|
||||||
|
json = api_call(:put, @path, @path_opts, { event: "publish", module_ids: @ids_to_update, skip_content_tags: true })
|
||||||
|
expect(json["completed"].sort).to eq @ids_to_update
|
||||||
|
expect(@test_modules.map { |tm| tm.reload.workflow_state }).to eq %w[active active unpublished active]
|
||||||
|
|
||||||
|
@wiki_page_tag.reload
|
||||||
|
expect(@wiki_page_tag.active?).to eq false
|
||||||
|
@wiki_page.reload
|
||||||
|
expect(@wiki_page.active?).to eq false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unpublishes module content tags by default" do
|
||||||
|
@wiki_page_tag.publish!
|
||||||
|
@wiki_page.publish!
|
||||||
|
@wiki_page_tag.reload
|
||||||
|
expect(@wiki_page_tag.active?).to eq true
|
||||||
|
@wiki_page.reload
|
||||||
|
expect(@wiki_page.active?).to eq true
|
||||||
|
|
||||||
|
json = api_call(:put, @path, @path_opts, { event: "unpublish", module_ids: @ids_to_update })
|
||||||
|
expect(json["completed"].sort).to eq @ids_to_update
|
||||||
|
expect(@test_modules.map { |tm| tm.reload.workflow_state }).to eq %w[active unpublished unpublished unpublished]
|
||||||
|
|
||||||
|
@wiki_page_tag.reload
|
||||||
|
expect(@wiki_page_tag.active?).to eq false
|
||||||
|
@wiki_page.reload
|
||||||
|
expect(@wiki_page.active?).to eq false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not unpublish module content tags if skip_content_tags is true" do
|
||||||
|
@wiki_page_tag.publish!
|
||||||
|
@wiki_page.publish!
|
||||||
|
@wiki_page_tag.reload
|
||||||
|
expect(@wiki_page_tag.active?).to eq true
|
||||||
|
@wiki_page.reload
|
||||||
|
expect(@wiki_page.active?).to eq true
|
||||||
|
|
||||||
|
json = api_call(:put, @path, @path_opts, { event: "unpublish", module_ids: @ids_to_update, skip_content_tags: true })
|
||||||
|
expect(json["completed"].sort).to eq @ids_to_update
|
||||||
|
expect(@test_modules.map { |tm| tm.reload.workflow_state }).to eq %w[active unpublished unpublished unpublished]
|
||||||
|
|
||||||
|
@wiki_page_tag.reload
|
||||||
|
expect(@wiki_page_tag.active?).to eq true
|
||||||
|
@wiki_page.reload
|
||||||
|
expect(@wiki_page.active?).to eq true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with module_publish_menu feature flag enabled" do
|
||||||
|
before :once do
|
||||||
|
Account.site_admin.enable_feature!(:module_publish_menu)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "publishes modules (and their tags)" do
|
||||||
|
json = api_call(:put, @path, @path_opts, { event: "publish", module_ids: @ids_to_update })
|
||||||
|
expect(json["completed"].sort).to eq @ids_to_update
|
||||||
|
expect(@test_modules.map { |tm| tm.reload.workflow_state }).to eq %w[active active unpublished active]
|
||||||
|
|
||||||
|
expect(@wiki_page_tag.reload.active?).to eq true
|
||||||
|
expect(@wiki_page.reload.active?).to eq true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "starts a background job if async is passed" do
|
||||||
|
json = api_call(:put, @path, @path_opts, { event: "publish", module_ids: @ids_to_update, async: true })
|
||||||
|
expect(json["completed"]).to eq nil
|
||||||
|
expect(json["progress"]).to be_present
|
||||||
|
|
||||||
|
expect(@wiki_page_tag.reload.active?).not_to eq true
|
||||||
|
expect(@wiki_page.reload.active?).not_to eq true
|
||||||
|
progress = Progress.last
|
||||||
|
expect(progress).to be_queued
|
||||||
|
expect(progress.tag).to eq "context_module_batch_update"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "update" do
|
describe "update" do
|
||||||
|
@ -614,6 +694,71 @@ describe "Modules API", type: :request do
|
||||||
new_module.reload
|
new_module.reload
|
||||||
expect(new_module.prerequisites.pluck(:id).sort).to be_empty
|
expect(new_module.prerequisites.pluck(:id).sort).to be_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "with module_publish_menu feature flag" do
|
||||||
|
before :once do
|
||||||
|
Account.site_admin.enable_feature!(:module_publish_menu)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "skips publishing module content tags if skip_content_tags is true" do
|
||||||
|
json = api_call(:put, "/api/v1/courses/#{@course.id}/modules/#{@module1.id}",
|
||||||
|
{ controller: "context_modules_api", action: "update", format: "json",
|
||||||
|
course_id: @course.id.to_s, id: @module1.id.to_s },
|
||||||
|
{ module: { published: "1", skip_content_tags: "true" } })
|
||||||
|
expect(json["published"]).to eq true
|
||||||
|
@module1.reload
|
||||||
|
expect(@module1.active?).to eq true
|
||||||
|
|
||||||
|
@wiki_page_tag.reload
|
||||||
|
expect(@wiki_page_tag.active?).to eq false
|
||||||
|
@wiki_page.reload
|
||||||
|
expect(@wiki_page.active?).to eq false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unpublishes module content tags by default" do
|
||||||
|
@wiki_page_tag.publish!
|
||||||
|
@wiki_page.publish!
|
||||||
|
@wiki_page_tag.reload
|
||||||
|
expect(@wiki_page_tag.active?).to eq true
|
||||||
|
@wiki_page.reload
|
||||||
|
expect(@wiki_page.active?).to eq true
|
||||||
|
|
||||||
|
json = api_call(:put, "/api/v1/courses/#{@course.id}/modules/#{@module1.id}",
|
||||||
|
{ controller: "context_modules_api", action: "update", format: "json",
|
||||||
|
course_id: @course.id.to_s, id: @module1.id.to_s },
|
||||||
|
{ module: { published: "0" } })
|
||||||
|
expect(json["published"]).to eq false
|
||||||
|
@module1.reload
|
||||||
|
expect(@module1.unpublished?).to eq true
|
||||||
|
|
||||||
|
@wiki_page_tag.reload
|
||||||
|
expect(@wiki_page_tag.active?).to eq false
|
||||||
|
@wiki_page.reload
|
||||||
|
expect(@wiki_page.active?).to eq false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not unpublish module content tags if skip_content_tags is true" do
|
||||||
|
@wiki_page_tag.publish!
|
||||||
|
@wiki_page.publish!
|
||||||
|
@wiki_page_tag.reload
|
||||||
|
expect(@wiki_page_tag.active?).to eq true
|
||||||
|
@wiki_page.reload
|
||||||
|
expect(@wiki_page.active?).to eq true
|
||||||
|
|
||||||
|
json = api_call(:put, "/api/v1/courses/#{@course.id}/modules/#{@module1.id}",
|
||||||
|
{ controller: "context_modules_api", action: "update", format: "json",
|
||||||
|
course_id: @course.id.to_s, id: @module1.id.to_s },
|
||||||
|
{ module: { published: "0", skip_content_tags: "true" } })
|
||||||
|
expect(json["published"]).to eq false
|
||||||
|
@module1.reload
|
||||||
|
expect(@module1.unpublished?).to eq true
|
||||||
|
|
||||||
|
@wiki_page_tag.reload
|
||||||
|
expect(@wiki_page_tag.active?).to eq true
|
||||||
|
@wiki_page.reload
|
||||||
|
expect(@wiki_page.active?).to eq true
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "create" do
|
describe "create" do
|
||||||
|
|
|
@ -958,4 +958,44 @@ describe ContentTag do
|
||||||
expect(@course_pace.course_pace_module_items.where(module_item_id: tag.id).exists?).to eq(false)
|
expect(@course_pace.course_pace_module_items.where(module_item_id: tag.id).exists?).to eq(false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#trigger_publish!" do
|
||||||
|
it "publishes the tag if it is unpublished" do
|
||||||
|
course_factory
|
||||||
|
tag = ContentTag.create!(context: @course, workflow_state: "unpublished")
|
||||||
|
expect(tag.published?).to eq(false)
|
||||||
|
tag.trigger_publish!
|
||||||
|
expect(tag.reload.published?).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "publishes the tag and the attachment content if possible" do
|
||||||
|
course_factory
|
||||||
|
tag = ContentTag.create!(context: @course, content: attachment_model(locked: true), workflow_state: "unpublished")
|
||||||
|
expect(tag.published?).to eq(false)
|
||||||
|
expect(@attachment.published?).to eq(false)
|
||||||
|
tag.trigger_publish!
|
||||||
|
expect(tag.reload.published?).to eq(true)
|
||||||
|
expect(@attachment.reload.published?).to eq(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#trigger_unpublish!" do
|
||||||
|
it "unpublishes the tag if it is published" do
|
||||||
|
course_factory
|
||||||
|
tag = ContentTag.create!(context: @course, workflow_state: "published")
|
||||||
|
expect(tag.published?).to eq(true)
|
||||||
|
tag.trigger_unpublish!
|
||||||
|
expect(tag.reload.published?).to eq(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unpublishes the tag and locks the attachment content" do
|
||||||
|
course_factory
|
||||||
|
tag = ContentTag.create!(context: @course, content: attachment_model, workflow_state: "published")
|
||||||
|
expect(tag.published?).to eq(true)
|
||||||
|
expect(@attachment.published?).to eq(true)
|
||||||
|
tag.trigger_unpublish!
|
||||||
|
expect(tag.reload.published?).to eq(false)
|
||||||
|
expect(@attachment.reload.published?).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7659,4 +7659,121 @@ describe Course do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#batch_update_context_modules" do
|
||||||
|
before do
|
||||||
|
@course = course_model
|
||||||
|
@test_modules = (1..4).map { |x| @course.context_modules.create! name: "test module #{x}" }
|
||||||
|
@test_modules[2..3].each { |m| m.update_attribute(:workflow_state, "unpublished") }
|
||||||
|
@modules_to_update = [@test_modules[1], @test_modules[3]]
|
||||||
|
|
||||||
|
@wiki_page = @course.wiki_pages.create(title: "Wiki Page Title")
|
||||||
|
@wiki_page.unpublish!
|
||||||
|
@wiki_page_tag = @test_modules[3].add_item(id: @wiki_page.id, type: "wiki_page")
|
||||||
|
@wiki_page_tag.trigger_unpublish!
|
||||||
|
|
||||||
|
@ids_to_update = @modules_to_update.map(&:id)
|
||||||
|
Account.site_admin.enable_feature!(:module_publish_menu)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with publish event" do
|
||||||
|
it "publishes the modules" do
|
||||||
|
@course.batch_update_context_modules(module_ids: @ids_to_update, event: :publish)
|
||||||
|
@modules_to_update.each do |m|
|
||||||
|
expect(m.reload).to be_published
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "publishes the items" do
|
||||||
|
@course.batch_update_context_modules(module_ids: @ids_to_update, event: :publish)
|
||||||
|
@modules_to_update.each do |m|
|
||||||
|
expect(m.reload).to be_published
|
||||||
|
m.content_tags.each do |tag|
|
||||||
|
expect(tag.reload).to be_published
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not publish the items when skip_content_tags is true" do
|
||||||
|
@course.batch_update_context_modules(module_ids: @ids_to_update, event: :publish, skip_content_tags: true)
|
||||||
|
@modules_to_update.each do |m|
|
||||||
|
expect(m.reload).to be_published
|
||||||
|
m.content_tags.each do |tag|
|
||||||
|
expect(tag.reload).not_to be_published
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with unpublish event" do
|
||||||
|
it "unpublishes the modules" do
|
||||||
|
@course.batch_update_context_modules(module_ids: @ids_to_update, event: :unpublish)
|
||||||
|
@modules_to_update.each do |m|
|
||||||
|
expect(m.reload).to be_unpublished
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "unpublishes the items" do
|
||||||
|
@course.batch_update_context_modules(module_ids: @ids_to_update, event: :unpublish)
|
||||||
|
@modules_to_update.each do |m|
|
||||||
|
expect(m.reload).to be_unpublished
|
||||||
|
m.content_tags.each do |tag|
|
||||||
|
expect(tag.reload).to be_unpublished
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not unpublish the items when skip_content_tags is true" do
|
||||||
|
@wiki_page_tag.trigger_publish!
|
||||||
|
@course.batch_update_context_modules(module_ids: @ids_to_update, event: :unpublish, skip_content_tags: true)
|
||||||
|
@modules_to_update.each do |m|
|
||||||
|
expect(m.reload).to be_unpublished
|
||||||
|
m.content_tags.each do |tag|
|
||||||
|
expect(tag.reload).not_to be_unpublished
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with delete event" do
|
||||||
|
it "deletes the modules" do
|
||||||
|
@course.batch_update_context_modules(module_ids: @ids_to_update, event: :delete)
|
||||||
|
@modules_to_update.each do |m|
|
||||||
|
expect(m.reload).to be_deleted
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "deletes the items" do
|
||||||
|
@course.batch_update_context_modules(module_ids: @ids_to_update, event: :delete)
|
||||||
|
@modules_to_update.each do |m|
|
||||||
|
expect(m.reload).to be_deleted
|
||||||
|
m.content_tags.each do |tag|
|
||||||
|
expect(tag.reload).to be_deleted
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "deletes the content tags even if skip_content_tags is true" do
|
||||||
|
@wiki_page_tag.trigger_publish!
|
||||||
|
@course.batch_update_context_modules(module_ids: @ids_to_update, event: :delete, skip_content_tags: true)
|
||||||
|
@modules_to_update.each do |m|
|
||||||
|
expect(m.reload).to be_deleted
|
||||||
|
m.content_tags.each do |tag|
|
||||||
|
expect(tag.reload).to be_deleted
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "increments the progress" do
|
||||||
|
progress = Progress.create!(context: @course, tag: "context_module_batch_update", user: @teacher)
|
||||||
|
expect(progress).to receive(:increment_completion!).twice
|
||||||
|
@course.batch_update_context_modules(progress, module_ids: @ids_to_update, event: :publish)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns the completed_ids" do
|
||||||
|
completed_ids = @course.batch_update_context_modules(module_ids: @ids_to_update, event: :publish)
|
||||||
|
expect(completed_ids).to eq @ids_to_update
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 - present Instructure, Inc.
|
||||||
|
*
|
||||||
|
* This file is part of Canvas.
|
||||||
|
*
|
||||||
|
* Canvas is free software: you can redistribute it and/or modify it under
|
||||||
|
* the terms of the GNU Affero General Public License as published by the Free
|
||||||
|
* Software Foundation, version 3 of the License.
|
||||||
|
*
|
||||||
|
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
* details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License along
|
||||||
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import ContextModulesPublishIcon from '@canvas/context-modules-publish-icon/ContextModulesPublishIcon'
|
||||||
|
import ready from '@instructure/ready'
|
||||||
|
|
||||||
|
ready(() => {
|
||||||
|
const menuElements = document.getElementsByClassName(
|
||||||
|
'module-publish-icon'
|
||||||
|
) as HTMLCollectionOf<HTMLElement> // eslint-disable-line no-undef
|
||||||
|
Array.from(menuElements).forEach(el => {
|
||||||
|
ReactDOM.render(
|
||||||
|
<ContextModulesPublishIcon
|
||||||
|
courseId={el.dataset.courseId}
|
||||||
|
moduleId={el.dataset.moduleId}
|
||||||
|
published={el.dataset.published === 'true'}
|
||||||
|
/>,
|
||||||
|
el
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "@canvas-features/context_modules_publish_icon",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 - present Instructure, Inc.
|
||||||
|
*
|
||||||
|
* This file is part of Canvas.
|
||||||
|
*
|
||||||
|
* Canvas is free software: you can redistribute it and/or modify it under
|
||||||
|
* the terms of the GNU Affero General Public License as published by the Free
|
||||||
|
* Software Foundation, version 3 of the License.
|
||||||
|
*
|
||||||
|
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
* details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License along
|
||||||
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import ContextModulesPublishMenu from '@canvas/context-modules-publish-menu/ContextModulesPublishMenu'
|
||||||
|
import ready from '@instructure/ready'
|
||||||
|
|
||||||
|
ready(() => {
|
||||||
|
const menuElement = document.getElementById('context-modules-publish-menu')
|
||||||
|
if (menuElement) {
|
||||||
|
ReactDOM.render(
|
||||||
|
<ContextModulesPublishMenu
|
||||||
|
courseId={menuElement.dataset.courseId}
|
||||||
|
runningProgressId={menuElement.dataset.progressId}
|
||||||
|
/>,
|
||||||
|
menuElement
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "@canvas-features/context_modules_publish_menu",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
|
@ -0,0 +1,153 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 - present Instructure, Inc.
|
||||||
|
*
|
||||||
|
* This file is part of Canvas.
|
||||||
|
*
|
||||||
|
* Canvas is free software: you can redistribute it and/or modify it under
|
||||||
|
* the terms of the GNU Affero General Public License as published by the Free
|
||||||
|
* Software Foundation, version 3 of the License.
|
||||||
|
*
|
||||||
|
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
* details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License along
|
||||||
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import $ from 'jquery'
|
||||||
|
import React, {useState} from 'react'
|
||||||
|
|
||||||
|
import {IconMiniArrowDownLine, IconPublishSolid, IconUnpublishedLine} from '@instructure/ui-icons'
|
||||||
|
import {IconButton} from '@instructure/ui-buttons'
|
||||||
|
import {Menu} from '@instructure/ui-menu'
|
||||||
|
import {Spinner} from '@instructure/ui-spinner'
|
||||||
|
import {View} from '@instructure/ui-view'
|
||||||
|
|
||||||
|
import {showFlashError} from '@canvas/alerts/react/FlashAlert'
|
||||||
|
import doFetchApi from '@canvas/do-fetch-api-effect'
|
||||||
|
import {useScope as useI18nScope} from '@canvas/i18n'
|
||||||
|
|
||||||
|
import {initPublishButton} from '@canvas/context-modules/jquery/utils'
|
||||||
|
|
||||||
|
const I18n = useI18nScope('context_modules_publish_icon')
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
readonly courseId: string
|
||||||
|
readonly moduleId: string
|
||||||
|
readonly published: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContextModulesPublishIcon: React.FC<Props> = ({courseId, moduleId, published}) => {
|
||||||
|
const [isPublished, setIsPublished] = useState(published)
|
||||||
|
const [isPublishing, setIsPublishing] = useState(false)
|
||||||
|
|
||||||
|
const statusIcon = () => {
|
||||||
|
const iconStyles = {
|
||||||
|
paddingLeft: '0.25rem',
|
||||||
|
}
|
||||||
|
if (isPublishing) {
|
||||||
|
return <Spinner renderTitle={I18n.t('Loading')} size="x-small" />
|
||||||
|
} else if (isPublished) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconPublishSolid size="x-small" color="success" style={iconStyles} />
|
||||||
|
<IconMiniArrowDownLine size="x-small" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconUnpublishedLine size="x-small" color="secondary" style={iconStyles} />
|
||||||
|
<IconMiniArrowDownLine size="x-small" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateApiCall = (newPublishedState: boolean, skipModuleItems: boolean = false) => {
|
||||||
|
if (isPublishing) return
|
||||||
|
|
||||||
|
const path = `/api/v1/courses/${courseId}/modules/${moduleId}`
|
||||||
|
|
||||||
|
setIsPublishing(true)
|
||||||
|
return doFetchApi({
|
||||||
|
path,
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
module: {
|
||||||
|
published: newPublishedState,
|
||||||
|
skip_content_tags: skipModuleItems,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
setIsPublished(result.json.published)
|
||||||
|
if (!skipModuleItems) {
|
||||||
|
updateModuleItemPublishedStates(result.json.published)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => showFlashError(I18n.t('There was an error while saving your changes'))(error))
|
||||||
|
.finally(() => setIsPublishing(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateModuleItemPublishedStates = (isPublished: boolean) => {
|
||||||
|
document.querySelectorAll(`#context_module_content_${moduleId} .ig-row`).forEach(element => {
|
||||||
|
if (isPublished) {
|
||||||
|
element.classList.add('ig-published')
|
||||||
|
} else {
|
||||||
|
element.classList.remove('ig-published')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelectorAll(`#context_module_content_${moduleId} .publish-icon`)
|
||||||
|
.forEach(element => {
|
||||||
|
const publishIcon = $(element)
|
||||||
|
publishIcon.data('published', isPublished)
|
||||||
|
initPublishButton(publishIcon)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unpublishAll = () => {
|
||||||
|
updateApiCall(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishAll = () => {
|
||||||
|
updateApiCall(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishModuleOnly = () => {
|
||||||
|
updateApiCall(true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View textAlign="center">
|
||||||
|
<Menu
|
||||||
|
placement="bottom"
|
||||||
|
trigger={
|
||||||
|
<IconButton withBorder={false} screenReaderLabel={I18n.t('Module publish menu')}>
|
||||||
|
{statusIcon()}
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
disabled={isPublishing}
|
||||||
|
>
|
||||||
|
<Menu.Item onClick={publishAll}>
|
||||||
|
<IconPublishSolid color="success" /> {I18n.t('Publish module and all items')}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item onClick={publishModuleOnly}>
|
||||||
|
<IconPublishSolid color="success" /> {I18n.t('Publish module only')}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item onClick={unpublishAll}>
|
||||||
|
<IconUnpublishedLine /> {I18n.t('Unpublish module and all items')}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContextModulesPublishIcon
|
|
@ -0,0 +1,92 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 - present Instructure, Inc.
|
||||||
|
*
|
||||||
|
* This file is part of Canvas.
|
||||||
|
*
|
||||||
|
* Canvas is free software: you can redistribute it and/or modify it under
|
||||||
|
* the terms of the GNU Affero General Public License as published by the Free
|
||||||
|
* Software Foundation, version 3 of the License.
|
||||||
|
*
|
||||||
|
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
* details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License along
|
||||||
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import {act, render} from '@testing-library/react'
|
||||||
|
import doFetchApi from '@canvas/do-fetch-api-effect'
|
||||||
|
import ContextModulesPublishIcon from '../ContextModulesPublishIcon'
|
||||||
|
|
||||||
|
jest.mock('@canvas/do-fetch-api-effect')
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
courseId: '1',
|
||||||
|
moduleId: '1',
|
||||||
|
published: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
doFetchApi.mockResolvedValue({response: {ok: true}, json: {published: true}})
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
doFetchApi.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ContextModulesPublishIcon', () => {
|
||||||
|
it('renders the menu when clicked', () => {
|
||||||
|
const {getByRole, getByText} = render(<ContextModulesPublishIcon {...defaultProps} />)
|
||||||
|
const menuButton = getByRole('button')
|
||||||
|
act(() => menuButton.click())
|
||||||
|
expect(getByText('Publish module and all items')).toBeInTheDocument()
|
||||||
|
expect(getByText('Publish module only')).toBeInTheDocument()
|
||||||
|
expect(getByText('Unpublish module and all items')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls publishAll when clicked publish all menu item is clicked', () => {
|
||||||
|
const {getByRole, getByText} = render(<ContextModulesPublishIcon {...defaultProps} />)
|
||||||
|
const menuButton = getByRole('button')
|
||||||
|
act(() => menuButton.click())
|
||||||
|
const publishButton = getByText('Publish module and all items')
|
||||||
|
act(() => publishButton.click())
|
||||||
|
expect(doFetchApi).toHaveBeenCalledWith({
|
||||||
|
path: '/api/v1/courses/1/modules/1',
|
||||||
|
method: 'PUT',
|
||||||
|
body: {module: {published: true, skip_content_tags: false}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls publishModuleOnly when clicked publish module menu item is clicked', () => {
|
||||||
|
const {getByRole, getByText} = render(<ContextModulesPublishIcon {...defaultProps} />)
|
||||||
|
const menuButton = getByRole('button')
|
||||||
|
act(() => menuButton.click())
|
||||||
|
const publishButton = getByText('Publish module only')
|
||||||
|
act(() => publishButton.click())
|
||||||
|
expect(doFetchApi).toHaveBeenCalledWith({
|
||||||
|
path: '/api/v1/courses/1/modules/1',
|
||||||
|
method: 'PUT',
|
||||||
|
body: {module: {published: true, skip_content_tags: true}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls unpublishAll when clicked unpublish all items is clicked', () => {
|
||||||
|
const {getByRole, getByText} = render(<ContextModulesPublishIcon {...defaultProps} />)
|
||||||
|
const menuButton = getByRole('button')
|
||||||
|
act(() => menuButton.click())
|
||||||
|
const publishButton = getByText('Unpublish module and all items')
|
||||||
|
act(() => publishButton.click())
|
||||||
|
expect(doFetchApi).toHaveBeenCalledWith({
|
||||||
|
path: '/api/v1/courses/1/modules/1',
|
||||||
|
method: 'PUT',
|
||||||
|
body: {module: {published: false, skip_content_tags: false}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "@canvas/context-modules-publish-icon",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
|
@ -0,0 +1,236 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 - present Instructure, Inc.
|
||||||
|
*
|
||||||
|
* This file is part of Canvas.
|
||||||
|
*
|
||||||
|
* Canvas is free software: you can redistribute it and/or modify it under
|
||||||
|
* the terms of the GNU Affero General Public License as published by the Free
|
||||||
|
* Software Foundation, version 3 of the License.
|
||||||
|
*
|
||||||
|
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
* details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License along
|
||||||
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import $ from 'jquery'
|
||||||
|
import React, {useEffect, useState} from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconMiniArrowDownLine,
|
||||||
|
IconPublishLine,
|
||||||
|
IconPublishSolid,
|
||||||
|
IconUnpublishedLine,
|
||||||
|
} from '@instructure/ui-icons'
|
||||||
|
import {Button} from '@instructure/ui-buttons'
|
||||||
|
import {Menu} from '@instructure/ui-menu'
|
||||||
|
import {Spinner} from '@instructure/ui-spinner'
|
||||||
|
import {View} from '@instructure/ui-view'
|
||||||
|
|
||||||
|
import doFetchApi from '@canvas/do-fetch-api-effect'
|
||||||
|
import {useScope as useI18nScope} from '@canvas/i18n'
|
||||||
|
|
||||||
|
import {showFlashError, showFlashSuccess} from '@canvas/alerts/react/FlashAlert'
|
||||||
|
import {initPublishButton} from '@canvas/context-modules/jquery/utils'
|
||||||
|
import ContextModulesPublishIcon from '@canvas/context-modules-publish-icon/ContextModulesPublishIcon'
|
||||||
|
import ContextModulesPublishModal from './ContextModulesPublishModal'
|
||||||
|
|
||||||
|
const I18n = useI18nScope('context_modules_publish_menu')
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
readonly courseId: string
|
||||||
|
readonly runningProgressId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContextModulesPublishMenu: React.FC<Props> = ({courseId, runningProgressId}) => {
|
||||||
|
const [isPublishing, setIsPublishing] = useState(false)
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [shouldPublishModules, setShouldPublishModules] = useState(false)
|
||||||
|
const [shouldSkipModuleItems, setShouldSkipModuleItems] = useState(false)
|
||||||
|
const [progressId, setProgressId] = useState(runningProgressId)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (progressId) {
|
||||||
|
setIsPublishing(true)
|
||||||
|
setIsModalOpen(true)
|
||||||
|
} else {
|
||||||
|
setIsPublishing(false)
|
||||||
|
}
|
||||||
|
}, [progressId])
|
||||||
|
|
||||||
|
const statusIcon = () => {
|
||||||
|
if (isPublishing) {
|
||||||
|
return <Spinner renderTitle={I18n.t('Loading')} size="x-small" />
|
||||||
|
} else {
|
||||||
|
return <IconPublishLine size="x-small" color="success" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleIds = (): Array<Number> => {
|
||||||
|
const ids = new Set<Number>()
|
||||||
|
const dataModules = document.querySelectorAll(
|
||||||
|
'.context_module[data-module-id]'
|
||||||
|
) as NodeListOf<HTMLElement> // eslint-disable-line no-undef
|
||||||
|
dataModules.forEach(el => {
|
||||||
|
if (el.id === undefined) return
|
||||||
|
|
||||||
|
const id = parseInt(el.id.replace(/\D/g, ''), 10)
|
||||||
|
ids.add(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...ids.values()].filter(Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchUpdateApiCall = () => {
|
||||||
|
if (isPublishing) return
|
||||||
|
|
||||||
|
const path = `/api/v1/courses/${courseId}/modules`
|
||||||
|
|
||||||
|
const event = shouldPublishModules ? 'publish' : 'unpublish'
|
||||||
|
const async = true
|
||||||
|
|
||||||
|
setIsPublishing(true)
|
||||||
|
return doFetchApi({
|
||||||
|
path,
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
module_ids: moduleIds(),
|
||||||
|
event,
|
||||||
|
skip_content_tags: shouldSkipModuleItems,
|
||||||
|
async,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
if (result.json.progress) {
|
||||||
|
setProgressId(result.json.progress.progress.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => showFlashError(I18n.t('There was an error while saving your changes'))(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPublishComplete = (isPublished: boolean) => {
|
||||||
|
updateModulePublishedStates(isPublished, moduleIds())
|
||||||
|
if (!shouldSkipModuleItems) {
|
||||||
|
updateModuleItemPublishedStates(isPublished, moduleIds())
|
||||||
|
}
|
||||||
|
setIsPublishing(false)
|
||||||
|
setProgressId(null)
|
||||||
|
showFlashSuccess(I18n.t('Modules updated'))()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateModulePublishedStates = (isPublished: boolean, completedModuleIds: Array<Number>) => {
|
||||||
|
completedModuleIds.forEach(moduleId => {
|
||||||
|
const publishIcon = document.querySelector(
|
||||||
|
`#context_module_${moduleId} .module-publish-icon`
|
||||||
|
) as HTMLElement | null
|
||||||
|
if (publishIcon) {
|
||||||
|
// Update the new state of the module then we unmount the component to render the newly changed state
|
||||||
|
publishIcon.dataset.published = isPublished.toString()
|
||||||
|
ReactDOM.unmountComponentAtNode(publishIcon)
|
||||||
|
ReactDOM.render(
|
||||||
|
<ContextModulesPublishIcon
|
||||||
|
courseId={publishIcon.dataset.courseId}
|
||||||
|
moduleId={publishIcon.dataset.moduleId}
|
||||||
|
published={publishIcon.dataset.published === 'true'}
|
||||||
|
/>,
|
||||||
|
publishIcon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateModuleItemPublishedStates = (
|
||||||
|
isPublished: boolean,
|
||||||
|
completedModuleIds: Array<Number>
|
||||||
|
) => {
|
||||||
|
completedModuleIds.forEach(moduleId => {
|
||||||
|
document.querySelectorAll(`#context_module_content_${moduleId} .ig-row`).forEach(element => {
|
||||||
|
if (isPublished) {
|
||||||
|
element.classList.add('ig-published')
|
||||||
|
} else {
|
||||||
|
element.classList.remove('ig-published')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelectorAll(`#context_module_content_${moduleId} .publish-icon`)
|
||||||
|
.forEach(element => {
|
||||||
|
const publishIcon = $(element)
|
||||||
|
publishIcon.data('published', isPublished)
|
||||||
|
initPublishButton(publishIcon)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unpublishAll = () => {
|
||||||
|
setShouldPublishModules(false)
|
||||||
|
setShouldSkipModuleItems(false)
|
||||||
|
setIsModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishAll = () => {
|
||||||
|
setShouldPublishModules(true)
|
||||||
|
setShouldSkipModuleItems(false)
|
||||||
|
setIsModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishModuleOnly = () => {
|
||||||
|
setShouldPublishModules(true)
|
||||||
|
setShouldSkipModuleItems(true)
|
||||||
|
setIsModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalTitle = () => {
|
||||||
|
if (shouldPublishModules) {
|
||||||
|
if (shouldSkipModuleItems) {
|
||||||
|
return I18n.t('Publish modules only')
|
||||||
|
} else {
|
||||||
|
return I18n.t('Publish all modules and items')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return I18n.t('Unpublish all modules and items')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View textAlign="center">
|
||||||
|
<Menu
|
||||||
|
placement="bottom"
|
||||||
|
trigger={
|
||||||
|
<Button renderIcon={statusIcon}>
|
||||||
|
{I18n.t('Publish All')} <IconMiniArrowDownLine size="x-small" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
disabled={isPublishing}
|
||||||
|
>
|
||||||
|
<Menu.Item onClick={publishAll}>
|
||||||
|
<IconPublishSolid color="success" /> {I18n.t('Publish all modules and items')}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item onClick={publishModuleOnly}>
|
||||||
|
<IconPublishSolid color="success" /> {I18n.t('Publish modules only')}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item onClick={unpublishAll}>
|
||||||
|
<IconUnpublishedLine /> {I18n.t('Unpublish all modules and items')}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
<ContextModulesPublishModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onPublish={batchUpdateApiCall}
|
||||||
|
onPublishComplete={onPublishComplete}
|
||||||
|
progressId={progressId}
|
||||||
|
publishItems={shouldPublishModules}
|
||||||
|
title={modalTitle()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContextModulesPublishMenu
|
|
@ -0,0 +1,212 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 - present Instructure, Inc.
|
||||||
|
*
|
||||||
|
* This file is part of Canvas.
|
||||||
|
*
|
||||||
|
* Canvas is free software: you can redistribute it and/or modify it under
|
||||||
|
* the terms of the GNU Affero General Public License as published by the Free
|
||||||
|
* Software Foundation, version 3 of the License.
|
||||||
|
*
|
||||||
|
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
* details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License along
|
||||||
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, {useEffect, useState} from 'react'
|
||||||
|
|
||||||
|
import {Button, CloseButton} from '@instructure/ui-buttons'
|
||||||
|
import {Heading} from '@instructure/ui-heading'
|
||||||
|
import {Modal} from '@instructure/ui-modal'
|
||||||
|
import {ProgressBar} from '@instructure/ui-progress'
|
||||||
|
import {Text} from '@instructure/ui-text'
|
||||||
|
import {View} from '@instructure/ui-view'
|
||||||
|
|
||||||
|
import {showFlashError} from '@canvas/alerts/react/FlashAlert'
|
||||||
|
import doFetchApi from '@canvas/do-fetch-api-effect'
|
||||||
|
import {useScope as useI18nScope} from '@canvas/i18n'
|
||||||
|
|
||||||
|
const I18n = useI18nScope('context_modules_publish_menu')
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
readonly isOpen: boolean
|
||||||
|
readonly onClose: () => void
|
||||||
|
readonly onPublish: () => void
|
||||||
|
readonly onPublishComplete: (publish: boolean) => void
|
||||||
|
readonly progressId: string | null
|
||||||
|
readonly publishItems: boolean
|
||||||
|
readonly title: string
|
||||||
|
}
|
||||||
|
export const PUBLISH_STATUS_POLLING_MS = 1000
|
||||||
|
|
||||||
|
const ContextModulesPublishModal: React.FC<Props> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onPublish,
|
||||||
|
onPublishComplete,
|
||||||
|
progressId,
|
||||||
|
publishItems,
|
||||||
|
title,
|
||||||
|
}) => {
|
||||||
|
const [isPublishing, setIsPublishing] = useState(false)
|
||||||
|
const [progress, setProgress] = useState(null)
|
||||||
|
const [progressCurrent, setProgressCurrent] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (progressId) {
|
||||||
|
const interval = setInterval(pollProgress, PUBLISH_STATUS_POLLING_MS)
|
||||||
|
return function cleanup() {
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pollProgress = () => {
|
||||||
|
if (!progressId) return
|
||||||
|
if (
|
||||||
|
progress &&
|
||||||
|
(progress.workflow_state === 'completed' || progress.workflow_state === 'failed')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
const pollingLoop = () => {
|
||||||
|
doFetchApi({
|
||||||
|
path: `/api/v1/progress/${progressId}`,
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
setProgress(result.json)
|
||||||
|
setProgressCurrent(result.json.completion)
|
||||||
|
if (result.json.workflow_state === 'completed') {
|
||||||
|
handlePublishComplete()
|
||||||
|
} else if (result.json.workflow_state === 'failed') {
|
||||||
|
showFlashError(I18n.t('Your publishing job has failed.'))
|
||||||
|
handlePublishComplete()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showFlashError(I18n.t('There was an error while saving your changes'))(error)
|
||||||
|
setIsPublishing(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pollingLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsPublishing(false)
|
||||||
|
if (!progressId) return
|
||||||
|
if (
|
||||||
|
progress &&
|
||||||
|
(progress.workflow_state === 'completed' || progress.workflow_state === 'failed')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
doFetchApi({
|
||||||
|
path: `/api/v1/progress/${progressId}/cancel`,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
.then(_result => {
|
||||||
|
setProgress(null)
|
||||||
|
window.location.reload() // We reload the page to get the current state of all the module items
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showFlashError(I18n.t('There was an error while saving your changes'))(error)
|
||||||
|
setIsPublishing(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePublish = () => {
|
||||||
|
setIsPublishing(true)
|
||||||
|
onPublish()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePublishComplete = () => {
|
||||||
|
// Remove progress id if one was loaded with page
|
||||||
|
const publishMenu = document.getElementById('context-modules-publish-menu')
|
||||||
|
if (publishMenu) {
|
||||||
|
publishMenu.dataset.progressId = ''
|
||||||
|
}
|
||||||
|
onPublishComplete(publishItems)
|
||||||
|
setProgress(null)
|
||||||
|
setProgressCurrent(0)
|
||||||
|
setIsPublishing(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressBar = () => {
|
||||||
|
if (!progressId) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View as="div" padding="medium none">
|
||||||
|
<Text size="small" weight="bold">
|
||||||
|
{I18n.t('Publishing Progress')}
|
||||||
|
</Text>
|
||||||
|
<ProgressBar
|
||||||
|
screenReaderLabel={I18n.t('Publishing Progress')}
|
||||||
|
formatScreenReaderValue={({valueNow, valueMax}) => {
|
||||||
|
return Math.round((valueNow / valueMax) * 100) + ' percent'
|
||||||
|
}}
|
||||||
|
renderValue={({valueNow, valueMax}) => {
|
||||||
|
return <Text size="small">{Math.round((valueNow / valueMax) * 100)}%</Text>
|
||||||
|
}}
|
||||||
|
valueMax={100}
|
||||||
|
valueNow={progressCurrent}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={isOpen}
|
||||||
|
onDismiss={onClose}
|
||||||
|
size="small"
|
||||||
|
label={title}
|
||||||
|
shouldCloseOnDocumentClick={false}
|
||||||
|
>
|
||||||
|
<Modal.Header>
|
||||||
|
<CloseButton
|
||||||
|
placement="end"
|
||||||
|
offset="small"
|
||||||
|
onClick={onClose}
|
||||||
|
screenReaderLabel={I18n.t('Close')}
|
||||||
|
/>
|
||||||
|
<Heading>{title}</Heading>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<View as="div">
|
||||||
|
<Text>
|
||||||
|
{I18n.t(
|
||||||
|
'This process could take a few minutes. Hitting cancel will stop the process, but items that have already been processed will not be reverted to their previous state.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{progressBar()}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button onClick={handleCancel} disabled={!isPublishing} margin="0 x-small 0 0">
|
||||||
|
{I18n.t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
handlePublish()
|
||||||
|
}}
|
||||||
|
color="primary"
|
||||||
|
disabled={isPublishing}
|
||||||
|
>
|
||||||
|
{I18n.t('Continue')}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContextModulesPublishModal
|
|
@ -0,0 +1,81 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 - present Instructure, Inc.
|
||||||
|
*
|
||||||
|
* This file is part of Canvas.
|
||||||
|
*
|
||||||
|
* Canvas is free software: you can redistribute it and/or modify it under
|
||||||
|
* the terms of the GNU Affero General Public License as published by the Free
|
||||||
|
* Software Foundation, version 3 of the License.
|
||||||
|
*
|
||||||
|
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
* details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License along
|
||||||
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import {act, render} from '@testing-library/react'
|
||||||
|
import doFetchApi from '@canvas/do-fetch-api-effect'
|
||||||
|
import ContextModulesPublishMenu from '../ContextModulesPublishMenu'
|
||||||
|
|
||||||
|
// jest.mock('@canvas/do-fetch-api-effect')
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
courseId: '1',
|
||||||
|
}
|
||||||
|
|
||||||
|
// beforeAll(() => {
|
||||||
|
// doFetchApi.mockResolvedValue({response: {ok: true}, json: {completed: []}})
|
||||||
|
// })
|
||||||
|
|
||||||
|
// beforeEach(() => {
|
||||||
|
// doFetchApi.mockClear()
|
||||||
|
// })
|
||||||
|
|
||||||
|
// afterEach(() => {
|
||||||
|
// jest.clearAllMocks()
|
||||||
|
// })
|
||||||
|
|
||||||
|
describe('ContextModulesPublishMenu', () => {
|
||||||
|
it('renders the menu when clicked', () => {
|
||||||
|
const {getByRole, getByText} = render(<ContextModulesPublishMenu {...defaultProps} />)
|
||||||
|
const menuButton = getByRole('button')
|
||||||
|
act(() => menuButton.click())
|
||||||
|
expect(getByText('Publish all modules and items')).toBeInTheDocument()
|
||||||
|
expect(getByText('Publish modules only')).toBeInTheDocument()
|
||||||
|
expect(getByText('Unpublish all modules and items')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls publishAll when clicked publish all menu item is clicked', () => {
|
||||||
|
const {getByRole, getByText} = render(<ContextModulesPublishMenu {...defaultProps} />)
|
||||||
|
const menuButton = getByRole('button')
|
||||||
|
act(() => menuButton.click())
|
||||||
|
const publishButton = getByText('Publish all modules and items')
|
||||||
|
act(() => publishButton.click())
|
||||||
|
const modalTitle = getByRole('heading', {name: 'Publish all modules and items'})
|
||||||
|
expect(modalTitle).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls publishModuleOnly when clicked publish module menu item is clicked', () => {
|
||||||
|
const {getByRole, getByText} = render(<ContextModulesPublishMenu {...defaultProps} />)
|
||||||
|
const menuButton = getByRole('button')
|
||||||
|
act(() => menuButton.click())
|
||||||
|
const publishButton = getByText('Publish modules only')
|
||||||
|
act(() => publishButton.click())
|
||||||
|
const modalTitle = getByRole('heading', {name: 'Publish modules only'})
|
||||||
|
expect(modalTitle).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls unpublishAll when clicked unpublish all items is clicked', () => {
|
||||||
|
const {getByRole, getByText} = render(<ContextModulesPublishMenu {...defaultProps} />)
|
||||||
|
const menuButton = getByRole('button')
|
||||||
|
act(() => menuButton.click())
|
||||||
|
const publishButton = getByText('Unpublish all modules and items')
|
||||||
|
act(() => publishButton.click())
|
||||||
|
const modalTitle = getByRole('heading', {name: 'Unpublish all modules and items'})
|
||||||
|
expect(modalTitle).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,81 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 - present Instructure, Inc.
|
||||||
|
*
|
||||||
|
* This file is part of Canvas.
|
||||||
|
*
|
||||||
|
* Canvas is free software: you can redistribute it and/or modify it under
|
||||||
|
* the terms of the GNU Affero General Public License as published by the Free
|
||||||
|
* Software Foundation, version 3 of the License.
|
||||||
|
*
|
||||||
|
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
* details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License along
|
||||||
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import {act, render} from '@testing-library/react'
|
||||||
|
import doFetchApi from '@canvas/do-fetch-api-effect'
|
||||||
|
import ContextModulesPublishModal from '../ContextModulesPublishModal'
|
||||||
|
|
||||||
|
jest.mock('@canvas/do-fetch-api-effect')
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
isOpen: true,
|
||||||
|
onClose: () => {},
|
||||||
|
onPublish: () => {},
|
||||||
|
onPublishComplete: () => {},
|
||||||
|
progressId: null,
|
||||||
|
publishItems: false,
|
||||||
|
title: 'Test Title',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
doFetchApi.mockResolvedValue({response: {ok: true}, json: {completed: []}})
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
doFetchApi.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ContextModulesPublishModal', () => {
|
||||||
|
it('renders the title', () => {
|
||||||
|
const {getByRole} = render(<ContextModulesPublishModal {...defaultProps} />)
|
||||||
|
const modalTitle = getByRole('heading', {name: 'Test Title'})
|
||||||
|
expect(modalTitle).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onPublish when the publish button is clicked', () => {
|
||||||
|
const onPublish = jest.fn()
|
||||||
|
const {getByText} = render(
|
||||||
|
<ContextModulesPublishModal {...defaultProps} onPublish={onPublish} />
|
||||||
|
)
|
||||||
|
const publishButton = getByText('Continue')
|
||||||
|
act(() => publishButton.click())
|
||||||
|
expect(onPublish).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a disabled cancel button if not publishing', () => {
|
||||||
|
const {getByRole} = render(<ContextModulesPublishModal {...defaultProps} />)
|
||||||
|
const cancelButton = getByRole('button', {name: 'Cancel'})
|
||||||
|
expect(cancelButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has an enabled cancel button if there is a progressId', () => {
|
||||||
|
const onPublish = jest.fn()
|
||||||
|
const {getByRole} = render(
|
||||||
|
<ContextModulesPublishModal {...defaultProps} onPublish={onPublish} />
|
||||||
|
)
|
||||||
|
const publishButton = getByRole('button', {name: 'Continue'})
|
||||||
|
act(() => publishButton.click())
|
||||||
|
const cancelButton = getByRole('button', {name: 'Cancel'})
|
||||||
|
expect(cancelButton).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "@canvas/context-modules-publish-menu",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
|
@ -69,6 +69,7 @@ import {
|
||||||
setExpandAllButton,
|
setExpandAllButton,
|
||||||
updateProgressionState,
|
updateProgressionState,
|
||||||
} from './utils'
|
} from './utils'
|
||||||
|
import ContextModulesPublishIcon from '@canvas/context-modules-publish-icon/ContextModulesPublishIcon'
|
||||||
|
|
||||||
const I18n = useI18nScope('context_modulespublic')
|
const I18n = useI18nScope('context_modulespublic')
|
||||||
|
|
||||||
|
@ -1011,6 +1012,23 @@ modules.initModuleManagement = function (duplicate) {
|
||||||
}
|
}
|
||||||
const view = initPublishButton($publishIcon, publishData)
|
const view = initPublishButton($publishIcon, publishData)
|
||||||
overrideModel(moduleItems, relock_modules_dialog, view.model, view)
|
overrideModel(moduleItems, relock_modules_dialog, view.model, view)
|
||||||
|
|
||||||
|
if (window.ENV?.FEATURES?.module_publish_menu) {
|
||||||
|
const publishMenu = $module.find('.module-publish-icon')[0]
|
||||||
|
// Make sure the data attributes on publishMenu are correct. When a new module is created it copies from the
|
||||||
|
// template and may not have the correct data attributes.
|
||||||
|
publishMenu.dataset.courseId = data.context_module.context_id
|
||||||
|
publishMenu.dataset.moduleId = data.context_module.id
|
||||||
|
publishMenu.dataset.published = data.context_module.workflow_state === 'published'
|
||||||
|
ReactDOM.render(
|
||||||
|
<ContextModulesPublishIcon
|
||||||
|
courseId={data.context_module.context_id}
|
||||||
|
moduleId={data.context_module.id}
|
||||||
|
published={data.context_module.workflow_state === 'published'}
|
||||||
|
/>,
|
||||||
|
publishMenu
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
relock_modules_dialog.renderIfNeeded(data.context_module)
|
relock_modules_dialog.renderIfNeeded(data.context_module)
|
||||||
$module.triggerHandler('update', data)
|
$module.triggerHandler('update', data)
|
||||||
|
|
Loading…
Reference in New Issue