mirror of
https://github.com/nextcloud/server.git
synced 2026-04-15 22:11:17 -04:00
Merge pull request #50679 from nextcloud/backport/50582/stable31
[stable31] fix(files): Correctly scroll selected file into view
This commit is contained in:
commit
5bddc7db06
11 changed files with 567 additions and 83 deletions
|
|
@ -141,7 +141,7 @@ export default defineComponent({
|
|||
<style scoped lang="scss">
|
||||
// Scoped row
|
||||
tr {
|
||||
margin-bottom: max(25vh, var(--body-container-margin));
|
||||
margin-bottom: var(--body-container-margin);
|
||||
border-top: 1px solid var(--color-border);
|
||||
// Prevent hover effect on the whole row
|
||||
background-color: transparent !important;
|
||||
|
|
|
|||
|
|
@ -536,7 +536,6 @@ export default defineComponent({
|
|||
flex-direction: column;
|
||||
width: 100%;
|
||||
background-color: var(--color-main-background);
|
||||
|
||||
}
|
||||
|
||||
// Table header
|
||||
|
|
@ -853,8 +852,7 @@ export default defineComponent({
|
|||
|
||||
<style lang="scss">
|
||||
// Grid mode
|
||||
tbody.files-list__tbody.files-list__tbody--grid {
|
||||
--half-clickable-area: calc(var(--clickable-area) / 2);
|
||||
.files-list--grid tbody.files-list__tbody {
|
||||
--item-padding: 16px;
|
||||
--icon-preview-size: 166px;
|
||||
--name-height: 32px;
|
||||
|
|
@ -945,7 +943,7 @@ tbody.files-list__tbody.files-list__tbody--grid {
|
|||
|
||||
.files-list__row-actions {
|
||||
position: absolute;
|
||||
inset-inline-end: calc(var(--half-clickable-area) / 2);
|
||||
inset-inline-end: calc(var(--clickable-area) / 4);
|
||||
inset-block-end: calc(var(--mtime-height) / 2);
|
||||
width: var(--clickable-area);
|
||||
height: var(--clickable-area);
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@
|
|||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<div class="files-list" data-cy-files-list>
|
||||
<div class="files-list"
|
||||
:class="{ 'files-list--grid': gridMode }"
|
||||
data-cy-files-list
|
||||
@scroll.passive="onScroll">
|
||||
<!-- Header -->
|
||||
<div ref="before" class="files-list__before">
|
||||
<slot name="before" />
|
||||
</div>
|
||||
|
||||
<div class="files-list__filters">
|
||||
<div ref="filters" class="files-list__filters">
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
|
||||
|
|
@ -31,7 +34,6 @@
|
|||
<!-- Body -->
|
||||
<tbody :style="tbodyStyle"
|
||||
class="files-list__tbody"
|
||||
:class="gridMode ? 'files-list__tbody--grid' : 'files-list__tbody--list'"
|
||||
data-cy-files-list-tbody>
|
||||
<component :is="dataComponent"
|
||||
v-for="({key, item}, i) in renderedItems"
|
||||
|
|
@ -42,7 +44,7 @@
|
|||
</tbody>
|
||||
|
||||
<!-- Footer -->
|
||||
<tfoot v-show="isReady"
|
||||
<tfoot ref="footer"
|
||||
class="files-list__tfoot"
|
||||
data-cy-files-list-tfoot>
|
||||
<slot name="footer" />
|
||||
|
|
@ -118,6 +120,7 @@ export default defineComponent({
|
|||
return {
|
||||
index: this.scrollToIndex,
|
||||
beforeHeight: 0,
|
||||
footerHeight: 0,
|
||||
headerHeight: 0,
|
||||
tableHeight: 0,
|
||||
resizeObserver: null as ResizeObserver | null,
|
||||
|
|
@ -145,16 +148,33 @@ export default defineComponent({
|
|||
// 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom)
|
||||
return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 55
|
||||
},
|
||||
|
||||
// Grid mode only
|
||||
itemWidth() {
|
||||
// 166px + 16px x 2 (padding left and right)
|
||||
return 166 + 16 + 16
|
||||
},
|
||||
|
||||
rowCount() {
|
||||
return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1
|
||||
/**
|
||||
* The number of rows currently (fully!) visible
|
||||
*/
|
||||
visibleRows(): number {
|
||||
return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight)
|
||||
},
|
||||
columnCount() {
|
||||
|
||||
/**
|
||||
* Number of rows that will be rendered.
|
||||
* This includes only visible + buffer rows.
|
||||
*/
|
||||
rowCount(): number {
|
||||
return this.visibleRows + (this.bufferItems / this.columnCount) * 2 + 1
|
||||
},
|
||||
|
||||
/**
|
||||
* Number of columns.
|
||||
* 1 for list view otherwise depending on the file list width.
|
||||
*/
|
||||
columnCount(): number {
|
||||
if (!this.gridMode) {
|
||||
return 1
|
||||
}
|
||||
|
|
@ -217,16 +237,18 @@ export default defineComponent({
|
|||
* The total number of rows that are available
|
||||
*/
|
||||
totalRowCount() {
|
||||
return Math.floor(this.dataSources.length / this.columnCount)
|
||||
return Math.ceil(this.dataSources.length / this.columnCount)
|
||||
},
|
||||
|
||||
tbodyStyle() {
|
||||
const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length
|
||||
const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
|
||||
const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount)
|
||||
// The number of (virtual) rows above the currently rendered ones.
|
||||
// start index is aligned so this should always be an integer
|
||||
const rowsAbove = Math.round(this.startIndex / this.columnCount)
|
||||
// The number of (virtual) rows below the currently rendered ones.
|
||||
const rowsBelow = Math.max(0, this.totalRowCount - rowsAbove - this.rowCount)
|
||||
|
||||
return {
|
||||
paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`,
|
||||
paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
|
||||
paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`,
|
||||
minHeight: `${this.totalRowCount * this.itemHeight}px`,
|
||||
}
|
||||
},
|
||||
|
|
@ -238,15 +260,14 @@ export default defineComponent({
|
|||
|
||||
totalRowCount() {
|
||||
if (this.scrollToIndex) {
|
||||
this.$nextTick(() => this.scrollTo(this.scrollToIndex))
|
||||
this.scrollTo(this.scrollToIndex)
|
||||
}
|
||||
},
|
||||
|
||||
columnCount(columnCount, oldColumnCount) {
|
||||
if (oldColumnCount === 0) {
|
||||
// We're initializing, the scroll position
|
||||
// is handled on mounted
|
||||
console.debug('VirtualList: columnCount is 0, skipping scroll')
|
||||
// We're initializing, the scroll position is handled on mounted
|
||||
logger.debug('VirtualList: columnCount is 0, skipping scroll')
|
||||
return
|
||||
}
|
||||
// If the column count changes in grid view,
|
||||
|
|
@ -256,30 +277,28 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
mounted() {
|
||||
const before = this.$refs?.before as HTMLElement
|
||||
const root = this.$el as HTMLElement
|
||||
const thead = this.$refs?.thead as HTMLElement
|
||||
this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]>
|
||||
|
||||
this.resizeObserver = new ResizeObserver(debounce(() => {
|
||||
this.beforeHeight = before?.clientHeight ?? 0
|
||||
this.headerHeight = thead?.clientHeight ?? 0
|
||||
this.tableHeight = root?.clientHeight ?? 0
|
||||
this.updateHeightVariables()
|
||||
logger.debug('VirtualList: resizeObserver updated')
|
||||
this.onScroll()
|
||||
}, 100, { immediate: false }))
|
||||
}, 100))
|
||||
this.resizeObserver.observe(this.$el)
|
||||
this.resizeObserver.observe(this.$refs.before as HTMLElement)
|
||||
this.resizeObserver.observe(this.$refs.filters as HTMLElement)
|
||||
this.resizeObserver.observe(this.$refs.footer as HTMLElement)
|
||||
|
||||
this.resizeObserver.observe(before)
|
||||
this.resizeObserver.observe(root)
|
||||
this.resizeObserver.observe(thead)
|
||||
|
||||
if (this.scrollToIndex) {
|
||||
this.scrollTo(this.scrollToIndex)
|
||||
}
|
||||
|
||||
// Adding scroll listener AFTER the initial scroll to index
|
||||
this.$el.addEventListener('scroll', this.onScroll, { passive: true })
|
||||
|
||||
this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]>
|
||||
this.$nextTick(() => {
|
||||
// Make sure height values are initialized
|
||||
this.updateHeightVariables()
|
||||
// If we need to scroll to an index we do so in the next tick.
|
||||
// This is needed to apply updates from the initialization of the height variables
|
||||
// which will update the tbody styles until next tick.
|
||||
if (this.scrollToIndex) {
|
||||
this.scrollTo(this.scrollToIndex)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
|
|
@ -294,17 +313,56 @@ export default defineComponent({
|
|||
return
|
||||
}
|
||||
|
||||
// Check if the content is smaller than the viewport, meaning no scrollbar
|
||||
const targetRow = Math.ceil(this.dataSources.length / this.columnCount)
|
||||
if (targetRow < this.rowCount) {
|
||||
logger.debug('VirtualList: Skip scrolling, nothing to scroll', { index, targetRow, rowCount: this.rowCount })
|
||||
// Check if the content is smaller (not equal! keep the footer in mind) than the viewport
|
||||
// meaning there is no scrollbar
|
||||
if (this.totalRowCount < this.visibleRows) {
|
||||
logger.debug('VirtualList: Skip scrolling, nothing to scroll', {
|
||||
index,
|
||||
totalRows: this.totalRowCount,
|
||||
visibleRows: this.visibleRows,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Scroll to one row and a half before the index
|
||||
const scrollTop = this.indexToScrollPos(index)
|
||||
logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount, beforeHeight: this.beforeHeight })
|
||||
this.$el.scrollTop = scrollTop
|
||||
// We can not scroll further as the last page of rows
|
||||
// For the grid view we also need to account for all columns in that row (columnCount - 1)
|
||||
const clampedIndex = (this.totalRowCount - this.visibleRows) * this.columnCount + (this.columnCount - 1)
|
||||
// The scroll position
|
||||
let scrollTop = this.indexToScrollPos(Math.min(index, clampedIndex))
|
||||
|
||||
// First we need to update the internal index for rendering.
|
||||
// This will cause the <tbody> element to be resized allowing us to set the correct scroll position.
|
||||
this.index = index
|
||||
|
||||
// If this is not the first row we can add a half row from above.
|
||||
// This is to help users understand the table is scrolled and not items did not just disappear.
|
||||
// But we also can only add a half row if we have enough rows below to scroll (visual rows / end of scrollable area)
|
||||
if (index >= this.columnCount && index <= clampedIndex) {
|
||||
scrollTop -= (this.itemHeight / 2)
|
||||
// As we render one half row more we also need to adjust the internal index
|
||||
this.index = index - this.columnCount
|
||||
} else if (index > clampedIndex) {
|
||||
// If we are on the last page we cannot scroll any further
|
||||
// but we can at least scroll the footer into view
|
||||
if (index <= (clampedIndex + this.columnCount)) {
|
||||
// We only show have of the footer for the first of the last page
|
||||
// To still show the previous row partly. Same reasoning as above:
|
||||
// help the user understand that the table is scrolled not "magically trimmed"
|
||||
scrollTop += this.footerHeight / 2
|
||||
} else {
|
||||
// We reached the very end of the files list and we are focussing not the first visible row
|
||||
// so all we now can do is scroll to the end (footer)
|
||||
scrollTop += this.footerHeight
|
||||
}
|
||||
}
|
||||
|
||||
// Now we need to wait for the <tbody> element to get resized so we can correctly apply the scrollTop position
|
||||
this.$nextTick(() => {
|
||||
this.$el.scrollTop = scrollTop
|
||||
logger.debug(`VirtualList: scrolling to index ${index}`, {
|
||||
clampedIndex, scrollTop, columnCount: this.columnCount, total: this.totalRowCount, visibleRows: this.visibleRows, beforeHeight: this.beforeHeight,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
onScroll() {
|
||||
|
|
@ -333,7 +391,22 @@ export default defineComponent({
|
|||
// Convert index to scroll position
|
||||
// It should be the opposite of `scrollPosToIndex`
|
||||
indexToScrollPos(index: number): number {
|
||||
return (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight
|
||||
return Math.floor(index / this.columnCount) * this.itemHeight + this.beforeHeight
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the height variables.
|
||||
* To be called by resize observer and `onMount`
|
||||
*/
|
||||
updateHeightVariables(): void {
|
||||
this.tableHeight = this.$el?.clientHeight ?? 0
|
||||
this.beforeHeight = (this.$refs.before as HTMLElement)?.clientHeight ?? 0
|
||||
this.footerHeight = (this.$refs.footer as HTMLElement)?.clientHeight ?? 0
|
||||
|
||||
// Get the header height which consists of table header and filters
|
||||
const theadHeight = (this.$refs.thead as HTMLElement)?.clientHeight ?? 0
|
||||
const filterHeight = (this.$refs.filters as HTMLElement)?.clientHeight ?? 0
|
||||
this.headerHeight = theadHeight + filterHeight
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,3 +48,43 @@ export enum UnifiedSearchFilter {
|
|||
export function getUnifiedSearchFilter(filter: UnifiedSearchFilter) {
|
||||
return getUnifiedSearchModal().find(`[data-cy-unified-search-filters] [data-cy-unified-search-filter="${CSS.escape(filter)}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assertion that an element is fully within the current viewport.
|
||||
* @param $el The element
|
||||
* @param expected If the element is expected to be fully in viewport or not fully
|
||||
* @example
|
||||
* ```js
|
||||
* cy.get('#my-element')
|
||||
* .should(beFullyInViewport)
|
||||
* ```
|
||||
*/
|
||||
export function beFullyInViewport($el: JQuery<HTMLElement>, expected = true) {
|
||||
const { top, left, bottom, right } = $el.get(0)!.getBoundingClientRect()
|
||||
const innerHeight = Cypress.$('body').innerHeight()!
|
||||
const innerWidth = Cypress.$('body').innerWidth()!
|
||||
const fullyVisible = top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth
|
||||
|
||||
console.debug(`fullyVisible: ${fullyVisible}, top: ${top >= 0}, left: ${left >= 0}, bottom: ${bottom <= innerHeight}, right: ${right <= innerWidth}`)
|
||||
|
||||
if (expected) {
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
expect(fullyVisible, 'Fully within viewport').to.be.true
|
||||
} else {
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
expect(fullyVisible, 'Not fully within viewport').to.be.false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opposite of `beFullyInViewport` - resolves when element is not or only partially in viewport.
|
||||
* @param $el The element
|
||||
* @example
|
||||
* ```js
|
||||
* cy.get('#my-element')
|
||||
* .should(notBeFullyInViewport)
|
||||
* ```
|
||||
*/
|
||||
export function notBeFullyInViewport($el: JQuery<HTMLElement>) {
|
||||
return beFullyInViewport($el, false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { User } from "@nextcloud/cypress"
|
||||
import type { User } from '@nextcloud/cypress'
|
||||
|
||||
export const getRowForFileId = (fileid: number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"]`)
|
||||
export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"]`)
|
||||
|
|
@ -214,3 +214,45 @@ export const reloadCurrentFolder = () => {
|
|||
cy.get('[data-cy-files-content-breadcrumbs]').findByRole('button', { description: 'Reload current directory' }).click()
|
||||
cy.wait('@propfind')
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the grid mode for the files list.
|
||||
* Will fail if already enabled!
|
||||
*/
|
||||
export function enableGridMode() {
|
||||
cy.intercept('**/apps/files/api/v1/config/grid_view').as('setGridMode')
|
||||
cy.findByRole('button', { name: 'Switch to grid view' })
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.wait('@setGridMode')
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the needed viewport height to limit the visible rows of the file list.
|
||||
* Requires a logged in user.
|
||||
*
|
||||
* @param rows The number of rows that should be displayed at the same time
|
||||
*/
|
||||
export function calculateViewportHeight(rows: number): Cypress.Chainable<number> {
|
||||
cy.visit('/apps/files')
|
||||
|
||||
return cy.get('[data-cy-files-list]')
|
||||
.should('be.visible')
|
||||
.then((filesList) => {
|
||||
const windowHeight = Cypress.$('body').outerHeight()!
|
||||
// Size of other page elements
|
||||
const outerHeight = Math.ceil(windowHeight - filesList.outerHeight()!)
|
||||
// Size of before and filters
|
||||
const beforeHeight = Math.ceil(Cypress.$('.files-list__before').outerHeight()!)
|
||||
const filterHeight = Math.ceil(Cypress.$('.files-list__filters').outerHeight()!)
|
||||
// Size of the table header
|
||||
const tableHeaderHeight = Math.ceil(Cypress.$('[data-cy-files-list-thead]').outerHeight()!)
|
||||
// table row height
|
||||
const rowHeight = Math.ceil(Cypress.$('[data-cy-files-list-tbody] tr').outerHeight()!)
|
||||
|
||||
// sum it up
|
||||
const viewportHeight = outerHeight + beforeHeight + filterHeight + tableHeaderHeight + rows * rowHeight
|
||||
cy.log(`Calculated viewport height: ${viewportHeight} (${outerHeight} + ${beforeHeight} + ${filterHeight} + ${tableHeaderHeight} + ${rows} * ${rowHeight})`)
|
||||
return cy.wrap(viewportHeight)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import type { User } from '@nextcloud/cypress'
|
||||
import { getRowForFile, haveValidity, renameFile, triggerActionForFile } from './FilesUtils'
|
||||
import { calculateViewportHeight, getRowForFile, haveValidity, renameFile, triggerActionForFile } from './FilesUtils'
|
||||
|
||||
describe('files: Rename nodes', { testIsolation: true }, () => {
|
||||
let user: User
|
||||
|
|
@ -12,7 +12,12 @@ describe('files: Rename nodes', { testIsolation: true }, () => {
|
|||
beforeEach(() => cy.createRandomUser().then(($user) => {
|
||||
user = $user
|
||||
|
||||
// remove welcome file
|
||||
cy.rm(user, '/welcome.txt')
|
||||
// create a file called "file.txt"
|
||||
cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
|
||||
|
||||
// login and visit files app
|
||||
cy.login(user)
|
||||
cy.visit('/apps/files')
|
||||
}))
|
||||
|
|
@ -116,34 +121,6 @@ describe('files: Rename nodes', { testIsolation: true }, () => {
|
|||
.should('not.exist')
|
||||
})
|
||||
|
||||
/**
|
||||
* This is a regression test of: https://github.com/nextcloud/server/issues/47438
|
||||
* The issue was that the renaming state was not reset when the new name moved the file out of the view of the current files list
|
||||
* due to virtual scrolling the renaming state was not changed then by the UI events (as the component was taken out of DOM before any event handling).
|
||||
*/
|
||||
it('correctly resets renaming state', () => {
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
cy.uploadContent(user, new Blob([]), 'text/plain', `/file${i}.txt`)
|
||||
}
|
||||
cy.viewport(1200, 500) // 500px is smaller then 20 * 50 which is the place that the files take up
|
||||
cy.login(user)
|
||||
cy.visit('/apps/files')
|
||||
|
||||
getRowForFile('file.txt').should('be.visible')
|
||||
// Z so it is shown last
|
||||
renameFile('file.txt', 'zzz.txt')
|
||||
// not visible any longer
|
||||
getRowForFile('zzz.txt').should('not.be.visible')
|
||||
// scroll file list to bottom
|
||||
cy.get('[data-cy-files-list]').scrollTo('bottom')
|
||||
cy.screenshot()
|
||||
// The file is no longer in rename state
|
||||
getRowForFile('zzz.txt')
|
||||
.should('be.visible')
|
||||
.findByRole('textbox', { name: 'Filename' })
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
it('cancel renaming on esc press', () => {
|
||||
// All are visible by default
|
||||
getRowForFile('file.txt').should('be.visible')
|
||||
|
|
@ -182,4 +159,38 @@ describe('files: Rename nodes', { testIsolation: true }, () => {
|
|||
.find('input[type="text"]')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
/**
|
||||
* This is a regression test of: https://github.com/nextcloud/server/issues/47438
|
||||
* The issue was that the renaming state was not reset when the new name moved the file out of the view of the current files list
|
||||
* due to virtual scrolling the renaming state was not changed then by the UI events (as the component was taken out of DOM before any event handling).
|
||||
*/
|
||||
it('correctly resets renaming state', () => {
|
||||
// Create 19 additional files
|
||||
for (let i = 1; i <= 19; i++) {
|
||||
cy.uploadContent(user, new Blob([]), 'text/plain', `/file${i}.txt`)
|
||||
}
|
||||
|
||||
// Calculate and setup a viewport where only the first 4 files are visible, causing 6 rows to be rendered
|
||||
cy.viewport(768, 500)
|
||||
cy.login(user)
|
||||
calculateViewportHeight(4)
|
||||
.then((height) => cy.viewport(768, height))
|
||||
|
||||
cy.visit('/apps/files')
|
||||
|
||||
getRowForFile('file.txt').should('be.visible')
|
||||
// Z so it is shown last
|
||||
renameFile('file.txt', 'zzz.txt')
|
||||
// not visible any longer
|
||||
getRowForFile('zzz.txt').should('not.exist')
|
||||
// scroll file list to bottom
|
||||
cy.get('[data-cy-files-list]').scrollTo('bottom')
|
||||
cy.screenshot()
|
||||
// The file is no longer in rename state
|
||||
getRowForFile('zzz.txt')
|
||||
.should('be.visible')
|
||||
.findByRole('textbox', { name: 'Filename' })
|
||||
.should('not.exist')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
292
cypress/e2e/files/scrolling.cy.ts
Normal file
292
cypress/e2e/files/scrolling.cy.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { User } from '@nextcloud/cypress'
|
||||
import { calculateViewportHeight, enableGridMode, getRowForFile } from './FilesUtils.ts'
|
||||
import { beFullyInViewport, notBeFullyInViewport } from '../core-utils.ts'
|
||||
|
||||
describe('files: Scrolling to selected file in file list', { testIsolation: true }, () => {
|
||||
const fileIds = new Map<number, string>()
|
||||
let user: User
|
||||
let viewportHeight: number
|
||||
|
||||
before(() => {
|
||||
cy.createRandomUser().then(($user) => {
|
||||
user = $user
|
||||
|
||||
cy.rm(user, '/welcome.txt')
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
cy.uploadContent(user, new Blob([]), 'text/plain', `/${i}.txt`)
|
||||
.then((response) => fileIds.set(i, Number.parseInt(response.headers['oc-fileid']).toString()))
|
||||
}
|
||||
|
||||
cy.login(user)
|
||||
cy.viewport(1200, 800)
|
||||
// Calculate height to ensure that those 10 elements can not be rendered in one list (only 6 will fit the screen)
|
||||
calculateViewportHeight(6)
|
||||
.then((height) => { viewportHeight = height })
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
cy.viewport(1200, viewportHeight)
|
||||
cy.login(user)
|
||||
})
|
||||
|
||||
it('Can see first file in list', () => {
|
||||
cy.visit(`/apps/files/files/${fileIds.get(1)}`)
|
||||
|
||||
// See file is visible
|
||||
getRowForFile('1.txt')
|
||||
.should('be.visible')
|
||||
|
||||
// we expect also element 6 to be visible
|
||||
getRowForFile('6.txt')
|
||||
.should('be.visible')
|
||||
// but not element 7 - though it should exist (be buffered)
|
||||
getRowForFile('7.txt')
|
||||
.should('exist')
|
||||
.and('not.be.visible')
|
||||
})
|
||||
|
||||
// Same kind of tests for partially visible top and bottom
|
||||
for (let i = 2; i <= 5; i++) {
|
||||
it(`correctly scrolls to row ${i}`, () => {
|
||||
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
|
||||
|
||||
// See file is visible
|
||||
getRowForFile(`${i}.txt`)
|
||||
.should('be.visible')
|
||||
.and(notBeOverlappedByTableHeader)
|
||||
|
||||
// we expect also element +4 to be visible
|
||||
// (6 visible rows -> 5 without our scrolled row -> so we only have 4 fully visible others + two 1/2 hidden rows)
|
||||
getRowForFile(`${i + 4}.txt`)
|
||||
.should('be.visible')
|
||||
// but not element -1 or +5 - though it should exist (be buffered)
|
||||
getRowForFile(`${i - 1}.txt`)
|
||||
.should('exist')
|
||||
.and(beOverlappedByTableHeader)
|
||||
getRowForFile(`${i + 5}.txt`)
|
||||
.should('exist')
|
||||
.and(notBeFullyInViewport)
|
||||
})
|
||||
}
|
||||
|
||||
// this will have half of the footer visible and half of the previous element
|
||||
it('correctly scrolls to row 6', () => {
|
||||
cy.visit(`/apps/files/files/${fileIds.get(6)}`)
|
||||
|
||||
// See file is visible
|
||||
getRowForFile('6.txt')
|
||||
.should('be.visible')
|
||||
.and(notBeOverlappedByTableHeader)
|
||||
|
||||
// we expect also element 7,8,9,10 visible
|
||||
getRowForFile('10.txt')
|
||||
.should('be.visible')
|
||||
// but not row 5
|
||||
getRowForFile('5.txt')
|
||||
.should('exist')
|
||||
.and(beOverlappedByTableHeader)
|
||||
// see footer is only shown partly
|
||||
cy.get('tfoot')
|
||||
.should('exist')
|
||||
.and(notBeFullyInViewport)
|
||||
.contains('10 files')
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
// For the last "page" of entries we can not scroll further
|
||||
// so we show all of the last 4 entries
|
||||
for (let i = 7; i <= 10; i++) {
|
||||
it(`correctly scrolls to row ${i}`, () => {
|
||||
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
|
||||
|
||||
// See file is visible
|
||||
getRowForFile(`${i}.txt`)
|
||||
.should('be.visible')
|
||||
.and(notBeOverlappedByTableHeader)
|
||||
|
||||
// there are only max. 4 rows left so also row 6+ should be visible
|
||||
getRowForFile('6.txt')
|
||||
.should('be.visible')
|
||||
getRowForFile('10.txt')
|
||||
.should('be.visible')
|
||||
// Also the footer is visible
|
||||
cy.get('tfoot')
|
||||
.contains('10 files')
|
||||
.should(beFullyInViewport)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('files: Scrolling to selected file in file list (GRID MODE)', { testIsolation: true }, () => {
|
||||
const fileIds = new Map<number, string>()
|
||||
let user: User
|
||||
let viewportHeight: number
|
||||
|
||||
before(() => {
|
||||
cy.wrap(Cypress.automation('remote:debugger:protocol', {
|
||||
command: 'Network.clearBrowserCache',
|
||||
}))
|
||||
|
||||
cy.createRandomUser().then(($user) => {
|
||||
user = $user
|
||||
|
||||
cy.rm(user, '/welcome.txt')
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
cy.uploadContent(user, new Blob([]), 'text/plain', `/${i}.txt`)
|
||||
.then((response) => fileIds.set(i, Number.parseInt(response.headers['oc-fileid']).toString()))
|
||||
}
|
||||
|
||||
// Set grid mode
|
||||
cy.login(user)
|
||||
cy.visit('/apps/files')
|
||||
enableGridMode()
|
||||
|
||||
// 768px width will limit the columns to 3
|
||||
cy.viewport(768, 800)
|
||||
// Calculate height to ensure that those 12 elements can not be rendered in one list (only 3 will fit the screen)
|
||||
calculateViewportHeight(3)
|
||||
.then((height) => { viewportHeight = height })
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
cy.viewport(768, viewportHeight)
|
||||
cy.login(user)
|
||||
})
|
||||
|
||||
// First row
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
it(`Can see files in first row (file ${i})`, () => {
|
||||
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
|
||||
|
||||
for (let j = 1; j <= 3; j++) {
|
||||
// See all files of that row are visible
|
||||
getRowForFile(`${j}.txt`)
|
||||
.should('be.visible')
|
||||
// we expect also the second row to be visible
|
||||
getRowForFile(`${j + 3}.txt`)
|
||||
.should('be.visible')
|
||||
// Because there is no half row on top we also see the third row
|
||||
getRowForFile(`${j + 6}.txt`)
|
||||
.should('be.visible')
|
||||
// But not the forth row
|
||||
getRowForFile(`${j + 9}.txt`)
|
||||
.should('exist')
|
||||
.and(notBeFullyInViewport)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Second row
|
||||
// Same kind of tests for partially visible top and bottom
|
||||
for (let i = 4; i <= 6; i++) {
|
||||
it(`correctly scrolls to second row (file ${i})`, () => {
|
||||
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
|
||||
|
||||
// See all three files of that row are visible
|
||||
for (let j = 4; j <= 6; j++) {
|
||||
getRowForFile(`${j}.txt`)
|
||||
.should('be.visible')
|
||||
.and(notBeOverlappedByTableHeader)
|
||||
// we expect also the next row to be visible
|
||||
getRowForFile(`${j + 3}.txt`)
|
||||
.should('be.visible')
|
||||
// but not the row below (should be half cut)
|
||||
getRowForFile(`${j + 6}.txt`)
|
||||
.should('exist')
|
||||
.and(notBeFullyInViewport)
|
||||
// Same for the row above
|
||||
getRowForFile(`${j - 3}.txt`)
|
||||
.should('exist')
|
||||
.and(beOverlappedByTableHeader)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Third row
|
||||
// this will have half of the footer visible and half of the previous row
|
||||
for (let i = 7; i <= 9; i++) {
|
||||
it(`correctly scrolls to third row (file ${i})`, () => {
|
||||
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
|
||||
|
||||
// See all three files of that row are visible
|
||||
for (let j = 7; j <= 9; j++) {
|
||||
getRowForFile(`${j}.txt`)
|
||||
.should('be.visible')
|
||||
// we expect also the next row to be visible
|
||||
getRowForFile(`${j + 3}.txt`)
|
||||
.should('be.visible')
|
||||
// but not the row above
|
||||
getRowForFile(`${j - 3}.txt`)
|
||||
.should('exist')
|
||||
.and(beOverlappedByTableHeader)
|
||||
}
|
||||
|
||||
// see footer is only shown partly
|
||||
cy.get('tfoot')
|
||||
.should(notBeFullyInViewport)
|
||||
.contains('span', '12 files')
|
||||
.should('be.visible')
|
||||
})
|
||||
}
|
||||
|
||||
// Forth row which only has row 4 and 3 visible and the full footer
|
||||
for (let i = 10; i <= 12; i++) {
|
||||
it(`correctly scrolls to forth row (file ${i})`, () => {
|
||||
cy.visit(`/apps/files/files/${fileIds.get(i)}`)
|
||||
|
||||
// See all three files of that row are visible
|
||||
for (let j = 10; j <= 12; j++) {
|
||||
getRowForFile(`${j}.txt`)
|
||||
.should('be.visible')
|
||||
.and(notBeOverlappedByTableHeader)
|
||||
// we expect also the row above to be visible
|
||||
getRowForFile(`${j - 3}.txt`)
|
||||
.should('be.visible')
|
||||
}
|
||||
|
||||
// see footer is shown
|
||||
cy.get('tfoot')
|
||||
.contains('.files-list__row-name', '12 files')
|
||||
.should(beFullyInViewport)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/// Some helpers
|
||||
|
||||
/**
|
||||
* Assert that an element is overlapped by the table header
|
||||
* @param $el The element
|
||||
* @param expected if it should be overlapped or NOT
|
||||
*/
|
||||
function beOverlappedByTableHeader($el: JQuery<HTMLElement>, expected = true) {
|
||||
const headerRect = Cypress.$('thead').get(0)!.getBoundingClientRect()
|
||||
const elementRect = $el.get(0)!.getBoundingClientRect()
|
||||
const overlap = !(headerRect.right < elementRect.left
|
||||
|| headerRect.left > elementRect.right
|
||||
|| headerRect.bottom < elementRect.top
|
||||
|| headerRect.top > elementRect.bottom)
|
||||
|
||||
if (expected) {
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
expect(overlap, 'Overlapped by table header').to.be.true
|
||||
} else {
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
expect(overlap, 'Not overlapped by table header').to.be.false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that an element is not overlapped by the table header
|
||||
* @param $el The element
|
||||
*/
|
||||
function notBeOverlappedByTableHeader($el: JQuery<HTMLElement>) {
|
||||
return beOverlappedByTableHeader($el, false)
|
||||
}
|
||||
|
|
@ -119,6 +119,29 @@ Cypress.Commands.add('mkdir', (user: User, target: string) => {
|
|||
})
|
||||
})
|
||||
|
||||
Cypress.Commands.add('rm', (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: 'DELETE',
|
||||
auth: {
|
||||
username: user.userId,
|
||||
password: user.password,
|
||||
},
|
||||
})
|
||||
cy.log(`delete file or directory ${target}`, response)
|
||||
} catch (error) {
|
||||
cy.log('error', error)
|
||||
throw new Error('Unable to delete file or directory')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* cy.uploadedContent - uploads a raw content
|
||||
* TODO: standardize in @nextcloud/cypress
|
||||
|
|
|
|||
5
cypress/support/cypress-e2e.d.ts
vendored
5
cypress/support/cypress-e2e.d.ts
vendored
|
|
@ -29,6 +29,11 @@ declare global {
|
|||
*/
|
||||
uploadContent(user: User, content: Blob, mimeType: string, target: string, mtime?: number): Cypress.Chainable<AxiosResponse>,
|
||||
|
||||
/**
|
||||
* Delete a file or directory
|
||||
*/
|
||||
rm(user: User, target: string): Cypress.Chainable<AxiosResponse>,
|
||||
|
||||
/**
|
||||
* Create a new directory
|
||||
* **Warning**: Using this function will reset the previous session
|
||||
|
|
|
|||
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