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[
|
||||
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
|
||||
course_paces_redesign course_paces_for_students rce_better_paste
|
||||
course_paces_redesign course_paces_for_students rce_better_paste module_publish_menu
|
||||
].freeze
|
||||
JS_ENV_ROOT_ACCOUNT_FEATURES = %i[
|
||||
product_tours files_dnd usage_rights_discussion_topics
|
||||
|
|
|
@ -254,6 +254,9 @@ class ContextModulesApiController < ApplicationController
|
|||
# @argument event [Required, String]
|
||||
# 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.
|
||||
#
|
||||
# @example_request
|
||||
|
@ -266,7 +269,8 @@ class ContextModulesApiController < ApplicationController
|
|||
#
|
||||
# @example_response
|
||||
# {
|
||||
# "completed": [1, 2]
|
||||
# "completed": [1, 2],
|
||||
# "progress": null,
|
||||
# }
|
||||
def batch_update
|
||||
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)
|
||||
return render(json: { message: "no modules found" }, status: :not_found) if modules.empty?
|
||||
|
||||
completed_ids = []
|
||||
modules.each do |mod|
|
||||
case event
|
||||
when "publish"
|
||||
unless mod.active?
|
||||
mod.publish
|
||||
mod.publish_items!
|
||||
end
|
||||
when "unpublish"
|
||||
mod.unpublish unless mod.unpublished?
|
||||
when "delete"
|
||||
mod.destroy
|
||||
end
|
||||
completed_ids << mod.id
|
||||
batch_update_params = {
|
||||
event: event,
|
||||
module_ids: modules.pluck(:id),
|
||||
skip_content_tags: value_to_boolean(params[:skip_content_tags])
|
||||
}
|
||||
if value_to_boolean(params[:async]) && Account.site_admin.feature_enabled?(:module_publish_menu)
|
||||
progress = Progress.create!(context: @context, tag: "context_module_batch_update", user: @current_user)
|
||||
progress.process_job(
|
||||
@context,
|
||||
:batch_update_context_modules,
|
||||
{ run_at: Time.now },
|
||||
batch_update_params
|
||||
)
|
||||
else
|
||||
completed_ids = @context.batch_update_context_modules(batch_update_params)
|
||||
end
|
||||
|
||||
render json: { completed: completed_ids }
|
||||
render json: { completed: completed_ids, progress: progress }
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -414,10 +418,15 @@ class ContextModulesApiController < ApplicationController
|
|||
if params[:module].key?(:published)
|
||||
if value_to_boolean(params[:module][:published])
|
||||
@module.publish
|
||||
@module.publish_items!
|
||||
publish_warning = @module.content_tags.any?(&:unpublished?)
|
||||
unless Account.site_admin.feature_enabled?(:module_publish_menu) && value_to_boolean(params[:module][:skip_content_tags])
|
||||
@module.publish_items!
|
||||
publish_warning = @module.content_tags.any?(&:unpublished?)
|
||||
end
|
||||
else
|
||||
@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
|
||||
relock_warning = @module.relock_warning?
|
||||
|
|
|
@ -182,6 +182,14 @@ class ContextModulesController < ApplicationController
|
|||
|
||||
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
|
||||
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?
|
||||
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
|
||||
|
|
|
@ -350,19 +350,19 @@ class ContextModule < ActiveRecord::Base
|
|||
}
|
||||
alias_method :published?, :active?
|
||||
|
||||
def publish_items!
|
||||
enable_publish_at = context.root_account.feature_enabled?(:scheduled_page_publication)
|
||||
content_tags.each do |tag|
|
||||
if tag.unpublished?
|
||||
if tag.content_type == "Attachment"
|
||||
tag.content.set_publish_state_for_usage_rights
|
||||
tag.content.save!
|
||||
tag.publish if tag.content.published?
|
||||
else
|
||||
tag.publish unless enable_publish_at && tag.content.respond_to?(:publish_at) && tag.content.publish_at
|
||||
end
|
||||
end
|
||||
tag.update_asset_workflow_state!
|
||||
def publish_items!(progress: nil)
|
||||
content_tags.each do |content_tag|
|
||||
break if progress&.reload&.failed?
|
||||
|
||||
content_tag.trigger_publish!
|
||||
end
|
||||
end
|
||||
|
||||
def unpublish_items!(progress: nil)
|
||||
content_tags.each do |content_tag|
|
||||
break if progress&.reload&.failed?
|
||||
|
||||
content_tag.trigger_unpublish!
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -4155,6 +4155,34 @@ class Course < ActiveRecord::Base
|
|||
!templated_accounts.exists?
|
||||
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
|
||||
|
||||
def effective_due_dates
|
||||
|
|
|
@ -22,9 +22,11 @@
|
|||
:COLLAPSED_MODULES => @collapsed_modules,
|
||||
:IS_STUDENT => can_do(@context, @current_user, :participate_as_student),
|
||||
:COURSE_ID => @context.id,
|
||||
:PUBLISH_MENU_PROGRESS_ID => @progress&.id,
|
||||
:NO_MODULE_PROGRESSIONS => @context.large_roster,
|
||||
})
|
||||
%>
|
||||
<% js_bundle :context_modules_publish_menu %>
|
||||
<% js_bundle :module_dnd %>
|
||||
<% css_bundle :react_files %>
|
||||
<% if course_home %>
|
||||
|
@ -56,6 +58,9 @@
|
|||
</button>
|
||||
<% 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 %>
|
||||
<button class="btn btn-primary add_module_link">
|
||||
<i class="icon-plus" role="presentation"></i>
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
can_direct_share = can_do(@context, @current_user, :direct_share)
|
||||
workflow_state = context_module && context_module.workflow_state
|
||||
@modules ||= []
|
||||
js_bundle :context_modules_publish_icon
|
||||
%>
|
||||
|
||||
<% cache_if_module(context_module, @can_view, @is_student, @can_view_unpublished, @current_user, @context) do %>
|
||||
|
@ -117,19 +118,28 @@
|
|||
<% end %>
|
||||
|
||||
<% if @can_edit%>
|
||||
<span
|
||||
data-module-type="module"
|
||||
data-id="<%= context_module && context_module.id %>"
|
||||
data-course-id="<%= context_module && context_module.context_id %>"
|
||||
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>
|
||||
<% if Account.site_admin.feature_enabled?(:module_publish_menu) %>
|
||||
<div
|
||||
data-course-id="<%= context_module && context_module.context_id %>"
|
||||
data-module-id="<%= context_module && context_module.id %>"
|
||||
data-published="<%= module_data[:published_status] == 'published' %>"
|
||||
class="module-publish-icon">
|
||||
</div>
|
||||
<% else %>
|
||||
<span
|
||||
data-module-type="module"
|
||||
data-id="<%= context_module && context_module.id %>"
|
||||
data-course-id="<%= context_module && context_module.context_id %>"
|
||||
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 %>
|
||||
|
||||
<% if @can_add %>
|
||||
|
|
|
@ -110,3 +110,8 @@ deprecate_faculty_journal:
|
|||
state: allowed_on
|
||||
development:
|
||||
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(other_module.reload).to be_active
|
||||
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
|
||||
|
||||
describe "update" do
|
||||
|
@ -614,6 +694,71 @@ describe "Modules API", type: :request do
|
|||
new_module.reload
|
||||
expect(new_module.prerequisites.pluck(:id).sort).to be_empty
|
||||
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
|
||||
|
||||
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)
|
||||
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
|
||||
|
|
|
@ -7659,4 +7659,121 @@ describe Course do
|
|||
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
|
||||
|
|
|
@ -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,
|
||||
updateProgressionState,
|
||||
} from './utils'
|
||||
import ContextModulesPublishIcon from '@canvas/context-modules-publish-icon/ContextModulesPublishIcon'
|
||||
|
||||
const I18n = useI18nScope('context_modulespublic')
|
||||
|
||||
|
@ -1011,6 +1012,23 @@ modules.initModuleManagement = function (duplicate) {
|
|||
}
|
||||
const view = initPublishButton($publishIcon, publishData)
|
||||
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)
|
||||
$module.triggerHandler('update', data)
|
||||
|
|
Loading…
Reference in New Issue