diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.html b/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.html index 34ef360c0f..b533b0a99a 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.html @@ -23,5 +23,6 @@

Applications

\ No newline at end of file diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.ts index 79bb6cb74c..7552b1a9d3 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application-wall/application-wall.component.ts @@ -2,15 +2,13 @@ import { animate, query, style, transition, trigger } from '@angular/animations' import { Component, OnDestroy } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; -import { Observable, Subscription } from 'rxjs'; +import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { CFAppState } from '../../../../../cloud-foundry/src/cf-app-state'; -import { applicationEntityType } from '../../../../../cloud-foundry/src/cf-entity-types'; import { ListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; import { CfAppConfigService } from '../../../shared/components/list/list-types/app/cf-app-config.service'; -import { CfAppsDataSource } from '../../../shared/components/list/list-types/app/cf-apps-data-source'; -import { CfOrgSpaceDataService, initCfOrgSpaceService } from '../../../shared/data-services/cf-org-space-service.service'; +import { CfOrgSpaceDataService } from '../../../shared/data-services/cf-org-space-service.service'; import { CloudFoundryService } from '../../../shared/data-services/cloud-foundry.service'; import { CfCurrentUserPermissions } from '../../../user-permissions/cf-user-permissions-checkers'; import { goToAppWall } from '../../cf/cf.helpers'; @@ -22,13 +20,13 @@ import { goToAppWall } from '../../cf/cf.helpers'; animations: [ trigger( 'cardEnter', [ - transition('* => *', [ - query(':enter', [ - style({ opacity: 0, transform: 'translateY(10px)' }), - animate('150ms ease-out', style({ opacity: 1, transform: 'translateY(0)' })) - ], { optional: true }) - ]) - ] + transition('* => *', [ + query(':enter', [ + style({ opacity: 0, transform: 'translateY(10px)' }), + animate('150ms ease-out', style({ opacity: 1, transform: 'translateY(0)' })) + ], { optional: true }) + ]) + ] ) ], providers: [{ @@ -40,7 +38,6 @@ import { goToAppWall } from '../../cf/cf.helpers'; }) export class ApplicationWallComponent implements OnDestroy { public cfIds$: Observable; - private initCfOrgSpaceService: Subscription; public canCreateApplication: string; @@ -49,7 +46,7 @@ export class ApplicationWallComponent implements OnDestroy { constructor( public cloudFoundryService: CloudFoundryService, private store: Store, - private cfOrgSpaceService: CfOrgSpaceDataService, + public cfOrgSpaceService: CfOrgSpaceDataService, activatedRoute: ActivatedRoute, ) { // If we have an endpoint ID, select it and redirect @@ -67,16 +64,8 @@ export class ApplicationWallComponent implements OnDestroy { this.haveConnectedCf$ = cloudFoundryService.connectedCFEndpoints$.pipe( map(endpoints => !!endpoints && endpoints.length > 0) ); - - this.initCfOrgSpaceService = initCfOrgSpaceService(this.store, - this.cfOrgSpaceService, - applicationEntityType, - CfAppsDataSource.paginationKey).subscribe(); } ngOnDestroy(): void { - if (this.initCfOrgSpaceService) { - this.initCfOrgSpaceService.unsubscribe(); - } } } diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application.component.ts index 438d60af57..48e54d2ce1 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/create-application/create-application.component.ts @@ -4,10 +4,10 @@ import { Subscription } from 'rxjs'; import { filter, first, tap } from 'rxjs/operators'; import { CFAppState } from '../../../../../cloud-foundry/src/cf-app-state'; -import { applicationEntityType } from '../../../../../cloud-foundry/src/cf-entity-types'; -import { selectCfPaginationState } from '../../../../../cloud-foundry/src/store/selectors/pagination.selectors'; +import { applicationEntityType } from '../../../cf-entity-types'; import { CfAppsDataSource } from '../../../shared/components/list/list-types/app/cf-apps-data-source'; import { CfOrgSpaceDataService } from '../../../shared/data-services/cf-org-space-service.service'; +import { selectCfPaginationState } from '../../../store/selectors/pagination.selectors'; @Component({ selector: 'app-create-application', @@ -22,6 +22,9 @@ export class CreateApplicationComponent implements OnInit, OnDestroy { ngOnInit() { // We will auto select endpoint/org/space that have been selected on the app wall. + this.cfOrgSpaceService.enableAutoSelectors(); + // FIXME: This has been broken for a while (setting cf will clear org + space after org and space has been set) + // With new tools (set initial/enable auto) this should be easier to fix const appWallPaginationState = this.store.select(selectCfPaginationState(applicationEntityType, CfAppsDataSource.paginationKey)); this.paginationStateSub = appWallPaginationState.pipe(filter(pag => !!pag), first(), tap(pag => { const { cf, org, space } = pag.clientPagination.filter.items; @@ -29,7 +32,6 @@ export class CreateApplicationComponent implements OnInit, OnDestroy { this.cfOrgSpaceService.cf.select.next(cf); } if (cf && org) { - this.cfOrgSpaceService.org.select.next(org); } if (cf && org && space) { diff --git a/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.html b/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.html index b47b7c7e12..d188fc7735 100644 --- a/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.html @@ -26,5 +26,6 @@

Services

\ No newline at end of file diff --git a/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.ts b/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.ts index 3f30841d43..05f0c8b613 100644 --- a/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.ts @@ -1,17 +1,13 @@ -import { Component, OnDestroy } from '@angular/core'; +import { Component } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable, Subscription } from 'rxjs'; import { map } from 'rxjs/operators'; import { CFAppState } from '../../../../../cloud-foundry/src/cf-app-state'; -import { serviceInstancesEntityType } from '../../../../../cloud-foundry/src/cf-entity-types'; import { ServiceInstancesWallListConfigService, } from '../../../../../cloud-foundry/src/shared/components/list/list-types/services-wall/service-instances-wall-list-config.service'; -import { - CfOrgSpaceDataService, - initCfOrgSpaceService, -} from '../../../../../cloud-foundry/src/shared/data-services/cf-org-space-service.service'; +import { CfOrgSpaceDataService } from '../../../../../cloud-foundry/src/shared/data-services/cf-org-space-service.service'; import { CloudFoundryService } from '../../../../../cloud-foundry/src/shared/data-services/cloud-foundry.service'; import { ListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; import { CSI_CANCEL_URL } from '../../../shared/components/add-service-instance/csi-mode.service'; @@ -29,7 +25,7 @@ import { CfCurrentUserPermissions } from '../../../user-permissions/cf-user-perm CfOrgSpaceDataService ] }) -export class ServicesWallComponent implements OnDestroy { +export class ServicesWallComponent { public haveConnectedCf$: Observable; @@ -41,7 +37,7 @@ export class ServicesWallComponent implements OnDestroy { constructor( public cloudFoundryService: CloudFoundryService, public store: Store, - private cfOrgSpaceService: CfOrgSpaceDataService) { + public cfOrgSpaceService: CfOrgSpaceDataService) { this.canCreateServiceInstance = CfCurrentUserPermissions.SERVICE_INSTANCE_CREATE; this.cfIds$ = cloudFoundryService.cFEndpoints$.pipe( @@ -51,11 +47,6 @@ export class ServicesWallComponent implements OnDestroy { ) ); - this.initCfOrgSpaceService = initCfOrgSpaceService(this.store, - this.cfOrgSpaceService, - serviceInstancesEntityType, - 'all').subscribe(); - this.haveConnectedCf$ = cloudFoundryService.connectedCFEndpoints$.pipe( map(endpoints => !!endpoints && endpoints.length > 0) ); @@ -64,8 +55,4 @@ export class ServicesWallComponent implements OnDestroy { [CSI_CANCEL_URL]: `/services` }; } - - ngOnDestroy(): void { - this.initCfOrgSpaceService.unsubscribe(); - } } diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/create-application/create-application-step1/create-application-step1.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/create-application/create-application-step1/create-application-step1.component.ts index 1e3dd731a4..1552c10c08 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/create-application/create-application-step1/create-application-step1.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/create-application/create-application-step1/create-application-step1.component.ts @@ -49,7 +49,7 @@ export class CreateApplicationStep1Component implements OnInit, AfterContentInit space: this.cfOrgSpaceService.space.select.getValue() })); return of({ success: true }); - } + }; ngOnInit() { if (this.route.root.snapshot.queryParams.endpointGuid) { diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-app-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-app-config.service.ts index a2b5398834..81e7adea09 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-app-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-app-config.service.ts @@ -48,13 +48,14 @@ export class CfAppConfigService extends ListConfig implements IList // Apply the initial cf guid to the data source. Normally this is done via applying the selection to the filter... however this is too // late for maxedResult world - this.initialised$ = this.cfOrgSpaceService.cf.loading$.pipe( + this.initialised$ = this.cfOrgSpaceService.isLoading$.pipe( filter(isLoading => !isLoading), switchMap(() => this.cfOrgSpaceService.cf.list$), first(), map(cfs => { const cfGuid = cfs.length === 1 ? cfs[0].guid : null; this.appsDataSource = new CfAppsDataSource(this.store, this, undefined, undefined, undefined, cfGuid); + this.cfOrgSpaceService.setInitialValuesFromAction(this.appsDataSource.action, 'cf', 'org', 'space'); return true; }) ); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-routes/cf-routes-list-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-routes/cf-routes-list-config.service.ts index f800809ecf..1b640b3a54 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-routes/cf-routes-list-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-routes/cf-routes-list-config.service.ts @@ -16,11 +16,7 @@ import { import { APIResource } from '../../../../../../../store/src/types/api.types'; import { CloudFoundryEndpointService } from '../../../../../features/cf/services/cloud-foundry-endpoint.service'; import { CfCurrentUserPermissions } from '../../../../../user-permissions/cf-user-permissions-checkers'; -import { - CfOrgSpaceDataService, - createCfOrgSpaceFilterConfig, - initCfOrgSpaceService, -} from '../../../../data-services/cf-org-space-service.service'; +import { CfOrgSpaceDataService, createCfOrgSpaceFilterConfig } from '../../../../data-services/cf-org-space-service.service'; import { CfRoutesDataSource } from './cf-routes-data-source'; import { ListCfRoute } from './cf-routes-data-source-base'; import { CfRoutesListConfigBase } from './cf-routes-list-config-base'; @@ -76,10 +72,6 @@ export class CfRoutesListConfigService extends CfRoutesListConfigBase implements createCfOrgSpaceFilterConfig('org', 'Organization', cfOrgSpaceService.org), ]; this.getMultiFiltersConfigs = () => multiFilterConfigs; - initCfOrgSpaceService(store, cfOrgSpaceService, - this.dataSource.masterAction.entityType, - this.dataSource.masterAction.paginationKey).subscribe(); - cfOrgSpaceService.cf.select.next(cfService.cfGuid); this.getInitialised = () => combineLatest( cfOrgSpaceService.cf.list$, @@ -89,5 +81,7 @@ export class CfRoutesListConfigService extends CfRoutesListConfigBase implements map(loading => !loading), startWith(true) ); + + cfOrgSpaceService.cf.select.next(cfService.cfGuid); } } diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/services-wall/service-instances-wall-list-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/services-wall/service-instances-wall-list-config.service.ts index 2c76635469..3f327ab0b7 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/services-wall/service-instances-wall-list-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/services-wall/service-instances-wall-list-config.service.ts @@ -91,6 +91,7 @@ export class ServiceInstancesWallListConfigService extends CfServiceInstancesLis breadcrumbs: 'service-wall' }; + this.cfOrgSpaceService.setInitialValuesFromAction(this.dataSource.masterAction, 'cf', 'org', 'space'); this.getInitialised = () => combineLatest( cfOrgSpaceService.cf.list$, cfOrgSpaceService.org.list$, diff --git a/src/frontend/packages/cloud-foundry/src/shared/data-services/cf-org-space-service.service.ts b/src/frontend/packages/cloud-foundry/src/shared/data-services/cf-org-space-service.service.ts index e8ce2c3184..188cb717af 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/data-services/cf-org-space-service.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/data-services/cf-org-space-service.service.ts @@ -1,6 +1,6 @@ import { Injectable, OnDestroy } from '@angular/core'; import { Store } from '@ngrx/store'; -import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; +import { BehaviorSubject, combineLatest, Observable, of, Subscription } from 'rxjs'; import { distinctUntilChanged, filter, @@ -28,10 +28,9 @@ import { PaginationMonitorFactory } from '../../../../store/src/monitors/paginat import { getPaginationObservables } from '../../../../store/src/reducers/pagination-reducer/pagination-reducer.helper'; import { getCurrentPageRequestInfo } from '../../../../store/src/reducers/pagination-reducer/pagination-reducer.types'; import { connectedEndpointsOfTypesSelector } from '../../../../store/src/selectors/endpoint.selectors'; -import { selectPaginationState } from '../../../../store/src/selectors/pagination.selectors'; import { APIResource } from '../../../../store/src/types/api.types'; import { EndpointModel } from '../../../../store/src/types/endpoint.types'; -import { PaginatedAction, PaginationParam } from '../../../../store/src/types/pagination.types'; +import { PaginatedAction, PaginationEntityState, PaginationParam } from '../../../../store/src/types/pagination.types'; import { IOrganization, ISpace } from '../../cf-api.types'; import { cfEntityCatalog } from '../../cf-entity-catalog'; import { cfEntityFactory } from '../../cf-entity-factory'; @@ -63,6 +62,8 @@ export function createCfOrgSpaceFilterConfig(key: string, label: string, cfOrgSp export interface CfOrgSpaceItem { list$: Observable; loading$: Observable; + // A lot of problems are caused by these being BehaviourSubject's (specifically auto select process in CfOrgSpaceDataService and sticky + // values). Ideally this would change to Subject... but some usages .value select: BehaviorSubject; } @@ -77,30 +78,6 @@ export const enum CfOrgSpaceSelectMode { ANY = 2 } - -export const initCfOrgSpaceService = ( - store: Store, - cfOrgSpaceService: CfOrgSpaceDataService, - entityKey: string, - paginationKey: string): Observable => { - return store.select(selectPaginationState(entityKey, paginationKey)).pipe( - filter((pag) => !!pag), - first(), - tap(pag => { - const { cf, org, space } = pag.clientPagination.filter.items; - if (cf) { - cfOrgSpaceService.cf.select.next(cf); - } - if (org) { - cfOrgSpaceService.org.select.next(org); - } - if (space) { - cfOrgSpaceService.space.select.next(space); - } - }) - ); -}; - export const createCfOrSpaceMultipleFilterFn = ( store: Store, action: PaginatedAction, @@ -154,6 +131,7 @@ export const createCfOrSpaceMultipleFilterFn = ( }; }; +interface InitialValues { cf: string; org: string; space: string; } /** * This service relies on OnDestroy, so must be `provided` by a component @@ -168,7 +146,7 @@ export class CfOrgSpaceDataService implements OnDestroy { public space: CfOrgSpaceItem; public isLoading$: Observable; - public paginationAction = this.createPaginationAction(); + public paginationAction = this.createOrgPaginationAction(); /** * This will contain all org and space data @@ -182,6 +160,15 @@ export class CfOrgSpaceDataService implements OnDestroy { private selectMode = CfOrgSpaceSelectMode.FIRST_ONLY; private subs: Subscription[] = []; + /* + * Observable that provides initial values for drop downs, output will be parsed through initialValuesMap before emitted on first + */ + public initialValues$: Observable; + /** + * Map values from `initialValues$` to supply initial values for drop downs + */ + public initialValuesMap: (param: any) => InitialValues; + constructor( private store: Store, public paginationMonitorFactory: PaginationMonitorFactory, @@ -190,15 +177,6 @@ export class CfOrgSpaceDataService implements OnDestroy { this.createOrg(); this.createSpace(); - // Start watching the cf/org/space plus automatically setting values only when we actually have values to auto select - this.org.list$.pipe( - first(), - ).subscribe({ - complete: () => { - this.setupAutoSelectors(); - } - }); - this.isLoading$ = combineLatest( this.cf.loading$, this.org.loading$, @@ -249,7 +227,7 @@ export class CfOrgSpaceDataService implements OnDestroy { loading$: list$.pipe( map(cfs => !cfs) ), - select: new BehaviorSubject(undefined) + select: new BehaviorSubject(null) // Should be different to undefined (sticky values & reset list) }; } @@ -270,7 +248,7 @@ export class CfOrgSpaceDataService implements OnDestroy { this.org = { list$: orgList$, loading$: this.allOrgsLoading$, - select: new BehaviorSubject(undefined) + select: new BehaviorSubject(null) // Should be different to undefined (sticky values & reset list) }; } @@ -297,11 +275,11 @@ export class CfOrgSpaceDataService implements OnDestroy { this.space = { list$: spaceList$, loading$: this.org.loading$, - select: new BehaviorSubject(undefined) + select: new BehaviorSubject(null) // Should be different to undefined (sticky values & reset list) }; } - private createPaginationAction() { + private createOrgPaginationAction() { return cfEntityCatalog.org.actions.getMultiple(null, CfOrgSpaceDataService.CfOrgSpaceServicePaginationKey, { includeRelations: [ createEntityRelationKey(organizationEntityType, spaceEntityType), @@ -318,12 +296,57 @@ export class CfOrgSpaceDataService implements OnDestroy { ); } - private setupAutoSelectors() { + public setInitialValuesFromAction( + paginatedAction: PaginatedAction, + cfKey: string, + orgKey: string, + spaceKey: string, + ) { + this.initialValuesMap = (p: PaginationEntityState) => ({ + cf: p.clientPagination?.filter?.items[cfKey], + org: p.clientPagination?.filter?.items[orgKey], + space: p.clientPagination?.filter?.items[spaceKey] + }); + this.initialValues$ = this.paginationMonitorFactory.create( + paginatedAction.paginationKey, + cfEntityFactory(paginatedAction.entityType), + paginatedAction.flattenPagination + ).pagination$.pipe( + filter(p => !!p?.clientPagination?.filter), + ); + } + + private getInitialValues(): Observable { + const initialValues$ = this.initialValues$ || of({ cf: undefined, org: undefined, space: undefined }); + const defaultMap = (a: any) => a; + const initialValuesMap = this.initialValuesMap || defaultMap; + return initialValues$.pipe( + first(), + map(initialValuesMap) // Map needs to happen at the point the auto selectors are enabled + ); + } + + public enableAutoSelectors() { + combineLatest( + // Start watching the cf/org/space plus automatically setting values only when we actually have values to auto select + this.org.list$, + // Get initial values only after we've given a prod... so first values emitted are the one's we want + this.getInitialValues(), + ).pipe(first()).subscribe(([, initialValues]) => { + this.setupAutoSelectors(initialValues.cf, initialValues.org); + }); + } + + private setupAutoSelectors(initialCf: string, initialOrg: string) { + // Clear or automatically select org + space given cf + let cfTapped = false; const orgResetSub = this.cf.select.asObservable().pipe( - startWith(undefined), + startWith(initialCf), distinctUntilChanged(), + filter(cf => cfTapped || cf !== initialCf), withLatestFrom(this.org.list$), tap(([selectedCF, orgs]) => { + cfTapped = true; if ( !!orgs.length && ((this.selectMode === CfOrgSpaceSelectMode.FIRST_ONLY && orgs.length === 1) || @@ -339,11 +362,14 @@ export class CfOrgSpaceDataService implements OnDestroy { this.subs.push(orgResetSub); // Clear or automatically select space given org + let orgTapped = false; const spaceResetSub = this.org.select.asObservable().pipe( - startWith(undefined), + startWith(initialOrg), distinctUntilChanged(), + filter(org => orgTapped || org !== initialOrg), withLatestFrom(this.space.list$), tap(([selectedOrg, spaces]) => { + orgTapped = true; if ( !!spaces.length && ((this.selectMode === CfOrgSpaceSelectMode.FIRST_ONLY && spaces.length === 1) || diff --git a/src/frontend/packages/cloud-foundry/src/shared/services/current-user-permissions.service.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/services/current-user-permissions-and-cfchecker.service.spec.ts similarity index 98% rename from src/frontend/packages/cloud-foundry/src/shared/services/current-user-permissions.service.spec.ts rename to src/frontend/packages/cloud-foundry/src/shared/services/current-user-permissions-and-cfchecker.service.spec.ts index ddf7d54350..0852f7e8b2 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/services/current-user-permissions.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/services/current-user-permissions-and-cfchecker.service.spec.ts @@ -2,16 +2,6 @@ import { TestBed } from '@angular/core/testing'; import { createBasicStoreModule, createEntityStoreState, TestStoreEntity } from '@stratosui/store/testing'; import { first, tap } from 'rxjs/operators'; -import { CFFeatureFlagTypes, IFeatureFlag } from '../../../../cloud-foundry/src/cf-api.types'; -import { cfEntityFactory } from '../../../../cloud-foundry/src/cf-entity-factory'; -import { generateCFEntities } from '../../../../cloud-foundry/src/cf-entity-generator'; -import { featureFlagEntityType } from '../../../../cloud-foundry/src/cf-entity-types'; -import { - CfCurrentUserPermissions, - cfCurrentUserPermissionsService, - CfPermissionTypes, - CfScopeStrings, -} from '../../../../cloud-foundry/src/user-permissions/cf-user-permissions-checkers'; import { PermissionConfig } from '../../../../core/src/core/permissions/current-user-permissions.config'; import { CurrentUserPermissionsService } from '../../../../core/src/core/permissions/current-user-permissions.service'; import { StratosScopeStrings } from '../../../../core/src/core/permissions/stratos-user-permissions.checker'; @@ -25,6 +15,16 @@ import { APIResource } from '../../../../store/src/types/api.types'; import { EndpointModel } from '../../../../store/src/types/endpoint.types'; import { BaseEntityValues } from '../../../../store/src/types/entity.types'; import { PaginationState } from '../../../../store/src/types/pagination.types'; +import { CFFeatureFlagTypes, IFeatureFlag } from '../../cf-api.types'; +import { cfEntityFactory } from '../../cf-entity-factory'; +import { generateCFEntities } from '../../cf-entity-generator'; +import { featureFlagEntityType } from '../../cf-entity-types'; +import { + CfCurrentUserPermissions, + cfCurrentUserPermissionsService, + CfPermissionTypes, + CfScopeStrings, +} from '../../user-permissions/cf-user-permissions-checkers'; const ffSchema = cfEntityFactory(featureFlagEntityType); @@ -520,7 +520,8 @@ describe('CurrentUserPermissionsService with CF checker', () => { }, totalResults: 2 }, - maxedState: {} + maxedState: {}, + isListPagination: true } }, cfFeatureFlag: { @@ -548,7 +549,8 @@ describe('CurrentUserPermissionsService with CF checker', () => { }, totalResults: 13 }, - maxedState: {} + maxedState: {}, + isListPagination: false }, 'endpoint-c80420ca-204b-4879-bf69-b6b7a202ad87': { pageCount: 1, @@ -574,7 +576,8 @@ describe('CurrentUserPermissionsService with CF checker', () => { }, totalResults: 13 }, - maxedState: {} + maxedState: {}, + isListPagination: false } }, }; diff --git a/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts index 90d5401db5..9d7f3bb0af 100644 --- a/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts +++ b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts @@ -124,7 +124,8 @@ describe('CurrentUserPermissionsService', () => { }, totalResults: 2 }, - maxedState: {} + maxedState: {}, + isListPagination: true } }, }; diff --git a/src/frontend/packages/core/src/features/user-profile/profile-info/profile-info.component.html b/src/frontend/packages/core/src/features/user-profile/profile-info/profile-info.component.html index 8c4cf894bf..462d79f69e 100644 --- a/src/frontend/packages/core/src/features/user-profile/profile-info/profile-info.component.html +++ b/src/frontend/packages/core/src/features/user-profile/profile-info/profile-info.component.html @@ -50,7 +50,7 @@

User Profile

- Settings + Local Settings - diff --git a/src/frontend/packages/core/src/features/user-profile/profile-info/profile-info.component.scss b/src/frontend/packages/core/src/features/user-profile/profile-info/profile-info.component.scss index ba56f7161a..3be8be5d30 100644 --- a/src/frontend/packages/core/src/features/user-profile/profile-info/profile-info.component.scss +++ b/src/frontend/packages/core/src/features/user-profile/profile-info/profile-info.component.scss @@ -49,4 +49,14 @@ $user-profile-avatar-size: 48px; &__content { margin: 2% 24px; } + + &__local-storage { + &--div { + padding-bottom: 10px; + } + + button { + margin-left: 15px; + } + } } diff --git a/src/frontend/packages/core/src/features/user-profile/profile-info/profile-info.component.ts b/src/frontend/packages/core/src/features/user-profile/profile-info/profile-info.component.ts index d696e016dd..5f959bd90b 100644 --- a/src/frontend/packages/core/src/features/user-profile/profile-info/profile-info.component.ts +++ b/src/frontend/packages/core/src/features/user-profile/profile-info/profile-info.component.ts @@ -1,10 +1,12 @@ import { Component } from '@angular/core'; import { Store } from '@ngrx/store'; import { combineLatest, Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { filter, first, map } from 'rxjs/operators'; import { SetPollingEnabledAction, SetSessionTimeoutAction } from '../../../../../store/src/actions/dashboard-actions'; -import { DashboardOnlyAppState } from '../../../../../store/src/app-state'; +import { AppState } from '../../../../../store/src/app-state'; +import { LocalStorageService } from '../../../../../store/src/helpers/local-storage-service'; +import { selectSessionData } from '../../../../../store/src/reducers/auth.reducer'; import { selectDashboardState } from '../../../../../store/src/selectors/dashboard.selectors'; import { ThemeService } from '../../../../../store/src/theme.service'; import { UserProfileInfo } from '../../../../../store/src/types/user-profile.types'; @@ -12,6 +14,7 @@ import { CurrentUserPermissionsService } from '../../../core/permissions/current import { StratosCurrentUserPermissions } from '../../../core/permissions/stratos-user-permissions.checker'; import { UserProfileService } from '../../../core/user-profile.service'; import { UserService } from '../../../core/user.service'; +import { ConfirmationDialogService } from '../../../shared/components/confirmation-dialog.service'; import { SetGravatarEnabledAction } from './../../../../../store/src/actions/dashboard-actions'; @Component({ @@ -21,19 +24,22 @@ import { SetGravatarEnabledAction } from './../../../../../store/src/actions/das }) export class ProfileInfoComponent { - public timeoutSession$ = this.store.select(selectDashboardState).pipe( + private dashboardState$ = this.store.select(selectDashboardState); + private sessionData$ = this.store.select(selectSessionData()); + + public timeoutSession$ = this.dashboardState$.pipe( map(dashboardState => dashboardState.timeoutSession ? 'true' : 'false') ); - public pollingEnabled$ = this.store.select(selectDashboardState).pipe( + public pollingEnabled$ = this.dashboardState$.pipe( map(dashboardState => dashboardState.pollingEnabled ? 'true' : 'false') ); - public gravatarEnabled$ = this.store.select(selectDashboardState).pipe( + public gravatarEnabled$ = this.dashboardState$.pipe( map(dashboardState => dashboardState.gravatarEnabled ? 'true' : 'false') ); - public allowGravatar$ = this.store.select(selectDashboardState).pipe( + public allowGravatar$ = this.dashboardState$.pipe( map(dashboardState => dashboardState.gravatarEnabled) ); @@ -44,6 +50,8 @@ export class ProfileInfoComponent { primaryEmailAddress$: Observable; hasMultipleThemes: boolean; + localStorageSize$: Observable; + public updateSessionKeepAlive(timeoutSession: string) { const newVal = !(timeoutSession === 'true'); this.setSessionTimeout(newVal); @@ -71,10 +79,11 @@ export class ProfileInfoComponent { } constructor( - private userProfileService: UserProfileService, - private store: Store, + userProfileService: UserProfileService, + private store: Store, public userService: UserService, public themeService: ThemeService, + private confirmationService: ConfirmationDialogService, private currentUserPermissionsService: CurrentUserPermissionsService, ) { this.isError$ = userProfileService.isError$; @@ -89,6 +98,15 @@ export class ProfileInfoComponent { ); this.hasMultipleThemes = themeService.getThemes().length > 1; + + this.localStorageSize$ = this.sessionData$.pipe( + map(sessionData => LocalStorageService.localStorageSize(sessionData)), + filter(bytes => bytes !== -1), + ); + } + + clearLocalStorage() { + this.sessionData$.pipe(first()).subscribe(sessionData => LocalStorageService.clearLocalStorage(sessionData, this.confirmationService)); } } diff --git a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source.ts b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source.ts index 16ea225294..78e6054907 100644 --- a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source.ts +++ b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source.ts @@ -146,6 +146,7 @@ export abstract class ListDataSource extends DataSource implements this.masterAction, this.isLocal ); + const { pagination$, entities$ } = getPaginationObservables({ store: this.store, action: this.action, @@ -266,6 +267,16 @@ export abstract class ListDataSource extends DataSource implements } this.entitySelectConfig = this.getEntitySelectConfig(config.schema); } + /* tslint:disable-next-line:no-string-literal */ + if (this.action['length']) { + this.action = (this.action as PaginatedAction[]).map(a => ({ + ...a, + isList: true + })); + } else { + (this.action as PaginatedAction).isList = true; + } + this.masterAction.isList = true; } private getEntitySelectConfig(multiActionConfig: MultiActionConfig) { diff --git a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-pagination-controller.ts b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-pagination-controller.ts index 0cdf9bd632..05cb64e459 100644 --- a/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-pagination-controller.ts +++ b/src/frontend/packages/core/src/shared/components/list/data-sources-controllers/list-pagination-controller.ts @@ -115,7 +115,7 @@ export class ListPaginationController implements IListPaginationController }, this.dataSource.isLocal)); } }); - } + }; filterByString = filterString => { onPaginationEntityState(this.dataSource.pagination$, (paginationEntityState) => { if (this.dataSource.isLocal) { @@ -132,7 +132,7 @@ export class ListPaginationController implements IListPaginationController this.dataSource.setFilterParam(filterString, paginationEntityState); } }); - } + }; handleMultiFilter = (changes: ListPaginationMultiFilterChange[]) => { onPaginationEntityState(this.dataSource.pagination$, (paginationEntityState) => { @@ -140,9 +140,17 @@ export class ListPaginationController implements IListPaginationController return; } - // We don't want to dispatch actions if it's a no op (values are not different, falsies are treated as the same). This avoids other + // Changes may include multiple updates for the same key, so only use the very latest + const uniqueChanges = []; + for (let i = changes.length - 1; i >= 0; i--) { + const change = changes[i]; + if (!uniqueChanges.find(e => e.key === change.key)) { + uniqueChanges.push(change); + } + } + // We don't want to dispatch actions if it's a no op (values are not different, falsies are treated as the same). This avoids other // chained actions from firing. - const cleanChanges = changes.reduce((newCleanChanges, change) => { + const cleanChanges = uniqueChanges.reduce((newCleanChanges, change) => { const storeFilterParamValue = valueOrCommonFalsy(paginationEntityState.clientPagination.filter.items[change.key]); const newFilterParamValue = valueOrCommonFalsy(change.value); if (storeFilterParamValue !== newFilterParamValue) { @@ -172,14 +180,14 @@ export class ListPaginationController implements IListPaginationController } }); - } + }; multiFilter = (filterConfig: IListMultiFilterConfig, filterValue: string) => { if (!this.dataSource.isLocal) { return; } this.multiFilterStream.next({ key: filterConfig.key, value: filterValue }); - } + }; private cloneMultiFilter(paginationClientFilter: PaginationClientFilter) { return { diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoints-list-config.service.ts b/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoints-list-config.service.ts index 59764085a0..24fb90718b 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoints-list-config.service.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoints-list-config.service.ts @@ -153,7 +153,7 @@ export class EndpointsListConfigService implements IListConfig { stratosEntityCatalog.endpoint.store.getPaginationMonitor().pagination$ ]).pipe( debounceTime(100), // This can get pretty spammy, to help protect resetEndpointTypeFilter allow a pause - filter(([endpoints, pagination]) => !!endpoints), + filter(([endpoints]) => !!endpoints), map(([endpoints, pagination]) => { // Provide a list of endpoint types only if there are more than two registered endpoint types const types: { [type: string]: boolean; } = {}; diff --git a/src/frontend/packages/core/src/shared/components/list/list.component.html b/src/frontend/packages/core/src/shared/components/list/list.component.html index db67aaa1e0..79b69e69bf 100644 --- a/src/frontend/packages/core/src/shared/components/list/list.component.html +++ b/src/frontend/packages/core/src/shared/components/list/list.component.html @@ -64,8 +64,7 @@
- + {{ entitySelectConfig.selectEmptyText }} {{item.label}} @@ -106,12 +105,14 @@ - -
@@ -144,14 +145,23 @@ add
+
+ +
- - diff --git a/src/frontend/packages/core/src/shared/components/list/list.component.ts b/src/frontend/packages/core/src/shared/components/list/list.component.ts index 2bc6da4898..0bfe7df8e7 100644 --- a/src/frontend/packages/core/src/shared/components/list/list.component.ts +++ b/src/frontend/packages/core/src/shared/components/list/list.component.ts @@ -3,12 +3,14 @@ import { AfterViewInit, ChangeDetectorRef, Component, + EventEmitter, Input, NgZone, OnChanges, OnDestroy, OnInit, Optional, + Output, SimpleChanges, TemplateRef, ViewChild, @@ -49,12 +51,18 @@ import { ListView, SetListViewAction, } from '../../../../../store/src/actions/list.actions'; -import { SetClientFilterKey, SetPage } from '../../../../../store/src/actions/pagination.actions'; +import { + ResetPagination, + ResetPaginationSortFilter, + SetClientFilterKey, + SetPage, +} from '../../../../../store/src/actions/pagination.actions'; import { GeneralAppState } from '../../../../../store/src/app-state'; import { entityCatalog } from '../../../../../store/src/entity-catalog/entity-catalog'; import { EntityCatalogEntityConfig } from '../../../../../store/src/entity-catalog/entity-catalog.types'; import { ActionState } from '../../../../../store/src/reducers/api-request-reducer/types'; import { getListStateObservables } from '../../../../../store/src/reducers/list.reducer'; +import { PaginatedAction } from '../../../../../store/src/types/pagination.types'; import { safeUnsubscribe } from '../../../core/utils.service'; import { EntitySelectConfig, @@ -112,7 +120,10 @@ export class ListComponent implements OnInit, OnChanges, OnDestroy, AfterView // List config when supplied as an attribute rather than a dependency @Input() listConfig: ListConfig; - initialEntitySelection$: Observable; + + entitySelectValue = new BehaviorSubject(undefined); + entitySelectValue$: Observable = this.entitySelectValue.asObservable(); + pPaginator: MatPaginator; private filterString: string; @@ -156,12 +167,15 @@ export class ListComponent implements OnInit, OnChanges, OnDestroy, AfterView })).subscribe(); } + @Output() initialised = new EventEmitter(); + private componentInitialised = false; + private initialPageEvent: PageEvent; private paginatorSettings: { pageSizeOptions: number[], pageSize: number, pageIndex: number, - length: number + length: number; } = { pageSizeOptions: null, pageSize: null, @@ -206,8 +220,6 @@ export class ListComponent implements OnInit, OnChanges, OnDestroy, AfterView showProgressBar$: Observable; isRefreshing$: Observable; - - // Observable which allows you to determine if the paginator control should be hidden hidePaginator$: Observable; listViewKey: string; @@ -220,6 +232,8 @@ export class ListComponent implements OnInit, OnChanges, OnDestroy, AfterView pendingActions: Map, Subscription> = new Map, Subscription>(); + subs: Subscription[] = []; + public safeAddForm() { // Something strange is afoot. When using addform in [disabled] it thinks this is null, even when initialised // When applying the question mark (addForm?) it's value is ignored by [disabled] @@ -289,10 +303,14 @@ export class ListComponent implements OnInit, OnChanges, OnDestroy, AfterView this.columns = this.config.getColumns(); this.dataSource = this.config.getDataSource(); this.entitySelectConfig = this.dataSource.entitySelectConfig; - this.initialEntitySelection$ = this.dataSource.pagination$.pipe( + + this.dataSource.pagination$.pipe( first(), - map(pag => pag.forcedLocalPage) - ); + ).subscribe(pag => { + this.entitySelectValue.next(pag.forcedLocalPage); + }); + + if (this.dataSource.rowsState) { this.dataSource.getRowState = this.getRowStateFromRowsState; } else if (!this.dataSource.getRowState) { @@ -400,48 +418,60 @@ export class ListComponent implements OnInit, OnChanges, OnDestroy, AfterView this.filterColumns = this.config.getFilters ? this.config.getFilters() : []; - const filterStoreToWidget = this.paginationController.filter$.pipe(tap((paginationFilter: ListFilter) => { - this.filterString = paginationFilter.string; - - const filterKey = paginationFilter.filterKey; - if (filterKey) { - this.filterSelected = this.filterColumns.find(filterConfig => { - return filterConfig.key === filterKey; - }); - } else if (this.filterColumns) { - this.filterSelected = this.filterColumns.find(filterConfig => filterConfig.default); - if (this.filterSelected) { - this.updateListFilter(this.filterSelected); + const filterStoreToWidget = this.paginationController.filter$.pipe( + distinctUntilChanged(), + tap((paginationFilter: ListFilter) => { + this.filterString = paginationFilter.string; + + const filterKey = paginationFilter.filterKey; + if (filterKey) { + this.filterSelected = this.filterColumns.find(filterConfig => { + return filterConfig.key === filterKey; + }); + } else if (this.filterColumns) { + this.filterSelected = this.filterColumns.find(filterConfig => filterConfig.default); + if (this.filterSelected) { + this.updateListFilter(this.filterSelected); + } } - } - - // Pipe store values to filter managers. This ensures any changes such as automatically selected orgs/spaces are shown in the drop - // downs (change org to one with one space results in that space being selected) - Object.values(this.multiFilterManagers).forEach((filterManager: MultiFilterManager, index: number) => { - filterManager.applyValue(paginationFilter.items); - }); - })); - // Multi filters (e.g. cf/org/space) - // - Pass any multi filter changes made by the user to the pagination controller and thus the store - // - If the first multi filter has one value it's not shown, ensure it's automatically selected to ensure other filters are correct - this.multiFilterWidgetObservables = new Array(); - filterStoreToWidget.pipe( - first(), - tap(() => { + // Pipe store values to filter managers. This ensures any changes such as automatically selected orgs/spaces are shown in the drop + // downs (change org to one with one space results in that space being selected) Object.values(this.multiFilterManagers).forEach((filterManager: MultiFilterManager, index: number) => { - // The first filter will be hidden if there's only one filter option. - // To ensure subsequent filters behave correctly automatically select it + // If this is NOT the first... and we have the value to apply + if (index !== 0 || filterManager.hasValue(paginationFilter.items)) { + filterManager.applyValue(paginationFilter.items); + return; + } + + // If we're the first drop down filter and there are other drop downs... select the first one if (index === 0 && this.multiFilterManagers.length > 1) { filterManager.filterItems$.pipe( first() ).subscribe(list => { if (list && list.length === 1) { filterManager.selectItem(list[0].value); + } else { + filterManager.applyValue(paginationFilter.items); } }); + return; } + filterManager.applyValue(paginationFilter.items); + + }); + }), + ); + + // Multi filters (e.g. cf/org/space) + // - Pass any multi filter changes made by the user to the pagination controller and thus the store + // - If the first multi filter has one value it's not shown, ensure it's automatically selected to ensure other filters are correct + this.multiFilterWidgetObservables = new Array(); + this.paginationController.filter$.pipe( + first(), + tap(() => { + Object.values(this.multiFilterManagers).forEach((filterManager: MultiFilterManager, index: number) => { // Pipe changes in the widgets to the store const sub = filterManager.multiFilterConfig.select.asObservable().pipe(tap((filterItem: string) => { this.paginationController.multiFilter(filterManager.multiFilterConfig, filterItem); @@ -486,11 +516,22 @@ export class ListComponent implements OnInit, OnChanges, OnDestroy, AfterView this.uberSub = observableCombineLatest( paginationStoreToWidget, - filterStoreToWidget, sortStoreToWidget, haveMultiActions ).subscribe(); + const initialisedSub = observableCombineLatest([ + filterStoreToWidget // Should not be unsubbed until destroy + ]).subscribe(() => { + // When this fires the first time we're initialised + if (!this.componentInitialised) { + this.componentInitialised = true; + this.initialised.next(true); + } + }); + + this.subs.push(initialisedSub); + this.pageState$ = observableCombineLatest( this.paginationController.pagination$, this.dataSource.isLoadingPage$, @@ -575,7 +616,8 @@ export class ListComponent implements OnInit, OnChanges, OnDestroy, AfterView this.paginationWidgetToStore, this.filterWidgetToStore, this.uberSub, - this.multiFilterChangesSub + this.multiFilterChangesSub, + ...this.subs, ); if (this.dataSource) { this.dataSource.destroy(); @@ -594,6 +636,20 @@ export class ListComponent implements OnInit, OnChanges, OnDestroy, AfterView } } + public resetFilteringAndSort() { + /* tslint:disable-next-line:no-string-literal */ + const pAction: PaginatedAction = this.dataSource.action['length'] ? this.dataSource.action[0] : this.dataSource.action; + this.store.dispatch(new ResetPaginationSortFilter(pAction)); + + if (!this.dataSource.isLocal) { + this.store.dispatch(new ResetPagination(pAction, pAction.paginationKey)); + } + + // Reset the multi-entity filter + this.entitySelectValue.next(undefined); + this.setEntityPage(undefined); + } + updateListView(listView: ListView) { this.store.dispatch(new SetListViewAction(this.listViewKey, listView)); } @@ -658,6 +714,7 @@ export class ListComponent implements OnInit, OnChanges, OnDestroy, AfterView } } + // Used by multi-entity lists public setEntityPage(page: number) { this.pPaginator.firstPage(); this.store.dispatch(new SetPage( @@ -708,7 +765,7 @@ export class ListComponent implements OnInit, OnChanges, OnDestroy, AfterView } private getRowStateFromRowsState = (row: T): Observable => - this.dataSource.rowsState.pipe(map(state => state[this.dataSource.getRowUniqueId(row)] || getDefaultRowState())) + this.dataSource.rowsState.pipe(map(state => state[this.dataSource.getRowUniqueId(row)] || getDefaultRowState())); public showAllAfterMax() { this.dataSource.showAllAfterMax(); diff --git a/src/frontend/packages/core/src/shared/components/list/list.component.types.ts b/src/frontend/packages/core/src/shared/components/list/list.component.types.ts index 4602379d29..e64a927efa 100644 --- a/src/frontend/packages/core/src/shared/components/list/list.component.types.ts +++ b/src/frontend/packages/core/src/shared/components/list/list.component.types.ts @@ -1,7 +1,7 @@ import { Type } from '@angular/core'; import moment from 'moment'; import { BehaviorSubject, combineLatest, Observable, of as observableOf } from 'rxjs'; -import { first, map, startWith } from 'rxjs/operators'; +import { filter, first, map, startWith, switchMap } from 'rxjs/operators'; import { ListView } from '../../../../../store/src/actions/list.actions'; import { ActionState } from '../../../../../store/src/reducers/api-request-reducer/types'; @@ -255,16 +255,26 @@ export class MultiFilterManager { } public applyValue(multiFilters: {}) { - const value = multiFilters[this.multiFilterConfig.key]; - if (value) { - this.value = value; - this.selectItem(value); - } + this.selectItem(multiFilters[this.multiFilterConfig.key]); + + } + + public hasValue(multiFilters: {}): boolean { + return !!multiFilters[this.multiFilterConfig.key]; } public selectItem(itemValue: string) { - this.multiFilterConfig.select.next(itemValue); - this.value = itemValue; + this.multiFilterConfig.loading$.pipe( + filter(ready => !ready), + switchMap(() => this.filterItems$), + first(), + ).subscribe(items => { + // Ensure we actually have the item. Could be from storage and invalid + if (itemValue === undefined || items.find(i => i.value === itemValue)) { + this.value = itemValue; + this.multiFilterConfig.select.next(itemValue); + } + }); } } diff --git a/src/frontend/packages/core/src/shared/components/list/max-list-message/max-list-message.component.html b/src/frontend/packages/core/src/shared/components/list/max-list-message/max-list-message.component.html index a478fbd906..130d7fddcf 100644 --- a/src/frontend/packages/core/src/shared/components/list/max-list-message/max-list-message.component.html +++ b/src/frontend/packages/core/src/shared/components/list/max-list-message/max-list-message.component.html @@ -3,13 +3,16 @@ [otherLines]="state.otherLines"> diff --git a/src/frontend/packages/core/src/shared/components/polling-indicator/polling-indicator.component.html b/src/frontend/packages/core/src/shared/components/polling-indicator/polling-indicator.component.html index 3c350ac46b..d4a15a557d 100644 --- a/src/frontend/packages/core/src/shared/components/polling-indicator/polling-indicator.component.html +++ b/src/frontend/packages/core/src/shared/components/polling-indicator/polling-indicator.component.html @@ -1,10 +1,12 @@ - - \ No newline at end of file diff --git a/src/frontend/packages/store/src/actions/list.actions.ts b/src/frontend/packages/store/src/actions/list.actions.ts index 8827fc0f36..1bc5fb6895 100644 --- a/src/frontend/packages/store/src/actions/list.actions.ts +++ b/src/frontend/packages/store/src/actions/list.actions.ts @@ -1,6 +1,7 @@ import { SortDirection } from '@angular/material/sort'; import { Action } from '@ngrx/store'; +import { ListsState } from '../reducers/list.reducer'; import { defaultClientPaginationPageSize } from '../reducers/pagination-reducer/pagination-reducer-reset-pagination'; @@ -25,7 +26,8 @@ export class ListFilter { export const ListStateActionTypes = { SET: '[List] Set', - SET_VIEW: '[List] Set View' + SET_VIEW: '[List] Set View', + HYDRATE: '[List] Hydrate' }; export type ListView = 'table' | 'cards'; @@ -39,3 +41,9 @@ export class SetListViewAction implements Action { constructor(public key: string, public view: ListView) { } type = ListStateActionTypes.SET_VIEW; } + + +export class HydrateListsStateAction implements Action { + constructor(public listsState: ListsState) { } + type = ListStateActionTypes.HYDRATE; +} diff --git a/src/frontend/packages/store/src/actions/pagination.actions.ts b/src/frontend/packages/store/src/actions/pagination.actions.ts index 9c133b7bf7..49be14635a 100644 --- a/src/frontend/packages/store/src/actions/pagination.actions.ts +++ b/src/frontend/packages/store/src/actions/pagination.actions.ts @@ -1,11 +1,12 @@ import { Action } from '@ngrx/store'; import { EntityCatalogEntityConfig, extractEntityCatalogEntityConfig } from '../entity-catalog/entity-catalog.types'; -import { PaginationClientFilter, PaginationParam } from '../types/pagination.types'; +import { PaginatedAction, PaginationClientFilter, PaginationParam, PaginationState } from '../types/pagination.types'; export const CLEAR_PAGINATION_OF_TYPE = '[Pagination] Clear all pages of type'; export const CLEAR_PAGINATION_OF_ENTITY = '[Pagination] Clear pagination of entity'; export const RESET_PAGINATION = '[Pagination] Reset pagination'; +export const RESET_PAGINATION_SORT_FILTER = '[Pagination] Reset pagination sort & filter'; export const RESET_PAGINATION_OF_TYPE = '[Pagination] Reset pagination of type'; export const CREATE_PAGINATION = '[Pagination] Create pagination'; export const CLEAR_PAGES = '[Pagination] Clear pages only'; @@ -23,6 +24,8 @@ export const SET_PAGE_BUSY = '[Pagination] Set Page Busy'; export const REMOVE_ID_FROM_PAGINATION = '[Pagination] Remove id from pagination'; export const UPDATE_MAXED_STATE = '[Pagination] Update maxed state'; export const IGNORE_MAXED_STATE = '[Pagination] Ignore maxed state'; +export const HYDRATE_PAGINATION_STATE = '[Pagination] Hydrate pagination state'; +export const SET_PAGINATION_IS_LIST = '[Pagination] Set Is List'; export function getPaginationKey(type: string, id: string, endpointGuid?: string) { const key = `${type}-${id}`; @@ -58,6 +61,16 @@ export class ResetPagination extends BasePaginationAction implements Action { type = RESET_PAGINATION; } +/** + * Reset filter & sorting like ResetPagination except retain the results + */ +export class ResetPaginationSortFilter extends BasePaginationAction implements Action { + constructor(public pAction: PaginatedAction) { + super(pAction); + } + type = RESET_PAGINATION_SORT_FILTER; +} + export class ResetPaginationOfType extends BasePaginationAction implements Action { constructor(pEntityConfig: Partial) { super(pEntityConfig); @@ -231,3 +244,13 @@ export class IgnorePaginationMaxedState implements Action, EntityCatalogEntityCo public forcedEntityKey?: string ) { } } + +export class HydratePaginationStateAction implements Action { + constructor(public paginationState: PaginationState) { } + type = HYDRATE_PAGINATION_STATE; +} + +export class SetPaginationIsList implements Action { + constructor(public pagAction: PaginatedAction) { } + type = SET_PAGINATION_IS_LIST; +} diff --git a/src/frontend/packages/store/src/effects/auth.effects.ts b/src/frontend/packages/store/src/effects/auth.effects.ts index 2bd61626d2..850c9a5413 100644 --- a/src/frontend/packages/store/src/effects/auth.effects.ts +++ b/src/frontend/packages/store/src/effects/auth.effects.ts @@ -23,13 +23,12 @@ import { VERIFY_SESSION, VerifySession, } from '../actions/auth.actions'; -import { HydrateDashboardStateAction } from '../actions/dashboard-actions'; import { GET_ENDPOINTS_SUCCESS, GetAllEndpointsSuccess } from '../actions/endpoint.actions'; import { DispatchOnlyAppState } from '../app-state'; import { BrowserStandardEncoder } from '../browser-encoder'; -import { getDashboardStateSessionId } from '../helpers/store-helpers'; +import { LocalStorageService } from '../helpers/local-storage-service'; import { stratosEntityCatalog } from '../stratos-entity-catalog'; -import { SessionData, SessionDataEnvelope } from '../types/auth.types'; +import { SessionDataEnvelope } from '../types/auth.types'; const SETUP_HEADER = 'stratos-setup-required'; const UPGRADE_HEADER = 'retry-after'; @@ -88,7 +87,7 @@ export class AuthEffect { } else { const sessionData = envelope.data; sessionData.sessionExpiresOn = parseInt(response.headers.get('x-cap-session-expires-on'), 10) * 1000; - this.rehydrateDashboardState(this.store, sessionData); + LocalStorageService.localStorageToStore(this.store, sessionData); return [ stratosEntityCatalog.systemInfo.actions.getSystemInfo(true), new VerifiedSession(sessionData, action.updateEndpoints) @@ -163,19 +162,5 @@ export class AuthEffect { return false; } - private rehydrateDashboardState(store: Store, sessionData: SessionData) { - const storage = localStorage || window.localStorage; - // We use the username to key the session storage. We could replace this with the users id? - if (storage && sessionData.user) { - const sessionId = getDashboardStateSessionId(sessionData.user.name); - if (sessionId) { - try { - const dashboardData = JSON.parse(storage.getItem(sessionId)); - store.dispatch(new HydrateDashboardStateAction(dashboardData)); - } catch (e) { - console.warn('Failed to parse user settings from session storage, consider clearing them manually', e); - } - } - } - } + } diff --git a/src/frontend/packages/store/src/helpers/local-storage-service.ts b/src/frontend/packages/store/src/helpers/local-storage-service.ts new file mode 100644 index 0000000000..15fb2d16f6 --- /dev/null +++ b/src/frontend/packages/store/src/helpers/local-storage-service.ts @@ -0,0 +1,254 @@ +import { ActionReducer, Store } from '@ngrx/store'; +import { localStorageSync } from 'ngrx-store-localstorage'; + +import { ConfirmationDialogConfig } from '../../../core/src/shared/components/confirmation-dialog.config'; +import { ConfirmationDialogService } from '../../../core/src/shared/components/confirmation-dialog.service'; +import { HydrateDashboardStateAction } from '../actions/dashboard-actions'; +import { HydrateListsStateAction } from '../actions/list.actions'; +import { HydratePaginationStateAction } from '../actions/pagination.actions'; +import { DispatchOnlyAppState } from '../app-state'; +import { SessionData } from '../types/auth.types'; +import { PaginationState } from '../types/pagination.types'; + + +export enum LocalStorageSyncTypes { + DASHBOARD = 'dashboard', + PAGINATION = 'pagination', + LISTS = 'lists', +} + +export class LocalStorageService { + + /** + * Convenience for dev + */ + private static Encrypt = true; + + /** + * Object used to access/update local storage + */ + private static getStorage(): Storage { + return localStorage || window.localStorage; + } + + /** + * Make a key used by local storage that relates to a section of the user's settings in the console's store + */ + private static makeKey(userId: string, storeKey: LocalStorageSyncTypes) { + if (storeKey === LocalStorageSyncTypes.DASHBOARD) { + // Legacy support for when we only stored dashboard + return userId; + } + return userId + '-' + storeKey; + } + + /** + * Normally used on app init, move local storage data into the console's store + */ + public static localStorageToStore(store: Store, sessionData: SessionData) { + const storage = LocalStorageService.getStorage(); + // We use the username to key the session storage. We could replace this with the users id? + if (storage && sessionData.user) { + const sessionId = LocalStorageService.getLocalStorageSessionId(sessionData.user.name); + if (sessionId) { + LocalStorageService.localStorageToStoreSection( + LocalStorageSyncTypes.DASHBOARD, + dataForStore => store.dispatch(new HydrateDashboardStateAction(dataForStore)), + storage, + sessionId + ); + LocalStorageService.localStorageToStoreSection( + LocalStorageSyncTypes.PAGINATION, + dataForStore => store.dispatch(new HydratePaginationStateAction(dataForStore)), + storage, + sessionId, + true + ); + LocalStorageService.localStorageToStoreSection( + LocalStorageSyncTypes.LISTS, + dataForStore => store.dispatch(new HydrateListsStateAction(dataForStore)), + storage, + sessionId + ); + } + } + } + + /** + * For a given storage type fetch it's data for the given user from local storage and dispatch an action that will + * be handled by the reducers for that storage type (dashboard, pagination, etc) + */ + private static localStorageToStoreSection( + type: LocalStorageSyncTypes, + dispatch: (dataForStore: any) => void, + storage: Storage, + sessionId: string, + encrypted = false, + ) { + const key = LocalStorageService.makeKey(sessionId, type); + try { + const fromStorage = storage.getItem(key); + if (!fromStorage) { + // Could be first load using the new local storage process... or content has been cleared + return; + } + const strValue = encrypted ? LocalStorageService.decrypt(fromStorage) : fromStorage; + dispatch(JSON.parse(strValue)); + } catch (e) { + console.warn(`Failed to parse user settings with key '${key}' from session storage, consider clearing manually`, e); + } + } + + /** + * This will ensure changes in the store are selectively pushed to local storage + */ + public static storeToLocalStorageSyncReducer(reducer: ActionReducer): ActionReducer { + // This is done to ensure we don't accidentally apply state from session storage from another user. + let globalUserId = null; + return localStorageSync({ + // Decide the key to store each section by + storageKeySerializer: (storeKey: LocalStorageSyncTypes) => LocalStorageService.makeKey(globalUserId, storeKey), + syncCondition: () => { + if (globalUserId) { + return true; + } + const userId = LocalStorageService.getLocalStorageSessionId(); + if (userId) { + globalUserId = userId; + return true; + } + return false; + }, + keys: [ + LocalStorageSyncTypes.DASHBOARD, + LocalStorageSyncTypes.LISTS, + { + [LocalStorageSyncTypes.PAGINATION]: { + serialize: (pagination: PaginationState) => LocalStorageService.parseStorePartForLocalStorage( + pagination, + LocalStorageSyncTypes.PAGINATION + ), + }, + }, + ], + // Don't push local storage state into store on start up... we need the logged in user's id before we can do that + rehydrate: false, + + })(reducer); + } + + /** + * Get a unique identifier for the user + */ + private static getLocalStorageSessionId(username?: string) { + const prefix = 'stratos-'; + if (username) { + return prefix + username; + } + const idElement = document.getElementById('__stratos-userid__'); + if (idElement) { + return prefix + idElement.innerText; + } + return null; + } + + /** + * Allow for selective persistence of data. For pagination we only store params and clientPagination + */ + private static parseStorePartForLocalStorage(storePart: T, type: LocalStorageSyncTypes): object { + switch (type) { + case LocalStorageSyncTypes.PAGINATION: + const pagination: PaginationState = storePart as unknown as PaginationState; + // Convert each pagination section that we care about into an object with only the properties we care about + // For each entity type.... + const abs = Object.keys(pagination).reduce((res, entityTypes) => { + // For each pagination section of the entity type... + const perEntity = Object.keys(pagination[entityTypes]).reduce((res2, paginationKeysOfEntityType) => { + const paginationSection = pagination[entityTypes][paginationKeysOfEntityType]; + // Only store pagination section for lists + if (!paginationSection.isListPagination) { + return res2; + } + res2[paginationKeysOfEntityType] = { + params: paginationSection.params, + clientPagination: paginationSection.clientPagination, + isListPagination: paginationSection.isListPagination, // We do not persist any that are false + forcedLocalPage: paginationSection.forcedLocalPage // Value of the multi-entity filter + }; + return res2; + }, {}); + + // If this entity type has pagination section that we've cared about store it, else ignore + if (Object.keys(perEntity).length > 0) { + res[entityTypes] = perEntity; + } + return res; + }, {}); + return LocalStorageService.encrypt(abs); + } + return LocalStorageService.encrypt(storePart); + } + + public static localStorageSize(sessionData: SessionData): number { + const storage = LocalStorageService.getStorage(); + const sessionId = LocalStorageService.getLocalStorageSessionId(sessionData.user.name); + if (storage && sessionId) { + return Object.values(LocalStorageSyncTypes).reduce((total, type) => { + const key = LocalStorageService.makeKey(sessionId, type); + const content = storage.getItem(key); + // We're getting an approximate size in bytes, so just assume a character is one byte + return total + content.length; + }, 0); + } + return -1; + } + + /** + * Clear local storage and the store + */ + public static clearLocalStorage(sessionData: SessionData, confirmationService: ConfirmationDialogService, reloadTo = '/user-profile') { + const config: ConfirmationDialogConfig = { + message: 'This will clear your stored settings and reload the application', + confirm: 'Clear', + critical: true, + title: 'Are you sure?' + }; + + const successAction = res => { + if (!res) { + return; + } + + const storage = LocalStorageService.getStorage(); + const sessionId = LocalStorageService.getLocalStorageSessionId(sessionData.user.name); + if (storage && sessionId) { + Object.values(LocalStorageSyncTypes).forEach(type => { + const key = LocalStorageService.makeKey(sessionId, type); + storage.removeItem(key); + }, 0); + + // This is a brutal approach but is a lot easier than reverting all user changes in the store + window.location.assign(reloadTo); + } else { + console.warn('Unable to clear local storage, either storage or session id is missing'); + } + }; + + confirmationService.openWithCancel(config, successAction, () => { }); + } + + private static encrypt(obj: {}) { + if (LocalStorageService.Encrypt) { + const strObj = JSON.stringify(obj); + return btoa(strObj); + } + return obj; + } + + private static decrypt(strObj: string): string { + if (LocalStorageService.Encrypt) { + return atob(strObj); + } + return strObj; + } +} diff --git a/src/frontend/packages/store/src/helpers/store-helpers.ts b/src/frontend/packages/store/src/helpers/store-helpers.ts index cac7ba4e0a..601f9777ed 100644 --- a/src/frontend/packages/store/src/helpers/store-helpers.ts +++ b/src/frontend/packages/store/src/helpers/store-helpers.ts @@ -9,18 +9,6 @@ import { APIResource } from '../types/api.types'; import { IFavoritesInfo } from '../types/user-favorites.types'; -export function getDashboardStateSessionId(username?: string) { - const prefix = 'stratos-'; - if (username) { - return prefix + username; - } - const idElement = document.getElementById('__stratos-userid__'); - if (idElement) { - return prefix + idElement.innerText; - } - return null; -} - export function getFavoriteInfoObservable(store: Store): Observable { return combineLatest( store.select(fetchingFavoritesSelector), diff --git a/src/frontend/packages/store/src/reducers.module.ts b/src/frontend/packages/store/src/reducers.module.ts index af7b4c3104..002717aaae 100644 --- a/src/frontend/packages/store/src/reducers.module.ts +++ b/src/frontend/packages/store/src/reducers.module.ts @@ -1,8 +1,7 @@ import { NgModule } from '@angular/core'; -import { ActionReducer, ActionReducerMap, StoreModule } from '@ngrx/store'; -import { localStorageSync } from 'ngrx-store-localstorage'; +import { ActionReducerMap, StoreModule } from '@ngrx/store'; -import { getDashboardStateSessionId } from './helpers/store-helpers'; +import { LocalStorageService } from './helpers/local-storage-service'; import { requestReducer } from './reducers/api-request-reducers.generator'; import { authReducer } from './reducers/auth.reducer'; import { currentUserRolesReducer } from './reducers/current-user-roles-reducer/current-user-roles.reducer'; @@ -16,6 +15,7 @@ import { requestPaginationReducer } from './reducers/pagination-reducer.generato import { routingReducer } from './reducers/routing.reducer'; import { uaaSetupReducer } from './reducers/uaa-setup.reducers'; + // NOTE: Revisit when ngrx-store-logger supports Angular 7 (https://github.com/btroncone/ngrx-store-logger) // import { storeLogger } from 'ngrx-store-logger'; @@ -26,7 +26,7 @@ import { uaaSetupReducer } from './reducers/uaa-setup.reducers'; // return storeLogger()(reducer); // } -export const appReducers = { +export const appReducers: ActionReducerMap<{}> = { auth: authReducer, uaaSetup: uaaSetupReducer, endpoints: endpointsReducer, @@ -41,40 +41,16 @@ export const appReducers = { currentUserRoles: currentUserRolesReducer, userFavoritesGroups: userFavoriteGroupsReducer, recentlyVisited: recentlyVisitedReducer, -} as ActionReducerMap<{}>; - -export function localStorageSyncReducer(reducer: ActionReducer): ActionReducer { - // This is done to ensure we don't accidentally apply state from session storage from another user. - let globalUserId = null; - return localStorageSync({ - storageKeySerializer: (id) => { - return globalUserId || id; - }, - syncCondition: () => { - if (globalUserId) { - return true; - } - const userId = getDashboardStateSessionId(); - if (userId) { - globalUserId = userId; - return true; - } - return false; - }, - keys: ['dashboard'], - rehydrate: false, - - })(reducer); -} - -const metaReducers = [localStorageSyncReducer]; +}; @NgModule({ imports: [ StoreModule.forRoot( appReducers, { - metaReducers, + metaReducers: [ + LocalStorageService.storeToLocalStorageSyncReducer + ], runtimeChecks: { strictStateImmutability: true, strictActionImmutability: false diff --git a/src/frontend/packages/store/src/reducers/list.reducer.ts b/src/frontend/packages/store/src/reducers/list.reducer.ts index 392f627b69..3348067c41 100644 --- a/src/frontend/packages/store/src/reducers/list.reducer.ts +++ b/src/frontend/packages/store/src/reducers/list.reducer.ts @@ -1,9 +1,9 @@ import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { ListStateActionTypes, ListView, SetListViewAction } from '../actions/list.actions'; -import { mergeState } from '../helpers/reducer.helper'; +import { HydrateListsStateAction, ListStateActionTypes, ListView, SetListViewAction } from '../actions/list.actions'; import { ListsOnlyAppState } from '../app-state'; +import { mergeState } from '../helpers/reducer.helper'; export class ListsState { [key: string]: ListState; @@ -34,6 +34,11 @@ export function listReducer(state = defaultListsState, action): ListsState { 'view', listView ? listView.toString() : '' ); + case ListStateActionTypes.HYDRATE: + const hydrate = action as HydrateListsStateAction; + return { + ...hydrate.listsState + }; default: return state; } diff --git a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-reset-pagination.ts b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-reset-pagination.ts index d1614ef7ab..f426ad7241 100644 --- a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-reset-pagination.ts +++ b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-reset-pagination.ts @@ -25,7 +25,8 @@ const defaultPaginationEntityState: PaginationEntityState = { }, maxedState: { isMaxedMode: false - } + }, + isListPagination: false }; export function getDefaultPaginationEntityState(ignoreMaxed?: boolean): PaginationEntityState { diff --git a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-reset-sort-filter.ts b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-reset-sort-filter.ts new file mode 100644 index 0000000000..3e34f35893 --- /dev/null +++ b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-reset-sort-filter.ts @@ -0,0 +1,34 @@ +import { ResetPaginationSortFilter } from '../../actions/pagination.actions'; +import { entityCatalog } from '../../entity-catalog/entity-catalog'; +import { PaginationEntityState, PaginationState } from '../../types/pagination.types'; + +export function paginationResetSortAndFilter(state: PaginationState, action: ResetPaginationSortFilter): PaginationState { + const { pAction } = action; + const entityKey = entityCatalog.getEntityKey(pAction); + const pKey = action.pAction.paginationKey; + + if (!state[entityKey] || !state[entityKey][pKey]) { + return state; + } + const pSection: PaginationEntityState = state[entityKey][pKey]; + const res: PaginationEntityState = { + ...pSection, + clientPagination: { + ...pSection.clientPagination, + filter: { + items: [], + string: '' + }, + }, + params: { + ...pAction.initialParams + }, + }; + return { + ...state, + [entityKey]: { + ...state[entityKey], + [pKey]: res + } + }; +} diff --git a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-set-params.ts b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-set-params.ts index 15602c6c91..0d75f94961 100644 --- a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-set-params.ts +++ b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-set-params.ts @@ -1,9 +1,9 @@ -import { SetParams } from '../../actions/pagination.actions'; +import { SetInitialParams, SetParams } from '../../actions/pagination.actions'; import { PaginationEntityState } from '../../types/pagination.types'; import { removeEmptyParams } from './pagination-reducer.helper'; import { resultPerPageParam, resultPerPageParamDefault } from './pagination-reducer.types'; -export function paginationSetParams(state: PaginationEntityState, action: SetParams) { +export function paginationSetParams(state: PaginationEntityState, action: SetInitialParams | SetParams): PaginationEntityState { let params; if (action.overwrite) { // Overwrite any existing values diff --git a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer.helper.ts b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer.helper.ts index 427a184b21..b7495490cd 100644 --- a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer.helper.ts +++ b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer.helper.ts @@ -12,7 +12,7 @@ import { tap, } from 'rxjs/operators'; -import { SetInitialParams } from '../../actions/pagination.actions'; +import { SetInitialParams, SetPaginationIsList } from '../../actions/pagination.actions'; import { AppState, GeneralEntityAppState } from '../../app-state'; import { entityCatalog } from '../../entity-catalog/entity-catalog'; import { PaginationMonitor } from '../../monitors/pagination-monitor'; @@ -67,7 +67,6 @@ export function getPaginationKeyFromAction(action: PaginatedAction) { return apiAction.paginationKey; } -// TODO: This needs to be a service not just a function! - #3802 export const getPaginationObservables = ( { store, action, paginationMonitor }: { store: Store, @@ -79,6 +78,9 @@ export const getPaginationObservables = const baseAction = Array.isArray(action) ? action[0] : action; const paginationKey = paginationMonitor.paginationKey; const entityKey = paginationMonitor.schema.key; + + store.dispatch(new SetPaginationIsList(baseAction)); + // FIXME: This will reset pagination every time regardless of if we need to (or just want the pag settings/entities from pagination // section) if (baseAction.initialParams) { @@ -152,7 +154,7 @@ function paginationParamsString(params: PaginationParam): string { } -function sortStringify(obj: { [key: string]: string | string[] | number }): string { +function sortStringify(obj: { [key: string]: string | string[] | number, }): string { const keys = Object.keys(obj).sort(); return keys.reduce((res, key) => { return res += `${key}-${obj[key]},`; diff --git a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination.reducer.ts b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination.reducer.ts index eef23bc50d..a0e0f1503b 100644 --- a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination.reducer.ts +++ b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination.reducer.ts @@ -10,11 +10,15 @@ import { CLEAR_PAGINATION_OF_TYPE, ClearPaginationOfType, CREATE_PAGINATION, + HYDRATE_PAGINATION_STATE, + HydratePaginationStateAction, IGNORE_MAXED_STATE, IgnorePaginationMaxedState, REMOVE_PARAMS, RESET_PAGINATION, RESET_PAGINATION_OF_TYPE, + RESET_PAGINATION_SORT_FILTER, + ResetPaginationSortFilter, SET_CLIENT_FILTER, SET_CLIENT_FILTER_KEY, SET_CLIENT_PAGE, @@ -22,8 +26,10 @@ import { SET_INITIAL_PARAMS, SET_PAGE, SET_PAGE_BUSY, + SET_PAGINATION_IS_LIST, SET_PARAMS, SET_RESULT_COUNT, + SetPaginationIsList, UPDATE_MAXED_STATE, } from '../../actions/pagination.actions'; import { ApiActionTypes } from '../../actions/request.actions'; @@ -31,7 +37,7 @@ import { InitCatalogEntitiesAction } from '../../entity-catalog.actions'; import { entityCatalog } from '../../entity-catalog/entity-catalog'; import { getDefaultStateFromEntityCatalog } from '../../entity-catalog/entity-catalog.store-setup'; import { mergeState } from '../../helpers/reducer.helper'; -import { PaginationEntityState, PaginationState } from '../../types/pagination.types'; +import { PaginationEntityState, PaginationEntityTypeState, PaginationState } from '../../types/pagination.types'; import { UpdatePaginationMaxedState } from './../../actions/pagination.actions'; import { paginationAddParams } from './pagination-reducer-add-params'; import { paginationClearPages } from './pagination-reducer-clear-pages'; @@ -45,6 +51,7 @@ import { paginationResetPagination, resetEndpointEntities, } from './pagination-reducer-reset-pagination'; +import { paginationResetSortAndFilter } from './pagination-reducer-reset-sort-filter'; import { paginationSetClientFilter } from './pagination-reducer-set-client-filter'; import { paginationSetClientFilterKey } from './pagination-reducer-set-client-filter-key'; import { paginationSetClientPage } from './pagination-reducer-set-client-page'; @@ -105,7 +112,7 @@ function paginationReducer(updatePagination) { }; } -function paginate(action, state = {}, updatePagination) { +function paginate(action, state: PaginationState = {}, updatePagination) { if (action.type === ApiActionTypes.API_REQUEST_START) { return state; } @@ -152,9 +159,77 @@ function paginate(action, state = {}, updatePagination) { return paginationIgnoreMaxed(state, action as IgnorePaginationMaxedState); } + if (action.type === HYDRATE_PAGINATION_STATE) { + return hydratePagination(state, action as HydratePaginationStateAction); + } + + if (action.type === RESET_PAGINATION_SORT_FILTER) { + return paginationResetSortAndFilter(state, action as ResetPaginationSortFilter); + } + + if (action.type === SET_PAGINATION_IS_LIST) { + return setPaginationIsList(state, action as SetPaginationIsList); + } + return enterPaginationReducer(state, action, updatePagination); } +function setPaginationIsList(state: PaginationState, action: SetPaginationIsList): PaginationState { + const entityKey = entityCatalog.getEntityKey(action.pagAction); + const existingPag = state[entityKey] ? state[entityKey][action.pagAction.paginationKey] : null; + const pag = existingPag || getDefaultPaginationEntityState(); + + if (pag.isListPagination === action.pagAction.isList) { + return state; + } + + const entityState: PaginationEntityTypeState = { + ...state[entityKey], + [action.pagAction.paginationKey]: { + ...pag, + isListPagination: action.pagAction.isList + } + }; + return { + ...state, + [entityKey]: entityState + }; +} + +/** + * Push data from local storage back into the pagination state + */ +function hydratePagination(state: PaginationState, action: HydratePaginationStateAction): PaginationState { + const hydrate = action.paginationState || {}; + const entityKeys = Object.keys(hydrate); + if (entityKeys.length === 0) { + return state; + } + + // Loop through all entity types.... and pagination sections in those types.... merging in state from storage + const newState = entityKeys.reduce((res, entityKey) => { + const existingEntityState = state[entityKey] || {}; + const hydrateEntityState = action.paginationState[entityKey]; + + res[entityKey] = Object.keys(hydrateEntityState).reduce((res2, paginationKey) => { + const existingPageState = existingEntityState[paginationKey] || getDefaultPaginationEntityState(); + const hydratePagSection = hydrateEntityState[paginationKey]; + res2[paginationKey] = { + ...existingPageState, + ...hydratePagSection + }; + return res2; + }, { + ...existingEntityState + }); + + return res; + }, { + ...state + }); + return newState; +} + function isEndpointAction(action) { // ... that we care about. return action.type === DISCONNECT_ENDPOINTS_SUCCESS || diff --git a/src/frontend/packages/store/src/types/pagination.types.ts b/src/frontend/packages/store/src/types/pagination.types.ts index 09a7e5634d..d5c6376fe8 100644 --- a/src/frontend/packages/store/src/types/pagination.types.ts +++ b/src/frontend/packages/store/src/types/pagination.types.ts @@ -55,7 +55,7 @@ export class PaginationEntityState { ids = {}; params: PaginationParam; pageRequests: { - [pageNumber: string]: ListActionState + [pageNumber: string]: ListActionState, }; clientPagination?: PaginationClientPagination; /** @@ -63,6 +63,7 @@ export class PaginationEntityState { */ seed?: string; maxedState: PaginationMaxedState; + isListPagination = false; } export function isPaginatedAction(obj: any): PaginatedAction { @@ -90,6 +91,7 @@ export interface PaginatedAction extends BasePaginatedAction, EntityRequestActio // Internal, used for local multi action lists __forcedPageNumber__?: number; __forcedPageEntityConfig__?: EntityCatalogEntityConfig; + isList?: boolean; } export interface PaginationEntityTypeState { diff --git a/src/frontend/packages/store/testing/src/store-test-helper.ts b/src/frontend/packages/store/testing/src/store-test-helper.ts index 389fdf1d7f..92440c6f7a 100644 --- a/src/frontend/packages/store/testing/src/store-test-helper.ts +++ b/src/frontend/packages/store/testing/src/store-test-helper.ts @@ -235,12 +235,13 @@ function getDefaultInitialTestStoreState(): AppState { items: {} }, }, - maxedState: {} + maxedState: {}, + isListPagination: true } }, metrics: {}, stratosUserProfile: {}, - stratosUserFavorites: {} + stratosUserFavorites: {}, }, request: { stratosUserProfile: {}, diff --git a/src/test-e2e/cloud-foundry/cf-level/cf-top-level-page.po.ts b/src/test-e2e/cloud-foundry/cf-level/cf-top-level-page.po.ts index 2e2be71c7b..5555a068e1 100644 --- a/src/test-e2e/cloud-foundry/cf-level/cf-top-level-page.po.ts +++ b/src/test-e2e/cloud-foundry/cf-level/cf-top-level-page.po.ts @@ -183,6 +183,7 @@ export class CfTopLevelPage extends CFPage { clickOnCard(orgName: string) { const list = new ListComponent(); + list.header.clearFilters(); list.cards.findCardByTitle(orgName).then((card) => { expect(card).toBeDefined(); card.click(); diff --git a/src/test-e2e/endpoints/endpoints.po.ts b/src/test-e2e/endpoints/endpoints.po.ts index b9899f7110..824e8a06b8 100644 --- a/src/test-e2e/endpoints/endpoints.po.ts +++ b/src/test-e2e/endpoints/endpoints.po.ts @@ -3,14 +3,14 @@ import { ElementFinder } from 'protractor/built'; import { E2EEndpointConfig } from '../e2e.types'; import { ConsoleUserType, E2EHelpers } from '../helpers/e2e-helpers'; -import { ListCardComponent, ListComponent, ListHeaderComponent, ListTableComponent } from '../po/list.po'; +import { ListCardComponent, ListComponent, ListTableComponent } from '../po/list.po'; import { MetaCard, MetaCardItem } from '../po/meta-card.po'; import { Page } from '../po/page.po'; import { SnackBarPo } from '../po/snackbar.po'; export class EndpointCards extends ListCardComponent { - constructor(locator: ElementFinder, header: ListHeaderComponent) { - super(locator, header); + constructor(locator: ElementFinder, list: ListComponent) { + super(locator, list); } findCardByTitle(title: string, subtitle = 'Cloud Foundry'): promise.Promise { @@ -108,7 +108,7 @@ export class EndpointsPage extends Page { // Endpoints table (as opposed to generic list.table) public table = new EndpointsTable(this.list.getComponent()); - public cards = new EndpointCards(this.list.locator, this.list.header); + public cards = new EndpointCards(this.list.locator, this.list); constructor() { super('/endpoints'); diff --git a/src/test-e2e/marketplace/delete-service-instance-e2e.spec.ts b/src/test-e2e/marketplace/delete-service-instance-e2e.spec.ts index 7d12abe649..cec34d7400 100644 --- a/src/test-e2e/marketplace/delete-service-instance-e2e.spec.ts +++ b/src/test-e2e/marketplace/delete-service-instance-e2e.spec.ts @@ -48,7 +48,7 @@ describe('Delete Service Instance', () => { ]) .then(() => { servicesWall.navigateTo(); - + servicesWall.serviceInstancesList.header.clearFilters(); servicesWall.serviceInstancesList.header.refresh(); servicesWall.serviceInstancesList.cards.waitForCardByTitle(serviceInstanceName1); return servicesWall.serviceInstancesList.cards.waitForCardByTitle(serviceInstanceName2); diff --git a/src/test-e2e/marketplace/delete-ups-service-instance-e2e.spec.ts b/src/test-e2e/marketplace/delete-ups-service-instance-e2e.spec.ts index 112dec4037..ee11f32f11 100644 --- a/src/test-e2e/marketplace/delete-ups-service-instance-e2e.spec.ts +++ b/src/test-e2e/marketplace/delete-ups-service-instance-e2e.spec.ts @@ -49,6 +49,7 @@ describe('Delete Service Instance (User Provided Service)', () => { names.push(serviceInstanceName); servicesHelperE2E.createUserProvidedService(e2e.secrets.getDefaultCFEndpoint().services.publicService.name, serviceInstanceName); servicesWall.waitForPage(); + servicesWall.serviceInstancesList.header.clearFilters(); servicesWall.serviceInstancesList.cards.waitForCardByTitle(serviceInstanceName); }, timeout); diff --git a/src/test-e2e/po/list.po.ts b/src/test-e2e/po/list.po.ts index b6f163ad0a..da579af6b7 100644 --- a/src/test-e2e/po/list.po.ts +++ b/src/test-e2e/po/list.po.ts @@ -15,7 +15,7 @@ export interface CardMetadata { } export interface TableData { - [columnHeader: string]: string + [columnHeader: string]: string; } // Page Object for the List Table View @@ -161,7 +161,7 @@ export class ListCardComponent extends Component { static cardsCss = 'app-card:not(.row-filler)'; - constructor(locator: ElementFinder, private header: ListHeaderComponent) { + constructor(locator: ElementFinder, private list: ListComponent) { super(locator); } @@ -181,6 +181,12 @@ export class ListCardComponent extends Component { } private findCardElementByTitle(title: string, metaType = MetaCardTitleType.CUSTOM): ElementFinder { + this.list.isTableView().then(isTableView => { + if (isTableView) { + return this.list.header.getCardListViewToggleButton().click(); + } + }); + const card = this.locator.all(by.css(`${ListCardComponent.cardsCss} ${metaType}`)).filter(elem => elem.getText().then(text => text === title) ).first(); @@ -198,8 +204,8 @@ export class ListCardComponent extends Component { findCardByTitle(title: string, metaType = MetaCardTitleType.CUSTOM, filter = false): promise.Promise { if (filter) { - this.header.waitUntilShown(); - this.header.setSearchText(title); + this.list.header.waitUntilShown(); + this.list.header.setSearchText(title); return this.waitForCardByTitle(title, metaType); } @@ -380,6 +386,14 @@ export class ListHeaderComponent extends Component { return this.getLeftHeaderSection().element(by.cssContainingText('button mat-icon', iconText)); } + clearFilters(): promise.Promise { + return this.getClearButton().click(); + } + + getClearButton() { + return this.locator.element(by.cssContainingText('.list-component__header__right button mat-icon', 'highlight_off')); + } + } export class ListPaginationComponent extends Component { @@ -477,7 +491,7 @@ export class ListComponent extends Component { super(locator); this.table = new ListTableComponent(locator); this.header = new ListHeaderComponent(locator); - this.cards = new ListCardComponent(locator, this.header); + this.cards = new ListCardComponent(locator, this); this.pagination = new ListPaginationComponent(locator); this.empty = new ListEmptyComponent(locator); }