Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/files/src/components/FilesListTableFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 2 additions & 4 deletions apps/files/src/components/FilesListVirtual.vue
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,6 @@ export default defineComponent({
flex-direction: column;
width: 100%;
background-color: var(--color-main-background);

}

// Table header
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
165 changes: 119 additions & 46 deletions apps/files/src/components/VirtualList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand All @@ -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"
Expand All @@ -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" />
Expand Down Expand Up @@ -118,6 +120,7 @@ export default defineComponent({
return {
index: this.scrollToIndex,
beforeHeight: 0,
footerHeight: 0,
headerHeight: 0,
tableHeight: 0,
resizeObserver: null as ResizeObserver | null,
Expand Down Expand Up @@ -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)
},

/**
* Number of rows that will be rendered.
* This includes only visible + buffer rows.
*/
rowCount(): number {
return this.visibleRows + (this.bufferItems / this.columnCount) * 2 + 1
},
columnCount() {

/**
* Number of columns.
* 1 for list view otherwise depending on the file list width.
*/
columnCount(): number {
if (!this.gridMode) {
return 1
}
Expand Down Expand Up @@ -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`,
}
},
Expand All @@ -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,
Expand All @@ -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 }))

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]>
}, 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.$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() {
Expand All @@ -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() {
Expand Down Expand Up @@ -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
},
},
})
Expand Down
40 changes: 40 additions & 0 deletions cypress/e2e/core-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
44 changes: 43 additions & 1 deletion cypress/e2e/files/FilesUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"]`)
Expand Down Expand Up @@ -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)
})
}
Loading
Loading