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:
Eric Saupe 2022-10-31 10:35:43 -07:00
parent 2197a798a8
commit 2adf275017
25 changed files with 1406 additions and 46 deletions

View File

@ -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

View File

@ -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?

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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 %>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
)
})
})

View File

@ -0,0 +1,5 @@
{
"name": "@canvas-features/context_modules_publish_icon",
"private": true,
"version": "1.0.0"
}

View File

@ -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
)
}
})

View File

@ -0,0 +1,5 @@
{
"name": "@canvas-features/context_modules_publish_menu",
"private": true,
"version": "1.0.0"
}

View File

@ -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

View File

@ -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}},
})
})
})

View File

@ -0,0 +1,5 @@
{
"name": "@canvas/context-modules-publish-icon",
"private": true,
"version": "1.0.0"
}

View File

@ -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

View File

@ -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

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -0,0 +1,5 @@
{
"name": "@canvas/context-modules-publish-menu",
"private": true,
"version": "1.0.0"
}

View File

@ -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)