mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-01 23:19:00 -06:00
Enhancement: improve filter drop-down performance with virtual scrolling (#11973)
This commit is contained in:
@@ -180,6 +180,9 @@ test('bulk edit', async ({ page }) => {
|
|||||||
await page.locator('pngx-document-card-small').nth(2).click()
|
await page.locator('pngx-document-card-small').nth(2).click()
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Tags' }).click()
|
await page.getByRole('button', { name: 'Tags' }).click()
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: 'Filter tags' })
|
||||||
|
.fill('TagWithPartial')
|
||||||
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
|
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Apply' }).click()
|
await page.getByRole('button', { name: 'Apply' }).click()
|
||||||
|
|||||||
@@ -34,8 +34,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (selectionModel.items) {
|
@if (selectionModel.items) {
|
||||||
<div class="items" #buttonItems>
|
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
|
||||||
@for (item of selectionModel.items | filter: filterText:'name'; track item; let i = $index) {
|
<div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
|
||||||
@if (allowSelectNone || item.id) {
|
@if (allowSelectNone || item.id) {
|
||||||
<pngx-toggleable-dropdown-button
|
<pngx-toggleable-dropdown-button
|
||||||
[item]="item"
|
[item]="item"
|
||||||
@@ -45,12 +45,11 @@
|
|||||||
[count]="getUpdatedDocumentCount(item.id)"
|
[count]="getUpdatedDocumentCount(item.id)"
|
||||||
(toggled)="selectionModel.toggle(item.id)"
|
(toggled)="selectionModel.toggle(item.id)"
|
||||||
(exclude)="excludeClicked(item.id)"
|
(exclude)="excludeClicked(item.id)"
|
||||||
(click)="setButtonItemIndex(i - 1)"
|
|
||||||
[disabled]="disabled">
|
[disabled]="disabled">
|
||||||
</pngx-toggleable-dropdown-button>
|
</pngx-toggleable-dropdown-button>
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
</cdk-virtual-scroll-viewport>
|
||||||
}
|
}
|
||||||
@if (editing) {
|
@if (editing) {
|
||||||
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
|
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ScrollingModule } from '@angular/cdk/scrolling'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import {
|
import {
|
||||||
@@ -64,7 +65,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
],
|
],
|
||||||
imports: [NgxBootstrapIconsModule.pick(allIcons)],
|
imports: [NgxBootstrapIconsModule.pick(allIcons), ScrollingModule],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
hotkeyService = TestBed.inject(HotKeyService)
|
hotkeyService = TestBed.inject(HotKeyService)
|
||||||
@@ -265,18 +266,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(document.activeElement).toEqual(
|
expect(document.activeElement).toEqual(
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
)
|
)
|
||||||
expect(
|
expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
|
||||||
Array.from(
|
|
||||||
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
|
|
||||||
).filter((b) => b.textContent.includes('Tag'))
|
|
||||||
).toHaveLength(2)
|
|
||||||
component.filterText = 'Tag2'
|
component.filterText = 'Tag2'
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(
|
expect(component.buttonsViewport.getRenderedRange().end).toEqual(1) // filtered
|
||||||
Array.from(
|
|
||||||
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
|
|
||||||
).filter((b) => b.textContent.includes('Tag'))
|
|
||||||
).toHaveLength(1)
|
|
||||||
component.dropdown.close()
|
component.dropdown.close()
|
||||||
expect(component.filterText).toHaveLength(0)
|
expect(component.filterText).toHaveLength(0)
|
||||||
}))
|
}))
|
||||||
@@ -331,6 +325,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(100)
|
tick(100)
|
||||||
|
component.buttonsViewport?.checkViewportSize()
|
||||||
|
fixture.detectChanges()
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
expect(document.activeElement).toEqual(filterInputEl)
|
expect(document.activeElement).toEqual(filterInputEl)
|
||||||
@@ -376,6 +372,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(100)
|
tick(100)
|
||||||
|
component.buttonsViewport?.checkViewportSize()
|
||||||
|
fixture.detectChanges()
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
expect(document.activeElement).toEqual(filterInputEl)
|
expect(document.activeElement).toEqual(filterInputEl)
|
||||||
@@ -412,6 +410,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(100)
|
tick(100)
|
||||||
|
component.buttonsViewport?.checkViewportSize()
|
||||||
|
fixture.detectChanges()
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
expect(document.activeElement).toEqual(filterInputEl)
|
expect(document.activeElement).toEqual(filterInputEl)
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
CdkVirtualScrollViewport,
|
||||||
|
ScrollingModule,
|
||||||
|
} from '@angular/cdk/scrolling'
|
||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
@@ -627,18 +631,27 @@ export class FilterableDropdownSelectionModel {
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
|
ScrollingModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FilterableDropdownComponent
|
export class FilterableDropdownComponent
|
||||||
extends LoadingComponentWithPermissions
|
extends LoadingComponentWithPermissions
|
||||||
implements OnInit
|
implements OnInit
|
||||||
{
|
{
|
||||||
|
public readonly FILTERABLE_BUTTON_HEIGHT_PX = 42
|
||||||
|
|
||||||
private filterPipe = inject(FilterPipe)
|
private filterPipe = inject(FilterPipe)
|
||||||
private hotkeyService = inject(HotKeyService)
|
private hotkeyService = inject(HotKeyService)
|
||||||
|
|
||||||
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
|
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
|
||||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||||
@ViewChild('buttonItems') buttonItems: ElementRef
|
@ViewChild('buttonsViewport') buttonsViewport: CdkVirtualScrollViewport
|
||||||
|
|
||||||
|
private get renderedButtons(): Array<HTMLButtonElement> {
|
||||||
|
return Array.from(
|
||||||
|
this.buttonsViewport.elementRef.nativeElement.querySelectorAll('button')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
public popperOptions = pngxPopperOptions
|
public popperOptions = pngxPopperOptions
|
||||||
|
|
||||||
@@ -752,6 +765,14 @@ export class FilterableDropdownComponent
|
|||||||
|
|
||||||
private keyboardIndex: number
|
private keyboardIndex: number
|
||||||
|
|
||||||
|
public get scrollViewportHeight(): number {
|
||||||
|
const filteredLength = this.filterPipe.transform(
|
||||||
|
this.items,
|
||||||
|
this.filterText
|
||||||
|
).length
|
||||||
|
return Math.min(filteredLength * this.FILTERABLE_BUTTON_HEIGHT_PX, 400)
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.selectionModelChange.subscribe((updatedModel) => {
|
this.selectionModelChange.subscribe((updatedModel) => {
|
||||||
@@ -776,6 +797,10 @@ export class FilterableDropdownComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public trackByItem(index: number, item: MatchingModel) {
|
||||||
|
return item?.id ?? index
|
||||||
|
}
|
||||||
|
|
||||||
applyClicked() {
|
applyClicked() {
|
||||||
if (this.selectionModel.isDirty()) {
|
if (this.selectionModel.isDirty()) {
|
||||||
this.dropdown.close()
|
this.dropdown.close()
|
||||||
@@ -794,6 +819,7 @@ export class FilterableDropdownComponent
|
|||||||
if (open) {
|
if (open) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.listFilterTextInput?.nativeElement.focus()
|
this.listFilterTextInput?.nativeElement.focus()
|
||||||
|
this.buttonsViewport?.checkViewportSize()
|
||||||
}, 0)
|
}, 0)
|
||||||
if (this.editing) {
|
if (this.editing) {
|
||||||
this.selectionModel.reset()
|
this.selectionModel.reset()
|
||||||
@@ -861,12 +887,14 @@ export class FilterableDropdownComponent
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
} else if (event.target instanceof HTMLButtonElement) {
|
} else if (event.target instanceof HTMLButtonElement) {
|
||||||
|
this.syncKeyboardIndexFromButton(event.target)
|
||||||
this.focusNextButtonItem()
|
this.focusNextButtonItem()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
if (event.target instanceof HTMLButtonElement) {
|
if (event.target instanceof HTMLButtonElement) {
|
||||||
|
this.syncKeyboardIndexFromButton(event.target)
|
||||||
if (this.keyboardIndex === 0) {
|
if (this.keyboardIndex === 0) {
|
||||||
this.listFilterTextInput.nativeElement.focus()
|
this.listFilterTextInput.nativeElement.focus()
|
||||||
} else {
|
} else {
|
||||||
@@ -903,15 +931,18 @@ export class FilterableDropdownComponent
|
|||||||
if (setFocus) this.setButtonItemFocus()
|
if (setFocus) this.setButtonItemFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
setButtonItemFocus() {
|
private syncKeyboardIndexFromButton(button: HTMLButtonElement) {
|
||||||
this.buttonItems.nativeElement.children[
|
// because of virtual scrolling, re-calculate the index
|
||||||
this.keyboardIndex
|
const idx = this.renderedButtons.indexOf(button)
|
||||||
]?.children[0].focus()
|
if (idx >= 0) {
|
||||||
|
this.keyboardIndex = this.buttonsViewport.getRenderedRange().start + idx
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setButtonItemIndex(index: number) {
|
setButtonItemFocus() {
|
||||||
// just track the index in case user uses arrows
|
const offset =
|
||||||
this.keyboardIndex = index
|
this.keyboardIndex - this.buttonsViewport.getRenderedRange().start
|
||||||
|
this.renderedButtons[offset]?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
hideCount(item: ObjectWithPermissions) {
|
hideCount(item: ObjectWithPermissions) {
|
||||||
|
|||||||
@@ -1306,7 +1306,8 @@ describe('FilterEditorComponent', () => {
|
|||||||
const tagsFilterableDropdown = fixture.debugElement.queryAll(
|
const tagsFilterableDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(FilterableDropdownComponent)
|
By.directive(FilterableDropdownComponent)
|
||||||
)[0]
|
)[0]
|
||||||
tagsFilterableDropdown.triggerEventHandler('opened')
|
tagsFilterableDropdown.componentInstance.dropdownOpenChange(true)
|
||||||
|
fixture.detectChanges()
|
||||||
const tagButton = tagsFilterableDropdown.queryAll(
|
const tagButton = tagsFilterableDropdown.queryAll(
|
||||||
By.directive(ToggleableDropdownButtonComponent)
|
By.directive(ToggleableDropdownButtonComponent)
|
||||||
)[0]
|
)[0]
|
||||||
@@ -1324,7 +1325,8 @@ describe('FilterEditorComponent', () => {
|
|||||||
const tagsFilterableDropdown = fixture.debugElement.queryAll(
|
const tagsFilterableDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(FilterableDropdownComponent)
|
By.directive(FilterableDropdownComponent)
|
||||||
)[0] // Tags dropdown
|
)[0] // Tags dropdown
|
||||||
tagsFilterableDropdown.triggerEventHandler('opened')
|
tagsFilterableDropdown.componentInstance.dropdownOpenChange(true)
|
||||||
|
fixture.detectChanges()
|
||||||
const tagButtons = tagsFilterableDropdown.queryAll(
|
const tagButtons = tagsFilterableDropdown.queryAll(
|
||||||
By.directive(ToggleableDropdownButtonComponent)
|
By.directive(ToggleableDropdownButtonComponent)
|
||||||
)
|
)
|
||||||
@@ -1375,7 +1377,8 @@ describe('FilterEditorComponent', () => {
|
|||||||
const correspondentsFilterableDropdown = fixture.debugElement.queryAll(
|
const correspondentsFilterableDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(FilterableDropdownComponent)
|
By.directive(FilterableDropdownComponent)
|
||||||
)[1] // Corresp dropdown
|
)[1] // Corresp dropdown
|
||||||
correspondentsFilterableDropdown.triggerEventHandler('opened')
|
correspondentsFilterableDropdown.componentInstance.dropdownOpenChange(true)
|
||||||
|
fixture.detectChanges()
|
||||||
const correspondentButtons = correspondentsFilterableDropdown.queryAll(
|
const correspondentButtons = correspondentsFilterableDropdown.queryAll(
|
||||||
By.directive(ToggleableDropdownButtonComponent)
|
By.directive(ToggleableDropdownButtonComponent)
|
||||||
)
|
)
|
||||||
@@ -1414,7 +1417,8 @@ describe('FilterEditorComponent', () => {
|
|||||||
const correspondentsFilterableDropdown = fixture.debugElement.queryAll(
|
const correspondentsFilterableDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(FilterableDropdownComponent)
|
By.directive(FilterableDropdownComponent)
|
||||||
)[1]
|
)[1]
|
||||||
correspondentsFilterableDropdown.triggerEventHandler('opened')
|
correspondentsFilterableDropdown.componentInstance.dropdownOpenChange(true)
|
||||||
|
fixture.detectChanges()
|
||||||
const notAssignedButton = correspondentsFilterableDropdown.queryAll(
|
const notAssignedButton = correspondentsFilterableDropdown.queryAll(
|
||||||
By.directive(ToggleableDropdownButtonComponent)
|
By.directive(ToggleableDropdownButtonComponent)
|
||||||
)[0]
|
)[0]
|
||||||
@@ -1445,7 +1449,8 @@ describe('FilterEditorComponent', () => {
|
|||||||
const documentTypesFilterableDropdown = fixture.debugElement.queryAll(
|
const documentTypesFilterableDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(FilterableDropdownComponent)
|
By.directive(FilterableDropdownComponent)
|
||||||
)[2] // DocType dropdown
|
)[2] // DocType dropdown
|
||||||
documentTypesFilterableDropdown.triggerEventHandler('opened')
|
documentTypesFilterableDropdown.componentInstance.dropdownOpenChange(true)
|
||||||
|
fixture.detectChanges()
|
||||||
const documentTypeButtons = documentTypesFilterableDropdown.queryAll(
|
const documentTypeButtons = documentTypesFilterableDropdown.queryAll(
|
||||||
By.directive(ToggleableDropdownButtonComponent)
|
By.directive(ToggleableDropdownButtonComponent)
|
||||||
)
|
)
|
||||||
@@ -1484,7 +1489,8 @@ describe('FilterEditorComponent', () => {
|
|||||||
const docTypesFilterableDropdown = fixture.debugElement.queryAll(
|
const docTypesFilterableDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(FilterableDropdownComponent)
|
By.directive(FilterableDropdownComponent)
|
||||||
)[2]
|
)[2]
|
||||||
docTypesFilterableDropdown.triggerEventHandler('opened')
|
docTypesFilterableDropdown.componentInstance.dropdownOpenChange(true)
|
||||||
|
fixture.detectChanges()
|
||||||
const notAssignedButton = docTypesFilterableDropdown.queryAll(
|
const notAssignedButton = docTypesFilterableDropdown.queryAll(
|
||||||
By.directive(ToggleableDropdownButtonComponent)
|
By.directive(ToggleableDropdownButtonComponent)
|
||||||
)[0]
|
)[0]
|
||||||
@@ -1515,7 +1521,8 @@ describe('FilterEditorComponent', () => {
|
|||||||
const storagePathFilterableDropdown = fixture.debugElement.queryAll(
|
const storagePathFilterableDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(FilterableDropdownComponent)
|
By.directive(FilterableDropdownComponent)
|
||||||
)[3] // StoragePath dropdown
|
)[3] // StoragePath dropdown
|
||||||
storagePathFilterableDropdown.triggerEventHandler('opened')
|
storagePathFilterableDropdown.componentInstance.dropdownOpenChange(true)
|
||||||
|
fixture.detectChanges()
|
||||||
const storagePathButtons = storagePathFilterableDropdown.queryAll(
|
const storagePathButtons = storagePathFilterableDropdown.queryAll(
|
||||||
By.directive(ToggleableDropdownButtonComponent)
|
By.directive(ToggleableDropdownButtonComponent)
|
||||||
)
|
)
|
||||||
@@ -1554,7 +1561,8 @@ describe('FilterEditorComponent', () => {
|
|||||||
const storagePathsFilterableDropdown = fixture.debugElement.queryAll(
|
const storagePathsFilterableDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(FilterableDropdownComponent)
|
By.directive(FilterableDropdownComponent)
|
||||||
)[3]
|
)[3]
|
||||||
storagePathsFilterableDropdown.triggerEventHandler('opened')
|
storagePathsFilterableDropdown.componentInstance.dropdownOpenChange(true)
|
||||||
|
fixture.detectChanges()
|
||||||
const notAssignedButton = storagePathsFilterableDropdown.queryAll(
|
const notAssignedButton = storagePathsFilterableDropdown.queryAll(
|
||||||
By.directive(ToggleableDropdownButtonComponent)
|
By.directive(ToggleableDropdownButtonComponent)
|
||||||
)[0]
|
)[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user