mirror of
https://github.com/zitadel/zitadel.git
synced 2025-05-07 04:26:46 +00:00
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:
parent
6858ed7b21
commit
f482231f79
@ -1,6 +1,7 @@
|
||||
import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
standalone: true,
|
||||
selector: '[cnslScrollable]',
|
||||
})
|
||||
export class ScrollableDirective {
|
||||
@ -15,7 +16,6 @@ export class ScrollableDirective {
|
||||
const top = event.target.scrollTop;
|
||||
const height = this.el.nativeElement.scrollHeight;
|
||||
const offset = this.el.nativeElement.offsetHeight;
|
||||
|
||||
// emit bottom event
|
||||
if (top > height - offset - 1) {
|
||||
this.scrollPosition.emit('bottom');
|
||||
|
@ -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 {}
|
@ -6,7 +6,6 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
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 { 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';
|
||||
@ -18,7 +17,6 @@ import { ChangesComponent } from './changes.component';
|
||||
declarations: [ChangesComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ScrollableModule,
|
||||
MatProgressSpinnerModule,
|
||||
TranslateModule,
|
||||
MatIconModule,
|
||||
@ -30,6 +28,6 @@ import { ChangesComponent } from './changes.component';
|
||||
MatTooltipModule,
|
||||
AvatarModule,
|
||||
],
|
||||
exports: [ChangesComponent, ScrollableModule],
|
||||
exports: [ChangesComponent],
|
||||
})
|
||||
export class ChangesModule {}
|
||||
|
@ -15,7 +15,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="org-wrapper">
|
||||
<div class="org-wrapper" cnslScrollable (scrollPosition)="onNearEndScroll($event)">
|
||||
<button
|
||||
class="org-button-with-pin"
|
||||
mat-button
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
|
||||
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 { 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 { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
|
||||
const ORG_QUERY_LIMIT = 100;
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-org-context',
|
||||
@ -16,7 +19,30 @@ export class OrgContextComponent implements OnInit {
|
||||
public pinned: SelectionModel<Org.AsObject> = new SelectionModel<Org.AsObject>(true, []);
|
||||
|
||||
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('');
|
||||
@Input() public org!: Org.AsObject;
|
||||
@ViewChild('input', { static: false }) input!: ElementRef;
|
||||
@ -26,15 +52,17 @@ export class OrgContextComponent implements OnInit {
|
||||
constructor(
|
||||
public authService: AuthenticationService,
|
||||
private auth: GrpcAuthService,
|
||||
private toast: ToastService,
|
||||
) {
|
||||
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 {
|
||||
this.focusFilter();
|
||||
this.loadOrgs();
|
||||
this.init();
|
||||
}
|
||||
|
||||
public setActiveOrg(org: Org.AsObject) {
|
||||
@ -42,7 +70,24 @@ export class OrgContextComponent implements OnInit {
|
||||
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) {
|
||||
const value = this.input?.nativeElement?.value;
|
||||
if (value) {
|
||||
@ -61,29 +106,52 @@ export class OrgContextComponent implements OnInit {
|
||||
orgNameQuery.setMethod(TextQueryMethod.TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE);
|
||||
query.setNameQuery(orgNameQuery);
|
||||
}
|
||||
|
||||
this.orgLoading$.next(true);
|
||||
this.orgs$ = from(this.auth.listMyProjectOrgs(undefined, 0, query ? [query] : undefined)).pipe(
|
||||
map((resp) => {
|
||||
return resp.resultList.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}),
|
||||
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(() => {
|
||||
return this.auth
|
||||
.listMyProjectOrgs(ORG_QUERY_LIMIT, offset, query ? [query] : undefined, OrgFieldName.ORG_FIELD_NAME_NAME, 'asc')
|
||||
.then((result) => {
|
||||
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 {
|
||||
|
@ -12,11 +12,13 @@ import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
|
||||
|
||||
import { InputModule } from '../input/input.module';
|
||||
import { OrgContextComponent } from './org-context.component';
|
||||
import { ScrollableDirective } from 'src/app/directives/scrollable/scrollable.directive';
|
||||
|
||||
@NgModule({
|
||||
declarations: [OrgContextComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ScrollableDirective,
|
||||
FormsModule,
|
||||
A11yModule,
|
||||
ReactiveFormsModule,
|
||||
|
@ -52,7 +52,7 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>
|
||||
<th mat-header-cell mat-sort-header *matHeaderCellDef>
|
||||
{{ 'ORG.PAGES.NAME' | translate }}
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let org" (click)="setAndNavigateToOrg(org)">
|
||||
|
@ -418,9 +418,12 @@ export class GrpcAuthService {
|
||||
if (queryList) {
|
||||
req.setQueriesList(queryList);
|
||||
}
|
||||
// if (sortingColumn) {
|
||||
// req.setSortingColumn(sortingColumn);
|
||||
// }
|
||||
if (sortingDirection) {
|
||||
query.setAsc(sortingDirection === 'asc');
|
||||
}
|
||||
if (sortingColumn) {
|
||||
req.setSortingColumn(sortingColumn);
|
||||
}
|
||||
|
||||
req.setQuery(query);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user