Skip to content

Commit

Permalink
[ACA-4729] Add infinite scroll to version list (#9248)
Browse files Browse the repository at this point in the history
* [ACA-4729] Add infinite scroll to version list

* [ACA-4729] CR fixes

* [ACA-4729] CR fixes

* [ACA-4729] Items count fix for infinite scroll datasource
  • Loading branch information
MichalKinas committed Jan 25, 2024
1 parent 2c0ad71 commit a7e7934
Show file tree
Hide file tree
Showing 13 changed files with 488 additions and 151 deletions.
66 changes: 66 additions & 0 deletions docs/content-services/components/infinite-scroll-datasource.md
Original file line number Diff line number Diff line change
@@ -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<VersionEntry> {
constructor(private versionsApi: VersionsApi, private node: Node) {
super();
}

getNextBatch(pagingOptions: ContentPagingQuery): Observable<VersionEntry[]> {
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
<cdk-virtual-scroll-viewport appendOnly itemSize="88">
<div *cdkVirtualFor="let version of versionsDataSource"></div>
</cdk-virtual-scroll-viewport>
```

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)`<boolean>` | | 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)`<T>`<br/>
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)`<T>` - Data stream containing fetched items.
- **disconnect**(): void<br/>
Called when viewport is destroyed, disconnects the datasource, unsubscribes from the view changes.
- **reset**(): void<br/>
Resets the datasource by fetching the first batch.
18 changes: 18 additions & 0 deletions lib/content-services/src/lib/infinite-scroll-datasource/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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<TestData>) {
if (input) {
Object.assign(this, input);
}
}
}

class TestDataSource extends InfiniteScrollDatasource<TestData> {
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<TestData[]> {
if (pagingOptions.skipCount === 4) {
return from([this.testDataBatch2]);
} else if (pagingOptions.skipCount === 0) {
return from([this.testDataBatch1]);
} else {
return from([]);
}
}
}

@Component({
template: ` <cdk-virtual-scroll-viewport appendOnly itemSize="300" style="height: 500px; width: 100%;">
<div *cdkVirtualFor="let item of testDatasource" class="test-item" style="display: block; height: 100%; width: 100%;">
{{ item.testDescription }}
</div>
</cdk-virtual-scroll-viewport>`
})
class TestComponent implements OnInit {
testDatasource = new TestDataSource();

ngOnInit() {
this.testDatasource.batchSize = 4;
}
}

describe('InfiniteScrollDatasource', () => {
let fixture: ComponentFixture<TestComponent>;
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');
});
}));
});
Original file line number Diff line number Diff line change
@@ -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<T> extends DataSource<T> {
protected readonly dataStream = new BehaviorSubject<T[]>([]);
private isLoading$ = new Subject<boolean>();
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<T[]>;

connect(collectionViewer: CollectionViewer): Observable<T[]> {
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;
}
}
Original file line number Diff line number Diff line change
@@ -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';

0 comments on commit a7e7934

Please sign in to comment.