fix(console): infinite scrolling for org context (#7965)

* add scroll directive, iterative loading

* sorting column

* fix: filter

* batch of 100, max height 350px

* cleanup
This commit is contained in:
Max Peintner 2024-05-17 19:28:29 +02:00 committed by GitHub
parent 6858ed7b21
commit f482231f79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 107 additions and 47 deletions

View File

@ -1,6 +1,7 @@
import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core'; import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core';
@Directive({ @Directive({
standalone: true,
selector: '[cnslScrollable]', selector: '[cnslScrollable]',
}) })
export class ScrollableDirective { export class ScrollableDirective {
@ -15,7 +16,6 @@ export class ScrollableDirective {
const top = event.target.scrollTop; const top = event.target.scrollTop;
const height = this.el.nativeElement.scrollHeight; const height = this.el.nativeElement.scrollHeight;
const offset = this.el.nativeElement.offsetHeight; const offset = this.el.nativeElement.offsetHeight;
// emit bottom event // emit bottom event
if (top > height - offset - 1) { if (top > height - offset - 1) {
this.scrollPosition.emit('bottom'); this.scrollPosition.emit('bottom');

View File

@ -1,11 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ScrollableDirective } from './scrollable.directive';
@NgModule({
declarations: [ScrollableDirective],
imports: [CommonModule],
exports: [ScrollableDirective],
})
export class ScrollableModule {}

View File

@ -6,7 +6,6 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ScrollableModule } from 'src/app/directives/scrollable/scrollable.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module'; import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module'; import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module';
import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date-pipe.module'; import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date-pipe.module';
@ -18,7 +17,6 @@ import { ChangesComponent } from './changes.component';
declarations: [ChangesComponent], declarations: [ChangesComponent],
imports: [ imports: [
CommonModule, CommonModule,
ScrollableModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
TranslateModule, TranslateModule,
MatIconModule, MatIconModule,
@ -30,6 +28,6 @@ import { ChangesComponent } from './changes.component';
MatTooltipModule, MatTooltipModule,
AvatarModule, AvatarModule,
], ],
exports: [ChangesComponent, ScrollableModule], exports: [ChangesComponent],
}) })
export class ChangesModule {} export class ChangesModule {}

View File

@ -15,7 +15,7 @@
/> />
</div> </div>
<div class="org-wrapper"> <div class="org-wrapper" cnslScrollable (scrollPosition)="onNearEndScroll($event)">
<button <button
class="org-button-with-pin" class="org-button-with-pin"
mat-button mat-button

View File

@ -1,11 +1,14 @@
import { SelectionModel } from '@angular/cdk/collections'; import { SelectionModel } from '@angular/cdk/collections';
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { UntypedFormControl } from '@angular/forms'; import { UntypedFormControl } from '@angular/forms';
import { BehaviorSubject, catchError, debounceTime, finalize, from, map, Observable, of, pipe, tap } from 'rxjs'; import { BehaviorSubject, catchError, debounceTime, finalize, from, map, Observable, of, pipe, scan, take, tap } from 'rxjs';
import { TextQueryMethod } from 'src/app/proto/generated/zitadel/object_pb'; import { TextQueryMethod } from 'src/app/proto/generated/zitadel/object_pb';
import { Org, OrgNameQuery, OrgQuery, OrgState, OrgStateQuery } from 'src/app/proto/generated/zitadel/org_pb'; import { Org, OrgFieldName, OrgNameQuery, OrgQuery, OrgState, OrgStateQuery } from 'src/app/proto/generated/zitadel/org_pb';
import { AuthenticationService } from 'src/app/services/authentication.service'; import { AuthenticationService } from 'src/app/services/authentication.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ToastService } from 'src/app/services/toast.service';
const ORG_QUERY_LIMIT = 100;
@Component({ @Component({
selector: 'cnsl-org-context', selector: 'cnsl-org-context',
@ -16,7 +19,30 @@ export class OrgContextComponent implements OnInit {
public pinned: SelectionModel<Org.AsObject> = new SelectionModel<Org.AsObject>(true, []); public pinned: SelectionModel<Org.AsObject> = new SelectionModel<Org.AsObject>(true, []);
public orgLoading$: BehaviorSubject<any> = new BehaviorSubject(false); public orgLoading$: BehaviorSubject<any> = new BehaviorSubject(false);
public orgs$: Observable<Org.AsObject[]> = of([]);
public bottom: boolean = false;
private _done: BehaviorSubject<any> = new BehaviorSubject(false);
private _loading: BehaviorSubject<any> = new BehaviorSubject(false);
public _orgs: BehaviorSubject<Org.AsObject[]> = new BehaviorSubject<Org.AsObject[]>([]);
public orgs$: Observable<Org.AsObject[]> = this._orgs.pipe(
map((orgs) => {
return orgs.sort((left, right) => left.name.localeCompare(right.name));
}),
pipe(
tap((orgs: Org.AsObject[]) => {
this.pinned.clear();
this.getPrefixedItem('pinned-orgs').then((stringifiedOrgs) => {
if (stringifiedOrgs) {
const orgIds: string[] = JSON.parse(stringifiedOrgs);
const pinnedOrgs = orgs.filter((o) => orgIds.includes(o.id));
pinnedOrgs.forEach((o) => this.pinned.select(o));
}
});
}),
),
);
public filterControl: UntypedFormControl = new UntypedFormControl(''); public filterControl: UntypedFormControl = new UntypedFormControl('');
@Input() public org!: Org.AsObject; @Input() public org!: Org.AsObject;
@ViewChild('input', { static: false }) input!: ElementRef; @ViewChild('input', { static: false }) input!: ElementRef;
@ -26,15 +52,17 @@ export class OrgContextComponent implements OnInit {
constructor( constructor(
public authService: AuthenticationService, public authService: AuthenticationService,
private auth: GrpcAuthService, private auth: GrpcAuthService,
private toast: ToastService,
) { ) {
this.filterControl.valueChanges.pipe(debounceTime(500)).subscribe((value) => { this.filterControl.valueChanges.pipe(debounceTime(500)).subscribe((value) => {
this.loadOrgs(value.trim().toLowerCase()); const filteredValues = this.loadOrgs(0, value.trim().toLowerCase());
this.mapAndUpdate(filteredValues, true);
}); });
} }
public ngOnInit(): void { public ngOnInit(): void {
this.focusFilter(); this.focusFilter();
this.loadOrgs(); this.init();
} }
public setActiveOrg(org: Org.AsObject) { public setActiveOrg(org: Org.AsObject) {
@ -42,7 +70,24 @@ export class OrgContextComponent implements OnInit {
this.closedCard.emit(); this.closedCard.emit();
} }
public loadOrgs(filter?: string): void { public onNearEndScroll(position: 'top' | 'bottom'): void {
if (position === 'bottom') {
this.more();
}
}
public more(): void {
const _cursor = this._orgs.getValue().length;
let more: Promise<Org.AsObject[]> = this.loadOrgs(_cursor, '');
this.mapAndUpdate(more);
}
public init(): void {
let first: Promise<Org.AsObject[]> = this.loadOrgs(0);
this.mapAndUpdate(first);
}
public loadOrgs(offset: number, filter?: string): Promise<Org.AsObject[]> {
if (!filter) { if (!filter) {
const value = this.input?.nativeElement?.value; const value = this.input?.nativeElement?.value;
if (value) { if (value) {
@ -61,29 +106,52 @@ export class OrgContextComponent implements OnInit {
orgNameQuery.setMethod(TextQueryMethod.TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE); orgNameQuery.setMethod(TextQueryMethod.TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE);
query.setNameQuery(orgNameQuery); query.setNameQuery(orgNameQuery);
} }
this.orgLoading$.next(true); this.orgLoading$.next(true);
this.orgs$ = from(this.auth.listMyProjectOrgs(undefined, 0, query ? [query] : undefined)).pipe( return this.auth
map((resp) => { .listMyProjectOrgs(ORG_QUERY_LIMIT, offset, query ? [query] : undefined, OrgFieldName.ORG_FIELD_NAME_NAME, 'asc')
return resp.resultList.sort((left, right) => left.name.localeCompare(right.name)); .then((result) => {
}),
catchError(() => of([])),
pipe(
tap((orgs: Org.AsObject[]) => {
this.pinned.clear();
this.getPrefixedItem('pinned-orgs').then((stringifiedOrgs) => {
if (stringifiedOrgs) {
const orgIds: string[] = JSON.parse(stringifiedOrgs);
const pinnedOrgs = orgs.filter((o) => orgIds.includes(o.id));
pinnedOrgs.forEach((o) => this.pinned.select(o));
}
});
}),
),
finalize(() => {
this.orgLoading$.next(false); this.orgLoading$.next(false);
}), return result.resultList;
); })
.catch((error) => {
this.orgLoading$.next(false);
this.toast.showError(error);
return [];
});
}
private mapAndUpdate(col: Promise<Org.AsObject[]>, clear?: boolean): any {
if (clear === false && (this._done.value || this._loading.value)) {
return;
}
if (!this.bottom) {
this._loading.next(true);
return from(col)
.pipe(
take(1),
tap((res: Org.AsObject[]) => {
const current = this._orgs.getValue();
if (clear) {
this._orgs.next(res);
} else {
this._orgs.next([...current, ...res]);
}
this._loading.next(false);
if (!res.length) {
this._done.next(true);
}
}),
catchError((_) => {
this._loading.next(false);
this.bottom = true;
return of([]);
}),
)
.subscribe();
}
} }
public closeCard(element: HTMLElement): void { public closeCard(element: HTMLElement): void {

View File

@ -12,11 +12,13 @@ import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { InputModule } from '../input/input.module'; import { InputModule } from '../input/input.module';
import { OrgContextComponent } from './org-context.component'; import { OrgContextComponent } from './org-context.component';
import { ScrollableDirective } from 'src/app/directives/scrollable/scrollable.directive';
@NgModule({ @NgModule({
declarations: [OrgContextComponent], declarations: [OrgContextComponent],
imports: [ imports: [
CommonModule, CommonModule,
ScrollableDirective,
FormsModule, FormsModule,
A11yModule, A11yModule,
ReactiveFormsModule, ReactiveFormsModule,

View File

@ -52,7 +52,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef> <th mat-header-cell mat-sort-header *matHeaderCellDef>
{{ 'ORG.PAGES.NAME' | translate }} {{ 'ORG.PAGES.NAME' | translate }}
</th> </th>
<td mat-cell *matCellDef="let org" (click)="setAndNavigateToOrg(org)"> <td mat-cell *matCellDef="let org" (click)="setAndNavigateToOrg(org)">

View File

@ -418,9 +418,12 @@ export class GrpcAuthService {
if (queryList) { if (queryList) {
req.setQueriesList(queryList); req.setQueriesList(queryList);
} }
// if (sortingColumn) { if (sortingDirection) {
// req.setSortingColumn(sortingColumn); query.setAsc(sortingDirection === 'asc');
// } }
if (sortingColumn) {
req.setSortingColumn(sortingColumn);
}
req.setQuery(query); req.setQuery(query);