MDL-33671 core: Allow for bulk delete of files

* Enable bulk delete in a details view
* Behat tests for new functionality
* Unit test for new function
This commit is contained in:
Peter 2018-12-06 09:20:53 +08:00 committed by Peter Dias
parent d7374522ed
commit 2f03923602
13 changed files with 291 additions and 41 deletions

View File

@ -113,7 +113,8 @@ class core_files_renderer extends plugin_renderer_base {
array('invalidjson', 'repository'), array('popupblockeddownload', 'repository'),
array('unknownoriginal', 'repository'), array('confirmdeletefolder', 'repository'),
array('confirmdeletefilewithhref', 'repository'), array('confirmrenamefolder', 'repository'),
array('confirmrenamefile', 'repository'), array('newfolder', 'repository'), array('edit', 'moodle')
array('confirmrenamefile', 'repository'), array('newfolder', 'repository'), array('edit', 'moodle'),
['nofilesselected', 'repository'], ['confirmdeleteselectedfile', 'repository']
)
);
if ($this->page->requires->should_create_one_time_item_now('core_file_managertemplate')) {

View File

@ -67,6 +67,7 @@ $string['configsyncfiletimeout'] = 'Timeout in seconds for synchronising the ext
$string['configsyncimagetimeout'] = 'Timeout in seconds for downloading an image file from external repository during synchronisation.';
$string['confirmdelete'] = 'Are you sure you want to delete the repository {$a}? If you choose "Continue and download", file references to external contents will be downloaded to Moodle. This could take a long time to process.';
$string['confirmdeletefile'] = 'Are you sure you want to delete this file?';
$string['confirmdeleteselectedfile'] = 'Are you sure you want to delete the selected {$a} file(s)?';
$string['confirmrenamefile'] = 'Are you sure you want to rename/move this file? There are {$a} alias/shortcut files that use this file as their source. If you proceed then those aliases will be converted to true copies.';
$string['confirmdeletefilewithhref'] = 'Are you sure you want to delete this file? There are {$a} alias/shortcut files that use this file as their source. If you proceed then those aliases will be converted to true copies.';
$string['confirmdeletefolder'] = 'Are you sure you want to delete this folder? All files and subfolders will be deleted.';
@ -97,6 +98,7 @@ $string['displaytree'] = 'Display folder as file tree';
$string['download'] = 'Download';
$string['downloadallfiles'] = 'Download all files';
$string['downloadfolder'] = 'Download all';
$string['deleteselected'] = 'Delete selected';
$string['downloadsucc'] = 'The file has been downloaded successfully';
$string['draftareanofiles'] = 'Cannot be downloaded because there is no files attached';
$string['editrepositoryinstance'] = 'Edit repository instance';
@ -174,6 +176,7 @@ $string['newfoldername'] = 'New folder name';
$string['noenter'] = 'Nothing entered';
$string['nofilesattached'] = 'No files attached';
$string['nofilesavailable'] = 'No files available';
$string['nofilesselected'] = 'No files selected';
$string['nomorefiles'] = 'No more attachments allowed';
$string['nopathselected'] = 'No destination path select yet (double click tree node to select)';
$string['nopermissiontoaccess'] = 'No permission to access this repository.';

View File

@ -273,6 +273,7 @@ M.form_filemanager.init = function(Y, options) {
var button_download = this.filemanager.one('.fp-btn-download');
var button_create = this.filemanager.one('.fp-btn-mkdir');
var button_addfile = this.filemanager.one('.fp-btn-add');
var buttonDeleteFile = this.filemanager.one('.fp-btn-delete');
// setup 'add file' button
button_addfile.on('click', this.show_filepicker, this);
@ -403,6 +404,54 @@ M.form_filemanager.init = function(Y, options) {
});
}, this);
buttonDeleteFile.on('click', function(e) {
e.preventDefault();
var dialogOptions = {};
var markedForDeletion = this.filemanager.all('.mark-for-deletion:checked');
var filenames = [];
markedForDeletion.each(function(item) {
var fileinfo = this.options.list.find(function(element) {
return item.getData().fullname == element.fullname;
});
if (fileinfo && fileinfo != undefined) {
filenames.push({
filepath: fileinfo.filepath,
filename: fileinfo.filename
});
}
}, this);
if (!filenames.length) {
this.print_msg(M.util.get_string('nofilesselected', 'repository'), 'error');
return;
}
dialogOptions.scope = this;
var params = {
selected: Y.JSON.stringify(filenames)
};
dialogOptions.message = M.util.get_string('confirmdeleteselectedfile', 'repository', filenames.length);
dialogOptions.callbackargs = [params];
dialogOptions.callback = function(params) {
this.request({
action: 'deleteselected',
scope: this,
params: params,
callback: function(id, obj, args) {
// Do something here
args.scope.filecount -= params.length;
if (obj && obj.length) {
args.scope.refresh(obj[0]);
}
if (typeof M.core_formchangechecker != 'undefined') {
M.core_formchangechecker.set_form_changed();
}
}
});
};
this.show_confirm_dialog(dialogOptions);
}, this);
this.filemanager.all('.fp-vb-icons,.fp-vb-tree,.fp-vb-details').
on('click', function(e) {
e.preventDefault();
@ -569,6 +618,12 @@ M.form_filemanager.init = function(Y, options) {
this.viewmode = 1;
element_template = Y.Node.create(M.form_filemanager.templates.iconfilename);
}
if (this.viewmode == 1 || this.viewmode == 2) {
this.filemanager.one('.fp-btn-delete').addClass('d-none');
} else {
this.filemanager.one('.fp-btn-delete').removeClass('d-none');
}
var options = {
viewmode : this.viewmode,
appendonly : appendfiles != null,

View File

@ -49,6 +49,11 @@
{{#pix}}a/download_all{{/pix}}
</a>
</div>
<div class="fp-btn-delete">
<a role="button" title="{{#str}}deleteselected, repository{{/str}}" class="btn btn-secondary btn-sm" href="#">
{{#pix}}i/trash{{/pix}}
</a>
</div>
<span class="fp-img-downloading">
<span class="sr-only">{{#str}}loadinghelp{{/str}}</span>
{{#pix}}i/loading_small{{/pix}}

View File

@ -41,7 +41,7 @@ $action = required_param('action', PARAM_ALPHA);
$draftid = required_param('itemid', PARAM_INT);
$filepath = optional_param('filepath', '/', PARAM_PATH);
$user_context = context_user::instance($USER->id);
$usercontext = context_user::instance($USER->id);
echo $OUTPUT->header(); // send headers
@ -73,7 +73,7 @@ switch ($action) {
$newdirname = required_param('newdirname', PARAM_FILE);
$fs = get_file_storage();
$fs->create_directory($user_context->id, 'user', 'draft', $draftid, file_correct_filepath(file_correct_filepath($filepath).$newdirname));
$fs->create_directory($usercontext->id, 'user', 'draft', $draftid, file_correct_filepath(file_correct_filepath($filepath).$newdirname));
$return = new stdClass();
$return->filepath = $filepath;
echo json_encode($return);
@ -82,31 +82,28 @@ switch ($action) {
case 'delete':
$filename = required_param('filename', PARAM_FILE);
$filepath = required_param('filepath', PARAM_PATH);
$selectedfile = (object)[
'filename' => $filename,
'filepath' => $filepath
];
$return = repository_delete_selected_files($usercontext, 'user', 'draft', $draftid, [$selectedfile]);
$fs = get_file_storage();
$filepath = file_correct_filepath($filepath);
$return = new stdClass();
if ($stored_file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, $filename)) {
$parent_path = $stored_file->get_parent_directory()->get_filepath();
if ($stored_file->is_directory()) {
$files = $fs->get_directory_files($user_context->id, 'user', 'draft', $draftid, $filepath, true);
foreach ($files as $file) {
$file->delete();
}
$stored_file->delete();
$return->filepath = $parent_path;
echo json_encode($return);
} else {
if($result = $stored_file->delete()) {
$return->filepath = $parent_path;
echo json_encode($return);
} else {
echo json_encode(false);
}
}
} else {
echo json_encode(false);
if ($return) {
$response = new stdClass();
$response->filepath = array_keys($return)[0];
echo json_encode($response);
die;
}
echo json_encode(false);
die;
case 'deleteselected':
$selected = required_param('selected', PARAM_RAW);
$return = [];
$selectedfiles = json_decode($selected);
$return = repository_delete_selected_files($usercontext, 'user', 'draft', $draftid, $selectedfiles);
echo (json_encode($return ? array_keys($return) : false));
die;
case 'setmainfile':
@ -115,9 +112,9 @@ switch ($action) {
$filepath = file_correct_filepath($filepath);
// reset sort order
file_reset_sortorder($user_context->id, 'user', 'draft', $draftid);
file_reset_sortorder($usercontext->id, 'user', 'draft', $draftid);
// set main file
$return = file_set_sortorder($user_context->id, 'user', 'draft', $draftid, $filepath, $filename, 1);
$return = file_set_sortorder($usercontext->id, 'user', 'draft', $draftid, $filepath, $filename, 1);
echo json_encode($return);
die;
@ -159,7 +156,7 @@ switch ($action) {
$zipper = get_file_packer('application/zip');
$fs = get_file_storage();
$file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, '.');
$file = $fs->get_file($usercontext->id, 'user', 'draft', $draftid, $filepath, '.');
$parent_path = $file->get_parent_directory()->get_filepath();
@ -167,7 +164,7 @@ switch ($action) {
$filepath = array_pop($filepath);
$zipfile = repository::get_unused_filename($draftid, $parent_path, $filepath . '.zip');
if ($newfile = $zipper->archive_to_storage(array($filepath => $file), $user_context->id, 'user', 'draft', $draftid, $parent_path, $zipfile, $USER->id)) {
if ($newfile = $zipper->archive_to_storage([$filepath => $file], $usercontext->id, 'user', 'draft', $draftid, $parent_path, $zipfile, $USER->id)) {
$return = new stdClass();
$return->filepath = $parent_path;
echo json_encode($return);
@ -187,7 +184,7 @@ switch ($action) {
die;
}
$stored_file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, '.');
$stored_file = $fs->get_file($usercontext->id, 'user', 'draft', $draftid, $filepath, '.');
if ($filepath === '/') {
$filename = get_string('files').'.zip';
} else {
@ -197,7 +194,7 @@ switch ($action) {
// archive compressed file to an unused draft area
$newdraftitemid = file_get_unused_draft_itemid();
if ($newfile = $zipper->archive_to_storage(array('/' => $stored_file), $user_context->id, 'user', 'draft', $newdraftitemid, '/', $filename, $USER->id)) {
if ($newfile = $zipper->archive_to_storage(['/' => $stored_file], $usercontext->id, 'user', 'draft', $newdraftitemid, '/', $filename, $USER->id)) {
$return = new stdClass();
$return->fileurl = moodle_url::make_draftfile_url($newdraftitemid, '/', $filename)->out();
$return->filepath = $filepath;
@ -215,15 +212,15 @@ switch ($action) {
$fs = get_file_storage();
$file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, $filename);
$file = $fs->get_file($usercontext->id, 'user', 'draft', $draftid, $filepath, $filename);
// Find unused name for directory to extract the archive.
$temppath = $fs->get_unused_dirname($user_context->id, 'user', 'draft', $draftid, $filepath. pathinfo($filename, PATHINFO_FILENAME). '/');
$temppath = $fs->get_unused_dirname($usercontext->id, 'user', 'draft', $draftid, $filepath. pathinfo($filename, PATHINFO_FILENAME). '/');
$donotremovedirs = array();
$doremovedirs = array($temppath);
// Extract archive and move all files from $temppath to $filepath
if ($file->extract_to_storage($zipper, $user_context->id, 'user', 'draft', $draftid, $temppath, $USER->id) !== false) {
$extractedfiles = $fs->get_directory_files($user_context->id, 'user', 'draft', $draftid, $temppath, true);
if ($file->extract_to_storage($zipper, $usercontext->id, 'user', 'draft', $draftid, $temppath, $USER->id) !== false) {
$extractedfiles = $fs->get_directory_files($usercontext->id, 'user', 'draft', $draftid, $temppath, true);
$xtemppath = preg_quote($temppath, '|');
foreach ($extractedfiles as $file) {
$realpath = preg_replace('|^'.$xtemppath.'|', $filepath, $file->get_filepath());
@ -231,7 +228,7 @@ switch ($action) {
// Set the source to the extracted file to indicate that it came from archive.
$file->set_source(serialize((object)array('source' => $filepath)));
}
if (!$fs->file_exists($user_context->id, 'user', 'draft', $draftid, $realpath, $file->get_filename())) {
if (!$fs->file_exists($usercontext->id, 'user', 'draft', $draftid, $realpath, $file->get_filename())) {
// File or directory did not exist, just move it.
$file->rename($realpath, $file->get_filename());
} else if (!$file->is_directory()) {
@ -250,7 +247,7 @@ switch ($action) {
}
// Remove remaining temporary directories.
foreach (array_diff($doremovedirs, $donotremovedirs) as $filepath) {
if ($file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, '.')) {
if ($file = $fs->get_file($usercontext->id, 'user', 'draft', $draftid, $filepath, '.')) {
$file->delete();
}
}
@ -261,7 +258,7 @@ switch ($action) {
$filepath = required_param('filepath', PARAM_PATH);
$fs = get_file_storage();
$file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, $filename);
$file = $fs->get_file($usercontext->id, 'user', 'draft', $draftid, $filepath, $filename);
if (!$file) {
echo json_encode(false);
} else {
@ -275,7 +272,7 @@ switch ($action) {
$filepath = required_param('filepath', PARAM_PATH);
$fs = get_file_storage();
$file = $fs->get_file($user_context->id, 'user', 'draft', $draftid, $filepath, $filename);
$file = $fs->get_file($usercontext->id, 'user', 'draft', $draftid, $filepath, $filename);
if (!$file) {
echo json_encode(false);
} else {

View File

@ -317,6 +317,16 @@ YUI.add('moodle-core_filepicker', function(Y) {
// TODO add tooltip with o.data['title'] (o.value) or o.data['thumbnail_title']
return el.getContent();
}
var formatCheckbox = function(o) {
var el = Y.Node.create('<div/>');
var checkbox = Y.Node.create('<input/>');
checkbox.setAttribute('type', 'checkbox')
.setAttribute('class', 'mark-for-deletion')
.setAttribute('data-fullname', o.data.fullname);
el.appendChild(checkbox);
return el.getContent();
};
/** sorting function for table view */
var sortFoldersFirst = function(a, b, desc) {
if (a.get('isfolder') && !b.get('isfolder')) {
@ -331,6 +341,9 @@ YUI.add('moodle-core_filepicker', function(Y) {
/** initialize table view */
var initialize_table_view = function() {
var cols = [
{key: "", label: "<input type='checkbox' id='select-all'/>",
allowHTML: true, formatter: formatCheckbox,
sortable: false},
{key: "displayname", label: M.util.get_string('name', 'moodle'), allowHTML: true, formatter: formatTitle,
sortable: true, sortFn: sortFoldersFirst},
{key: "datemodified", label: M.util.get_string('lastmodified', 'moodle'), allowHTML: true, formatter: formatValue,
@ -350,7 +363,15 @@ YUI.add('moodle-core_filepicker', function(Y) {
}
Y.bind(callback, this)(e, record.getAttrs());
}
}, 'tr', options.callbackcontext, scope.tableview);
}, 'tr td:not(:first-child)', options.callbackcontext, scope.tableview);
scope.tableview.delegate('change', function(e) {
e.preventDefault();
if (e.target.get('checked')) {
e.container.all('.mark-for-deletion').setAttribute('checked', true);
} else {
e.container.all('.mark-for-deletion').removeAttribute('checked');
}
}, '#select-all', options.callbackcontext, scope.tableview);
if (options.rightclickcallback) {
scope.tableview.delegate('contextmenu', function (e, tableview) {
var record = tableview.getRecord(e.currentTarget.get('id'));

View File

@ -3209,3 +3209,43 @@ function initialise_filepicker($args) {
}
return $return;
}
/**
* Convenience function to handle deletion of files.
*
* @param object $context The context where the delete is called
* @param string $component component
* @param string $filearea filearea
* @param int $itemid the item id
* @param array $files Array of files object with each item having filename/filepath as values
* @return array $return Array of strings matching up to the parent directory of the deleted files
* @throws coding_exception
*/
function repository_delete_selected_files($context, string $component, string $filearea, $itemid, array $files) {
$fs = get_file_storage();
$return = [];
foreach ($files as $selectedfile) {
$filename = clean_filename($selectedfile->filename);
$filepath = clean_param($selectedfile->filepath, PARAM_PATH);
$filepath = file_correct_filepath($filepath);
if ($storedfile = $fs->get_file($context->id, $component, $filearea, $itemid, $filepath, $filename)) {
$parentpath = $storedfile->get_parent_directory()->get_filepath();
if ($storedfile->is_directory()) {
$files = $fs->get_directory_files($context->id, $component, $filearea, $itemid, $filepath, true);
foreach ($files as $file) {
$file->delete();
}
$storedfile->delete();
$return[$parentpath] = "";
} else {
if ($result = $storedfile->delete()) {
$return[$parentpath] = "";
}
}
}
}
return $return;
}

View File

@ -176,6 +176,38 @@ class behat_filepicker extends behat_base {
$okbutton->click();
}
/**
* Marks for deletion the specified file or folder from the specified filemanager field.
*
* @Given /^I mark for deletion "(?P<file_or_folder_name_string>(?:[^"]|\\")*)" from filemanager$/
* @throws ExpectationException Thrown by behat_base::find
* @param string $name
*/
public function i_mark_for_deletion_from_filemanager($name) {
$name = behat_context_helper::escape($name);
$okbutton = $this->find('css', "input.mark-for-deletion[data-fullname=$name]");
$okbutton->click();
}
/**
* Executes delete function and confirms delete
*
* @Given /^I confirm deletion$/
* @throws ExpectationException Thrown by behat_base::find
*/
public function i_confirm_deletion() {
$name = get_string('deleteselected');
// Execute the action.
$okbutton = $this->find('css', "a[title='$name']");
$okbutton->click();
// Yes, we are sure.
// Using xpath + click instead of pressButton as 'Ok' it is a common string.
$okbutton = $this->find('css', 'div.fp-dlg button.fp-dlg-butconfirm');
$okbutton->click();
}
/**
* Makes sure user can see the exact number of elements (files in folders) in the filemanager.
*

View File

@ -19,3 +19,42 @@ Feature: Delete files and folders from the file manager
And I delete "Delete me" from "Files" filemanager
And I press "Save changes"
And I should not see "Delete me"
@javascript
Scenario: Delete a file and a folder using bulk functionality (individually)
Given I log in as "admin"
And I follow "Manage private files"
And I upload "lib/tests/fixtures/empty.txt" file to "Files" filemanager
And I create "Delete me" folder in "Files" filemanager
And I press "Save changes"
And I follow "Manage private files"
And I click on "[title='Display folder with file details']" "css_element"
And I mark for deletion "empty.txt" from filemanager
And I confirm deletion
Then I should not see "empty.txt"
And I press "Save changes"
And I follow "Manage private files"
Then I should not see "empty.txt"
And I mark for deletion "Delete me" from filemanager
And I confirm deletion
Then I should not see "Delete me"
And I press "Save changes"
And I should not see "Delete me"
@javascript
Scenario: Delete a file and a folder using bulk functionality (multiple)
Given I log in as "admin"
And I follow "Manage private files"
And I upload "lib/tests/fixtures/empty.txt" file to "Files" filemanager
And I create "Delete me" folder in "Files" filemanager
And I press "Save changes"
And I follow "Manage private files"
And I click on "[title='Display folder with file details']" "css_element"
And I mark for deletion "empty.txt" from filemanager
And I mark for deletion "Delete me" from filemanager
And I confirm deletion
Then I should not see "Delete me"
Then I should not see "empty.txt"
And I press "Save changes"
And I should not see "Delete me"
Then I should not see "empty.txt"

View File

@ -154,6 +154,60 @@ class core_repositorylib_testcase extends advanced_testcase {
}
}
public function test_delete_selected_files() {
global $USER;
$this->resetAfterTest(true);
$this->setAdminUser();
$fs = get_file_storage();
$draftitemid = file_get_unused_draft_itemid();
$context = context_user::instance($USER->id);
$dummy = [
'contextid' => $context->id,
'component' => 'user',
'filearea' => 'draft',
'itemid' => $draftitemid,
'filepath' => '/',
'filename' => ''
];
// Create some files.
$existingfiles = [
'The Matrix.movie',
'Astalavista.txt',
'foobar',
];
$selectedfiles = [
'The Matrix.movie' => [],
'Astalavista.txt' => []
];
foreach ($existingfiles as $filename) {
$dummy['filename'] = $filename;
$file = $fs->create_file_from_string($dummy, 'Content of ' . $filename);
if (array_key_exists($filename, $selectedfiles)) {
$selectedfiles[$filename] = (object)[
'filename' => $filename,
'filepath' => $file->get_filepath()
];
}
}
// Get area files with default options.
$areafiles = $fs->get_area_files($context->id, 'user', 'draft', $draftitemid);
// Should be the 3 files we added plus the folder.
$this->assertEquals(4, count($areafiles));
repository_delete_selected_files($context, 'user', 'draft', $draftitemid, $selectedfiles);
$areafiles = $fs->get_area_files($context->id, 'user', 'draft', $draftitemid);
// Should be the 1 file left plus the folder.
$this->assertEquals(2, count($areafiles));
}
public function test_can_be_edited_by_user() {
$this->resetAfterTest(true);

View File

@ -553,6 +553,7 @@ a.ygtvspacer:hover {
.filemanager.fm-maxfiles .fp-btn-add,
.filemanager.fm-maxfiles .dndupload-message,
.filemanager.fm-noitems .fp-btn-download,
.filemanager.fm-noitems .fp-btn-delete,
.filemanager .fm-empty-container,
.filemanager.fm-noitems .filemanager-container .fp-content {
display: none;

View File

@ -13930,6 +13930,7 @@ a.ygtvspacer:hover {
.filemanager.fm-maxfiles .fp-btn-add,
.filemanager.fm-maxfiles .dndupload-message,
.filemanager.fm-noitems .fp-btn-download,
.filemanager.fm-noitems .fp-btn-delete,
.filemanager .fm-empty-container,
.filemanager.fm-noitems .filemanager-container .fp-content {
display: none; }

View File

@ -14145,6 +14145,7 @@ a.ygtvspacer:hover {
.filemanager.fm-maxfiles .fp-btn-add,
.filemanager.fm-maxfiles .dndupload-message,
.filemanager.fm-noitems .fp-btn-download,
.filemanager.fm-noitems .fp-btn-delete,
.filemanager .fm-empty-container,
.filemanager.fm-noitems .filemanager-container .fp-content {
display: none; }