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:
Venk Natarajan 2017-10-09 09:04:57 -06:00
parent 625da046e6
commit d352cfbd8b
14 changed files with 421 additions and 103 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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+$/, '')

View File

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

View File

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

View File

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

View File

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

View File

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