Merge pull request #50679 from nextcloud/backport/50582/stable31

[stable31] fix(files): Correctly scroll selected file into view
This commit is contained in:
Andy Scherzinger 2025-02-06 07:31:21 +01:00 committed by GitHub
commit 5bddc7db06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 567 additions and 83 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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)
}

View file

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

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long