From: Vincent Petry Date: Fri, 4 Apr 2014 16:46:08 +0000 (+0200) Subject: Fixed selection to be based on FileList.files X-Git-Tag: v7.0.0alpha2~339^2~6 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=fd982df6aea09492e02cc65de02ee8250a1a229c;p=nextcloud-server.git Fixed selection to be based on FileList.files The file selection is now based on the internal model array FileList.files instead of the visible checkboxes. This makes it possible to virtually select files that haven't been rendered yet (select all, then deselect a visible one) Added more unit tests for selection (with shift and ctrl as well) --- diff --git a/apps/files/js/filelist.js b/apps/files/js/filelist.js index 0847edd02bb..7c82ec92473 100644 --- a/apps/files/js/filelist.js +++ b/apps/files/js/filelist.js @@ -28,6 +28,23 @@ window.FileList = { pageSize: 20, totalPages: 0, + /** + * Array of files in the current folder. + * The entries are of file data. + */ + files: [], + + /** + * Map of file id to file data + */ + _selectedFiles: {}, + + /** + * Summary of selected files. + * Instance of FileSummary. + */ + _selectionSummary: null, + /** * Compare two file info objects, sorting by * folders first, then by name. @@ -55,6 +72,8 @@ window.FileList = { this.$el = $('#filestable'); this.$fileList = $('#fileList'); this.files = []; + this._selectedFiles = {}; + this._selectionSummary = new FileSummary(); this.fileSummary = this._createSummary(); @@ -81,49 +100,71 @@ window.FileList = { this.$el.find('.delete-selected').click(this._onClickDeleteSelected); }, + /** + * Selected/deselects the given file element and updated + * the internal selection cache. + * + * @param $tr single file row element + * @param state true to select, false to deselect + */ + _selectFileEl: function($tr, state) { + var $checkbox = $tr.find('input:checkbox'); + var oldData = !!this._selectedFiles[$tr.data('id')]; + var data; + $checkbox.prop('checked', state); + $tr.toggleClass('selected', state); + // already selected ? + if (state === oldData) { + return; + } + data = this.elementToFile($tr); + if (state) { + this._selectedFiles[$tr.data('id')] = data; + this._selectionSummary.add(data); + } + else { + delete this._selectedFiles[$tr.data('id')]; + this._selectionSummary.remove(data); + } + this.$el.find('#select_all').prop('checked', this._selectionSummary.getTotal() === this.files.length); + }, + /** * Event handler for when clicking on files to select them */ _onClickFile: function(event) { + var $tr = $(this).closest('tr'); if (event.ctrlKey || event.shiftKey) { event.preventDefault(); if (event.shiftKey) { - var last = $(FileList._lastChecked).parent().parent().prevAll().length; - var first = $(this).parent().parent().prevAll().length; - var start = Math.min(first, last); - var end = Math.max(first, last); - var rows = $(this).parent().parent().parent().children('tr'); - for (var i = start; i < end; i++) { - $(rows).each(function(index) { - if (index === i) { - var checkbox = $(this).children().children('input:checkbox'); - $(checkbox).attr('checked', 'checked'); - $(checkbox).parent().parent().addClass('selected'); - } - }); + var $lastTr = $(FileList._lastChecked); + var lastIndex = $lastTr.index(); + var currentIndex = $tr.index(); + var $rows = FileList.$fileList.children('tr'); + + // last clicked checkbox below current one ? + if (lastIndex > currentIndex) { + var aux = lastIndex; + lastIndex = currentIndex; + currentIndex = aux; } - } - var checkbox = $(this).parent().children('input:checkbox'); - FileList._lastChecked = checkbox; - if ($(checkbox).attr('checked')) { - $(checkbox).removeAttr('checked'); - $(checkbox).parent().parent().removeClass('selected'); - $('#select_all').removeAttr('checked'); - } else { - $(checkbox).attr('checked', 'checked'); - $(checkbox).parent().parent().toggleClass('selected'); - var selectedCount = $('td.filename input:checkbox:checked').length; - if (selectedCount === $('td.filename input:checkbox').length) { - $('#select_all').attr('checked', 'checked'); + + // auto-select everything in-between + for (var i = lastIndex + 1; i < currentIndex; i++) { + FileList._selectFileEl($rows.eq(i), true); } } + else { + FileList._lastChecked = $tr; + } + var $checkbox = $tr.find('input:checkbox'); + FileList._selectFileEl($tr, !$checkbox.prop('checked')); FileList.updateSelectionSummary(); } else { - var filename=$(this).parent().parent().attr('data-file'); - var tr = FileList.findFileEl(filename); - var renaming=tr.data('renaming'); + var filename = $tr.attr('data-file'); + var renaming = $tr.data('renaming'); if (!renaming) { - FileActions.currentFile = $(this).parent(); + FileActions.currentFile = $tr.find('td'); var mime=FileActions.getCurrentMimeType(); var type=FileActions.getCurrentType(); var permissions = FileActions.getCurrentPermissions(); @@ -134,49 +175,36 @@ window.FileList = { } } } - }, /** * Event handler for when clicking on a file's checkbox */ - _onClickFileCheckbox: function(event) { - // FIXME: not sure what the difference is supposed to be with FileList._onClickFile - if (event.shiftKey) { - var last = $(FileList._lastChecked).parent().parent().prevAll().length; - var first = $(this).parent().parent().prevAll().length; - var start = Math.min(first, last); - var end = Math.max(first, last); - var rows = $(this).parent().parent().parent().children('tr'); - for (var i = start; i < end; i++) { - $(rows).each(function(index) { - if (index === i) { - var checkbox = $(this).children().children('input:checkbox'); - $(checkbox).attr('checked', 'checked'); - $(checkbox).parent().parent().addClass('selected'); - } - }); - } - } - var selectedCount=$('td.filename input:checkbox:checked').length; - $(this).parent().parent().toggleClass('selected'); - if (!$(this).attr('checked')) { - $('#select_all').attr('checked',false); - } else { - if (selectedCount===$('td.filename input:checkbox').length) { - $('#select_all').attr('checked',true); - } - } + _onClickFileCheckbox: function() { + var $tr = $(this).closest('tr'); + FileList._selectFileEl($tr, !$tr.hasClass('selected')); + FileList._lastChecked = $tr; FileList.updateSelectionSummary(); }, /** * Event handler for when selecting/deselecting all files */ - _onClickSelectAll: function(e) { + _onClickSelectAll: function() { var checked = $(this).prop('checked'); FileList.$fileList.find('td.filename input:checkbox').prop('checked', checked) - .parent().parent().toggleClass('selected', checked); + .closest('tr').toggleClass('selected', checked); + FileList._selectedFiles = {}; + if (checked) { + for (var i = 0; i < FileList.files.length; i++) { + var fileData = FileList.files[i]; + FileList._selectedFiles[fileData.id] = fileData; + FileList._selectionSummary.add(fileData); + } + } + else { + FileList._selectionSummary.clear(); + } FileList.updateSelectionSummary(); }, @@ -191,7 +219,7 @@ window.FileList = { dir = OC.dirname(dir) || '/'; } else { - files = FileList.getSelectedFiles('name'); + files = _.pluck(FileList.getSelectedFiles(), 'name'); } OC.Notification.show(t('files','Your download is being prepared. This might take some time if the files are big.')); OC.redirect(Files.getDownloadUrl(files, dir)); @@ -204,7 +232,7 @@ window.FileList = { _onClickDeleteSelected: function(event) { var files = null; if (!FileList.isAllSelected()) { - files = FileList.getSelectedFiles('name'); + files = _.pluck(FileList.getSelectedFiles(), 'name'); } FileList.do_delete(files); event.preventDefault(); @@ -322,8 +350,9 @@ window.FileList = { var index = this.$fileList.children().length, count = this.pageSize, tr, + fileData, newTrs = [], - selected = this.isAllSelected(); + isAllSelected = this.isAllSelected(); if (index >= this.files.length) { return; @@ -332,9 +361,10 @@ window.FileList = { this.pageNumber++; while (count > 0 && index < this.files.length) { - tr = this._renderRow(this.files[index], {updateSummary: false}); + fileData = this.files[index]; + tr = this._renderRow(fileData, {updateSummary: false}); this.$fileList.append(tr); - if (selected) { + if (isAllSelected || this._selectedFiles[fileData.id]) { tr.addClass('selected'); tr.find('input:checkbox').prop('checked', true); } @@ -371,7 +401,7 @@ window.FileList = { this.$fileList.empty(); // clear "Select all" checkbox - $('#select_all').prop('checked', false); + this.$el.find('#select_all').prop('checked', false); this.isEmpty = this.files.length === 0; this._nextPage(); @@ -675,7 +705,9 @@ window.FileList = { previousDir: currentDir } )); - FileList.reload(); + this._selectedFiles = {}; + this._selectionSummary.clear(); + this.reload(); }, linkTo: function(dir) { return OC.linkTo('files', 'index.php')+"?dir="+ encodeURIComponent(dir).replace(/%2F/g, '/'); @@ -812,6 +844,10 @@ window.FileList = { if (!fileEl.length) { return null; } + if (this._selectedFiles[fileEl.data('id')]) { + // remove from selection first + this._selectFileEl(fileEl, false); + } if (fileEl.data('permissions') & OC.PERMISSION_DELETE) { // file is only draggable when delete permissions are set fileEl.find('td.filename').draggable('destroy'); @@ -1131,40 +1167,17 @@ window.FileList = { * Update UI based on the current selection */ updateSelectionSummary: function() { - var allSelected = this.isAllSelected(); - var selected; - var summary = { - totalFiles: 0, - totalDirs: 0, - totalSize: 0 - }; - - if (allSelected) { - summary = this.fileSummary.summary; - } - else { - selected = this.getSelectedFiles(); - for (var i = 0; i < selected.length; i++ ){ - if (selected[i].type === 'dir') { - summary.totalDirs++; - } - else { - summary.totalFiles++; - } - summary.totalSize += parseInt(selected[i].size, 10) || 0; - } - } + var summary = this._selectionSummary.summary; if (summary.totalFiles === 0 && summary.totalDirs === 0) { $('#headerName span.name').text(t('files','Name')); $('#headerSize').text(t('files','Size')); $('#modified').text(t('files','Modified')); $('table').removeClass('multiselect'); $('.selectedActions').addClass('hidden'); - $('#select_all').removeAttr('checked'); } else { $('.selectedActions').removeClass('hidden'); - $('#headerSize').text(humanFileSize(summary.totalSize)); + $('#headerSize').text(OC.Util.humanFileSize(summary.totalSize)); var selection = ''; if (summary.totalDirs > 0) { selection += n('files', '%n folder', '%n folders', summary.totalDirs); @@ -1190,32 +1203,14 @@ window.FileList = { }, /** - * @brief get a list of selected files - * @param {string} property (option) the property of the file requested - * @return {array} + * Returns the file info of the selected files * - * possible values for property: name, mime, size and type - * if property is set, an array with that property for each file is returnd - * if it's ommited an array of objects with all properties is returned + * @return array of file names */ - getSelectedFiles: function(property) { - var elements=$('td.filename input:checkbox:checked').parent().parent(); - var files=[]; - elements.each(function(i,element) { - // TODO: make the json format the same as in FileList.add() - var file = FileList.elementToFile($(element)); - // FIXME: legacy attributes - file.origin = file.id; - file.mime = file.mimetype; - if (property) { - files.push(file[property]); - } else { - files.push(file); - } - }); - return files; + getSelectedFiles: function() { + return _.values(this._selectedFiles); } -} +}; $(document).ready(function() { FileList.initialize(); diff --git a/apps/files/js/files.js b/apps/files/js/files.js index 6cb0d41a611..41c762f0fa1 100644 --- a/apps/files/js/files.js +++ b/apps/files/js/files.js @@ -313,19 +313,15 @@ var createDragShadow = function(event) { var isDragSelected = $(event.target).parents('tr').find('td input:first').prop('checked'); if (!isDragSelected) { //select dragged file - $(event.target).parents('tr').find('td input:first').prop('checked',true); + FileList._selectFileEl($(event.target).parents('tr:first'), true); } - var selectedFiles = FileList.getSelectedFiles(); + // do not show drag shadow for too many files + var selectedFiles = _.first(FileList.getSelectedFiles(), FileList.pageSize); if (!isDragSelected && selectedFiles.length === 1) { //revert the selection - $(event.target).parents('tr').find('td input:first').prop('checked',false); - } - - //also update class when we dragged more than one file - if (selectedFiles.length > 1) { - $(event.target).parents('tr').addClass('selected'); + FileList._selectFileEl($(event.target).parents('tr:first'), false); } // build dragshadow diff --git a/apps/files/js/filesummary.js b/apps/files/js/filesummary.js index bbe4d43ba49..b3e3beeb24a 100644 --- a/apps/files/js/filesummary.js +++ b/apps/files/js/filesummary.js @@ -28,8 +28,9 @@ * @param $tr table row element * $param summary optional initial summary value */ - var FileSummary = function($tr, summary) { + var FileSummary = function($tr) { this.$el = $tr; + this.clear(); this.render(); }; @@ -74,6 +75,12 @@ this.update(); } }, + /** + * Returns the total of files and directories + */ + getTotal: function() { + return this.summary.totalDirs + this.summary.totalFiles; + }, /** * Recalculates the summary based on the given files array * @param files array of files @@ -98,6 +105,12 @@ } this.setSummary(summary); }, + /** + * Clears the summary + */ + clear: function() { + this.calculate([]); + }, /** * Sets the current summary values * @param summary map @@ -111,6 +124,9 @@ * Renders the file summary element */ update: function() { + if (!this.$el) { + return; + } if (!this.summary.totalFiles && !this.summary.totalDirs) { this.$el.addClass('hidden'); return; @@ -144,6 +160,10 @@ } }, render: function() { + if (!this.$el) { + return; + } + // TODO: ideally this should be separate to a template or something var summary = this.summary; var directoryInfo = n('files', '%n folder', '%n folders', summary.totalDirs); var fileInfo = n('files', '%n file', '%n files', summary.totalFiles); diff --git a/apps/files/tests/js/filelistSpec.js b/apps/files/tests/js/filelistSpec.js index 23261759d03..be285a7b636 100644 --- a/apps/files/tests/js/filelistSpec.js +++ b/apps/files/tests/js/filelistSpec.js @@ -24,6 +24,33 @@ describe('FileList tests', function() { var testFiles, alertStub, notificationStub, pushStateStub; + /** + * Generate test file data + */ + function generateFiles(startIndex, endIndex) { + var files = []; + var name; + for (var i = startIndex; i <= endIndex; i++) { + name = 'File with index '; + if (i < 10) { + // do not rely on localeCompare here + // and make the sorting predictable + // cross-browser + name += '0'; + } + name += i + '.txt'; + files.push({ + id: i, + type: 'file', + name: name, + mimetype: 'text/plain', + size: i * 2, + etag: 'abc' + }); + } + return files; + } + beforeEach(function() { // init horrible parameters var $body = $('body'); @@ -592,31 +619,6 @@ describe('FileList tests', function() { }); }); describe('Rendering next page on scroll', function() { - - function generateFiles(startIndex, endIndex) { - var files = []; - var name; - for (var i = startIndex; i <= endIndex; i++) { - name = 'File with index '; - if (i < 10) { - // do not rely on localeCompare here - // and make the sorting predictable - // cross-browser - name += '0'; - } - name += i + '.txt'; - files.push({ - id: i, - type: 'file', - name: name, - mimetype: 'text/plain', - size: i * 2, - etag: 'abc' - }); - } - return files; - } - beforeEach(function() { FileList.setFiles(generateFiles(0, 64)); }); @@ -1012,17 +1014,91 @@ describe('FileList tests', function() { expect($tr.find('input:checkbox').prop('checked')).toEqual(true); }); + it('Selects/deselect a file when clicking on the name while holding Ctrl', function() { + var $tr = FileList.findFileEl('One.txt'); + var $tr2 = FileList.findFileEl('Three.pdf'); + var e; + expect($tr.find('input:checkbox').prop('checked')).toEqual(false); + expect($tr2.find('input:checkbox').prop('checked')).toEqual(false); + e = new $.Event('click'); + e.ctrlKey = true; + $tr.find('td.filename .name').trigger(e); + + expect($tr.find('input:checkbox').prop('checked')).toEqual(true); + expect($tr2.find('input:checkbox').prop('checked')).toEqual(false); + + // click on second entry, does not clear the selection + e = new $.Event('click'); + e.ctrlKey = true; + $tr2.find('td.filename .name').trigger(e); + expect($tr.find('input:checkbox').prop('checked')).toEqual(true); + expect($tr2.find('input:checkbox').prop('checked')).toEqual(true); + + expect(_.pluck(FileList.getSelectedFiles(), 'name')).toEqual(['One.txt', 'Three.pdf']); + + // deselect now + e = new $.Event('click'); + e.ctrlKey = true; + $tr2.find('td.filename .name').trigger(e); + expect($tr.find('input:checkbox').prop('checked')).toEqual(true); + expect($tr2.find('input:checkbox').prop('checked')).toEqual(false); + expect(_.pluck(FileList.getSelectedFiles(), 'name')).toEqual(['One.txt']); + }); + it('Selects a range when clicking on one file then Shift clicking on another one', function() { + var $tr = FileList.findFileEl('One.txt'); + var $tr2 = FileList.findFileEl('Three.pdf'); + var e; + $tr.find('td.filename input:checkbox').click(); + e = new $.Event('click'); + e.shiftKey = true; + $tr2.find('td.filename .name').trigger(e); + + expect($tr.find('input:checkbox').prop('checked')).toEqual(true); + expect($tr2.find('input:checkbox').prop('checked')).toEqual(true); + expect(FileList.findFileEl('Two.jpg').find('input:checkbox').prop('checked')).toEqual(true); + var selection = _.pluck(FileList.getSelectedFiles(), 'name'); + expect(selection.length).toEqual(3); + expect(selection).toContain('One.txt'); + expect(selection).toContain('Two.jpg'); + expect(selection).toContain('Three.pdf'); + }); + it('Selects a range when clicking on one file then Shift clicking on another one that is above the first one', function() { + var $tr = FileList.findFileEl('One.txt'); + var $tr2 = FileList.findFileEl('Three.pdf'); + var e; + $tr2.find('td.filename input:checkbox').click(); + e = new $.Event('click'); + e.shiftKey = true; + $tr.find('td.filename .name').trigger(e); + + expect($tr.find('input:checkbox').prop('checked')).toEqual(true); + expect($tr2.find('input:checkbox').prop('checked')).toEqual(true); + expect(FileList.findFileEl('Two.jpg').find('input:checkbox').prop('checked')).toEqual(true); + var selection = _.pluck(FileList.getSelectedFiles(), 'name'); + expect(selection.length).toEqual(3); + expect(selection).toContain('One.txt'); + expect(selection).toContain('Two.jpg'); + expect(selection).toContain('Three.pdf'); + }); it('Selecting all files will automatically check "select all" checkbox', function() { expect($('#select_all').prop('checked')).toEqual(false); $('#fileList tr td.filename input:checkbox').click(); expect($('#select_all').prop('checked')).toEqual(true); }); + it('Selecting all files on the first visible page will not automatically check "select all" checkbox', function() { + FileList.setFiles(generateFiles(0, 41)); + expect($('#select_all').prop('checked')).toEqual(false); + $('#fileList tr td.filename input:checkbox').click(); + expect($('#select_all').prop('checked')).toEqual(false); + }); it('Clicking "select all" will select/deselect all files', function() { + FileList.setFiles(generateFiles(0, 41)); $('#select_all').click(); expect($('#select_all').prop('checked')).toEqual(true); $('#fileList tr input:checkbox').each(function() { expect($(this).prop('checked')).toEqual(true); }); + expect(_.pluck(FileList.getSelectedFiles(), 'name').length).toEqual(42); $('#select_all').click(); expect($('#select_all').prop('checked')).toEqual(false); @@ -1030,6 +1106,7 @@ describe('FileList tests', function() { $('#fileList tr input:checkbox').each(function() { expect($(this).prop('checked')).toEqual(false); }); + expect(_.pluck(FileList.getSelectedFiles(), 'name').length).toEqual(0); }); it('Clicking "select all" then deselecting a file will uncheck "select all"', function() { $('#select_all').click(); @@ -1039,6 +1116,18 @@ describe('FileList tests', function() { $tr.find('input:checkbox').click(); expect($('#select_all').prop('checked')).toEqual(false); + expect(_.pluck(FileList.getSelectedFiles(), 'name').length).toEqual(3); + }); + it('Auto-selects files on next page when "select all" is checked', function() { + FileList.setFiles(generateFiles(0, 41)); + $('#select_all').click(); + + expect(FileList.$fileList.find('tr input:checkbox:checked').length).toEqual(20); + FileList._nextPage(true); + expect(FileList.$fileList.find('tr input:checkbox:checked').length).toEqual(40); + FileList._nextPage(true); + expect(FileList.$fileList.find('tr input:checkbox:checked').length).toEqual(42); + expect(_.pluck(FileList.getSelectedFiles(), 'name').length).toEqual(42); }); it('Selecting files updates selection summary', function() { var $summary = $('#headerName span.name'); @@ -1080,6 +1169,19 @@ describe('FileList tests', function() { FileList.changeDirectory('/'); fakeServer.respond(); expect($('#select_all').prop('checked')).toEqual(false); + expect(_.pluck(FileList.getSelectedFiles(), 'name')).toEqual([]); + }); + it('getSelectedFiles returns the selected files even when they are on the next page', function() { + var selectedFiles; + FileList.setFiles(generateFiles(0, 41)); + $('#select_all').click(); + // unselect one to not have the "allFiles" case + FileList.$fileList.find('tr input:checkbox:first').click(); + + // only 20 files visible, must still return all the selected ones + selectedFiles = _.pluck(FileList.getSelectedFiles(), 'name'); + + expect(selectedFiles.length).toEqual(41); }); describe('Actions', function() { beforeEach(function() { @@ -1087,38 +1189,53 @@ describe('FileList tests', function() { FileList.findFileEl('Three.pdf').find('input:checkbox').click(); FileList.findFileEl('somedir').find('input:checkbox').click(); }); - it('getSelectedFiles returns the selected files', function() { + it('getSelectedFiles returns the selected file data', function() { var files = FileList.getSelectedFiles(); expect(files.length).toEqual(3); expect(files[0]).toEqual({ id: 1, name: 'One.txt', - mime: 'text/plain', mimetype: 'text/plain', type: 'file', size: 12, - etag: 'abc', - origin: 1 + etag: 'abc' }); expect(files[1]).toEqual({ id: 3, type: 'file', name: 'Three.pdf', - mime: 'application/pdf', mimetype: 'application/pdf', size: 58009, - etag: '123', - origin: 3 + etag: '123' }); expect(files[2]).toEqual({ id: 4, type: 'dir', name: 'somedir', - mime: 'httpd/unix-directory', mimetype: 'httpd/unix-directory', size: 250, - etag: '456', - origin: 4 + etag: '456' + }); + }); + it('Removing a file removes it from the selection', function() { + FileList.remove('Three.pdf'); + var files = FileList.getSelectedFiles(); + expect(files.length).toEqual(2); + expect(files[0]).toEqual({ + id: 1, + name: 'One.txt', + mimetype: 'text/plain', + type: 'file', + size: 12, + etag: 'abc' + }); + expect(files[1]).toEqual({ + id: 4, + type: 'dir', + name: 'somedir', + mimetype: 'httpd/unix-directory', + size: 250, + etag: '456' }); }); describe('Download', function() { diff --git a/apps/files_trashbin/js/filelist.js b/apps/files_trashbin/js/filelist.js index d4445e11c86..42ab89ef6a6 100644 --- a/apps/files_trashbin/js/filelist.js +++ b/apps/files_trashbin/js/filelist.js @@ -113,7 +113,7 @@ }; } else { - files = FileList.getSelectedFiles('name'); + files = _.pluck(FileList.getSelectedFiles(), 'name'); for (var i = 0; i < files.length; i++) { var deleteAction = FileList.findFileEl(files[i]).children("td.date").children(".action.delete"); deleteAction.removeClass('delete-icon').addClass('progress-icon'); @@ -155,7 +155,7 @@ }; } else { - files = FileList.getSelectedFiles('name'); + files = _.pluck(FileList.getSelectedFiles(), 'name'); params = { files: JSON.stringify(files), dir: FileList.getCurrentDirectory()