diff --git a/docs/content-services/components/infinite-scroll-datasource.md b/docs/content-services/components/infinite-scroll-datasource.md new file mode 100644 index 00000000000..33d11eb1d05 --- /dev/null +++ b/docs/content-services/components/infinite-scroll-datasource.md @@ -0,0 +1,66 @@ +--- +Title: Infinite Scroll Datasource +Added: v6.6.0 +Status: Active +Last reviewed: 2024-01-15 +--- + +# [Infinite Scroll Datasource](../../../lib/content-services/src/lib/infinite-scroll-datasource/infinite-scroll-datasource.ts "Defined in infinite-scroll-datasource.ts") + +Contains abstract class acting as a baseline for various datasources for infinite scrolls. + +## Basic Usage + +First step to use infinite scroll datasource in any component is creating a datasource class extending `InfiniteScrollDatasource` using specific source of data e.g. one of the content endpoints. +```ts +export class VersionListDataSource extends InfiniteScrollDatasource { + constructor(private versionsApi: VersionsApi, private node: Node) { + super(); + } + + getNextBatch(pagingOptions: ContentPagingQuery): Observable { + return from(this.versionsApi.listVersionHistory(this.node.id, pagingOptions)).pipe( + take(1), + map((versionPaging) => versionPaging.list.entries) + ); + } +} +``` + +Then in component that will have the infinite scroll define the datasource as instance of a class created in previous step, optionally you can set custom size of the items batch or listen to loading state changes: +```ts +this.versionsDataSource = new VersionListDataSource(this.versionsApi, this.node); +this.versionsDataSource.batchSize = 50; +this.versionsDataSource.isLoading.pipe(takeUntil(this.onDestroy$)).subscribe((isLoading) => this.isLoading = isLoading); +``` + +Final step is to add the [CdkVirtualScrollViewport](https://material.angular.io/cdk/scrolling/api#CdkVirtualScrollViewport) with [CdkVirtualFor](https://material.angular.io/cdk/scrolling/api#CdkVirtualForOf) loop displaying items from the datasource. +```html + +
+
+``` + +When user will scroll down to the bottom of the list next batch of items will be fetched until all items are visible. + +## Class members + +### Properties + +| Name | Type | Default value | Description | +| ---- | ---- | ------------- | ----------- | +| batchSize | `number` | 100 | Determines how much items will be fetched within one batch. | +| firstItem | `T` | | Returns the first item ever fetched. | +| isLoading | [`Observable`](https://rxjs.dev/api/index/class/Observable)`` | | Observable representing the state of loading the first batch. | +| itemsCount | `number` | | Number of items fetched so far. | + +### Methods + +- **connect**(collectionViewer: [`CollectionViewer`](https://material.angular.io/cdk/collections/api)): [`Observable`](https://rxjs.dev/api/index/class/Observable)``
+ Called by the virtual scroll viewport to receive a stream that emits the data array that should be rendered. + - collectionViewer:_ [`CollectionViewer`](https://material.angular.io/cdk/collections/api) - collection viewer providing view changes that are listened to so that next batch can be fetched + - **Returns** [`Observable`](https://rxjs.dev/api/index/class/Observable)`` - Data stream containing fetched items. +- **disconnect**(): void
+ Called when viewport is destroyed, disconnects the datasource, unsubscribes from the view changes. +- **reset**(): void
+ Resets the datasource by fetching the first batch. diff --git a/lib/content-services/src/lib/infinite-scroll-datasource/index.ts b/lib/content-services/src/lib/infinite-scroll-datasource/index.ts new file mode 100644 index 00000000000..e58e5c1c414 --- /dev/null +++ b/lib/content-services/src/lib/infinite-scroll-datasource/index.ts @@ -0,0 +1,18 @@ +/*! + * @license + * Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './public-api'; diff --git a/lib/content-services/src/lib/infinite-scroll-datasource/infinite-scroll-datasource.spec.ts b/lib/content-services/src/lib/infinite-scroll-datasource/infinite-scroll-datasource.spec.ts new file mode 100644 index 00000000000..96ea49b55ec --- /dev/null +++ b/lib/content-services/src/lib/infinite-scroll-datasource/infinite-scroll-datasource.spec.ts @@ -0,0 +1,159 @@ +/*! + * @license + * Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ContentPagingQuery } from '@alfresco/js-api'; +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { Component, OnInit } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; +import { from, Observable } from 'rxjs'; +import { ContentTestingModule } from '../testing/content.testing.module'; +import { InfiniteScrollDatasource } from './infinite-scroll-datasource'; + +class TestData { + testId: number; + testDescription: string; + + constructor(input?: Partial) { + if (input) { + Object.assign(this, input); + } + } +} + +class TestDataSource extends InfiniteScrollDatasource { + testDataBatch1: TestData[] = [ + { + testId: 1, + testDescription: 'test1' + }, + { + testId: 2, + testDescription: 'test2' + }, + { + testId: 3, + testDescription: 'test3' + }, + { + testId: 4, + testDescription: 'test4' + } + ]; + testDataBatch2: TestData[] = [ + { + testId: 5, + testDescription: 'test5' + }, + { + testId: 6, + testDescription: 'test6' + } + ]; + + getNextBatch(pagingOptions: ContentPagingQuery): Observable { + if (pagingOptions.skipCount === 4) { + return from([this.testDataBatch2]); + } else if (pagingOptions.skipCount === 0) { + return from([this.testDataBatch1]); + } else { + return from([]); + } + } +} + +@Component({ + template: ` +
+ {{ item.testDescription }} +
+
` +}) +class TestComponent implements OnInit { + testDatasource = new TestDataSource(); + + ngOnInit() { + this.testDatasource.batchSize = 4; + } +} + +describe('InfiniteScrollDatasource', () => { + let fixture: ComponentFixture; + let component: TestComponent; + + const getRenderedItems = (): HTMLDivElement[] => fixture.debugElement.queryAll(By.css('.test-item')).map(element => element.nativeElement); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), ContentTestingModule, ScrollingModule], + declarations: [TestComponent] + }); + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + }); + + it('should connect to the datasource and fetch first batch of items on init', async () => { + spyOn(component.testDatasource, 'connect').and.callThrough(); + spyOn(component.testDatasource, 'getNextBatch').and.callThrough(); + fixture.autoDetectChanges(); + await fixture.whenStable(); + await fixture.whenRenderingDone(); + + expect(component.testDatasource.connect).toHaveBeenCalled(); + expect(component.testDatasource.itemsCount).toBe(4); + expect(component.testDatasource.getNextBatch).toHaveBeenCalledWith({ skipCount: 0, maxItems: 4 }); + const renderedItems = getRenderedItems(); + // only 3 elements fit the viewport + expect(renderedItems.length).toBe(3); + expect(renderedItems[0].innerText).toBe('test1'); + expect(renderedItems[2].innerText).toBe('test3'); + }); + + it('should load next batch when user scrolls towards the end of the list', fakeAsync(() => { + fixture.autoDetectChanges(); + const stable = fixture.whenStable(); + const renderingDone = fixture.whenRenderingDone(); + Promise.all([stable, renderingDone]).then(() => { + spyOn(component.testDatasource, 'getNextBatch').and.callThrough(); + const viewport = fixture.debugElement.query(By.css('cdk-virtual-scroll-viewport')).nativeElement; + viewport.scrollTop = 400; + tick(100); + + const renderedItems = getRenderedItems(); + expect(component.testDatasource.getNextBatch).toHaveBeenCalledWith({ skipCount: 4, maxItems: 4 }); + expect(component.testDatasource.itemsCount).toBe(6); + expect(renderedItems[3].innerText).toBe('test4'); + }); + })); + + it('should reset the datastream and fetch first batch on reset', fakeAsync(() => { + fixture.autoDetectChanges(); + const stable = fixture.whenStable(); + const renderingDone = fixture.whenRenderingDone(); + Promise.all([stable, renderingDone]).then(() => { + spyOn(component.testDatasource, 'getNextBatch').and.callThrough(); + component.testDatasource.reset(); + tick(100); + + const renderedItems = getRenderedItems(); + expect(component.testDatasource.getNextBatch).toHaveBeenCalledWith({ skipCount: 0, maxItems: 4 }); + expect(renderedItems.length).toBe(3); + expect(renderedItems[2].innerText).toBe('test3'); + }); + })); +}); diff --git a/lib/content-services/src/lib/infinite-scroll-datasource/infinite-scroll-datasource.ts b/lib/content-services/src/lib/infinite-scroll-datasource/infinite-scroll-datasource.ts new file mode 100644 index 00000000000..4283da15fde --- /dev/null +++ b/lib/content-services/src/lib/infinite-scroll-datasource/infinite-scroll-datasource.ts @@ -0,0 +1,82 @@ +/*! + * @license + * Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ContentPagingQuery } from '@alfresco/js-api'; +import { CollectionViewer, DataSource } from '@angular/cdk/collections'; +import { BehaviorSubject, forkJoin, Observable, Subject, Subscription } from 'rxjs'; +import { take, tap } from 'rxjs/operators'; + +export abstract class InfiniteScrollDatasource extends DataSource { + protected readonly dataStream = new BehaviorSubject([]); + private isLoading$ = new Subject(); + private subscription = new Subscription(); + private batchesFetched = 0; + private _itemsCount = 0; + private _firstItem: T; + + /* Determines size of each batch to be fetched */ + batchSize = 100; + + /* Observable with initial and on reset loading state */ + isLoading = this.isLoading$.asObservable(); + + get itemsCount(): number { + return this._itemsCount; + } + + get firstItem(): T { + return this._firstItem; + } + + abstract getNextBatch(pagingOptions: ContentPagingQuery): Observable; + + connect(collectionViewer: CollectionViewer): Observable { + this.reset(); + this.subscription.add( + collectionViewer.viewChange.subscribe((range) => { + if (this.batchesFetched * this.batchSize <= range.end) { + forkJoin([ + this.dataStream.asObservable().pipe(take(1)), + this.getNextBatch({ skipCount: this.batchSize * this.batchesFetched, maxItems: this.batchSize }).pipe( + take(1), + tap((nextBatch) => (this._itemsCount += nextBatch.length)) + ) + ]).subscribe((batchesArray) => this.dataStream.next([...batchesArray[0], ...batchesArray[1]])); + this.batchesFetched += 1; + } + }) + ); + return this.dataStream; + } + + disconnect(): void { + this.subscription.unsubscribe(); + } + + reset(): void { + this.isLoading$.next(true); + this.getNextBatch({ skipCount: 0, maxItems: this.batchSize }) + .pipe(take(1)) + .subscribe((firstBatch) => { + this._itemsCount = firstBatch.length; + this._firstItem = firstBatch[0]; + this.dataStream.next(firstBatch); + this.isLoading$.next(false); + }); + this.batchesFetched = 1; + } +} diff --git a/lib/content-services/src/lib/infinite-scroll-datasource/public-api.ts b/lib/content-services/src/lib/infinite-scroll-datasource/public-api.ts new file mode 100644 index 00000000000..f11c57defbc --- /dev/null +++ b/lib/content-services/src/lib/infinite-scroll-datasource/public-api.ts @@ -0,0 +1,18 @@ +/*! + * @license + * Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './infinite-scroll-datasource'; diff --git a/lib/content-services/src/lib/version-manager/version-list.component.html b/lib/content-services/src/lib/version-manager/version-list.component.html index 0aaa271f082..ae91acae47e 100644 --- a/lib/content-services/src/lib/version-manager/version-list.component.html +++ b/lib/content-services/src/lib/version-manager/version-list.component.html @@ -1,55 +1,53 @@ - - - insert_drive_file -

{{version.entry.name}}

-

- {{version.entry.id}} - - {{version.entry.modifiedAt | date}} -

-

{{version.entry.versionComment}}

+ + + + + insert_drive_file +

{{version.entry.name}}

+

+ {{version.entry.id}} - + {{version.entry.modifiedAt | date}} +

+

{{version.entry.versionComment}}

-
- - - + + + - - - - - + + - -
-
+ + +
+
- - - - diff --git a/lib/content-services/src/lib/version-manager/version-list.component.scss b/lib/content-services/src/lib/version-manager/version-list.component.scss index 982bbc5eb52..e49cb0f09cd 100644 --- a/lib/content-services/src/lib/version-manager/version-list.component.scss +++ b/lib/content-services/src/lib/version-manager/version-list.component.scss @@ -1,4 +1,8 @@ .adf-version-list { + &-viewport { + height: 100%; + } + .mat-list-item-content { border-bottom: 1px solid #d8d8d8; } diff --git a/lib/content-services/src/lib/version-manager/version-list.component.spec.ts b/lib/content-services/src/lib/version-manager/version-list.component.spec.ts index 009f928eb44..77b360015c3 100644 --- a/lib/content-services/src/lib/version-manager/version-list.component.spec.ts +++ b/lib/content-services/src/lib/version-manager/version-list.component.spec.ts @@ -18,13 +18,14 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { VersionListComponent } from './version-list.component'; +import { VersionListComponent, VersionListDataSource } from './version-list.component'; import { MatDialog } from '@angular/material/dialog'; import { of } from 'rxjs'; -import { Node, VersionPaging, NodeEntry, VersionEntry, Version } from '@alfresco/js-api'; +import { Node, NodeEntry, VersionEntry, Version } from '@alfresco/js-api'; import { ContentTestingModule } from '../testing/content.testing.module'; import { TranslateModule } from '@ngx-translate/core'; import { ContentVersionService } from './content-version.service'; +import { take } from 'rxjs/operators'; describe('VersionListComponent', () => { let component: VersionListComponent; @@ -56,14 +57,16 @@ describe('VersionListComponent', () => { component = fixture.componentInstance; component.node = { id: nodeId, allowableOperations: ['update'] } as Node; + component.isLoading = false; spyOn(component, 'downloadContent').and.stub(); spyOn(component.nodesApi, 'getNode').and.returnValue(Promise.resolve(new NodeEntry({ entry: new Node({ id: 'nodeInfoId' }) }))); + spyOn(VersionListDataSource.prototype, 'getNextBatch').and.callFake(() => of(versionTest)); + spyOn(component.versionsApi, 'listVersionHistory').and.callFake(() => Promise.resolve({ list: { entries: versionTest } })); }); it('should raise confirmation dialog on delete', () => { fixture.detectChanges(); - component.versions = versionTest; spyOn(dialog, 'open').and.returnValue({ afterClosed: () => of(false) @@ -74,15 +77,13 @@ describe('VersionListComponent', () => { expect(dialog.open).toHaveBeenCalled(); }); - it('should delete the version if user confirms', () => { - fixture.detectChanges(); - component.versions = versionTest; + it('should delete the version if user confirms', async () => { spyOn(dialog, 'open').and.returnValue({ afterClosed: () => of(true) } as any); - spyOn(component.versionsApi, 'deleteVersion').and.returnValue(Promise.resolve()); + fixture.detectChanges(); component.deleteVersion(versionId); expect(dialog.open).toHaveBeenCalled(); @@ -90,14 +91,12 @@ describe('VersionListComponent', () => { }); it('should not delete version if user rejects', () => { - component.versions = versionTest; - spyOn(dialog, 'open').and.returnValue({ afterClosed: () => of(false) } as any); spyOn(component.versionsApi, 'deleteVersion').and.returnValue(Promise.resolve()); - + fixture.detectChanges(); component.deleteVersion(versionId); expect(dialog.open).toHaveBeenCalled(); @@ -115,40 +114,35 @@ describe('VersionListComponent', () => { }); describe('Version history fetching', () => { - it('should use loading bar', () => { - spyOn(component.versionsApi, 'listVersionHistory').and.callFake(() => Promise.resolve({ list: { entries: versionTest } })); + it('should use loading bar', (done) => { + fixture.detectChanges(); let loadingProgressBar = fixture.debugElement.query(By.css('[data-automation-id="version-history-loading-bar"]')); expect(loadingProgressBar).toBeNull(); - component.ngOnChanges(); - fixture.detectChanges(); + component.versionsDataSource.isLoading.pipe(take(1)).subscribe(() => { + fixture.detectChanges(); + loadingProgressBar = fixture.debugElement.query(By.css('[data-automation-id="version-history-loading-bar"]')); + expect(loadingProgressBar).not.toBeNull(); + done(); + }); - loadingProgressBar = fixture.debugElement.query(By.css('[data-automation-id="version-history-loading-bar"]')); - expect(loadingProgressBar).not.toBeNull(); + component.ngOnChanges(); }); it('should load the versions for a given id', () => { - spyOn(component.versionsApi, 'listVersionHistory').and.callFake(() => Promise.resolve({ list: { entries: versionTest } })); + fixture.detectChanges(); + spyOn(component.versionsDataSource, 'reset'); component.ngOnChanges(); fixture.detectChanges(); - expect(component.versionsApi.listVersionHistory).toHaveBeenCalledWith(nodeId); + expect(component.versionsDataSource.reset).toHaveBeenCalled(); + expect(component.versionsDataSource.getNextBatch).toHaveBeenCalled(); }); it('should show the versions after loading', (done) => { fixture.detectChanges(); - spyOn(component.versionsApi, 'listVersionHistory').and.callFake(() => - Promise.resolve( - new VersionPaging({ - list: { - entries: [versionTest[0]] - } - }) - ) - ); - component.ngOnChanges(); fixture.whenStable().then(() => { @@ -165,16 +159,6 @@ describe('VersionListComponent', () => { }); it('should NOT show the versions comments if input property is set not to show them', (done) => { - spyOn(component.versionsApi, 'listVersionHistory').and.callFake(() => - Promise.resolve( - new VersionPaging({ - list: { - entries: [versionTest[0]] - } - }) - ) - ); - component.showComments = false; fixture.detectChanges(); @@ -190,9 +174,6 @@ describe('VersionListComponent', () => { }); it('should be able to download a version', () => { - spyOn(component.versionsApi, 'listVersionHistory').and.returnValue( - Promise.resolve(new VersionPaging({ list: { entries: [versionTest[0]] } })) - ); spyOn(contentVersionService.contentApi, 'getContentUrl').and.returnValue('the/download/url'); fixture.detectChanges(); @@ -209,9 +190,6 @@ describe('VersionListComponent', () => { }); it('should NOT be able to download a version if configured so', () => { - spyOn(component.versionsApi, 'listVersionHistory').and.callFake(() => - Promise.resolve(new VersionPaging({ list: { entries: [versionTest[0]] } })) - ); const spyOnDownload = spyOn(component.contentApi, 'getContentUrl').and.stub(); component.allowDownload = false; @@ -232,10 +210,7 @@ describe('VersionListComponent', () => { it('should load the versions for a given id', () => { fixture.detectChanges(); - component.versions = versionTest; - const spyOnRevertVersion = spyOn(component.versionsApi, 'revertVersion').and.callFake(() => Promise.resolve(versionTest[0])); - component.restore(versionId); expect(spyOnRevertVersion).toHaveBeenCalledWith(nodeId, versionId, { majorVersion: true, comment: '' }); @@ -243,8 +218,6 @@ describe('VersionListComponent', () => { it('should get node info after restoring the node', fakeAsync(() => { fixture.detectChanges(); - component.versions = versionTest; - spyOn(component.versionsApi, 'listVersionHistory').and.callFake(() => Promise.resolve({ list: { entries: versionTest } })); spyOn(component.versionsApi, 'revertVersion').and.callFake(() => Promise.resolve(versionTest[0])); @@ -257,8 +230,6 @@ describe('VersionListComponent', () => { it('should emit with node info data', fakeAsync(() => { fixture.detectChanges(); - component.versions = versionTest; - spyOn(component.versionsApi, 'listVersionHistory').and.callFake(() => Promise.resolve({ list: { entries: versionTest } })); spyOn(component.versionsApi, 'revertVersion').and.callFake(() => Promise.resolve(versionTest[0])); @@ -273,18 +244,16 @@ describe('VersionListComponent', () => { it('should reload the version list after a version restore', fakeAsync(() => { fixture.detectChanges(); - component.versions = versionTest; - const spyOnListVersionHistory = spyOn(component.versionsApi, 'listVersionHistory').and.callFake(() => - Promise.resolve({ list: { entries: versionTest } }) - ); spyOn(component.versionsApi, 'revertVersion').and.callFake(() => Promise.resolve(null)); + spyOn(component.versionsDataSource, 'reset'); component.restore(versionId); fixture.detectChanges(); tick(); - expect(spyOnListVersionHistory).toHaveBeenCalledTimes(1); + expect(component.versionsDataSource.reset).toHaveBeenCalled(); + expect(component.versionsDataSource.getNextBatch).toHaveBeenCalled(); })); }); @@ -302,15 +271,6 @@ describe('VersionListComponent', () => { beforeEach(() => { fixture.detectChanges(); versionTest[1].entry.id = '1.1'; - spyOn(component.versionsApi, 'listVersionHistory').and.callFake(() => - Promise.resolve( - new VersionPaging({ - list: { - entries: versionTest - } - }) - ) - ); }); describe('showActions', () => { @@ -320,8 +280,6 @@ describe('VersionListComponent', () => { }); it('should show Actions if showActions is true', (done) => { - component.versions = versionTest; - component.showActions = true; fixture.detectChanges(); diff --git a/lib/content-services/src/lib/version-manager/version-list.component.ts b/lib/content-services/src/lib/version-manager/version-list.component.ts index 65e593d1b7d..6d40690648c 100644 --- a/lib/content-services/src/lib/version-manager/version-list.component.ts +++ b/lib/content-services/src/lib/version-manager/version-list.component.ts @@ -16,12 +16,29 @@ */ import { AlfrescoApiService } from '@alfresco/adf-core'; -import { Component, Input, OnChanges, ViewEncapsulation, EventEmitter, Output } from '@angular/core'; -import { VersionsApi, Node, VersionEntry, VersionPaging, NodesApi, NodeEntry, ContentApi } from '@alfresco/js-api'; +import { Component, Input, OnChanges, ViewEncapsulation, EventEmitter, Output, OnInit, OnDestroy, ViewChild } from '@angular/core'; +import { VersionsApi, Node, VersionEntry, NodesApi, NodeEntry, ContentApi, ContentPagingQuery } from '@alfresco/js-api'; import { MatDialog } from '@angular/material/dialog'; import { ConfirmDialogComponent } from '../dialogs/confirm.dialog'; import { ContentVersionService } from './content-version.service'; import { ContentService } from '../common/services/content.service'; +import { InfiniteScrollDatasource } from '../infinite-scroll-datasource'; +import { from, Observable, Subject } from 'rxjs'; +import { map, take, takeUntil } from 'rxjs/operators'; +import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; + +export class VersionListDataSource extends InfiniteScrollDatasource { + constructor(private versionsApi: VersionsApi, private node: Node) { + super(); + } + + getNextBatch(pagingOptions: ContentPagingQuery): Observable { + return from(this.versionsApi.listVersionHistory(this.node.id, pagingOptions)).pipe( + take(1), + map((versionPaging) => versionPaging.list.entries) + ); + } +} @Component({ selector: 'adf-version-list', @@ -30,8 +47,8 @@ import { ContentService } from '../common/services/content.service'; encapsulation: ViewEncapsulation.None, host: { class: 'adf-version-list' } }) -export class VersionListComponent implements OnChanges { - +export class VersionListComponent implements OnChanges, OnInit, OnDestroy { + private onDestroy$ = new Subject(); private _contentApi: ContentApi; get contentApi(): ContentApi { this._contentApi = this._contentApi ?? new ContentApi(this.alfrescoApi.getInstance()); @@ -50,7 +67,8 @@ export class VersionListComponent implements OnChanges { return this._nodesApi; } - versions: VersionEntry[] = []; + versionsDataSource: VersionListDataSource; + latestVersion: VersionEntry; isLoading = true; /** The target node. */ @@ -85,34 +103,48 @@ export class VersionListComponent implements OnChanges { @Output() viewVersion = new EventEmitter(); - constructor(private alfrescoApi: AlfrescoApiService, - private contentService: ContentService, - private contentVersionService: ContentVersionService, - private dialog: MatDialog) { + @ViewChild('viewport') + viewport: CdkVirtualScrollViewport; + + constructor( + private alfrescoApi: AlfrescoApiService, + private contentService: ContentService, + private contentVersionService: ContentVersionService, + private dialog: MatDialog + ) {} + + ngOnInit() { + this.versionsDataSource = new VersionListDataSource(this.versionsApi, this.node); + this.versionsDataSource.isLoading.pipe(takeUntil(this.onDestroy$)).subscribe((isLoading) => { + this.isLoading = isLoading; + this.latestVersion = this.versionsDataSource.firstItem; + }); } ngOnChanges() { - this.loadVersionHistory(); + if (this.versionsDataSource) { + this.loadVersionHistory(); + } + } + + ngOnDestroy() { + this.onDestroy$.next(); + this.onDestroy$.complete(); } canUpdate(): boolean { - return this.contentService.hasAllowableOperations(this.node, 'update') && this.versions.length > 1; + return this.contentService.hasAllowableOperations(this.node, 'update') && this.versionsDataSource.itemsCount > 1; } canDelete(): boolean { - return this.contentService.hasAllowableOperations(this.node, 'delete') && this.versions.length > 1; + return this.contentService.hasAllowableOperations(this.node, 'delete') && this.versionsDataSource.itemsCount > 1; } restore(versionId: string) { if (this.canUpdate()) { this.versionsApi .revertVersion(this.node.id, versionId, { majorVersion: true, comment: '' }) - .then(() => - this.nodesApi.getNode( - this.node.id, - { include: ['permissions', 'path', 'isFavorite', 'allowableOperations'] } - ) - ) + .then(() => this.nodesApi.getNode(this.node.id, { include: ['permissions', 'path', 'isFavorite', 'allowableOperations'] })) .then((node) => this.onVersionRestored(node)); } } @@ -122,18 +154,16 @@ export class VersionListComponent implements OnChanges { } loadVersionHistory() { - this.isLoading = true; - this.versionsApi.listVersionHistory(this.node.id).then((versionPaging: VersionPaging) => { - this.versions = versionPaging.list.entries; - this.isLoading = false; - }); + this.viewport.scrollToIndex(0); + this.versionsDataSource.reset(); } downloadVersion(versionId: string) { if (this.allowDownload) { this.contentVersionService .getVersionContentUrl(this.node.id, versionId, true) - .subscribe(versionDownloadUrl => this.downloadContent(versionDownloadUrl)); + .pipe(takeUntil(this.onDestroy$)) + .subscribe((versionDownloadUrl) => this.downloadContent(versionDownloadUrl)); } } @@ -149,13 +179,14 @@ export class VersionListComponent implements OnChanges { minWidth: '250px' }); - dialogRef.afterClosed().subscribe((result) => { - if (result === true) { - this.versionsApi - .deleteVersion(this.node.id, versionId) - .then(() => this.onVersionDeleted(this.node)); - } - }); + dialogRef + .afterClosed() + .pipe(takeUntil(this.onDestroy$)) + .subscribe((result) => { + if (result) { + this.versionsApi.deleteVersion(this.node.id, versionId).then(() => this.onVersionDeleted(this.node)); + } + }); } } diff --git a/lib/content-services/src/lib/version-manager/version-manager.component.html b/lib/content-services/src/lib/version-manager/version-manager.component.html index 3390106b639..e29ac2cd422 100644 --- a/lib/content-services/src/lib/version-manager/version-manager.component.html +++ b/lib/content-services/src/lib/version-manager/version-manager.component.html @@ -10,7 +10,7 @@ id="adf-version-upload-button" [node]="node" [newFileVersion]="newFileVersion" - [currentVersion]="versionList?.versions[0]?.entry" + [currentVersion]="versionList?.latestVersion?.entry" (success)="onUploadSuccess($event)" (cancel)="onUploadCancel()" (error)="onUploadError($event)"> diff --git a/lib/content-services/src/lib/version-manager/version-manager.component.spec.ts b/lib/content-services/src/lib/version-manager/version-manager.component.spec.ts index b2c4ea7eb97..6b5952773e3 100644 --- a/lib/content-services/src/lib/version-manager/version-manager.component.spec.ts +++ b/lib/content-services/src/lib/version-manager/version-manager.component.spec.ts @@ -55,7 +55,7 @@ describe('VersionManagerComponent', () => { it('should load the versions for a given node', () => { fixture.detectChanges(); - expect(spyOnListVersionHistory).toHaveBeenCalledWith(node.id); + expect(spyOnListVersionHistory).toHaveBeenCalledWith(node.id, { skipCount: 0, maxItems: 100 }); }); it('should toggle new version if given a new file as input', () => { diff --git a/lib/content-services/src/lib/version-manager/version-manager.module.ts b/lib/content-services/src/lib/version-manager/version-manager.module.ts index 2f8be0ca351..ded68d244ae 100644 --- a/lib/content-services/src/lib/version-manager/version-manager.module.ts +++ b/lib/content-services/src/lib/version-manager/version-manager.module.ts @@ -27,6 +27,7 @@ import { UploadModule } from '../upload/upload.module'; import { VersionCompatibilityModule } from '../version-compatibility/version-compatibility.module'; import { CoreModule } from '@alfresco/adf-core'; import { VersionComparisonComponent } from './version-comparison.component'; +import { ScrollingModule } from '@angular/cdk/scrolling'; @NgModule({ imports: [ @@ -35,7 +36,8 @@ import { VersionComparisonComponent } from './version-comparison.component'; CoreModule, UploadModule, VersionCompatibilityModule, - FormsModule + FormsModule, + ScrollingModule ], exports: [ VersionUploadComponent, diff --git a/lib/content-services/src/public-api.ts b/lib/content-services/src/public-api.ts index 20b98a62860..72e32778cbb 100644 --- a/lib/content-services/src/public-api.ts +++ b/lib/content-services/src/public-api.ts @@ -46,5 +46,6 @@ export * from './lib/tree/index'; export * from './lib/category/index'; export * from './lib/viewer/index'; export * from './lib/security/index'; +export * from './lib/infinite-scroll-datasource'; export * from './lib/content.module';