mirror of
https://github.com/nextcloud/server.git
synced 2026-06-08 16:26:59 -04:00
Merge pull request #41519 from nextcloud/fix/files-sorting
fix(files): Ensure folders and favorites are sorted first regardless of sorting mode
This commit is contained in:
commit
82e08f1df3
6 changed files with 374 additions and 30 deletions
|
|
@ -217,6 +217,40 @@ export default Vue.extend({
|
|||
return this.filesStore.getNode(fileId)
|
||||
},
|
||||
|
||||
/**
|
||||
* Directory content sorting parameters
|
||||
* Provided by an extra computed property for caching
|
||||
*/
|
||||
sortingParameters() {
|
||||
const identifiers = [
|
||||
// 1: Sort favorites first if enabled
|
||||
...(this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : []),
|
||||
// 2: Sort folders first if sorting by name
|
||||
...(this.sortingMode === 'basename' ? [v => v.type !== 'folder'] : []),
|
||||
// 3: Use sorting mode if NOT basename (to be able to use displayName too)
|
||||
...(this.sortingMode !== 'basename' ? [v => v[this.sortingMode]] : []),
|
||||
// 4: Use displayName if available, fallback to name
|
||||
v => v.attributes?.displayName || v.basename,
|
||||
// 5: Finally, use basename if all previous sorting methods failed
|
||||
v => v.basename,
|
||||
]
|
||||
const orders = [
|
||||
// (for 1): always sort favorites before normal files
|
||||
...(this.userConfig.sort_favorites_first ? ['asc'] : []),
|
||||
// (for 2): always sort folders before files
|
||||
...(this.sortingMode === 'basename' ? ['asc'] : []),
|
||||
// (for 3): Reverse if sorting by mtime as mtime higher means edited more recent -> lower
|
||||
...(this.sortingMode === 'mtime' ? [this.isAscSorting ? 'desc' : 'asc'] : []),
|
||||
// (also for 3 so make sure not to conflict with 2 and 3)
|
||||
...(this.sortingMode !== 'mtime' && this.sortingMode !== 'basename' ? [this.isAscSorting ? 'asc' : 'desc'] : []),
|
||||
// for 4: use configured sorting direction
|
||||
this.isAscSorting ? 'asc' : 'desc',
|
||||
// for 5: use configured sorting direction
|
||||
this.isAscSorting ? 'asc' : 'desc',
|
||||
]
|
||||
return [identifiers, orders] as const
|
||||
},
|
||||
|
||||
/**
|
||||
* The current directory contents.
|
||||
*/
|
||||
|
|
@ -234,24 +268,9 @@ export default Vue.extend({
|
|||
return this.isAscSorting ? results : results.reverse()
|
||||
}
|
||||
|
||||
const identifiers = [
|
||||
// Sort favorites first if enabled
|
||||
...this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : [],
|
||||
// Sort folders first if sorting by name
|
||||
...this.sortingMode === 'basename' ? [v => v.type !== 'folder'] : [],
|
||||
// Use sorting mode if NOT basename (to be able to use displayName too)
|
||||
...this.sortingMode !== 'basename' ? [v => v[this.sortingMode]] : [],
|
||||
// Use displayName if available, fallback to name
|
||||
v => v.attributes?.displayName || v.basename,
|
||||
// Finally, use basename if all previous sorting methods failed
|
||||
v => v.basename,
|
||||
]
|
||||
const orders = new Array(identifiers.length).fill(this.isAscSorting ? 'asc' : 'desc')
|
||||
|
||||
return orderBy(
|
||||
[...this.dirContents],
|
||||
identifiers,
|
||||
orders,
|
||||
...this.sortingParameters,
|
||||
)
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -19,21 +19,15 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
describe('Login with a new user and open the files app', function() {
|
||||
before(function() {
|
||||
describe('Files', { testIsolation: true }, () => {
|
||||
beforeEach(() => {
|
||||
cy.createRandomUser().then((user) => {
|
||||
cy.login(user)
|
||||
})
|
||||
})
|
||||
|
||||
after(function() {
|
||||
cy.logout()
|
||||
})
|
||||
|
||||
it('See the default file welcome.txt in the files list', function() {
|
||||
it('Login with a user and open the files app', () => {
|
||||
cy.visit('/apps/files')
|
||||
cy.get('[data-cy-files-list] [data-cy-files-list-row-name="welcome.txt"]').should('be.visible')
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting -- Wait for all to finish loading
|
||||
cy.wait(500)
|
||||
})
|
||||
})
|
||||
262
cypress/e2e/files/files_sorting.cy.ts
Normal file
262
cypress/e2e/files/files_sorting.cy.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/**
|
||||
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
describe('Files: Sorting the file list', { testIsolation: true }, () => {
|
||||
let currentUser
|
||||
beforeEach(() => {
|
||||
cy.createRandomUser().then((user) => {
|
||||
currentUser = user
|
||||
cy.login(user)
|
||||
})
|
||||
})
|
||||
|
||||
it('Files are sorted by name ascending by default', () => {
|
||||
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 first.txt')
|
||||
.uploadContent(currentUser, new Blob(), 'text/plain', '/z last.txt')
|
||||
.uploadContent(currentUser, new Blob(), 'text/plain', '/A.txt')
|
||||
.uploadContent(currentUser, new Blob(), 'text/plain', '/Ä.txt')
|
||||
.mkdir(currentUser, '/m')
|
||||
.mkdir(currentUser, '/4')
|
||||
cy.login(currentUser)
|
||||
cy.visit('/apps/files')
|
||||
|
||||
cy.get('[data-cy-files-list-row]').each(($row, index) => {
|
||||
switch (index) {
|
||||
case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('4')
|
||||
break
|
||||
case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('m')
|
||||
break
|
||||
case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 first.txt')
|
||||
break
|
||||
case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('A.txt')
|
||||
break
|
||||
case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('Ä.txt')
|
||||
break
|
||||
case 5: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
|
||||
break
|
||||
case 6: expect($row.attr('data-cy-files-list-row-name')).to.eq('z last.txt')
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('Can sort by size', () => {
|
||||
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 tiny.txt')
|
||||
.uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z big.txt')
|
||||
.uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a medium.txt')
|
||||
.mkdir(currentUser, '/folder')
|
||||
cy.login(currentUser)
|
||||
cy.visit('/apps/files')
|
||||
|
||||
// click sort button
|
||||
cy.get('th').contains('button', 'Size').click()
|
||||
// sorting is set
|
||||
cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending')
|
||||
// Files are sorted
|
||||
cy.get('[data-cy-files-list-row]').each(($row, index) => {
|
||||
switch (index) {
|
||||
case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt')
|
||||
break
|
||||
case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder')
|
||||
break
|
||||
case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
|
||||
break
|
||||
case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt')
|
||||
break
|
||||
case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// click sort button
|
||||
cy.get('th').contains('button', 'Size').click()
|
||||
// sorting is set
|
||||
cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending')
|
||||
// Files are sorted
|
||||
cy.get('[data-cy-files-list-row]').each(($row, index) => {
|
||||
switch (index) {
|
||||
case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt')
|
||||
break
|
||||
case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt')
|
||||
break
|
||||
case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
|
||||
break
|
||||
case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder')
|
||||
break
|
||||
case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt')
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('Can sort by mtime', () => {
|
||||
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1.txt', Date.now() / 1000 - 86400 - 1000)
|
||||
.uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z.txt', Date.now() / 1000 - 86400)
|
||||
.uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a.txt', Date.now() / 1000 - 86400 - 500)
|
||||
cy.login(currentUser)
|
||||
cy.visit('/apps/files')
|
||||
|
||||
// click sort button
|
||||
cy.get('th').contains('button', 'Modified').click()
|
||||
// sorting is set
|
||||
cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'ascending')
|
||||
// Files are sorted
|
||||
cy.get('[data-cy-files-list-row]').each(($row, index) => {
|
||||
switch (index) {
|
||||
case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') // uploaded right now
|
||||
break
|
||||
case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') // fake time of yesterday
|
||||
break
|
||||
case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') // fake time of yesterday and few minutes
|
||||
break
|
||||
case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') // fake time of yesterday and ~15 minutes ago
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// reverse order
|
||||
cy.get('th').contains('button', 'Modified').click()
|
||||
cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'descending')
|
||||
cy.get('[data-cy-files-list-row]').each(($row, index) => {
|
||||
switch (index) {
|
||||
case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') // uploaded right now
|
||||
break
|
||||
case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') // fake time of yesterday
|
||||
break
|
||||
case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') // fake time of yesterday and few minutes
|
||||
break
|
||||
case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') // fake time of yesterday and ~15 minutes ago
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('Favorites are sorted first', () => {
|
||||
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1.txt', Date.now() / 1000 - 86400 - 1000)
|
||||
.uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z.txt', Date.now() / 1000 - 86400)
|
||||
.uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a.txt', Date.now() / 1000 - 86400 - 500)
|
||||
.setFileAsFavorite(currentUser, '/a.txt')
|
||||
cy.login(currentUser)
|
||||
cy.visit('/apps/files')
|
||||
|
||||
cy.log('By name - ascending')
|
||||
cy.get('th').contains('button', 'Name').click()
|
||||
cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'ascending')
|
||||
|
||||
cy.get('[data-cy-files-list-row]').each(($row, index) => {
|
||||
switch (index) {
|
||||
case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt')
|
||||
break
|
||||
case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
|
||||
break
|
||||
case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
|
||||
break
|
||||
case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
cy.log('By name - descending')
|
||||
cy.get('th').contains('button', 'Name').click()
|
||||
cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'descending')
|
||||
|
||||
cy.get('[data-cy-files-list-row]').each(($row, index) => {
|
||||
switch (index) {
|
||||
case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt')
|
||||
break
|
||||
case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
|
||||
break
|
||||
case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
|
||||
break
|
||||
case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
cy.log('By size - ascending')
|
||||
cy.get('th').contains('button', 'Size').click()
|
||||
cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending')
|
||||
|
||||
cy.get('[data-cy-files-list-row]').each(($row, index) => {
|
||||
switch (index) {
|
||||
case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt')
|
||||
break
|
||||
case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
|
||||
break
|
||||
case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
|
||||
break
|
||||
case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
cy.log('By size - descending')
|
||||
cy.get('th').contains('button', 'Size').click()
|
||||
cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending')
|
||||
|
||||
cy.get('[data-cy-files-list-row]').each(($row, index) => {
|
||||
switch (index) {
|
||||
case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt')
|
||||
break
|
||||
case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
|
||||
break
|
||||
case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
|
||||
break
|
||||
case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
cy.log('By mtime - ascending')
|
||||
cy.get('th').contains('button', 'Modified').click()
|
||||
cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'ascending')
|
||||
|
||||
cy.get('[data-cy-files-list-row]').each(($row, index) => {
|
||||
switch (index) {
|
||||
case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt')
|
||||
break
|
||||
case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
|
||||
break
|
||||
case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
|
||||
break
|
||||
case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
cy.log('By mtime - descending')
|
||||
cy.get('th').contains('button', 'Modified').click()
|
||||
cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'descending')
|
||||
|
||||
cy.get('[data-cy-files-list-row]').each(($row, index) => {
|
||||
switch (index) {
|
||||
case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt')
|
||||
break
|
||||
case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt')
|
||||
break
|
||||
case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt')
|
||||
break
|
||||
case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt')
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -49,7 +49,18 @@ declare global {
|
|||
* Upload a raw content to a given user storage.
|
||||
* **Warning**: Using this function will reset the previous session
|
||||
*/
|
||||
uploadContent(user: User, content: Blob, mimeType: string, target: string): Cypress.Chainable<void>,
|
||||
uploadContent(user: User, content: Blob, mimeType: string, target: string, mtime?: number): Cypress.Chainable<void>,
|
||||
|
||||
/**
|
||||
* Create a new directory
|
||||
* **Warning**: Using this function will reset the previous session
|
||||
*/
|
||||
mkdir(user: User, target: string): Cypress.Chainable<void>,
|
||||
|
||||
/**
|
||||
* Set a file as favorite (or remove from favorite)
|
||||
*/
|
||||
setFileAsFavorite(user: User, target: string, favorite?: boolean): Cypress.Chainable<void>,
|
||||
|
||||
/**
|
||||
* Reset the admin theming entirely.
|
||||
|
|
@ -121,6 +132,63 @@ Cypress.Commands.add('uploadFile', (user, fixture = 'image.jpg', mimeType = 'ima
|
|||
})
|
||||
})
|
||||
|
||||
Cypress.Commands.add('setFileAsFavorite', (user: User, target: string, favorite = true) => {
|
||||
// eslint-disable-next-line cypress/unsafe-to-chain-command
|
||||
cy.clearAllCookies()
|
||||
.then(async () => {
|
||||
try {
|
||||
const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}`
|
||||
const filePath = target.split('/').map(encodeURIComponent).join('/')
|
||||
const response = await axios({
|
||||
url: `${rootPath}${filePath}`,
|
||||
method: 'PROPPATCH',
|
||||
auth: {
|
||||
username: user.userId,
|
||||
password: user.password,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
},
|
||||
data: `<?xml version="1.0"?>
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<oc:favorite>${favorite ? 1 : 0}</oc:favorite>
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</d:propertyupdate>`
|
||||
})
|
||||
cy.log(`Created directory ${target}`, response)
|
||||
} catch (error) {
|
||||
cy.log('error', error)
|
||||
throw new Error('Unable to process fixture')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Cypress.Commands.add('mkdir', (user: User, target: string) => {
|
||||
// eslint-disable-next-line cypress/unsafe-to-chain-command
|
||||
cy.clearCookies()
|
||||
.then(async () => {
|
||||
try {
|
||||
const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}`
|
||||
const filePath = target.split('/').map(encodeURIComponent).join('/')
|
||||
const response = await axios({
|
||||
url: `${rootPath}${filePath}`,
|
||||
method: 'MKCOL',
|
||||
auth: {
|
||||
username: user.userId,
|
||||
password: user.password,
|
||||
},
|
||||
})
|
||||
cy.log(`Created directory ${target}`, response)
|
||||
} catch (error) {
|
||||
cy.log('error', error)
|
||||
throw new Error('Unable to process fixture')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* cy.uploadedContent - uploads a raw content
|
||||
* TODO: standardise in @nextcloud/cypress
|
||||
|
|
@ -130,7 +198,7 @@ Cypress.Commands.add('uploadFile', (user, fixture = 'image.jpg', mimeType = 'ima
|
|||
* @param {string} mimeType e.g. image/png
|
||||
* @param {string} target the target of the file relative to the user root
|
||||
*/
|
||||
Cypress.Commands.add('uploadContent', (user, blob, mimeType, target) => {
|
||||
Cypress.Commands.add('uploadContent', (user, blob, mimeType, target, mtime = undefined) => {
|
||||
// eslint-disable-next-line cypress/unsafe-to-chain-command
|
||||
cy.clearCookies()
|
||||
.then(async () => {
|
||||
|
|
@ -147,6 +215,7 @@ Cypress.Commands.add('uploadContent', (user, blob, mimeType, target) => {
|
|||
data: file,
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'X-OC-MTime': mtime ? `${mtime}` : undefined,
|
||||
},
|
||||
auth: {
|
||||
username: user.userId,
|
||||
|
|
|
|||
4
dist/files-main.js
vendored
4
dist/files-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-main.js.map
vendored
2
dist/files-main.js.map
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue