Add the ability to duplicate modules.
Closes COMMS-148 Closes COMMS-147 Test Plan: * Create a module with a variety of items in it. * Duplicate it. * The new module should appear now. * Make sure that this all works in conjunction with various other ui actions on the page without needing a refresh. * The "duplicate" option should appear iff the module in question has no quizzes in it. Modules with quizzes cannot be duplicated for the moment. * Make sure that the ability to "duplicate" is updated properly if quizzes are added to/removed from the module. Change-Id: I288c56f42a89abad5c725fa3cbd91b02ea11a460 Reviewed-on: https://gerrit.instructure.com/128960 Reviewed-by: Felix Milea-Ciobanu <fmileaciobanu@instructure.com> QA-Review: Heath Hales <hhales@instructure.com> Tested-by: Jenkins Product-Review: Christi Wruck
This commit is contained in:
parent
625da046e6
commit
d352cfbd8b
|
@ -415,6 +415,36 @@ class ContextModulesApiController < ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def duplicate
|
||||
if authorized_action(@context, @current_user, :manage_content)
|
||||
old_module = @context.modules_visible_to(@current_user).find(params[:module_id])
|
||||
if !@context.root_account.feature_enabled?(:duplicate_modules)
|
||||
return render json: { error: 'duplicating objects not enabled' }, status: :bad_request
|
||||
end
|
||||
return render json: { error: 'unable to find module to duplicate' }, status: :bad_request unless old_module
|
||||
return render json: { error: 'cannot duplicate this module' }, status: :bad_request unless old_module.can_be_duplicated?
|
||||
|
||||
new_module = old_module.duplicate
|
||||
new_module.save!
|
||||
new_module.insert_at(old_module.position + 1)
|
||||
if new_module
|
||||
result_json = new_module.as_json(include: :content_tags, methods: :workflow_state)
|
||||
attachment_tags = new_module.content_tags.select do |content_tag|
|
||||
content_tag.content_type == 'Attachment'
|
||||
end
|
||||
result_json['ENV_UPDATE'] = attachment_tags.map do |attachment_tag|
|
||||
{ :id => attachment_tag.id.to_s,
|
||||
:content_id => attachment_tag.content_id,
|
||||
:content_details => content_details(attachment_tag, @current_user, :for_admin => true)
|
||||
}
|
||||
end
|
||||
render :json => result_json
|
||||
else
|
||||
render :json => { error: 'cannot save new module' }, status: :bad_request
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# @note API Update multiple modules
|
||||
#
|
||||
# Update multiple modules in an account.
|
||||
|
|
|
@ -75,6 +75,7 @@ class ContextModulesController < ApplicationController
|
|||
module_file_details = load_module_file_details if @context.grants_right?(@current_user, session, :manage_content)
|
||||
js_env :course_id => @context.id,
|
||||
:CONTEXT_URL_ROOT => polymorphic_path([@context]),
|
||||
:DUPLICATE_ENABLED => @domain_root_account.feature_enabled?(:duplicate_modules),
|
||||
:FILES_CONTEXTS => [{asset_string: @context.asset_string}],
|
||||
:MODULE_FILE_DETAILS => module_file_details,
|
||||
:MODULE_FILE_PERMISSIONS => {
|
||||
|
|
|
@ -174,7 +174,7 @@ class Assignment < ActiveRecord::Base
|
|||
# override later. Just helps to avoid duplicate positions.
|
||||
result.position = Assignment.active.where(assignment_group: assignment_group).maximum(:position) + 1
|
||||
result.title =
|
||||
opts_with_default[:copy_title] ? opts_with_default[:copy_title] : get_copy_title(self, t("Copy"))
|
||||
opts_with_default[:copy_title] ? opts_with_default[:copy_title] : get_copy_title(self, t("Copy"), self.title)
|
||||
|
||||
if self.wiki_page && opts_with_default[:duplicate_wiki_page]
|
||||
result.wiki_page = self.wiki_page.duplicate({
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
class ContextModule < ActiveRecord::Base
|
||||
include Workflow
|
||||
include SearchTermHelper
|
||||
include DuplicatingObjects
|
||||
|
||||
belongs_to :context, polymorphic: [:course]
|
||||
has_many :context_module_progressions, :dependent => :destroy
|
||||
|
@ -155,6 +156,81 @@ class ContextModule < ActiveRecord::Base
|
|||
self.position
|
||||
end
|
||||
|
||||
def get_potentially_conflicting_titles(title_base)
|
||||
ContextModule.not_deleted.where(context_id: self.context_id)
|
||||
.starting_with_name(title_base).pluck("name").to_set
|
||||
end
|
||||
|
||||
def duplicate_base_model(copy_title)
|
||||
ContextModule.new({
|
||||
:context_id => self.context_id,
|
||||
:context_type => self.context_type,
|
||||
:name => copy_title,
|
||||
:position => ContextModule.not_deleted.where(context_id: self.context_id).maximum(:position) + 1,
|
||||
:prerequisites => self.prerequisites,
|
||||
:completion_requirements => self.completion_requirements,
|
||||
:workflow_state => 'unpublished',
|
||||
:unlock_at => self.unlock_at,
|
||||
:require_sequential_progress => self.require_sequential_progress,
|
||||
:completion_events => self.completion_events,
|
||||
:requirement_count => self.requirement_count
|
||||
})
|
||||
end
|
||||
|
||||
def can_be_duplicated?
|
||||
self.content_tags.none? do |content_tag|
|
||||
!content_tag.deleted? && content_tag.content_type_class == 'quiz'
|
||||
end
|
||||
end
|
||||
|
||||
# This is intended for duplicating a content tag when we are duplicating a module
|
||||
# Not intended for duplicating a content tag to keep in the original module
|
||||
def duplicate_content_tag_base_model(original_content_tag)
|
||||
ContentTag.new(
|
||||
:content_id => original_content_tag.content_id,
|
||||
:content_type => original_content_tag.content_type,
|
||||
:context_id => original_content_tag.context_id,
|
||||
:context_type => original_content_tag.context_type,
|
||||
:title => original_content_tag.title,
|
||||
:tag_type => original_content_tag.tag_type,
|
||||
:position => original_content_tag.position,
|
||||
:indent => original_content_tag.indent,
|
||||
:learning_outcome_id => original_content_tag.learning_outcome_id,
|
||||
:context_code => original_content_tag.context_code,
|
||||
:mastery_score => original_content_tag.mastery_score,
|
||||
:workflow_state => 'unpublished'
|
||||
)
|
||||
end
|
||||
private :duplicate_content_tag_base_model
|
||||
|
||||
# Intended for taking a content_tag in this module and duplicating it
|
||||
# into a new module. Not intended for duplicating a content tag to be
|
||||
# kept in the same module.
|
||||
def duplicate_content_tag(original_content_tag)
|
||||
new_tag = duplicate_content_tag_base_model(original_content_tag)
|
||||
if original_content_tag.content&.respond_to?(:duplicate)
|
||||
new_tag.content = original_content_tag.content.duplicate
|
||||
# If we have multiple assignments (e.g.) make sure they each get unused titles.
|
||||
# A title isn't marked used if the assignment hasn't been saved yet.
|
||||
new_tag.content.save!
|
||||
new_tag.title = nil
|
||||
end
|
||||
new_tag
|
||||
end
|
||||
private :duplicate_content_tag
|
||||
|
||||
def duplicate
|
||||
copy_title = get_copy_title(self, t("Copy"), self.name)
|
||||
new_module = duplicate_base_model(copy_title)
|
||||
living_tags = self.content_tags.select do |content_tag|
|
||||
!content_tag.deleted?
|
||||
end
|
||||
new_module.content_tags = living_tags.map do |content_tag|
|
||||
duplicate_content_tag(content_tag)
|
||||
end
|
||||
new_module
|
||||
end
|
||||
|
||||
def validate_prerequisites
|
||||
positions = ContextModule.module_positions(self.context)
|
||||
@already_confirmed_valid_requirements = false
|
||||
|
@ -207,7 +283,9 @@ class ContextModule < ActiveRecord::Base
|
|||
scope :active, -> { where(:workflow_state => 'active') }
|
||||
scope :unpublished, -> { where(:workflow_state => 'unpublished') }
|
||||
scope :not_deleted, -> { where("context_modules.workflow_state<>'deleted'") }
|
||||
|
||||
scope :starting_with_name, lambda { |name|
|
||||
where('name ILIKE ?', "#{name}%")
|
||||
}
|
||||
alias_method :published?, :active?
|
||||
|
||||
def publish_items!
|
||||
|
|
|
@ -331,7 +331,8 @@ class DiscussionTopic < ActiveRecord::Base
|
|||
:user => nil
|
||||
}
|
||||
opts_with_default = default_opts.merge(opts)
|
||||
copy_title = opts_with_default[:copy_title] ? opts_with_default[:copy_title] : get_copy_title(self, t("Copy"))
|
||||
copy_title =
|
||||
opts_with_default[:copy_title] ? opts_with_default[:copy_title] : get_copy_title(self, t("Copy"), self.title)
|
||||
result = self.duplicate_base_model(copy_title, opts_with_default)
|
||||
|
||||
if self.assignment && opts_with_default[:duplicate_assignment]
|
||||
|
|
|
@ -424,7 +424,8 @@ class WikiPage < ActiveRecord::Base
|
|||
}
|
||||
opts_with_default = default_opts.merge(opts)
|
||||
result = WikiPage.new({
|
||||
:title => opts_with_default[:copy_title] ? opts_with_default[:copy_title] : get_copy_title(self, t("Copy")),
|
||||
:title =>
|
||||
opts_with_default[:copy_title] ? opts_with_default[:copy_title] : get_copy_title(self, t("Copy"), self.title),
|
||||
:wiki_id => self.wiki_id,
|
||||
:context_id => self.context_id,
|
||||
:context_type => self.context_type,
|
||||
|
|
|
@ -155,6 +155,13 @@
|
|||
class="delete_module_link icon-trash"
|
||||
title="<%= t('links.title.delete_module', %{Delete this module}) %>"><%= t('links.text.delete_module', %{Delete}) %></a>
|
||||
</li>
|
||||
<li role="presentation" class="duplicate_module_menu_item">
|
||||
<a
|
||||
href="/api/v1<%= context_url(@context, :context_url) %>/modules/<%= context_module ? context_module.id : "{{ id }}" %>/duplicate"
|
||||
class="duplicate_module_link icon-copy-course"
|
||||
aria-label="<%= t('Duplicate Module %{module_name}') %>"
|
||||
title="<%= t(%{Delete this module}) %>"><%= t(%{Duplicate}) %></a>
|
||||
</li>
|
||||
<%= external_tools_menu_items(@menu_tools[:module_menu], {link_class: "menu_tool_link", settings_key: :module_menu, in_list: true, url_params: {:modules => [context_module ? context_module.id : "{{ id }}"]}}) %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
|
|
@ -1556,6 +1556,7 @@ CanvasRails::Application.routes.draw do
|
|||
get "courses/:course_id/modules", action: :index, as: 'course_context_modules'
|
||||
get "courses/:course_id/modules/:id", action: :show, as: 'course_context_module'
|
||||
put "courses/:course_id/modules", action: :batch_update
|
||||
post "courses/:course_id/modules/:module_id/duplicate", action: :duplicate
|
||||
post "courses/:course_id/modules", action: :create, as: 'course_context_module_create'
|
||||
put "courses/:course_id/modules/:id", action: :update, as: 'course_context_module_update'
|
||||
delete "courses/:course_id/modules/:id", action: :destroy
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
module DuplicatingObjects
|
||||
# Lowercases title and replaces spaces with hyphens (to allow to check for
|
||||
# matching titles that differ only in case or space/hyphens
|
||||
# matching titles that differ only in case or space/hyphens)
|
||||
def normalize_title(title)
|
||||
title.gsub(/ /, '-').downcase
|
||||
end
|
||||
|
@ -27,7 +27,7 @@ module DuplicatingObjects
|
|||
# should provide a function "get_potentially_conflicting_titles" that
|
||||
# returns a set of titles that might conflict with the entity's title.
|
||||
#
|
||||
# If entity.title ends in "#{copy_suffix} #" (or without the number),
|
||||
# If the given title ends in "#{copy_suffix} #" (or without the number),
|
||||
# tries incrementing from # (or 2 if no number is given) until one is
|
||||
# found that isn't in the set returned by entity.get_potentially_conflicting_titles
|
||||
#
|
||||
|
@ -36,16 +36,16 @@ module DuplicatingObjects
|
|||
#
|
||||
# For the purposes of matching, conflicts are case-insensitive and also
|
||||
# treats hyphens and spaces as the same thing.
|
||||
def get_copy_title(entity, copy_suffix)
|
||||
title = entity.title
|
||||
is_multiple_copy = !(normalize_title(title)=~
|
||||
def get_copy_title(entity, copy_suffix, entity_title)
|
||||
is_multiple_copy = !(normalize_title(entity_title)=~
|
||||
/#{Regexp.quote(copy_suffix.downcase)}-[0-9]*$/).nil?
|
||||
if title.end_with?(copy_suffix) || is_multiple_copy
|
||||
potential_title = title
|
||||
possible_num_to_try = normalize_title(title).scan(/\d+$/).first
|
||||
normalized_suffix = normalize_title(copy_suffix)
|
||||
if normalize_title(entity_title).end_with?(normalized_suffix) || is_multiple_copy
|
||||
potential_title = entity_title
|
||||
possible_num_to_try = normalize_title(entity_title).scan(/\d+$/).first
|
||||
num_to_try = possible_num_to_try ? possible_num_to_try.to_i + 1 : 2
|
||||
else
|
||||
potential_title = "#{title} #{copy_suffix}"
|
||||
potential_title = "#{entity_title} #{copy_suffix}"
|
||||
num_to_try = 2
|
||||
end
|
||||
title_base = !is_multiple_copy ? potential_title + " " : potential_title.gsub(/\d+$/, '')
|
||||
|
|
|
@ -244,6 +244,15 @@ END
|
|||
root_opt_in: true,
|
||||
beta: true
|
||||
},
|
||||
'duplicate_modules' =>
|
||||
{
|
||||
display_name: -> { I18n.t('Duplicate Modules') },
|
||||
description: -> { I18n.t("Allows the duplicating of modules in Canvas") },
|
||||
applies_to: 'Account',
|
||||
state: 'hidden',
|
||||
root_opt_in: true,
|
||||
beta: true
|
||||
},
|
||||
'allow_opt_out_of_inbox' =>
|
||||
{
|
||||
display_name: -> { I18n.t('features.allow_opt_out_of_inbox', "Allow Users to Opt-out of the Inbox") },
|
||||
|
|
|
@ -57,6 +57,13 @@ import './vendor/jquery.scrollTo'
|
|||
import 'jqueryui/sortable'
|
||||
import 'compiled/jquery.rails_flash_notifications'
|
||||
|
||||
function refreshDuplicateLinkStatus($module) {
|
||||
if (ENV.DUPLICATE_ENABLED && !$module.find('.context_module_item.quiz').length) {
|
||||
$module.find('.duplicate_module_menu_item').show();
|
||||
} else {
|
||||
$module.find('.duplicate_module_menu_item').hide();
|
||||
}
|
||||
}
|
||||
// TODO: AMD don't export global, use as module
|
||||
/*global modules*/
|
||||
window.modules = (function() {
|
||||
|
@ -135,6 +142,9 @@ import 'compiled/jquery.rails_flash_notifications'
|
|||
$module.find(".content").errorBox(I18n.t('errors.reorder', 'Reorder failed, please try again.'));
|
||||
})
|
||||
);
|
||||
$('.context_module').each(function() {
|
||||
refreshDuplicateLinkStatus($(this));
|
||||
});
|
||||
},
|
||||
|
||||
updateProgressions: function(callback) {
|
||||
|
@ -424,6 +434,7 @@ import 'compiled/jquery.rails_flash_notifications'
|
|||
$before.before($item.show());
|
||||
}
|
||||
}
|
||||
refreshDuplicateLinkStatus($module);
|
||||
return $item;
|
||||
},
|
||||
|
||||
|
@ -1022,6 +1033,38 @@ import 'compiled/jquery.rails_flash_notifications'
|
|||
})
|
||||
});
|
||||
|
||||
$(".duplicate_module_link").live('click', function(event) {
|
||||
event.preventDefault();
|
||||
const duplicateRequestUrl = $(this).attr('href');
|
||||
const duplicatedModuleElement = $(this).parents(".context_module");
|
||||
const duplicatedModuleElementId = duplicatedModuleElement[0].id;
|
||||
|
||||
const renderDuplicatedModule = function(response) {
|
||||
response.data.ENV_UPDATE.forEach((newAttachmentItem) => {
|
||||
ENV.MODULE_FILE_DETAILS[newAttachmentItem.id] = newAttachmentItem
|
||||
});
|
||||
const contextModule = response.data.context_module;
|
||||
const newModuleId = response.data.context_module.id;
|
||||
var context_response = response;
|
||||
axios.get(location.href).then((response) => {
|
||||
const $newContent = $(response.data);
|
||||
const $newModule = $newContent.find("#context_module_" + newModuleId);
|
||||
$newModule.insertAfter(duplicatedModuleElement);
|
||||
modules.updateAssignmentData();
|
||||
// Without these two 'die' commands, the event handler happens twice after
|
||||
// initModuleManagement is called.
|
||||
$('.delete_module_link').die();
|
||||
$('.duplicate_module_link').die();
|
||||
$(".context_module").find(".expand_module_link,.collapse_module_link").bind('click keyclick', toggleModuleCollapse);
|
||||
modules.initModuleManagement();
|
||||
}).catch(showFlashError(I18n.t('Error rendering duplicated module')));
|
||||
}
|
||||
|
||||
axios.post(duplicateRequestUrl, {})
|
||||
.then(renderDuplicatedModule)
|
||||
.catch(showFlashError(I18n.t('Error duplicating module')));
|
||||
});
|
||||
|
||||
$(".delete_module_link").live('click', function(event) {
|
||||
event.preventDefault();
|
||||
$(this).parents(".context_module").confirmDelete({
|
||||
|
@ -1139,6 +1182,7 @@ import 'compiled/jquery.rails_flash_notifications'
|
|||
var $currentCogLink = $(this).closest('.cog-menu-container').children('.al-trigger');
|
||||
// Get the previous cog item to focus after delete
|
||||
var $allInCurrentModule = $(this).parents('.context_module_items').children()
|
||||
var $currentModule = $(this).parents('.context_module');
|
||||
var curIndex = $allInCurrentModule.index($(this).parents('.context_module_item'));
|
||||
var newIndex = curIndex - 1;
|
||||
var $previousCogLink;
|
||||
|
@ -1156,6 +1200,7 @@ import 'compiled/jquery.rails_flash_notifications'
|
|||
$(this).remove();
|
||||
modules.updateTaggedItems();
|
||||
$previousCogLink.focus();
|
||||
refreshDuplicateLinkStatus($currentModule);
|
||||
});
|
||||
$.flashMessage(I18n.t("Module item %{module_item_name} was successfully deleted.", {module_item_name: data.content_tag.title}));
|
||||
},
|
||||
|
@ -1454,7 +1499,6 @@ import 'compiled/jquery.rails_flash_notifications'
|
|||
|
||||
var initPublishButton = function($el, data) {
|
||||
data = data || $el.data();
|
||||
|
||||
if(data.moduleType == 'attachment'){
|
||||
// Module isNew if it was created with an ajax request vs being loaded when the page loads
|
||||
var moduleItem = {};
|
||||
|
@ -1650,7 +1694,95 @@ import 'compiled/jquery.rails_flash_notifications'
|
|||
}
|
||||
}
|
||||
|
||||
var toggleModuleCollapse = function(event, goSlow) {
|
||||
event.preventDefault();
|
||||
var expandCallback = null;
|
||||
if(goSlow && $.isFunction(goSlow)) {
|
||||
expandCallback = goSlow;
|
||||
goSlow = null;
|
||||
}
|
||||
var collapse = $(this).hasClass('collapse_module_link') ? '1' : '0';
|
||||
var $module = $(this).parents(".context_module");
|
||||
var reload_entries = $module.find(".content .context_module_items").children().length === 0;
|
||||
var toggle = function(show) {
|
||||
var callback = function() {
|
||||
$module.find(".collapse_module_link").css('display', $module.find(".content:visible").length > 0 ? 'inline-block' : 'none');
|
||||
$module.find(".expand_module_link").css('display', $module.find(".content:visible").length === 0 ? 'inline-block' : 'none');
|
||||
if($module.find(".content:visible").length > 0) {
|
||||
$module.find(".footer .manage_module").css('display', '');
|
||||
$module.toggleClass('collapsed_module', false);
|
||||
// Makes sure the resulting item has focus.
|
||||
$module.find(".collapse_module_link").focus();
|
||||
$.screenReaderFlashMessage(I18n.t('Expanded'));
|
||||
|
||||
} else {
|
||||
$module.find(".footer .manage_module").css('display', ''); //'none');
|
||||
$module.toggleClass('collapsed_module', true);
|
||||
// Makes sure the resulting item has focus.
|
||||
$module.find(".expand_module_link").focus();
|
||||
$.screenReaderFlashMessage(I18n.t('Collapsed'));
|
||||
}
|
||||
if(expandCallback && $.isFunction(expandCallback)) {
|
||||
expandCallback();
|
||||
}
|
||||
};
|
||||
if(show) {
|
||||
$module.find(".content").show();
|
||||
callback();
|
||||
} else {
|
||||
$module.find(".content").slideToggle(callback);
|
||||
}
|
||||
|
||||
}
|
||||
if(reload_entries || goSlow) {
|
||||
$module.loadingImage();
|
||||
}
|
||||
var url = $(this).attr('href');
|
||||
if(goSlow) {
|
||||
url = $module.find(".edit_module_link").attr('href');
|
||||
}
|
||||
$.ajaxJSON(url, (goSlow ? 'GET' : 'POST'), {collapse: collapse}, function(data) {
|
||||
if(goSlow) {
|
||||
$module.loadingImage('remove');
|
||||
var items = data;
|
||||
var next = function() {
|
||||
var item = items.shift();
|
||||
if(item) {
|
||||
modules.addItemToModule($module, item.content_tag);
|
||||
next();
|
||||
} else {
|
||||
$module.find(".context_module_items.ui-sortable").sortable('refresh');
|
||||
toggle(true);
|
||||
modules.updateProgressionState($module);
|
||||
$("#context_modules").triggerHandler('slow_load');
|
||||
}
|
||||
};
|
||||
next();
|
||||
} else {
|
||||
if(reload_entries) {
|
||||
$module.loadingImage('remove');
|
||||
for(var idx in data) {
|
||||
modules.addItemToModule($module, data[idx].content_tag);
|
||||
}
|
||||
$module.find(".context_module_items.ui-sortable").sortable('refresh');
|
||||
toggle();
|
||||
modules.updateProgressionState($module);
|
||||
}
|
||||
}
|
||||
}, function(data) {
|
||||
$module.loadingImage('remove');
|
||||
});
|
||||
if(collapse == '1' || !reload_entries) {
|
||||
toggle();
|
||||
}
|
||||
}
|
||||
|
||||
// THAT IS THE END
|
||||
|
||||
$(document).ready(function() {
|
||||
$('.context_module').each(function() {
|
||||
refreshDuplicateLinkStatus($(this));
|
||||
});
|
||||
if (ENV.IS_STUDENT) {
|
||||
$('.context_module').addClass('student-view');
|
||||
$('.context_module_item .ig-row').addClass('student-view');
|
||||
|
@ -1822,89 +1954,7 @@ import 'compiled/jquery.rails_flash_notifications'
|
|||
});
|
||||
});
|
||||
|
||||
$(".context_module").find(".expand_module_link,.collapse_module_link").bind('click keyclick', function(event, goSlow) {
|
||||
event.preventDefault();
|
||||
var expandCallback = null;
|
||||
if(goSlow && $.isFunction(goSlow)) {
|
||||
expandCallback = goSlow;
|
||||
goSlow = null;
|
||||
}
|
||||
var collapse = $(this).hasClass('collapse_module_link') ? '1' : '0';
|
||||
var $module = $(this).parents(".context_module");
|
||||
var reload_entries = $module.find(".content .context_module_items").children().length === 0;
|
||||
var toggle = function(show) {
|
||||
var callback = function() {
|
||||
$module.find(".collapse_module_link").css('display', $module.find(".content:visible").length > 0 ? 'inline-block' : 'none');
|
||||
$module.find(".expand_module_link").css('display', $module.find(".content:visible").length === 0 ? 'inline-block' : 'none');
|
||||
if($module.find(".content:visible").length > 0) {
|
||||
$module.find(".footer .manage_module").css('display', '');
|
||||
$module.toggleClass('collapsed_module', false);
|
||||
// Makes sure the resulting item has focus.
|
||||
$module.find(".collapse_module_link").focus();
|
||||
$.screenReaderFlashMessage(I18n.t('Expanded'));
|
||||
|
||||
} else {
|
||||
$module.find(".footer .manage_module").css('display', ''); //'none');
|
||||
$module.toggleClass('collapsed_module', true);
|
||||
// Makes sure the resulting item has focus.
|
||||
$module.find(".expand_module_link").focus();
|
||||
$.screenReaderFlashMessage(I18n.t('Collapsed'));
|
||||
}
|
||||
if(expandCallback && $.isFunction(expandCallback)) {
|
||||
expandCallback();
|
||||
}
|
||||
};
|
||||
if(show) {
|
||||
$module.find(".content").show();
|
||||
callback();
|
||||
} else {
|
||||
$module.find(".content").slideToggle(callback);
|
||||
}
|
||||
|
||||
}
|
||||
if(reload_entries || goSlow) {
|
||||
$module.loadingImage();
|
||||
}
|
||||
var url = $(this).attr('href');
|
||||
if(goSlow) {
|
||||
url = $module.find(".edit_module_link").attr('href');
|
||||
}
|
||||
$.ajaxJSON(url, (goSlow ? 'GET' : 'POST'), {collapse: collapse}, function(data) {
|
||||
if(goSlow) {
|
||||
$module.loadingImage('remove');
|
||||
var items = data;
|
||||
var next = function() {
|
||||
var item = items.shift();
|
||||
if(item) {
|
||||
modules.addItemToModule($module, item.content_tag);
|
||||
next();
|
||||
} else {
|
||||
$module.find(".context_module_items.ui-sortable").sortable('refresh');
|
||||
toggle(true);
|
||||
modules.updateProgressionState($module);
|
||||
$("#context_modules").triggerHandler('slow_load');
|
||||
}
|
||||
};
|
||||
next();
|
||||
} else {
|
||||
if(reload_entries) {
|
||||
$module.loadingImage('remove');
|
||||
for(var idx in data) {
|
||||
modules.addItemToModule($module, data[idx].content_tag);
|
||||
}
|
||||
$module.find(".context_module_items.ui-sortable").sortable('refresh');
|
||||
toggle();
|
||||
modules.updateProgressionState($module);
|
||||
}
|
||||
}
|
||||
}, function(data) {
|
||||
$module.loadingImage('remove');
|
||||
});
|
||||
if(collapse == '1' || !reload_entries) {
|
||||
toggle();
|
||||
}
|
||||
|
||||
});
|
||||
$(".context_module").find(".expand_module_link,.collapse_module_link").bind('click keyclick', toggleModuleCollapse);
|
||||
$(document).fragmentChange(function(event, hash) {
|
||||
if (hash == '#student_progressions') {
|
||||
$(".module_progressions_link").trigger('click');
|
||||
|
|
|
@ -72,6 +72,66 @@ describe "Modules API", type: :request do
|
|||
course_with_teacher(:course => @course, :active_all => true)
|
||||
end
|
||||
|
||||
describe "duplicating" do
|
||||
it "can duplicate if no quiz" do
|
||||
@course.account.enable_feature!(:duplicate_modules)
|
||||
course_module = @course.context_modules.create!(:name => "empty module", :workflow_state => "published")
|
||||
assignment = @course.assignments.create!(
|
||||
:name => "some assignment to duplicate",
|
||||
:workflow_state => "published"
|
||||
)
|
||||
course_module.add_item(:id => assignment.id, :type => 'assignment')
|
||||
course_module.add_item(:type => 'context_module_sub_header', :title => 'some header')
|
||||
course_module.save!
|
||||
json = api_call(:post, "/api/v1/courses/#{@course.id}/modules/#{course_module.id}/duplicate",
|
||||
{ :controller => "context_modules_api", :action => "duplicate", :format => "json",
|
||||
:course_id => @course.id.to_s, :module_id => course_module.id.to_s },
|
||||
{}, {},
|
||||
{:expected_status => 200})
|
||||
expect(json['context_module']['name']).to eq('empty module Copy')
|
||||
expect(json['context_module']['workflow_state']).to eq('unpublished')
|
||||
expect(json['context_module']['content_tags'].length).to eq(2)
|
||||
content_tags = json['context_module']['content_tags']
|
||||
expect(content_tags[0]['content_tag']['title']).to eq('some assignment to duplicate Copy')
|
||||
expect(content_tags[0]['content_tag']['content_type']).to eq('Assignment')
|
||||
expect(content_tags[0]['content_tag']['workflow_state']).to eq('unpublished')
|
||||
expect(content_tags[1]['content_tag']['title']).to eq('some header')
|
||||
expect(content_tags[1]['content_tag']['content_type']).to eq('ContextModuleSubHeader')
|
||||
end
|
||||
|
||||
it "cannot duplicate module with quiz" do
|
||||
@course.account.enable_feature!(:duplicate_modules)
|
||||
course_module = @course.context_modules.create!(:name => "empty module", :workflow_state => "published")
|
||||
# To be rigorous, make a quiz and add it as an *assignment*
|
||||
quiz = @course.quizzes.build(:title => "some quiz", :quiz_type => "assignment")
|
||||
quiz.save!
|
||||
course_module.add_item(:id => quiz.assignment_id, :type => 'assignment')
|
||||
course_module.save!
|
||||
api_call(:post, "/api/v1/courses/#{@course.id}/modules/#{course_module.id}/duplicate",
|
||||
{ :controller => "context_modules_api", :action => "duplicate", :format => "json",
|
||||
:course_id => @course.id.to_s, :module_id => course_module.id.to_s }, {}, {},
|
||||
{ :expected_status => 400 })
|
||||
end
|
||||
|
||||
it "cannot duplicate nonexistent module" do
|
||||
@course.account.enable_feature!(:duplicate_modules)
|
||||
bad_module_id = ContextModule.maximum(:id) + 1
|
||||
api_call(:post, "/api/v1/courses/#{@course.id}/modules/#{bad_module_id}/duplicate",
|
||||
{ :controller => "context_modules_api", :action => "duplicate", :format => "json",
|
||||
:course_id => @course.id.to_s, :module_id => bad_module_id.to_s }, {}, {},
|
||||
{ :expected_status => 404 })
|
||||
end
|
||||
|
||||
it "cannot duplicate if feature disabled" do
|
||||
@course.account.disable_feature!(:duplicate_modules)
|
||||
course_module = @course.context_modules.create!(:name => "empty module", :workflow_state => "published")
|
||||
api_call(:post, "/api/v1/courses/#{@course.id}/modules/#{course_module.id}/duplicate",
|
||||
{ :controller => "context_modules_api", :action => "duplicate", :format => "json",
|
||||
:course_id => @course.id.to_s, :module_id => course_module.id.to_s }, {}, {},
|
||||
{ :expected_status => 400 })
|
||||
end
|
||||
end
|
||||
|
||||
describe "index" do
|
||||
it "should list published and unpublished modules" do
|
||||
json = api_call(:get, "/api/v1/courses/#{@course.id}/modules",
|
||||
|
@ -703,6 +763,16 @@ describe "Modules API", type: :request do
|
|||
expect(json['state']).to eq 'locked'
|
||||
end
|
||||
|
||||
it "cannot duplicate" do
|
||||
@course.account.enable_feature!(:duplicate_modules)
|
||||
course_module = @course.context_modules.create!(:name => "empty module")
|
||||
api_call(:post, "/api/v1/courses/#{@course.id}/modules/#{course_module.id}/duplicate",
|
||||
{ :controller => "context_modules_api", :action => "duplicate", :format => "json",
|
||||
:course_id => @course.id.to_s, :module_id => course_module.id.to_s },
|
||||
{}, {},
|
||||
{:expected_status => 401})
|
||||
end
|
||||
|
||||
it "should show module progress" do
|
||||
# to simplify things, eliminate the requirements on the quiz and discussion topic for this test
|
||||
@module1.completion_requirements.reject! {|r| [@quiz_tag.id, @topic_tag.id].include? r[:id]}
|
||||
|
|
|
@ -34,30 +34,35 @@ describe DuplicatingObjects do
|
|||
end
|
||||
|
||||
def get_potentially_conflicting_titles(_title_base)
|
||||
[ 'Foo', 'Foo Copy', 'Foo Copy 1', 'Foo Copy 2', 'Foo Copy 5' ].to_set
|
||||
[ 'Foo', 'assignment Copy', 'Foo Copy', 'Foo Copy 1', 'Foo Copy 2', 'Foo Copy 5' ].to_set
|
||||
end
|
||||
|
||||
attr_accessor :title
|
||||
end
|
||||
|
||||
it 'copy treated as "Copy" but case is respected' do
|
||||
entity = MockEntity.new('assignment copy')
|
||||
expect(get_copy_title(entity, 'Copy', entity.title)).to eq('assignment copy 2')
|
||||
end
|
||||
|
||||
it 'no conflicts' do
|
||||
entity = MockEntity.new('Bar')
|
||||
expect(get_copy_title(entity, 'Copy')).to eq 'Bar Copy'
|
||||
expect(get_copy_title(entity, 'Copy', entity.title)).to eq 'Bar Copy'
|
||||
end
|
||||
|
||||
it 'conflict not ending in suffix' do
|
||||
entity = MockEntity.new('Foo')
|
||||
expect(get_copy_title(entity, 'Copy')).to eq 'Foo Copy 3'
|
||||
expect(get_copy_title(entity, 'Copy', entity.title)).to eq 'Foo Copy 3'
|
||||
end
|
||||
|
||||
it 'conflict ending in suffix' do
|
||||
entity = MockEntity.new('Foo Copy 1')
|
||||
expect(get_copy_title(entity, 'Copy')).to eq 'Foo Copy 3'
|
||||
expect(get_copy_title(entity, 'Copy', entity.title)).to eq 'Foo Copy 3'
|
||||
end
|
||||
|
||||
it 'increments from given number' do
|
||||
entity = MockEntity.new('Foo Copy 5')
|
||||
expect(get_copy_title(entity, 'Copy')).to eq 'Foo Copy 6'
|
||||
expect(get_copy_title(entity, 'Copy', entity.title)).to eq 'Foo Copy 6'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -56,10 +56,75 @@ describe ContextModule do
|
|||
end
|
||||
end
|
||||
|
||||
describe "can_be_duplicated?" do
|
||||
it "forbid quiz" do
|
||||
course_module
|
||||
quiz = @course.quizzes.build(:title => "some quiz", :quiz_type => "assignment")
|
||||
quiz.save!
|
||||
@module.add_item({:id => quiz.id, :type => 'quiz'})
|
||||
assignment = @course.assignments.create!(:title => "some assignment")
|
||||
@module.add_item({ id: assignment.id, :type => 'assignment' })
|
||||
expect(@module.can_be_duplicated?).to be_falsey
|
||||
end
|
||||
|
||||
it "forbid quiz even if added as assignment" do
|
||||
course_module
|
||||
quiz = @course.quizzes.build(:title => "some quiz", :quiz_type => "assignment")
|
||||
quiz.save!
|
||||
assignment = Assignment.find(quiz.assignment_id)
|
||||
@module.add_item({:id => assignment.id, :type => 'assignment'})
|
||||
expect(@module.can_be_duplicated?).to be_falsey
|
||||
end
|
||||
|
||||
it "*deleted* quiz tags are ok" do
|
||||
course_module
|
||||
quiz = @course.quizzes.build(:title => "some quiz", :quiz_type => "assignment")
|
||||
quiz.save!
|
||||
assignment = Assignment.find(quiz.assignment_id)
|
||||
@module.add_item({:id => assignment.id, :type => 'assignment'})
|
||||
@module.content_tags[0].workflow_state = 'deleted'
|
||||
expect(@module.can_be_duplicated?).to be_truthy
|
||||
end
|
||||
|
||||
it "ok if no quiz" do
|
||||
course_module
|
||||
assignment = @course.assignments.create!(:title => "some assignment")
|
||||
@module.add_item({ id: assignment.id, :type => 'assignment' })
|
||||
expect(@module.can_be_duplicated?).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
it "duplicate" do
|
||||
course_module # name is "some module"
|
||||
assignment1 = @course.assignments.create!(:title => "assignment")
|
||||
assignment2 = @course.assignments.create!(:title => "assignment copy")
|
||||
@module.add_item(type: 'context_module_sub_header', title: 'unpublished header')
|
||||
@module.add_item({:id => assignment1.id, :type => 'assignment'})
|
||||
quiz = @course.quizzes.build(:title => "some quiz", :quiz_type => "assignment")
|
||||
quiz.save!
|
||||
# It is permitted to duplicate a module with a deleted quiz tag, but the deleted
|
||||
# item should not be duplicated.
|
||||
@module.add_item({:id => quiz.id, :type => 'quiz'})
|
||||
@module.content_tags[2].workflow_state = 'deleted'
|
||||
@module.add_item({:id => assignment2.id, :type => 'assignment'})
|
||||
|
||||
@module.workflow_state = 'published'
|
||||
@module.save!
|
||||
new_module = @module.duplicate
|
||||
expect(new_module.name).to eq "some module Copy"
|
||||
expect(new_module.content_tags.length).to eq 3
|
||||
# Stuff with actual content should get unique names, but not stuff like headers.
|
||||
expect(new_module.content_tags[0].title).to eq('unpublished header')
|
||||
expect(new_module.content_tags[1].content.title).to eq('assignment Copy 2')
|
||||
# Respect original choice of "copy" if the thing I copied already made a decision.
|
||||
expect(new_module.content_tags[2].content.title).to eq('assignment copy 3')
|
||||
expect(new_module.workflow_state).to eq('unpublished')
|
||||
end
|
||||
|
||||
describe "available_for?" do
|
||||
it "should return true by default" do
|
||||
course_module
|
||||
expect(@module.available_for?(nil)).to eql(true)
|
||||
expect(@module.available_for?(nil)).to be(true)
|
||||
end
|
||||
|
||||
it "returns true by default when require_sequential_progress is true and there are no requirements" do
|
||||
|
|
Loading…
Reference in New Issue