You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@nifi.apache.org by rf...@apache.org on 2025/10/15 21:16:23 UTC
(nifi) branch main updated: NIFI-15089: Fixing conditional logic for rendering errors when loadin… (#10421)
This is an automated email from the ASF dual-hosted git repository.
rfellows pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 3279c5e881 NIFI-15089: Fixing conditional logic for rendering errors when loadin… (#10421)
3279c5e881 is described below
commit 3279c5e8818e77ee09cb4b3e5e449b7f132ed06f
Author: Matt Gilman <ma...@gmail.com>
AuthorDate: Wed Oct 15 17:16:17 2025 -0400
NIFI-15089: Fixing conditional logic for rendering errors when loadin… (#10421)
* NIFI-15089: Fixing conditional logic for rendering errors when loading data based on whether data is already loaded or not.
* NIFI-15089: Fixing lint issues noted in review.
This closes #10421
---
.../state/bulletin-board/bulletin-board.actions.ts | 5 +
.../bulletin-board/bulletin-board.effects.spec.ts | 193 ++++++++++++++
.../state/bulletin-board/bulletin-board.effects.ts | 30 ++-
.../state/bulletin-board/bulletin-board.reducer.ts | 6 +
.../bulletin-board/bulletin-board.selectors.ts | 5 +-
.../cluster-listing/cluster-listing.actions.ts | 5 +
.../cluster-listing.effects.spec.ts | 227 +++++++++++++++++
.../cluster-listing/cluster-listing.effects.ts | 22 +-
.../cluster-listing/cluster-listing.reducer.ts | 6 +
.../counter-listing/counter-listing.actions.ts | 5 +
.../counter-listing.effects.spec.ts | 91 ++++++-
.../counter-listing/counter-listing.effects.ts | 27 +-
.../counter-listing/counter-listing.reducer.ts | 5 +
.../counter-listing/counter-listing.selectors.ts | 7 +-
.../flow-configuration-history-listing.actions.ts | 5 +
...w-configuration-history-listing.effects.spec.ts | 227 +++++++++++++++++
.../flow-configuration-history-listing.effects.ts | 27 +-
.../flow-configuration-history-listing.reducer.ts | 6 +
.../controller-services.actions.ts | 5 +
.../controller-services.effects.spec.ts | 224 +++++++++++++++++
.../controller-services.effects.ts | 29 ++-
.../controller-services.reducer.ts | 5 +
.../controller-services.selectors.ts | 4 +-
.../flow-designer/state/flow/flow.effects.spec.ts | 65 ++++-
.../pages/flow-designer/state/flow/flow.effects.ts | 8 +-
.../flow-designer/state/flow/flow.selectors.ts | 5 +
.../manage-remote-ports.actions.ts | 5 +
.../manage-remote-ports.effects.spec.ts | 280 +++++++++++++++++++++
.../manage-remote-ports.effects.ts | 27 +-
.../manage-remote-ports.reducer.ts | 5 +
.../manage-remote-ports.selectors.ts | 5 +-
.../parameter-context-listing.actions.ts | 5 +
.../parameter-context-listing.effects.spec.ts | 154 +++++++++++-
.../parameter-context-listing.effects.ts | 27 +-
.../parameter-context-listing.reducer.ts | 5 +
.../parameter-context-listing.selectors.ts | 4 +-
.../flow-analysis-rules.actions.ts | 5 +
.../flow-analysis-rules.effects.spec.ts | 202 +++++++++++++++
.../flow-analysis-rules.effects.ts | 27 +-
.../flow-analysis-rules.reducer.ts | 5 +
.../flow-analysis-rules.selectors.ts | 4 +-
.../settings/state/general/general.effects.spec.ts | 122 +++++++++
.../settings/state/general/general.effects.ts | 10 +-
.../settings/state/general/general.selectors.ts | 2 -
.../management-controller-services.actions.ts | 5 +
.../management-controller-services.effects.spec.ts | 206 +++++++++++++++
.../management-controller-services.effects.ts | 27 +-
.../management-controller-services.reducer.ts | 5 +
.../management-controller-services.selectors.ts | 4 +-
.../parameter-providers.actions.ts | 7 +-
.../parameter-providers.effects.spec.ts | 204 +++++++++++++++
.../parameter-providers.effects.ts | 34 ++-
.../parameter-providers.reducer.ts | 6 +
.../parameter-providers.selectors.ts | 4 +-
.../registry-clients/registry-clients.actions.ts | 7 +-
.../registry-clients.effects.spec.ts | 227 +++++++++++++++++
.../registry-clients/registry-clients.effects.ts | 29 ++-
.../registry-clients/registry-clients.reducer.ts | 5 +
.../registry-clients/registry-clients.selectors.ts | 5 +-
.../reporting-tasks/reporting-tasks.actions.ts | 7 +-
.../reporting-tasks.effects.spec.ts | 191 ++++++++++++++
.../reporting-tasks/reporting-tasks.effects.ts | 29 ++-
.../reporting-tasks/reporting-tasks.reducer.ts | 5 +
.../reporting-tasks/reporting-tasks.selectors.ts | 5 +-
.../summary-listing/summary-listing.actions.ts | 5 +
.../summary-listing.effects.spec.ts | 251 ++++++++++++++++++
.../summary-listing/summary-listing.effects.ts | 29 ++-
.../summary-listing/summary-listing.reducer.ts | 6 +
.../state/user-listing/user-listing.actions.ts | 5 +
.../user-listing/user-listing.effects.spec.ts | 186 ++++++++++++++
.../state/user-listing/user-listing.effects.ts | 22 +-
.../state/user-listing/user-listing.reducer.ts | 5 +
.../state/user-listing/user-listing.selectors.ts | 5 +-
.../src/app/service/error-helper.service.spec.ts | 226 +++++++++++++++++
.../nifi/src/app/service/error-helper.service.ts | 4 +-
75 files changed, 3777 insertions(+), 117 deletions(-)
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.actions.ts
index 0d2fa79f3f..954fad9f6c 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.actions.ts
@@ -30,6 +30,11 @@ export const loadBulletinBoardSuccess = createAction(
props<{ response: LoadBulletinBoardResponse }>()
);
+export const loadBulletinBoardError = createAction(
+ `${BULLETIN_BOARD_PREFIX} Load Bulletin Board Error`,
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const resetBulletinBoardState = createAction(`${BULLETIN_BOARD_PREFIX} Reset Bulletin Board State`);
export const clearBulletinBoard = createAction(`${BULLETIN_BOARD_PREFIX} Clear Bulletin Board`);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.effects.spec.ts
new file mode 100644
index 0000000000..2cc682d539
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.effects.spec.ts
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { Action } from '@ngrx/store';
+import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { HttpErrorResponse } from '@angular/common/http';
+
+import * as BulletinBoardActions from './bulletin-board.actions';
+import { BulletinBoardEffects } from './bulletin-board.effects';
+import { BulletinBoardService } from '../../service/bulletin-board.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { initialBulletinBoardState } from './bulletin-board.reducer';
+
+describe('BulletinBoardEffects', () => {
+ interface SetupOptions {
+ bulletinBoardState?: any;
+ }
+
+ const mockResponse = {
+ bulletinBoard: {
+ generated: '2023-01-01 12:00:00 EST',
+ bulletins: []
+ }
+ };
+
+ async function setup({ bulletinBoardState = initialBulletinBoardState }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ BulletinBoardEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ bulletins: {
+ 'bulletin-board': bulletinBoardState
+ }
+ }
+ }),
+ { provide: BulletinBoardService, useValue: { getBulletins: jest.fn() } },
+ { provide: ErrorHelper, useValue: { handleLoadingError: jest.fn() } }
+ ]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const effects = TestBed.inject(BulletinBoardEffects);
+ const bulletinBoardService = TestBed.inject(BulletinBoardService);
+ const errorHelper = TestBed.inject(ErrorHelper);
+
+ return { effects, store, bulletinBoardService, errorHelper };
+ }
+
+ let action$: ReplaySubject<Action>;
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>();
+ });
+
+ it('should create', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('Load Bulletin Board', () => {
+ it('should load bulletin board successfully', async () => {
+ const { effects, bulletinBoardService } = await setup();
+
+ const request = { after: 1, limit: 10 };
+ action$.next(BulletinBoardActions.loadBulletinBoard({ request }));
+ jest.spyOn(bulletinBoardService, 'getBulletins').mockReturnValueOnce(of(mockResponse) as never);
+
+ const result = await new Promise((resolve) => effects.loadBulletinBoard$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ BulletinBoardActions.loadBulletinBoardSuccess({
+ response: {
+ bulletinBoard: mockResponse.bulletinBoard,
+ loadedTimestamp: mockResponse.bulletinBoard.generated
+ }
+ })
+ );
+ });
+
+ it('should fail to load bulletin board on initial load with hasExistingData=false', async () => {
+ const { effects, bulletinBoardService } = await setup();
+
+ const request = { after: 1, limit: 10 };
+ action$.next(BulletinBoardActions.loadBulletinBoard({ request }));
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(bulletinBoardService, 'getBulletins').mockImplementationOnce(() => throwError(() => error));
+
+ const result = await new Promise((resolve) => effects.loadBulletinBoard$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ BulletinBoardActions.loadBulletinBoardError({
+ errorResponse: error,
+ loadedTimestamp: initialBulletinBoardState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load bulletin board on refresh with hasExistingData=true', async () => {
+ const stateWithData = { ...initialBulletinBoardState, loadedTimestamp: '2023-01-01 11:00:00 EST' };
+ const { effects, bulletinBoardService } = await setup({ bulletinBoardState: stateWithData });
+
+ const request = { after: 1, limit: 10 };
+ action$.next(BulletinBoardActions.loadBulletinBoard({ request }));
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(bulletinBoardService, 'getBulletins').mockImplementationOnce(() => throwError(() => error));
+
+ const result = await new Promise((resolve) => effects.loadBulletinBoard$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ BulletinBoardActions.loadBulletinBoardError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+ });
+
+ describe('Bulletin Board Listing Error', () => {
+ it('should handle bulletin board listing error for initial load', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = BulletinBoardActions.loadBulletinBoardSuccess({
+ response: {
+ bulletinBoard: mockResponse.bulletinBoard,
+ loadedTimestamp: mockResponse.bulletinBoard.generated
+ }
+ });
+ action$.next(
+ BulletinBoardActions.loadBulletinBoardError({
+ errorResponse: error,
+ loadedTimestamp: initialBulletinBoardState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.bulletinBoardListingError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle bulletin board listing error for refresh', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = BulletinBoardActions.loadBulletinBoardSuccess({
+ response: {
+ bulletinBoard: mockResponse.bulletinBoard,
+ loadedTimestamp: mockResponse.bulletinBoard.generated
+ }
+ });
+ action$.next(
+ BulletinBoardActions.loadBulletinBoardError({
+ errorResponse: error,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.bulletinBoardListingError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.effects.ts
index a402f2c837..93525cc873 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.effects.ts
@@ -23,10 +23,11 @@ import { NiFiState } from '../../../../state';
import * as BulletinBoardActions from './bulletin-board.actions';
import { asyncScheduler, catchError, from, interval, map, of, switchMap, takeUntil } from 'rxjs';
import { BulletinBoardService } from '../../service/bulletin-board.service';
-import { selectBulletinBoardFilter, selectLastBulletinId, selectStatus } from './bulletin-board.selectors';
+import { selectBulletinBoardFilter, selectLastBulletinId, selectLoadedTimestamp } from './bulletin-board.selectors';
import { LoadBulletinBoardRequest } from './index';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { HttpErrorResponse } from '@angular/common/http';
+import { initialBulletinBoardState } from './bulletin-board.reducer';
@Injectable()
export class BulletinBoardEffects {
@@ -39,8 +40,8 @@ export class BulletinBoardEffects {
this.actions$.pipe(
ofType(BulletinBoardActions.loadBulletinBoard),
map((action) => action.request),
- concatLatestFrom(() => this.store.select(selectStatus)),
- switchMap(([request, status]) =>
+ concatLatestFrom(() => this.store.select(selectLoadedTimestamp)),
+ switchMap(([request, loadedTimestamp]) =>
from(
this.bulletinBoardService.getBulletins(request).pipe(
map((response: any) =>
@@ -52,7 +53,16 @@ export class BulletinBoardEffects {
})
),
catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
+ of(
+ BulletinBoardActions.loadBulletinBoardError({
+ errorResponse,
+ loadedTimestamp,
+ status:
+ loadedTimestamp !== initialBulletinBoardState.loadedTimestamp
+ ? 'success'
+ : 'pending'
+ })
+ )
)
)
)
@@ -60,6 +70,18 @@ export class BulletinBoardEffects {
)
);
+ bulletinBoardListingError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(BulletinBoardActions.loadBulletinBoardError),
+ map((action) =>
+ this.errorHelper.handleLoadingError(
+ action.loadedTimestamp !== initialBulletinBoardState.loadedTimestamp,
+ action.errorResponse
+ )
+ )
+ )
+ );
+
setBulletinBoardAutoRefresh$ = createEffect(() =>
this.actions$.pipe(
ofType(BulletinBoardActions.setBulletinBoardAutoRefresh),
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.reducer.ts
index 8a0eca6648..18fae2182d 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.reducer.ts
@@ -18,6 +18,7 @@
import { BulletinBoardEvent, BulletinBoardItem, BulletinBoardState } from './index';
import { createReducer, on } from '@ngrx/store';
import {
+ loadBulletinBoardError,
clearBulletinBoard,
loadBulletinBoard,
loadBulletinBoardSuccess,
@@ -69,6 +70,11 @@ export const bulletinBoardReducer = createReducer(
};
}),
+ on(loadBulletinBoardError, (state: BulletinBoardState, { status }) => ({
+ ...state,
+ status
+ })),
+
on(resetBulletinBoardState, () => ({ ...initialBulletinBoardState })),
on(clearBulletinBoard, (state) => ({
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.selectors.ts
index f4af3a5f5e..7e8b9260b3 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/bulletins/state/bulletin-board/bulletin-board.selectors.ts
@@ -34,4 +34,7 @@ export const selectBulletinBoardFilter = createSelector(
(state: BulletinBoardState) => state.filter
);
-export const selectStatus = createSelector(selectBulletinBoardState, (state: BulletinBoardState) => state.status);
+export const selectLoadedTimestamp = createSelector(
+ selectBulletinBoardState,
+ (state: BulletinBoardState) => state.loadedTimestamp
+);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.actions.ts
index de2ebab642..47e63c1af3 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.actions.ts
@@ -28,6 +28,11 @@ export const loadClusterListingSuccess = createAction(
props<{ response: ClusterListingEntity }>()
);
+export const loadClusterListingError = createAction(
+ `${CLUSTER_LISTING_PREFIX} Load Cluster Listing Error`,
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const confirmAndDisconnectNode = createAction(
`${CLUSTER_LISTING_PREFIX} Confirm And Disconnect Node`,
props<{ request: ClusterNode }>()
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.effects.spec.ts
new file mode 100644
index 0000000000..afe69a9db6
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.effects.spec.ts
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { ClusterListingEffects } from './cluster-listing.effects';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { initialClusterState } from './cluster-listing.reducer';
+import { of, ReplaySubject, take, throwError } from 'rxjs';
+import { Action } from '@ngrx/store';
+import * as ClusterListingActions from './cluster-listing.actions';
+import { ClusterService } from '../../service/cluster.service';
+import { MatDialog } from '@angular/material/dialog';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { HttpErrorResponse } from '@angular/common/http';
+import { Router } from '@angular/router';
+import { clusterFeatureKey } from '../index';
+import { clusterListingFeatureKey } from './index';
+
+describe('ClusterListingEffects', () => {
+ interface SetupOptions {
+ clusterListingState?: any;
+ }
+
+ const mockClusterResponse = {
+ cluster: {
+ nodes: [
+ {
+ nodeId: 'node-1',
+ address: '192.168.1.100',
+ apiPort: 8080,
+ status: 'CONNECTED',
+ heartbeat: '2023-01-01 12:00:00 EST',
+ roles: ['Primary Node'],
+ events: []
+ }
+ ],
+ generated: '2023-01-01 12:00:00 EST'
+ }
+ };
+
+ let action$: ReplaySubject<Action>;
+
+ async function setup({ clusterListingState = initialClusterState }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ ClusterListingEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ [clusterFeatureKey]: {
+ [clusterListingFeatureKey]: clusterListingState
+ }
+ }
+ }),
+ {
+ provide: ClusterService,
+ useValue: {
+ getClusterListing: jest.fn()
+ }
+ },
+ {
+ provide: MatDialog,
+ useValue: {
+ open: jest.fn()
+ }
+ },
+ {
+ provide: Router,
+ useValue: {
+ navigate: jest.fn()
+ }
+ },
+ {
+ provide: ErrorHelper,
+ useValue: {
+ handleLoadingError: jest.fn(),
+ getErrorString: jest.fn()
+ }
+ }
+ ]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const clusterService = TestBed.inject(ClusterService) as jest.Mocked<ClusterService>;
+ const errorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+ const effects = TestBed.inject(ClusterListingEffects);
+
+ return { store, clusterService, errorHelper, effects };
+ }
+
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>(1);
+ });
+
+ afterEach(() => {
+ action$.complete();
+ });
+
+ it('should be created', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('loadClusterListing', () => {
+ it('should load cluster listing successfully', async () => {
+ const { effects, clusterService } = await setup();
+
+ action$.next(ClusterListingActions.loadClusterListing());
+ jest.spyOn(clusterService, 'getClusterListing').mockReturnValueOnce(of(mockClusterResponse) as never);
+
+ const result = await new Promise((resolve) => effects.loadClusterListing$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ ClusterListingActions.loadClusterListingSuccess({
+ response: mockClusterResponse.cluster
+ })
+ );
+ });
+
+ it('should fail to load cluster listing on initial load (no existing data)', async () => {
+ const { effects, clusterService } = await setup();
+
+ action$.next(ClusterListingActions.loadClusterListing());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(clusterService, 'getClusterListing').mockImplementationOnce(() => throwError(() => error));
+
+ const result = await new Promise((resolve) => effects.loadClusterListing$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ ClusterListingActions.loadClusterListingError({
+ errorResponse: error,
+ loadedTimestamp: initialClusterState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load cluster listing on refresh (existing data)', async () => {
+ const stateWithData = {
+ ...initialClusterState,
+ loadedTimestamp: '2023-01-01 11:00:00 EST'
+ };
+ const { effects, clusterService } = await setup({ clusterListingState: stateWithData });
+
+ action$.next(ClusterListingActions.loadClusterListing());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(clusterService, 'getClusterListing').mockImplementationOnce(() => throwError(() => error));
+
+ const result = await new Promise((resolve) => effects.loadClusterListing$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ ClusterListingActions.loadClusterListingError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+ });
+
+ describe('loadClusterListingError', () => {
+ it('should handle loadClusterListingError with pending status', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = ClusterListingActions.loadClusterListingSuccess({
+ response: mockClusterResponse.cluster
+ });
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ action$.next(
+ ClusterListingActions.loadClusterListingError({
+ errorResponse: error,
+ loadedTimestamp: '',
+ status: 'pending'
+ })
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadClusterListingError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle loadClusterListingError with success status', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = ClusterListingActions.loadClusterListingSuccess({
+ response: mockClusterResponse.cluster
+ });
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ action$.next(
+ ClusterListingActions.loadClusterListingError({
+ errorResponse: error,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadClusterListingError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.effects.ts
index f4aac0841b..e6ae26c052 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.effects.ts
@@ -20,11 +20,12 @@ import { Actions, createEffect, ofType } from '@ngrx/effects';
import { ActionCreator, Creator, Store } from '@ngrx/store';
import { NiFiState } from '../../../../state';
import { ErrorHelper } from '../../../../service/error-helper.service';
+import { initialClusterState } from './cluster-listing.reducer';
import { Router } from '@angular/router';
import * as ClusterListingActions from './cluster-listing.actions';
import { catchError, filter, from, map, of, switchMap, take, tap } from 'rxjs';
import { HttpErrorResponse } from '@angular/common/http';
-import { selectClusterListingStatus } from './cluster-listing.selectors';
+import { selectClusterListingLoadedTimestamp } from './cluster-listing.selectors';
import { reloadSystemDiagnostics } from '../../../../state/system-diagnostics/system-diagnostics.actions';
import { ClusterService } from '../../service/cluster.service';
import { MatDialog } from '@angular/material/dialog';
@@ -47,12 +48,18 @@ export class ClusterListingEffects {
loadClusterListing$ = createEffect(() =>
this.actions$.pipe(
ofType(ClusterListingActions.loadClusterListing),
- concatLatestFrom(() => [this.store.select(selectClusterListingStatus)]),
- switchMap(([, listingStatus]) =>
+ concatLatestFrom(() => [this.store.select(selectClusterListingLoadedTimestamp)]),
+ switchMap(([, loadedTimestamp]) =>
from(this.clusterService.getClusterListing()).pipe(
map((response) => ClusterListingActions.loadClusterListingSuccess({ response: response.cluster })),
catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(listingStatus, errorResponse))
+ of(
+ ClusterListingActions.loadClusterListingError({
+ errorResponse,
+ loadedTimestamp,
+ status: loadedTimestamp !== initialClusterState.loadedTimestamp ? 'success' : 'pending'
+ })
+ )
)
)
)
@@ -75,6 +82,13 @@ export class ClusterListingEffects {
)
);
+ loadClusterListingError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ClusterListingActions.loadClusterListingError),
+ map((action) => this.errorHelper.handleLoadingError(action.status === 'success', action.errorResponse))
+ )
+ );
+
confirmAndDisconnectNode$ = createEffect(
() =>
this.actions$.pipe(
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.reducer.ts
index aac1eded30..9df15985e0 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/state/cluster-listing/cluster-listing.reducer.ts
@@ -21,6 +21,7 @@ import {
connectNode,
disconnectNode,
loadClusterListing,
+ loadClusterListingError,
loadClusterListingSuccess,
removeNode,
removeNodeSuccess,
@@ -55,6 +56,11 @@ export const clusterListingReducer = createReducer(
status: 'success' as const
})),
+ on(loadClusterListingError, (state, { status }) => ({
+ ...state,
+ status
+ })),
+
on(disconnectNode, connectNode, removeNode, (state) => ({
...state,
saving: true
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.actions.ts
index d30b84b043..908377dd31 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.actions.ts
@@ -28,6 +28,11 @@ export const loadCountersSuccess = createAction(
props<{ response: LoadCounterListingResponse }>()
);
+export const loadCountersError = createAction(
+ `${COUNTER_PREFIX} Load Counters Error`,
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const counterListingApiError = createAction(
`${COUNTER_PREFIX} Load Counter Listing Error`,
props<{ errorResponse: HttpErrorResponse }>()
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.effects.spec.ts
index 2a6f494b67..ca2235870d 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.effects.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.effects.spec.ts
@@ -28,6 +28,7 @@ import { MatDialog } from '@angular/material/dialog';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { HttpErrorResponse } from '@angular/common/http';
import { CounterEntity } from './index';
+import { countersFeatureKey } from '../index';
describe('CounterListingEffects', () => {
let action$: ReplaySubject<Action>;
@@ -69,8 +70,8 @@ describe('CounterListingEffects', () => {
provideMockActions(() => action$),
provideMockStore({
initialState: {
- counters: {
- counters: counterListingState
+ [countersFeatureKey]: {
+ [countersFeatureKey]: counterListingState
}
}
}),
@@ -142,21 +143,99 @@ describe('CounterListingEffects', () => {
);
});
- it('should fail to load counters', async () => {
- const { action$, effects, countersService, errorHelper } = await setup();
+ it('should fail to load counters on initial load with hasExistingData=false', async () => {
+ const { action$, effects, countersService } = await setup();
action$.next(CounterListingActions.loadCounters());
const error = new HttpErrorResponse({ status: 500 });
- const errorAction = CounterListingActions.counterListingApiError({ errorResponse: error });
jest.spyOn(countersService, 'getCounters').mockImplementationOnce(() => {
return throwError(() => error);
});
- jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
const result = await new Promise((resolve) => effects.loadCounters$.pipe(take(1)).subscribe(resolve));
+ expect(result).toEqual(
+ CounterListingActions.loadCountersError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load counters on refresh with hasExistingData=true', async () => {
+ const stateWithData = {
+ ...initialState,
+ loadedTimestamp: '2023-01-01 11:00:00 EST'
+ };
+ const { action$, effects, countersService } = await setup({
+ counterListingState: stateWithData
+ });
+
+ action$.next(CounterListingActions.loadCounters());
+
+ const error = new HttpErrorResponse({ status: 500 });
+
+ jest.spyOn(countersService, 'getCounters').mockImplementationOnce(() => {
+ return throwError(() => error);
+ });
+
+ const result = await new Promise((resolve) => effects.loadCounters$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ CounterListingActions.loadCountersError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+ });
+
+ describe('Counter Listing Error', () => {
+ it('should handle counter listing error for initial load', async () => {
+ const { action$, effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = CounterListingActions.counterListingApiError({ errorResponse: error });
+ action$.next(
+ CounterListingActions.loadCountersError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.counterListingError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle counter listing error for refresh', async () => {
+ const { action$, effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = CounterListingActions.counterListingApiError({ errorResponse: error });
+ action$.next(
+ CounterListingActions.loadCountersError({
+ errorResponse: error,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.counterListingError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
expect(result).toEqual(errorAction);
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.effects.ts
index 0bf5f254c9..8587e7e9d0 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.effects.ts
@@ -28,7 +28,8 @@ import { SMALL_DIALOG, YesNoDialog } from '@nifi/shared';
import * as ErrorActions from '../../../../state/error/error.actions';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { HttpErrorResponse } from '@angular/common/http';
-import { selectStatus } from './counter-listing.selectors';
+import { selectLoadedTimestamp } from './counter-listing.selectors';
+import { initialState } from './counter-listing.reducer';
@Injectable()
export class CounterListingEffects {
@@ -41,8 +42,8 @@ export class CounterListingEffects {
loadCounters$ = createEffect(() =>
this.actions$.pipe(
ofType(CounterListingActions.loadCounters),
- concatLatestFrom(() => this.store.select(selectStatus)),
- switchMap(([, status]) =>
+ concatLatestFrom(() => this.store.select(selectLoadedTimestamp)),
+ switchMap(([, loadedTimestamp]) =>
from(this.countersService.getCounters()).pipe(
map((response) =>
CounterListingActions.loadCountersSuccess({
@@ -53,13 +54,31 @@ export class CounterListingEffects {
})
),
catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
+ of(
+ CounterListingActions.loadCountersError({
+ errorResponse,
+ loadedTimestamp,
+ status: loadedTimestamp !== initialState.loadedTimestamp ? 'success' : 'pending'
+ })
+ )
)
)
)
)
);
+ counterListingError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(CounterListingActions.loadCountersError),
+ map((action) =>
+ this.errorHelper.handleLoadingError(
+ action.loadedTimestamp !== initialState.loadedTimestamp,
+ action.errorResponse
+ )
+ )
+ )
+ );
+
promptCounterReset$ = createEffect(
() =>
this.actions$.pipe(
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.reducer.ts
index 15c0d85507..b853be1b77 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.reducer.ts
@@ -19,6 +19,7 @@ import { CounterListingState } from './index';
import { createReducer, on } from '@ngrx/store';
import {
counterListingApiError,
+ loadCountersError,
loadCounters,
loadCountersSuccess,
resetAllCountersSuccess,
@@ -50,6 +51,10 @@ export const counterListingReducer = createReducer(
...state,
saving: false
})),
+ on(loadCountersError, (state, { status }) => ({
+ ...state,
+ status
+ })),
on(resetCounterSuccess, (state, { response }) => {
return produce(state, (draftState) => {
const index: number = draftState.counters.findIndex((c: any) => c.id === response.counter.id);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.selectors.ts
index f644e7910f..3d7aa21065 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/counters/state/counter-listing/counter-listing.selectors.ts
@@ -24,6 +24,7 @@ export const selectCounterListingState = createSelector(
(state: CountersState) => state[countersFeatureKey]
);
-export const selectCounters = createSelector(selectCounterListingState, (state: CounterListingState) => state.counters);
-
-export const selectStatus = createSelector(selectCounterListingState, (state: CounterListingState) => state.status);
+export const selectLoadedTimestamp = createSelector(
+ selectCounterListingState,
+ (state: CounterListingState) => state.loadedTimestamp
+);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.actions.ts
index 63c80e6f06..6f67620809 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.actions.ts
@@ -34,6 +34,11 @@ export const loadHistorySuccess = createAction(
props<{ response: HistoryEntity }>()
);
+export const loadHistoryError = createAction(
+ `${HISTORY_PREFIX} Load History Error`,
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const resetHistoryState = createAction(`${HISTORY_PREFIX} Reset History State`);
export const clearHistorySelection = createAction(`${HISTORY_PREFIX} Clear Selection`);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.effects.spec.ts
new file mode 100644
index 0000000000..2e255ec7aa
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.effects.spec.ts
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { FlowConfigurationHistoryListingEffects } from './flow-configuration-history-listing.effects';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { initialHistoryState } from './flow-configuration-history-listing.reducer';
+import { of, ReplaySubject, take, throwError } from 'rxjs';
+import { Action } from '@ngrx/store';
+import * as HistoryActions from './flow-configuration-history-listing.actions';
+import { FlowConfigurationHistoryService } from '../../service/flow-configuration-history.service';
+import { MatDialog } from '@angular/material/dialog';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { HttpErrorResponse } from '@angular/common/http';
+import { Router } from '@angular/router';
+import { flowConfigurationHistoryFeatureKey } from '../index';
+import { flowConfigurationHistoryListingFeatureKey } from './index';
+
+describe('FlowConfigurationHistoryListingEffects', () => {
+ interface SetupOptions {
+ historyListingState?: any;
+ }
+
+ const mockHistoryResponse = {
+ history: {
+ actions: [
+ {
+ id: 1,
+ timestamp: '2023-01-01 12:00:00 EST',
+ userIdentity: 'admin',
+ operation: 'Create',
+ componentDetails: {
+ name: 'Test Component',
+ type: 'Processor'
+ },
+ sourceId: 'source-1',
+ canRead: true
+ }
+ ],
+ total: 1,
+ lastRefreshed: '2023-01-01 12:00:00 EST'
+ }
+ };
+
+ let action$: ReplaySubject<Action>;
+
+ async function setup({ historyListingState = initialHistoryState }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ FlowConfigurationHistoryListingEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ [flowConfigurationHistoryFeatureKey]: {
+ [flowConfigurationHistoryListingFeatureKey]: historyListingState || initialHistoryState
+ }
+ }
+ }),
+ {
+ provide: FlowConfigurationHistoryService,
+ useValue: {
+ getHistory: jest.fn(),
+ purgeHistory: jest.fn()
+ }
+ },
+ {
+ provide: MatDialog,
+ useValue: {
+ open: jest.fn()
+ }
+ },
+ {
+ provide: Router,
+ useValue: {
+ navigate: jest.fn()
+ }
+ },
+ {
+ provide: ErrorHelper,
+ useValue: {
+ handleLoadingError: jest.fn(),
+ getErrorString: jest.fn()
+ }
+ }
+ ]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const historyService = TestBed.inject(
+ FlowConfigurationHistoryService
+ ) as jest.Mocked<FlowConfigurationHistoryService>;
+ const errorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+ const effects = TestBed.inject(FlowConfigurationHistoryListingEffects);
+
+ return { store, historyService, errorHelper, effects };
+ }
+
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>(1);
+ });
+
+ afterEach(() => {
+ action$.complete();
+ });
+
+ it('should be created', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('loadHistory', () => {
+ it('should load history successfully', async () => {
+ const { effects, historyService } = await setup();
+
+ const request = { count: 50, offset: 0 };
+ action$.next(HistoryActions.loadHistory({ request }));
+ jest.spyOn(historyService, 'getHistory').mockReturnValueOnce(of(mockHistoryResponse) as never);
+
+ const result = await new Promise((resolve) => effects.loadHistory$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ HistoryActions.loadHistorySuccess({
+ response: mockHistoryResponse
+ })
+ );
+ });
+
+ it('should fail to load history on initial load with hasExistingData=false', async () => {
+ const { effects, historyService } = await setup();
+
+ const request = { count: 50, offset: 0 };
+ action$.next(HistoryActions.loadHistory({ request }));
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(historyService, 'getHistory').mockImplementationOnce(() => throwError(() => error));
+
+ const result = await new Promise((resolve) => effects.loadHistory$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ HistoryActions.loadHistoryError({
+ errorResponse: error,
+ loadedTimestamp: initialHistoryState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load history on refresh with hasExistingData=true', async () => {
+ const stateWithData = {
+ ...initialHistoryState,
+ loadedTimestamp: '2023-01-01 11:00:00 EST'
+ };
+ const { effects, historyService } = await setup({ historyListingState: stateWithData });
+
+ const request = { count: 50, offset: 0 };
+ action$.next(HistoryActions.loadHistory({ request }));
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(historyService, 'getHistory').mockImplementationOnce(() => throwError(() => error));
+
+ const result = await new Promise((resolve) => effects.loadHistory$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ HistoryActions.loadHistoryError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+ });
+
+ describe('Load History Error', () => {
+ it('should handle history error for initial load', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = HistoryActions.flowConfigurationHistorySnackbarError({ errorResponse: error });
+ action$.next(
+ HistoryActions.loadHistoryError({
+ errorResponse: error,
+ loadedTimestamp: initialHistoryState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) => effects.loadHistoryError$.pipe(take(1)).subscribe(resolve));
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle history error for refresh', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = HistoryActions.flowConfigurationHistorySnackbarError({ errorResponse: error });
+ action$.next(
+ HistoryActions.loadHistoryError({
+ errorResponse: error,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) => effects.loadHistoryError$.pipe(take(1)).subscribe(resolve));
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.effects.ts
index 14f8976609..35b0bf1acf 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.effects.ts
@@ -21,10 +21,11 @@ import { concatLatestFrom } from '@ngrx/operators';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../../state';
import { ErrorHelper } from '../../../../service/error-helper.service';
+import { initialHistoryState } from './flow-configuration-history-listing.reducer';
import { MatDialog } from '@angular/material/dialog';
import * as HistoryActions from './flow-configuration-history-listing.actions';
import { catchError, from, map, of, switchMap, take, tap } from 'rxjs';
-import { selectHistoryQuery, selectHistoryStatus } from './flow-configuration-history-listing.selectors';
+import { selectHistoryLoadedTimestamp, selectHistoryQuery } from './flow-configuration-history-listing.selectors';
import { FlowConfigurationHistoryService } from '../../service/flow-configuration-history.service';
import { HistoryEntity } from './index';
import { HttpErrorResponse } from '@angular/common/http';
@@ -48,8 +49,8 @@ export class FlowConfigurationHistoryListingEffects {
this.actions$.pipe(
ofType(HistoryActions.loadHistory),
map((action) => action.request),
- concatLatestFrom(() => this.store.select(selectHistoryStatus)),
- switchMap(([request, status]) =>
+ concatLatestFrom(() => this.store.select(selectHistoryLoadedTimestamp)),
+ switchMap(([request, loadedTimestamp]) =>
from(this.historyService.getHistory(request)).pipe(
map((response: HistoryEntity) =>
HistoryActions.loadHistorySuccess({
@@ -57,13 +58,31 @@ export class FlowConfigurationHistoryListingEffects {
})
),
catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
+ of(
+ HistoryActions.loadHistoryError({
+ errorResponse,
+ loadedTimestamp,
+ status: loadedTimestamp !== initialHistoryState.loadedTimestamp ? 'success' : 'pending'
+ })
+ )
)
)
)
)
);
+ loadHistoryError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(HistoryActions.loadHistoryError),
+ map((action) =>
+ this.errorHelper.handleLoadingError(
+ action.loadedTimestamp !== initialHistoryState.loadedTimestamp,
+ action.errorResponse
+ )
+ )
+ )
+ );
+
flowConfigurationHistorySnackbarError = createEffect(() =>
this.actions$.pipe(
ofType(HistoryActions.flowConfigurationHistorySnackbarError),
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.reducer.ts
index 09025ba471..fa11eb7872 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-configuration-history/state/flow-configuration-history-listing/flow-configuration-history-listing.reducer.ts
@@ -22,6 +22,7 @@ import {
flowConfigurationHistorySnackbarError,
loadHistory,
loadHistorySuccess,
+ loadHistoryError,
purgeHistory,
purgeHistorySuccess,
resetHistoryState,
@@ -55,6 +56,11 @@ export const flowConfigurationHistoryListingReducer = createReducer(
total: response.history.total
})),
+ on(loadHistoryError, (state, { status }) => ({
+ ...state,
+ status
+ })),
+
on(resetHistoryState, () => ({
...initialHistoryState
})),
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.actions.ts
index c920c92764..a966e1c571 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.actions.ts
@@ -46,6 +46,11 @@ export const loadControllerServicesSuccess = createAction(
props<{ response: LoadControllerServicesResponse }>()
);
+export const loadControllerServicesError = createAction(
+ '[Controller Services] Load Controller Services Error',
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const controllerServicesBannerApiError = createAction(
'[Controller Services] Controller Services Banner Api Error',
props<{ error: string }>()
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.spec.ts
new file mode 100644
index 0000000000..f251b7c21e
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.spec.ts
@@ -0,0 +1,224 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { Action } from '@ngrx/store';
+import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { HttpErrorResponse } from '@angular/common/http';
+
+import * as ControllerServicesActions from './controller-services.actions';
+import { ControllerServicesEffects } from './controller-services.effects';
+import { ControllerServiceService } from '../../service/controller-service.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { MatDialog } from '@angular/material/dialog';
+import { Router } from '@angular/router';
+import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
+import { ParameterHelperService } from '../../service/parameter-helper.service';
+import { ExtensionTypesService } from '../../../../service/extension-types.service';
+import { Client } from '../../../../service/client.service';
+import { Storage } from '@nifi/shared';
+import { ParameterContextService } from '../../../parameter-contexts/service/parameter-contexts.service';
+import { initialState } from './controller-services.reducer';
+
+describe('ControllerServicesEffects', () => {
+ interface SetupOptions {
+ controllerServicesState?: any;
+ }
+
+ const mockControllerServicesResponse = {
+ controllerServices: [],
+ currentTime: '2023-01-01 12:00:00 EST'
+ };
+ const mockFlowResponse = {
+ processGroupFlow: {
+ id: 'pg-1',
+ breadcrumb: { id: 'pg-1', name: 'PG', permissions: { canRead: true }, versionedFlowState: null },
+ parameterContext: null
+ }
+ } as any;
+
+ async function setup({ controllerServicesState = initialState }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ ControllerServicesEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ canvas: {
+ controllerServiceListing: controllerServicesState
+ }
+ }
+ }),
+ {
+ provide: ControllerServiceService,
+ useValue: { getControllerServices: jest.fn(), getFlow: jest.fn() }
+ },
+ { provide: ErrorHelper, useValue: { handleLoadingError: jest.fn(), getErrorString: jest.fn() } },
+ { provide: MatDialog, useValue: { open: jest.fn(), closeAll: jest.fn() } },
+ { provide: Router, useValue: { navigate: jest.fn() } },
+ { provide: PropertyTableHelperService, useValue: {} },
+ { provide: ParameterHelperService, useValue: {} },
+ { provide: ExtensionTypesService, useValue: {} },
+ { provide: Client, useValue: {} },
+ { provide: Storage, useValue: { setItem: jest.fn(), getItem: jest.fn() } },
+ { provide: ParameterContextService, useValue: { getParameterContext: jest.fn() } }
+ ]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const effects = TestBed.inject(ControllerServicesEffects);
+ const controllerServiceService = TestBed.inject(ControllerServiceService);
+ const errorHelper = TestBed.inject(ErrorHelper);
+
+ return { effects, store, controllerServiceService, errorHelper };
+ }
+
+ let action$: ReplaySubject<Action>;
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>();
+ });
+
+ it('should create', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('Load Controller Services', () => {
+ it('should load controller services successfully', async () => {
+ const { effects, controllerServiceService } = await setup();
+
+ action$.next(ControllerServicesActions.loadControllerServices({ request: { processGroupId: 'pg-1' } }));
+ jest.spyOn(controllerServiceService, 'getControllerServices').mockReturnValueOnce(
+ of(mockControllerServicesResponse) as never
+ );
+ jest.spyOn(controllerServiceService, 'getFlow').mockReturnValueOnce(of(mockFlowResponse) as never);
+
+ const result = await new Promise((resolve) =>
+ effects.loadControllerServices$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ ControllerServicesActions.loadControllerServicesSuccess({
+ response: {
+ processGroupId: mockFlowResponse.processGroupFlow.id,
+ controllerServices: mockControllerServicesResponse.controllerServices,
+ loadedTimestamp: mockControllerServicesResponse.currentTime,
+ breadcrumb: mockFlowResponse.processGroupFlow.breadcrumb,
+ parameterContext: mockFlowResponse.processGroupFlow.parameterContext
+ }
+ })
+ );
+ });
+
+ it('should fail to load controller services on initial load with hasExistingData=false', async () => {
+ const { effects, controllerServiceService } = await setup();
+
+ action$.next(ControllerServicesActions.loadControllerServices({ request: { processGroupId: 'pg-1' } }));
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(controllerServiceService, 'getControllerServices').mockImplementationOnce(() =>
+ throwError(() => error)
+ );
+ jest.spyOn(controllerServiceService, 'getFlow').mockReturnValueOnce(of(mockFlowResponse) as never);
+
+ const result = await new Promise((resolve) =>
+ effects.loadControllerServices$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ ControllerServicesActions.loadControllerServicesError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load controller services on refresh with hasExistingData=true', async () => {
+ const stateWithData = { ...initialState, loadedTimestamp: '2023-01-01 11:00:00 EST' };
+ const { effects, controllerServiceService } = await setup({
+ controllerServicesState: stateWithData
+ });
+
+ action$.next(ControllerServicesActions.loadControllerServices({ request: { processGroupId: 'pg-1' } }));
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(controllerServiceService, 'getControllerServices').mockImplementationOnce(() =>
+ throwError(() => error)
+ );
+ jest.spyOn(controllerServiceService, 'getFlow').mockReturnValueOnce(of(mockFlowResponse) as never);
+
+ const result = await new Promise((resolve) =>
+ effects.loadControllerServices$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ ControllerServicesActions.loadControllerServicesError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+ });
+
+ describe('Controller Services Listing Error', () => {
+ it('should handle controller services listing error for initial load', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = ControllerServicesActions.controllerServicesBannerApiError({ error: 'Error loading' });
+ action$.next(
+ ControllerServicesActions.loadControllerServicesError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.controllerServicesListingError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle controller services listing error for refresh', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = ControllerServicesActions.controllerServicesBannerApiError({ error: 'Error loading' });
+ action$.next(
+ ControllerServicesActions.loadControllerServicesError({
+ errorResponse: error,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.controllerServicesListingError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.ts
index c2a047d1c1..c8ca4aff37 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.ts
@@ -40,9 +40,9 @@ import {
import { Router } from '@angular/router';
import {
selectCurrentProcessGroupId,
+ selectLoadedTimestamp,
selectParameterContext,
- selectSaving,
- selectStatus
+ selectSaving
} from './controller-services.selectors';
import { ControllerServiceService } from '../../service/controller-service.service';
import { EnableControllerService } from '../../../../ui/common/controller-service/enable-controller-service/enable-controller-service.component';
@@ -53,6 +53,7 @@ import { ErrorHelper } from '../../../../service/error-helper.service';
import { HttpErrorResponse } from '@angular/common/http';
import { ParameterHelperService } from '../../service/parameter-helper.service';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
+import { initialState } from './controller-services.reducer';
import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog';
import {
resetPropertyVerificationState,
@@ -87,8 +88,8 @@ export class ControllerServicesEffects {
this.actions$.pipe(
ofType(ControllerServicesActions.loadControllerServices),
map((action) => action.request),
- concatLatestFrom(() => this.store.select(selectStatus)),
- switchMap(([request, status]) =>
+ concatLatestFrom(() => this.store.select(selectLoadedTimestamp)),
+ switchMap(([request, loadedTimestamp]) =>
combineLatest([
this.controllerServiceService.getControllerServices(request.processGroupId),
this.controllerServiceService.getFlow(request.processGroupId)
@@ -105,13 +106,31 @@ export class ControllerServicesEffects {
})
),
catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
+ of(
+ ControllerServicesActions.loadControllerServicesError({
+ errorResponse,
+ loadedTimestamp,
+ status: loadedTimestamp !== initialState.loadedTimestamp ? 'success' : 'pending'
+ })
+ )
)
)
)
)
);
+ controllerServicesListingError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ControllerServicesActions.loadControllerServicesError),
+ map((action) =>
+ this.errorHelper.handleLoadingError(
+ action.loadedTimestamp !== initialState.loadedTimestamp,
+ action.errorResponse
+ )
+ )
+ )
+ );
+
openNewControllerServiceDialog$ = createEffect(
() =>
this.actions$.pipe(
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.reducer.ts
index 179511805a..a88f8ec9fe 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.reducer.ts
@@ -20,6 +20,7 @@ import {
configureControllerService,
configureControllerServiceSuccess,
controllerServicesBannerApiError,
+ loadControllerServicesError,
createControllerService,
createControllerServiceSuccess,
deleteControllerService,
@@ -71,6 +72,10 @@ export const controllerServicesReducer = createReducer(
loadedTimestamp: response.loadedTimestamp,
status: 'success' as const
})),
+ on(loadControllerServicesError, (state, { status }) => ({
+ ...state,
+ status
+ })),
on(controllerServicesBannerApiError, (state) => ({
...state,
saving: false
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.selectors.ts
index 0ce3a1cd33..41d8eba257 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.selectors.ts
@@ -31,9 +31,9 @@ export const selectSaving = createSelector(
(state: ControllerServicesState) => state.saving
);
-export const selectStatus = createSelector(
+export const selectLoadedTimestamp = createSelector(
selectControllerServicesState,
- (state: ControllerServicesState) => state.status
+ (state: ControllerServicesState) => state.loadedTimestamp
);
export const selectCurrentProcessGroupId = createSelector(
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
index d48a391558..2bddcbed21 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
@@ -16,7 +16,7 @@
*/
import { FlowService } from '../../service/flow.service';
import * as FlowActions from './flow.actions';
-import { of, ReplaySubject, take } from 'rxjs';
+import { of, ReplaySubject, take, throwError } from 'rxjs';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { ComponentHistoryEntity } from '../../../../state/shared';
import { EditProcessor } from '../../ui/canvas/items/processor/edit-processor/edit-processor.component';
@@ -59,6 +59,9 @@ import { BirdseyeView } from '../../service/birdseye-view.service';
import { selectDisconnectionAcknowledged } from '../../../../state/cluster-summary/cluster-summary.selectors';
import { ComponentType } from '@nifi/shared';
import { ParameterContextService } from '../../../parameter-contexts/service/parameter-contexts.service';
+import { HttpErrorResponse } from '@angular/common/http';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { selectConnectedStateChanged } from '../../../../state/cluster-summary/cluster-summary.selectors';
describe('FlowEffects', () => {
let action$: ReplaySubject<Action>;
@@ -913,6 +916,66 @@ describe('FlowEffects', () => {
}
});
+ describe('loadProcessGroup error handling', () => {
+ it('dispatches full-screen error on initial load (hasExistingData=false)', async () => {
+ // Arrange selectors
+ store.overrideSelector(flowSelectors.selectHasFlowData, false);
+ store.overrideSelector(selectConnectedStateChanged, false);
+
+ // Arrange service methods
+ (flowService as any).getFlow = jest.fn(() => throwError(() => new HttpErrorResponse({ status: 500 })));
+ (flowService as any).getFlowStatus = jest.fn(() => of({}));
+ (flowService as any).getControllerBulletins = jest.fn(() => of({}));
+ jest.spyOn(TestBed.inject(RegistryService), 'getRegistryClients').mockReturnValueOnce(
+ of({ registries: [] }) as any
+ );
+
+ // Arrange error helper
+ const errorHelper = TestBed.inject(ErrorHelper);
+ const errorAction = FlowActions.flowBannerError({
+ errorContext: { context: 'FLOW', errors: ['e'] } as any
+ });
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction as any);
+
+ // Act
+ action$.next(FlowActions.loadProcessGroup({ request: { id: 'pg-1', transitionRequired: false } }));
+ const result = await new Promise((resolve) => effects.loadProcessGroup$.pipe(take(1)).subscribe(resolve));
+
+ // Assert
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, expect.any(HttpErrorResponse));
+ expect(result).toEqual(errorAction);
+ });
+
+ it('dispatches snackbar/banner error on refresh (hasExistingData=true)', async () => {
+ // Arrange selectors
+ store.overrideSelector(flowSelectors.selectHasFlowData, true);
+ store.overrideSelector(selectConnectedStateChanged, false);
+
+ // Arrange service methods
+ (flowService as any).getFlow = jest.fn(() => throwError(() => new HttpErrorResponse({ status: 500 })));
+ (flowService as any).getFlowStatus = jest.fn(() => of({}));
+ (flowService as any).getControllerBulletins = jest.fn(() => of({}));
+ jest.spyOn(TestBed.inject(RegistryService), 'getRegistryClients').mockReturnValueOnce(
+ of({ registries: [] }) as any
+ );
+
+ // Arrange error helper
+ const errorHelper = TestBed.inject(ErrorHelper);
+ const errorAction = FlowActions.flowBannerError({
+ errorContext: { context: 'FLOW', errors: ['e'] } as any
+ });
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction as any);
+
+ // Act
+ action$.next(FlowActions.loadProcessGroup({ request: { id: 'pg-1', transitionRequired: false } }));
+ const result = await new Promise((resolve) => effects.loadProcessGroup$.pipe(take(1)).subscribe(resolve));
+
+ // Assert
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, expect.any(HttpErrorResponse));
+ expect(result).toEqual(errorAction);
+ });
+ });
+
describe('#moveToFront', () => {
it('calls the flow service to update the component when max index matches request index', async () => {
const REQUEST: MoveToFrontRequest = {
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
index 74f7d049cb..912e0c4019 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
@@ -85,7 +85,7 @@ import {
selectCurrentParameterContext,
selectCurrentProcessGroupId,
selectCurrentProcessGroupRevision,
- selectFlowLoadingStatus,
+ selectHasFlowData,
selectInputPort,
selectMaxZIndex,
selectOutputPort,
@@ -249,11 +249,11 @@ export class FlowEffects {
ofType(FlowActions.loadProcessGroup),
map((action) => action.request),
concatLatestFrom(() => [
- this.store.select(selectFlowLoadingStatus),
+ this.store.select(selectHasFlowData),
this.store.select(selectConnectedStateChanged)
]),
tap(() => this.store.dispatch(resetConnectedStateChanged())),
- switchMap(([request, status, connectedStateChanged]) =>
+ switchMap(([request, hasFlowData, connectedStateChanged]) =>
combineLatest([
this.flowService.getFlow(request.id),
this.flowService.getFlowStatus(),
@@ -274,7 +274,7 @@ export class FlowEffects {
});
}),
catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
+ of(this.errorHelper.handleLoadingError(hasFlowData, errorResponse))
)
)
)
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
index e3d30335b1..18193590c9 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
@@ -24,6 +24,11 @@ export const selectFlowState = createSelector(selectCanvasState, (state: CanvasS
export const selectFlowLoadingStatus = createSelector(selectFlowState, (state: FlowState) => state.status);
+export const selectHasFlowData = createSelector(
+ selectFlowState,
+ (state: FlowState) => state.flow.processGroupFlow.id !== ''
+);
+
export const selectChangeVersionRequest = createSelector(
selectFlowState,
(state: FlowState) => state.changeVersionRequest
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.actions.ts
index a084dc5b2d..9c267c43ef 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.actions.ts
@@ -39,6 +39,11 @@ export const loadRemotePortsSuccess = createAction(
props<{ response: LoadRemotePortsResponse }>()
);
+export const loadRemotePortsError = createAction(
+ '[Manage Remote Ports] Load Remote Ports Error',
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const remotePortsBannerApiError = createAction(
'[Manage Remote Ports] Remote Ports Banner Api Error',
props<{ error: string }>()
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.effects.spec.ts
new file mode 100644
index 0000000000..b556e3e656
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.effects.spec.ts
@@ -0,0 +1,280 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { ManageRemotePortsEffects } from './manage-remote-ports.effects';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { initialState } from './manage-remote-ports.reducer';
+import { of, ReplaySubject, take, throwError } from 'rxjs';
+import { Action } from '@ngrx/store';
+import * as ManageRemotePortsActions from './manage-remote-ports.actions';
+import { ManageRemotePortService } from '../../service/manage-remote-port.service';
+import { MatDialog } from '@angular/material/dialog';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { HttpErrorResponse } from '@angular/common/http';
+import { Router } from '@angular/router';
+import { ClusterConnectionService } from '../../../../service/cluster-connection.service';
+import { remotePortsFeatureKey } from './index';
+
+describe('ManageRemotePortsEffects', () => {
+ interface SetupOptions {
+ remotePortsState?: any;
+ }
+
+ const mockRemotePortsResponse = {
+ component: {
+ contents: {
+ inputPorts: [
+ {
+ id: 'port-1',
+ name: 'Test Input Port',
+ targetId: 'target-1',
+ targetRunning: false,
+ transmitting: false,
+ exists: true,
+ connected: true,
+ batchSettings: {
+ count: 1,
+ size: '1 KB',
+ duration: '0 sec'
+ },
+ comments: '',
+ concurrentlySchedulableTaskCount: 1,
+ groupId: 'rpg-1',
+ useCompression: false,
+ versionedComponentId: 'version-1'
+ }
+ ],
+ outputPorts: [
+ {
+ id: 'port-2',
+ name: 'Test Output Port',
+ targetId: 'target-2',
+ targetRunning: false,
+ transmitting: false,
+ exists: true,
+ connected: true,
+ batchSettings: {
+ count: 1,
+ size: '1 KB',
+ duration: '0 sec'
+ },
+ comments: '',
+ concurrentlySchedulableTaskCount: 1,
+ groupId: 'rpg-1',
+ useCompression: false,
+ versionedComponentId: 'version-1'
+ }
+ ]
+ }
+ },
+ id: 'rpg-1',
+ name: 'Test Remote Process Group'
+ };
+
+ const mockTimeOffset = 0;
+ const mockAbout = {
+ timezone: 'EST'
+ };
+
+ let action$: ReplaySubject<Action>;
+
+ async function setup({ remotePortsState = initialState }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ ManageRemotePortsEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ [remotePortsFeatureKey]: remotePortsState || initialState,
+ flowConfiguration: {
+ flowConfiguration: {
+ timeOffset: mockTimeOffset
+ }
+ },
+ about: {
+ about: mockAbout
+ }
+ }
+ }),
+ {
+ provide: ManageRemotePortService,
+ useValue: {
+ getRemotePorts: jest.fn(),
+ configureRemotePort: jest.fn()
+ }
+ },
+ {
+ provide: MatDialog,
+ useValue: {
+ open: jest.fn()
+ }
+ },
+ {
+ provide: Router,
+ useValue: {
+ navigate: jest.fn()
+ }
+ },
+ {
+ provide: ClusterConnectionService,
+ useValue: {
+ isDisconnectionAcknowledged: jest.fn().mockReturnValue(false)
+ }
+ },
+ {
+ provide: ErrorHelper,
+ useValue: {
+ handleLoadingError: jest.fn(),
+ getErrorString: jest.fn()
+ }
+ }
+ ]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const remotePortService = TestBed.inject(ManageRemotePortService) as jest.Mocked<ManageRemotePortService>;
+ const errorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+ const effects = TestBed.inject(ManageRemotePortsEffects);
+
+ return { store, remotePortService, errorHelper, effects };
+ }
+
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>(1);
+ });
+
+ afterEach(() => {
+ action$.complete();
+ });
+
+ it('should be created', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('loadRemotePorts', () => {
+ it('should load remote ports successfully', async () => {
+ const { effects, remotePortService } = await setup();
+
+ const request = { rpgId: 'rpg-1' };
+ action$.next(ManageRemotePortsActions.loadRemotePorts({ request }));
+ jest.spyOn(remotePortService, 'getRemotePorts').mockReturnValueOnce(of(mockRemotePortsResponse) as never);
+
+ const result = await new Promise((resolve) => effects.loadRemotePorts$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ ManageRemotePortsActions.loadRemotePortsSuccess({
+ response: expect.objectContaining({
+ ports: expect.any(Array),
+ loadedTimestamp: expect.any(String),
+ rpg: expect.any(Object)
+ })
+ })
+ );
+ });
+
+ it('should fail to load remote ports on initial load with hasExistingData=false', async () => {
+ const { effects, remotePortService } = await setup();
+
+ const request = { rpgId: 'rpg-1' };
+ action$.next(ManageRemotePortsActions.loadRemotePorts({ request }));
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(remotePortService, 'getRemotePorts').mockImplementationOnce(() => throwError(() => error));
+
+ const result = await new Promise((resolve) => effects.loadRemotePorts$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ ManageRemotePortsActions.loadRemotePortsError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load remote ports on refresh with hasExistingData=true', async () => {
+ const stateWithData = {
+ ...initialState,
+ loadedTimestamp: '2023-01-01 11:00:00 EST'
+ };
+ const { effects, remotePortService } = await setup({ remotePortsState: stateWithData });
+
+ const request = { rpgId: 'rpg-1' };
+ action$.next(ManageRemotePortsActions.loadRemotePorts({ request }));
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(remotePortService, 'getRemotePorts').mockImplementationOnce(() => throwError(() => error));
+
+ const result = await new Promise((resolve) => effects.loadRemotePorts$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ ManageRemotePortsActions.loadRemotePortsError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+ });
+
+ describe('Load Remote Ports Error', () => {
+ it('should handle remote ports error for initial load', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = ManageRemotePortsActions.remotePortsBannerApiError({ error: 'Error message' });
+ action$.next(
+ ManageRemotePortsActions.loadRemotePortsError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadRemotePortsError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle remote ports error for refresh', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = ManageRemotePortsActions.remotePortsBannerApiError({ error: 'Error message' });
+ action$.next(
+ ManageRemotePortsActions.loadRemotePortsError({
+ errorResponse: error,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadRemotePortsError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.effects.ts
index 963ad1d665..38bf553243 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.effects.ts
@@ -24,9 +24,10 @@ import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../../state';
import { Router } from '@angular/router';
-import { selectRpg, selectRpgIdFromRoute, selectStatus } from './manage-remote-ports.selectors';
+import { selectLoadedTimestamp, selectRpg, selectRpgIdFromRoute } from './manage-remote-ports.selectors';
import * as ErrorActions from '../../../../state/error/error.actions';
import { ErrorHelper } from '../../../../service/error-helper.service';
+import { initialState } from './manage-remote-ports.reducer';
import { HttpErrorResponse } from '@angular/common/http';
import { ManageRemotePortService } from '../../service/manage-remote-port.service';
import { PortSummary } from './index';
@@ -53,11 +54,11 @@ export class ManageRemotePortsEffects {
ofType(ManageRemotePortsActions.loadRemotePorts),
map((action) => action.request),
concatLatestFrom(() => [
- this.store.select(selectStatus),
+ this.store.select(selectLoadedTimestamp),
this.store.select(selectTimeOffset).pipe(isDefinedAndNotNull()),
this.store.select(selectAbout).pipe(isDefinedAndNotNull())
]),
- switchMap(([request, status, timeOffset, about]) => {
+ switchMap(([request, loadedTimestamp, timeOffset, about]) => {
return this.manageRemotePortService.getRemotePorts(request.rpgId).pipe(
map((response) => {
// get the current user time to properly convert the server time
@@ -100,13 +101,31 @@ export class ManageRemotePortsEffects {
});
}),
catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
+ of(
+ ManageRemotePortsActions.loadRemotePortsError({
+ errorResponse,
+ loadedTimestamp,
+ status: loadedTimestamp !== initialState.loadedTimestamp ? 'success' : 'pending'
+ })
+ )
)
);
})
)
);
+ loadRemotePortsError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ManageRemotePortsActions.loadRemotePortsError),
+ map((action) =>
+ this.errorHelper.handleLoadingError(
+ action.loadedTimestamp !== initialState.loadedTimestamp,
+ action.errorResponse
+ )
+ )
+ )
+ );
+
navigateToEditPort$ = createEffect(
() =>
this.actions$.pipe(
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.reducer.ts
index 98b5f244d6..aa8f0dcc7a 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.reducer.ts
@@ -21,6 +21,7 @@ import {
configureRemotePortSuccess,
loadRemotePorts,
loadRemotePortsSuccess,
+ loadRemotePortsError,
remotePortsBannerApiError,
resetRemotePortsState
} from './manage-remote-ports.actions';
@@ -51,6 +52,10 @@ export const manageRemotePortsReducer = createReducer(
rpg: response.rpg,
status: 'success' as const
})),
+ on(loadRemotePortsError, (state, { status }) => ({
+ ...state,
+ status
+ })),
on(remotePortsBannerApiError, (state) => ({
...state,
saving: false
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.selectors.ts
index c58d69054e..cd7e7e6b31 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/manage-remote-ports/manage-remote-ports.selectors.ts
@@ -23,7 +23,10 @@ export const selectRemotePortsState = createFeatureSelector<RemotePortsState>(re
export const selectSaving = createSelector(selectRemotePortsState, (state: RemotePortsState) => state.saving);
-export const selectStatus = createSelector(selectRemotePortsState, (state: RemotePortsState) => state.status);
+export const selectLoadedTimestamp = createSelector(
+ selectRemotePortsState,
+ (state: RemotePortsState) => state.loadedTimestamp
+);
export const selectRpgIdFromRoute = createSelector(selectCurrentRoute, (route) => {
if (route) {
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.actions.ts
index ec70ea82b6..11b50535e1 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.actions.ts
@@ -33,6 +33,11 @@ export const loadParameterContextsSuccess = createAction(
props<{ response: LoadParameterContextsResponse }>()
);
+export const loadParameterContextsError = createAction(
+ '[Parameter Context Listing] Load Parameter Contexts Error',
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const parameterContextListingSnackbarApiError = createAction(
'[Parameter Context Listing] Load Parameter Context Listing Snackbar Api Error',
props<{ error: string }>()
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.spec.ts
index e384df3ab4..2a445559bb 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.spec.ts
@@ -18,7 +18,8 @@
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Action } from '@ngrx/store';
-import { ReplaySubject, of } from 'rxjs';
+import { ReplaySubject, of, throwError } from 'rxjs';
+import { take } from 'rxjs/operators';
import { provideMockStore } from '@ngrx/store/testing';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
@@ -30,11 +31,13 @@ import { ErrorHelper } from '../../../../service/error-helper.service';
import { Storage, NiFiCommon } from '@nifi/shared';
import { initialState } from './parameter-context-listing.reducer';
import { ParameterContextUpdateRequest, ParameterContextUpdateRequestEntity } from '../../../../state/shared';
+import { HttpErrorResponse } from '@angular/common/http';
describe('ParameterContextListingEffects', () => {
interface SetupOptions {
updateRequest?: ParameterContextUpdateRequestEntity | null;
deleteUpdateRequestInitiated?: boolean;
+ listStateOverride?: any;
}
let action$: ReplaySubject<Action>;
@@ -54,24 +57,37 @@ describe('ParameterContextListingEffects', () => {
};
}
- async function setup({ updateRequest = null, deleteUpdateRequestInitiated = false }: SetupOptions = {}) {
+ async function setup({
+ updateRequest = null,
+ deleteUpdateRequestInitiated = false,
+ listStateOverride
+ }: SetupOptions = {}) {
await TestBed.configureTestingModule({
providers: [
ParameterContextListingEffects,
provideMockActions(() => action$),
provideMockStore({
initialState: {
- parameterContextListing: {
- ...initialState,
- updateRequestEntity: updateRequest,
- deleteUpdateRequestInitiated
+ parameterContexts: {
+ parameterContextListing: {
+ ...initialState,
+ ...listStateOverride,
+ updateRequestEntity: updateRequest,
+ deleteUpdateRequestInitiated
+ }
}
}
}),
- { provide: ParameterContextService, useValue: { deleteParameterContextUpdate: jest.fn() } },
+ {
+ provide: ParameterContextService,
+ useValue: { deleteParameterContextUpdate: jest.fn(), getParameterContexts: jest.fn() }
+ },
{ provide: MatDialog, useValue: { open: jest.fn() } },
{ provide: Router, useValue: { navigate: jest.fn() } },
- { provide: ErrorHelper, useValue: { getErrorString: jest.fn() } },
+ {
+ provide: ErrorHelper,
+ useValue: { getErrorString: jest.fn(), handleLoadingError: jest.fn(), fullScreenError: jest.fn() }
+ },
{ provide: Storage, useValue: { setItem: jest.fn() } },
{ provide: NiFiCommon, useValue: { stripProtocol: jest.fn() } }
]
@@ -81,7 +97,8 @@ describe('ParameterContextListingEffects', () => {
const parameterContextService = TestBed.inject(ParameterContextService) as jest.Mocked<ParameterContextService>;
action$ = new ReplaySubject<Action>();
- return { effects, parameterContextService };
+ const errorHelper = TestBed.inject(ErrorHelper);
+ return { effects, parameterContextService, errorHelper };
}
beforeEach(() => {
@@ -99,6 +116,125 @@ describe('ParameterContextListingEffects', () => {
expect(effects).toBeTruthy();
});
+ describe('loadParameterContexts$', () => {
+ it('loads successfully', async () => {
+ const { effects, parameterContextService } = await setup();
+
+ action$.next(ParameterContextListingActions.loadParameterContexts());
+
+ (parameterContextService.getParameterContexts as jest.Mock).mockReturnValueOnce(
+ of({ parameterContexts: [], currentTime: 't' })
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadParameterContexts$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ ParameterContextListingActions.loadParameterContextsSuccess({
+ response: { parameterContexts: [], loadedTimestamp: 't' }
+ })
+ );
+ });
+
+ it('errors on initial load (hasExistingData=false)', async () => {
+ const { effects, parameterContextService } = await setup();
+
+ action$.next(ParameterContextListingActions.loadParameterContexts());
+
+ const error = new HttpErrorResponse({ status: 500 });
+ (parameterContextService.getParameterContexts as jest.Mock).mockImplementationOnce(() =>
+ throwError(() => error)
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadParameterContexts$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ ParameterContextListingActions.loadParameterContextsError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('errors on refresh (hasExistingData=true)', async () => {
+ const stateWithData = { loadedTimestamp: 'prev' };
+ const { effects, parameterContextService } = await setup({ listStateOverride: stateWithData });
+
+ action$.next(ParameterContextListingActions.loadParameterContexts());
+
+ const error = new HttpErrorResponse({ status: 500 });
+ (parameterContextService.getParameterContexts as jest.Mock).mockImplementationOnce(() =>
+ throwError(() => error)
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadParameterContexts$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ ParameterContextListingActions.loadParameterContextsError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+ });
+
+ describe('loadParameterContextsError$', () => {
+ it('should handle parameter contexts error for initial load', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = ParameterContextListingActions.parameterContextListingBannerApiError({
+ error: 'Error message'
+ });
+ action$.next(
+ ParameterContextListingActions.loadParameterContextsError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadParameterContextsError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle parameter contexts error for refresh', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = ParameterContextListingActions.parameterContextListingBannerApiError({
+ error: 'Error message'
+ });
+ action$.next(
+ ParameterContextListingActions.loadParameterContextsError({
+ errorResponse: error,
+ loadedTimestamp: 'prev',
+ status: 'success'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadParameterContextsError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
+ expect(result).toEqual(errorAction);
+ });
+ });
+
describe('stopPollingParameterContextUpdateRequest$', () => {
it('should dispatch deleteParameterContextUpdateRequest when triggered', async () => {
const { effects } = await setup();
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.ts
index 1ca35bf3e5..e2eb5de8e2 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.ts
@@ -41,8 +41,8 @@ import { Router } from '@angular/router';
import { ParameterContextService } from '../../service/parameter-contexts.service';
import { Parameter, YesNoDialog } from '@nifi/shared';
import {
+ selectParameterContextLoadedTimestamp,
selectParameterContexts,
- selectParameterContextStatus,
selectSaving,
selectUpdateRequest,
selectDeleteUpdateRequestInitiated
@@ -52,6 +52,7 @@ import { EditParameterDialog } from '../../../../ui/common/edit-parameter-dialog
import { OkDialog } from '../../../../ui/common/ok-dialog/ok-dialog.component';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { HttpErrorResponse } from '@angular/common/http';
+import { initialState } from './parameter-context-listing.reducer';
import { BackNavigation } from '../../../../state/navigation';
import { isDefinedAndNotNull, MEDIUM_DIALOG, SMALL_DIALOG, XL_DIALOG, NiFiCommon, Storage } from '@nifi/shared';
import { ErrorContextKey } from '../../../../state/error';
@@ -70,8 +71,8 @@ export class ParameterContextListingEffects {
loadParameterContexts$ = createEffect(() =>
this.actions$.pipe(
ofType(ParameterContextListingActions.loadParameterContexts),
- concatLatestFrom(() => this.store.select(selectParameterContextStatus)),
- switchMap(([, status]) =>
+ concatLatestFrom(() => this.store.select(selectParameterContextLoadedTimestamp)),
+ switchMap(([, loadedTimestamp]) =>
from(this.parameterContextService.getParameterContexts()).pipe(
map((response) =>
ParameterContextListingActions.loadParameterContextsSuccess({
@@ -82,13 +83,31 @@ export class ParameterContextListingEffects {
})
),
catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
+ of(
+ ParameterContextListingActions.loadParameterContextsError({
+ errorResponse,
+ loadedTimestamp,
+ status: loadedTimestamp !== initialState.loadedTimestamp ? 'success' : 'pending'
+ })
+ )
)
)
)
)
);
+ loadParameterContextsError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ParameterContextListingActions.loadParameterContextsError),
+ map((action) =>
+ this.errorHelper.handleLoadingError(
+ action.loadedTimestamp !== initialState.loadedTimestamp,
+ action.errorResponse
+ )
+ )
+ )
+ );
+
openNewParameterContextDialog$ = createEffect(
() =>
this.actions$.pipe(
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.reducer.ts
index 21d9e67997..1b87cdda99 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.reducer.ts
@@ -25,6 +25,7 @@ import {
editParameterContextComplete,
loadParameterContexts,
loadParameterContextsSuccess,
+ loadParameterContextsError,
parameterContextListingSnackbarApiError,
parameterContextListingBannerApiError,
pollParameterContextUpdateRequestSuccess,
@@ -58,6 +59,10 @@ export const parameterContextListingReducer = createReducer(
error: null,
status: 'success' as const
})),
+ on(loadParameterContextsError, (state, { status }) => ({
+ ...state,
+ status
+ })),
on(parameterContextListingSnackbarApiError, parameterContextListingBannerApiError, (state) => ({
...state,
saving: false
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.selectors.ts
index 755dafe58e..c7129487c2 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.selectors.ts
@@ -55,9 +55,9 @@ export const selectParameterContexts = createSelector(
(state: ParameterContextListingState) => state.parameterContexts
);
-export const selectParameterContextStatus = createSelector(
+export const selectParameterContextLoadedTimestamp = createSelector(
selectParameterContextListingState,
- (state: ParameterContextListingState) => state.status
+ (state: ParameterContextListingState) => state.loadedTimestamp
);
export const selectContext = (id: string) =>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.actions.ts
index 11c9dc2778..6046cdeb94 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.actions.ts
@@ -42,6 +42,11 @@ export const loadFlowAnalysisRulesSuccess = createAction(
props<{ response: LoadFlowAnalysisRulesResponse }>()
);
+export const loadFlowAnalysisRulesError = createAction(
+ '[Flow Analysis Rules] Load Flow Analysis Rules Error',
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const openConfigureFlowAnalysisRuleDialog = createAction(
'[Flow Analysis Rules] Open Flow Analysis Rule Dialog',
props<{ request: EditFlowAnalysisRuleDialogRequest }>()
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.spec.ts
new file mode 100644
index 0000000000..f57c5d79c7
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.spec.ts
@@ -0,0 +1,202 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { Action } from '@ngrx/store';
+import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { HttpErrorResponse } from '@angular/common/http';
+
+import * as FlowAnalysisRuleActions from './flow-analysis-rules.actions';
+import { FlowAnalysisRulesEffects } from './flow-analysis-rules.effects';
+import { FlowAnalysisRuleService } from '../../service/flow-analysis-rule.service';
+import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { MatDialog } from '@angular/material/dialog';
+import { Router } from '@angular/router';
+import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
+import { ExtensionTypesService } from '../../../../service/extension-types.service';
+import { initialState } from './flow-analysis-rules.reducer';
+import { settingsFeatureKey } from '../index';
+import { flowAnalysisRulesFeatureKey } from './index';
+
+describe('FlowAnalysisRulesEffects', () => {
+ interface SetupOptions {
+ flowAnalysisRulesState?: any;
+ }
+
+ const mockResponse = {
+ flowAnalysisRules: [],
+ currentTime: '2023-01-01 12:00:00 EST'
+ };
+
+ async function setup({ flowAnalysisRulesState = initialState }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ FlowAnalysisRulesEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ [settingsFeatureKey]: {
+ [flowAnalysisRulesFeatureKey]: flowAnalysisRulesState
+ }
+ }
+ }),
+ { provide: FlowAnalysisRuleService, useValue: { getFlowAnalysisRule: jest.fn() } },
+ { provide: ManagementControllerServiceService, useValue: {} },
+ { provide: ErrorHelper, useValue: { handleLoadingError: jest.fn() } },
+ { provide: MatDialog, useValue: { open: jest.fn(), closeAll: jest.fn() } },
+ { provide: Router, useValue: { navigate: jest.fn() } },
+ { provide: PropertyTableHelperService, useValue: {} },
+ { provide: ExtensionTypesService, useValue: {} }
+ ]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const effects = TestBed.inject(FlowAnalysisRulesEffects);
+ const flowAnalysisRuleService = TestBed.inject(FlowAnalysisRuleService);
+ const errorHelper = TestBed.inject(ErrorHelper);
+
+ return { effects, store, flowAnalysisRuleService, errorHelper };
+ }
+
+ let action$: ReplaySubject<Action>;
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>();
+ });
+
+ it('should create', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('Load Flow Analysis Rules', () => {
+ it('should load flow analysis rules successfully', async () => {
+ const { effects, flowAnalysisRuleService } = await setup();
+
+ action$.next(FlowAnalysisRuleActions.loadFlowAnalysisRules());
+ jest.spyOn(flowAnalysisRuleService, 'getFlowAnalysisRule').mockReturnValueOnce(of(mockResponse) as never);
+
+ const result = await new Promise((resolve) =>
+ effects.loadFlowAnalysisRule$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ FlowAnalysisRuleActions.loadFlowAnalysisRulesSuccess({
+ response: {
+ flowAnalysisRules: mockResponse.flowAnalysisRules,
+ loadedTimestamp: mockResponse.currentTime
+ }
+ })
+ );
+ });
+
+ it('should fail to load flow analysis rules on initial load with hasExistingData=false', async () => {
+ const { effects, flowAnalysisRuleService } = await setup();
+
+ action$.next(FlowAnalysisRuleActions.loadFlowAnalysisRules());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(flowAnalysisRuleService, 'getFlowAnalysisRule').mockImplementationOnce(() =>
+ throwError(() => error)
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadFlowAnalysisRule$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ FlowAnalysisRuleActions.loadFlowAnalysisRulesError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load flow analysis rules on refresh with hasExistingData=true', async () => {
+ const stateWithData = { ...initialState, loadedTimestamp: '2023-01-01 11:00:00 EST' };
+ const { effects, flowAnalysisRuleService } = await setup({
+ flowAnalysisRulesState: stateWithData
+ });
+
+ action$.next(FlowAnalysisRuleActions.loadFlowAnalysisRules());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(flowAnalysisRuleService, 'getFlowAnalysisRule').mockImplementationOnce(() =>
+ throwError(() => error)
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadFlowAnalysisRule$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ FlowAnalysisRuleActions.loadFlowAnalysisRulesError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+ });
+
+ describe('Load Flow Analysis Rules Error', () => {
+ it('should handle flow analysis rules error for initial load', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = FlowAnalysisRuleActions.flowAnalysisRuleBannerApiError({ error: 'Error loading' });
+ action$.next(
+ FlowAnalysisRuleActions.loadFlowAnalysisRulesError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadFlowAnalysisRulesError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle flow analysis rules error for refresh', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = FlowAnalysisRuleActions.flowAnalysisRuleBannerApiError({ error: 'Error loading' });
+ action$.next(
+ FlowAnalysisRuleActions.loadFlowAnalysisRulesError({
+ errorResponse: error,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadFlowAnalysisRulesError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.ts
index e3349fb88e..04e317778a 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.ts
@@ -39,7 +39,8 @@ import {
import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
import * as ErrorActions from '../../../../state/error/error.actions';
import { ErrorHelper } from '../../../../service/error-helper.service';
-import { selectStatus } from './flow-analysis-rules.selectors';
+import { selectLoadedTimestamp } from './flow-analysis-rules.selectors';
+import { initialState } from './flow-analysis-rules.reducer';
import { HttpErrorResponse } from '@angular/common/http';
import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
@@ -70,8 +71,8 @@ export class FlowAnalysisRulesEffects {
loadFlowAnalysisRule$ = createEffect(() =>
this.actions$.pipe(
ofType(FlowAnalysisRuleActions.loadFlowAnalysisRules),
- concatLatestFrom(() => this.store.select(selectStatus)),
- switchMap(([, status]) =>
+ concatLatestFrom(() => this.store.select(selectLoadedTimestamp)),
+ switchMap(([, loadedTimestamp]) =>
from(this.flowAnalysisRuleService.getFlowAnalysisRule()).pipe(
map((response) =>
FlowAnalysisRuleActions.loadFlowAnalysisRulesSuccess({
@@ -82,13 +83,31 @@ export class FlowAnalysisRulesEffects {
})
),
catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
+ of(
+ FlowAnalysisRuleActions.loadFlowAnalysisRulesError({
+ errorResponse,
+ loadedTimestamp,
+ status: loadedTimestamp !== initialState.loadedTimestamp ? 'success' : 'pending'
+ })
+ )
)
)
)
)
);
+ loadFlowAnalysisRulesError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(FlowAnalysisRuleActions.loadFlowAnalysisRulesError),
+ map((action) =>
+ this.errorHelper.handleLoadingError(
+ action.loadedTimestamp !== initialState.loadedTimestamp,
+ action.errorResponse
+ )
+ )
+ )
+ );
+
openNewFlowAnalysisRuleDialog$ = createEffect(
() =>
this.actions$.pipe(
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.reducer.ts
index d558869999..0ef9563918 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.reducer.ts
@@ -26,6 +26,7 @@ import {
deleteFlowAnalysisRuleSuccess,
loadFlowAnalysisRules,
loadFlowAnalysisRulesSuccess,
+ loadFlowAnalysisRulesError,
flowAnalysisRuleBannerApiError,
resetFlowAnalysisRulesState,
disableFlowAnalysisRule,
@@ -58,6 +59,10 @@ export const flowAnalysisRulesReducer = createReducer(
loadedTimestamp: response.loadedTimestamp,
status: 'success' as const
})),
+ on(loadFlowAnalysisRulesError, (state, { status }) => ({
+ ...state,
+ status
+ })),
on(flowAnalysisRuleBannerApiError, flowAnalysisRuleSnackbarApiError, (state) => ({
...state,
saving: false
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.selectors.ts
index c26bf32830..5f5850bd52 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.selectors.ts
@@ -30,9 +30,9 @@ export const selectSaving = createSelector(
(state: FlowAnalysisRulesState) => state.saving
);
-export const selectStatus = createSelector(
+export const selectLoadedTimestamp = createSelector(
selectFlowAnalysisRulesState,
- (state: FlowAnalysisRulesState) => state.status
+ (state: FlowAnalysisRulesState) => state.loadedTimestamp
);
export const selectFlowAnalysisRuleIdFromRoute = createSelector(selectCurrentRoute, (route) => {
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/general/general.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/general/general.effects.spec.ts
new file mode 100644
index 0000000000..b2e60c1874
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/general/general.effects.spec.ts
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { provideMockStore } from '@ngrx/store/testing';
+import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { Action } from '@ngrx/store';
+
+import * as GeneralActions from './general.actions';
+import { GeneralEffects } from './general.effects';
+import { ControllerService } from '../../service/controller.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { HttpErrorResponse } from '@angular/common/http';
+
+describe('GeneralEffects', () => {
+ interface SetupOptions {
+ settingsState?: any;
+ }
+
+ async function setup({ settingsState = { general: {} } }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ GeneralEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ settings: settingsState
+ }
+ }),
+ {
+ provide: ControllerService,
+ useValue: {
+ getControllerConfig: jest.fn(),
+ updateControllerConfig: jest.fn()
+ }
+ },
+ {
+ provide: ErrorHelper,
+ useValue: {
+ fullScreenError: jest.fn(),
+ getErrorString: jest.fn()
+ }
+ }
+ ]
+ }).compileComponents();
+
+ const effects = TestBed.inject(GeneralEffects);
+ const controllerService = TestBed.inject(ControllerService);
+ const errorHelper = TestBed.inject(ErrorHelper);
+
+ return { effects, controllerService, errorHelper };
+ }
+
+ let action$: ReplaySubject<Action>;
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>();
+ });
+
+ it('should create', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('loadControllerConfig', () => {
+ it('loads successfully', async () => {
+ const { effects, controllerService } = await setup();
+
+ action$.next(GeneralActions.loadControllerConfig());
+
+ const controllerEntity = {
+ revision: { version: 1, clientId: 'c1' },
+ component: { maxTimerDrivenThreadCount: 10 }
+ } as any;
+ jest.spyOn(controllerService, 'getControllerConfig').mockReturnValueOnce(of(controllerEntity) as never);
+
+ const result = await new Promise((resolve) =>
+ effects.loadControllerConfig$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ GeneralActions.loadControllerConfigSuccess({
+ response: {
+ controller: controllerEntity
+ }
+ })
+ );
+ });
+
+ it('handles error with fullScreenError', async () => {
+ const { effects, controllerService, errorHelper } = await setup();
+
+ action$.next(GeneralActions.loadControllerConfig());
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = GeneralActions.controllerConfigApiError({ error: 'e' });
+
+ jest.spyOn(controllerService, 'getControllerConfig').mockImplementationOnce(() => throwError(() => error));
+ jest.spyOn(errorHelper, 'fullScreenError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadControllerConfig$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.fullScreenError).toHaveBeenCalledWith(error);
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/general/general.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/general/general.effects.ts
index 09d6503b25..6b6f4066bb 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/general/general.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/general/general.effects.ts
@@ -17,7 +17,6 @@
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
-import { concatLatestFrom } from '@ngrx/operators';
import * as GeneralActions from './general.actions';
import * as ErrorActions from '../../../../state/error/error.actions';
import { catchError, from, map, of, switchMap, tap } from 'rxjs';
@@ -25,16 +24,12 @@ import { ControllerService } from '../../service/controller.service';
import { MatDialog } from '@angular/material/dialog';
import { OkDialog } from '../../../../ui/common/ok-dialog/ok-dialog.component';
import { ErrorHelper } from '../../../../service/error-helper.service';
-import { selectStatus } from './general.selectors';
-import { NiFiState } from '../../../../state';
-import { Store } from '@ngrx/store';
import { HttpErrorResponse } from '@angular/common/http';
import { SMALL_DIALOG } from '@nifi/shared';
@Injectable()
export class GeneralEffects {
private actions$ = inject(Actions);
- private store = inject<Store<NiFiState>>(Store);
private controllerService = inject(ControllerService);
private errorHelper = inject(ErrorHelper);
private dialog = inject(MatDialog);
@@ -42,8 +37,7 @@ export class GeneralEffects {
loadControllerConfig$ = createEffect(() =>
this.actions$.pipe(
ofType(GeneralActions.loadControllerConfig),
- concatLatestFrom(() => this.store.select(selectStatus)),
- switchMap(([, status]) =>
+ switchMap(() =>
from(this.controllerService.getControllerConfig()).pipe(
map((response) =>
GeneralActions.loadControllerConfigSuccess({
@@ -53,7 +47,7 @@ export class GeneralEffects {
})
),
catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
+ of(this.errorHelper.fullScreenError(errorResponse))
)
)
)
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/general/general.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/general/general.selectors.ts
index 01b30d26e8..7c25d199bf 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/general/general.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/general/general.selectors.ts
@@ -22,5 +22,3 @@ import { generalFeatureKey, GeneralState } from './index';
export const selectGeneral = createSelector(selectSettingsState, (state: SettingsState) => state[generalFeatureKey]);
export const selectSaving = createSelector(selectGeneral, (state: GeneralState) => state.saving);
-
-export const selectStatus = createSelector(selectGeneral, (state: GeneralState) => state.status);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.actions.ts
index 9e470b13d6..f9a5e76528 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.actions.ts
@@ -46,6 +46,11 @@ export const loadManagementControllerServicesSuccess = createAction(
props<{ response: LoadManagementControllerServicesResponse }>()
);
+export const loadManagementControllerServicesError = createAction(
+ '[Management Controller Services] Load Management Controller Services Error',
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const managementControllerServicesBannerApiError = createAction(
'[Management Controller Services] Management Controller Services Banner Api Error',
props<{ error: string }>()
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.spec.ts
new file mode 100644
index 0000000000..e052cdfca7
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.spec.ts
@@ -0,0 +1,206 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { Action } from '@ngrx/store';
+import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { HttpErrorResponse } from '@angular/common/http';
+
+import * as ManagementControllerServicesActions from './management-controller-services.actions';
+import { ManagementControllerServicesEffects } from './management-controller-services.effects';
+import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { MatDialog } from '@angular/material/dialog';
+import { Router } from '@angular/router';
+import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
+import { Client } from '../../../../service/client.service';
+import { ExtensionTypesService } from '../../../../service/extension-types.service';
+import { initialState } from './management-controller-services.reducer';
+
+describe('ManagementControllerServicesEffects', () => {
+ interface SetupOptions {
+ managementControllerServicesState?: any;
+ }
+
+ const mockResponse = {
+ controllerServices: [],
+ currentTime: '2023-01-01 12:00:00 EST'
+ };
+
+ async function setup({ managementControllerServicesState = initialState }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ ManagementControllerServicesEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ settings: {
+ managementControllerServices: managementControllerServicesState
+ }
+ }
+ }),
+ { provide: ManagementControllerServiceService, useValue: { getControllerServices: jest.fn() } },
+ { provide: ErrorHelper, useValue: { handleLoadingError: jest.fn() } },
+ { provide: MatDialog, useValue: { open: jest.fn(), closeAll: jest.fn() } },
+ { provide: Router, useValue: { navigate: jest.fn() } },
+ { provide: PropertyTableHelperService, useValue: {} },
+ { provide: Client, useValue: {} },
+ { provide: ExtensionTypesService, useValue: {} }
+ ]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const effects = TestBed.inject(ManagementControllerServicesEffects);
+ const managementControllerServiceService = TestBed.inject(ManagementControllerServiceService);
+ const errorHelper = TestBed.inject(ErrorHelper);
+
+ return { effects, store, managementControllerServiceService, errorHelper };
+ }
+
+ let action$: ReplaySubject<Action>;
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>();
+ });
+
+ it('should create', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('Load Management Controller Services', () => {
+ it('should load management controller services successfully', async () => {
+ const { effects, managementControllerServiceService } = await setup();
+
+ action$.next(ManagementControllerServicesActions.loadManagementControllerServices());
+ jest.spyOn(managementControllerServiceService, 'getControllerServices').mockReturnValueOnce(
+ of(mockResponse) as never
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadManagementControllerServices$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ ManagementControllerServicesActions.loadManagementControllerServicesSuccess({
+ response: {
+ controllerServices: mockResponse.controllerServices,
+ loadedTimestamp: mockResponse.currentTime
+ }
+ })
+ );
+ });
+
+ it('should fail to load management controller services on initial load with hasExistingData=false', async () => {
+ const { effects, managementControllerServiceService } = await setup();
+
+ action$.next(ManagementControllerServicesActions.loadManagementControllerServices());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(managementControllerServiceService, 'getControllerServices').mockImplementationOnce(() =>
+ throwError(() => error)
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadManagementControllerServices$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ ManagementControllerServicesActions.loadManagementControllerServicesError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load management controller services on refresh with hasExistingData=true', async () => {
+ const stateWithData = { ...initialState, loadedTimestamp: '2023-01-01 11:00:00 EST' };
+ const { effects, managementControllerServiceService } = await setup({
+ managementControllerServicesState: stateWithData
+ });
+
+ action$.next(ManagementControllerServicesActions.loadManagementControllerServices());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(managementControllerServiceService, 'getControllerServices').mockImplementationOnce(() =>
+ throwError(() => error)
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadManagementControllerServices$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ ManagementControllerServicesActions.loadManagementControllerServicesError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+ });
+
+ describe('Load Management Controller Services Error', () => {
+ it('should handle management controller services error for initial load', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = ManagementControllerServicesActions.managementControllerServicesBannerApiError({
+ error: 'Error message'
+ });
+ action$.next(
+ ManagementControllerServicesActions.loadManagementControllerServicesError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadManagementControllerServicesError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle management controller services error for refresh', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = ManagementControllerServicesActions.managementControllerServicesBannerApiError({
+ error: 'Error message'
+ });
+ action$.next(
+ ManagementControllerServicesActions.loadManagementControllerServicesError({
+ errorResponse: error,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadManagementControllerServicesError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.ts
index c53bdd5a96..a141aec27c 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.ts
@@ -39,13 +39,14 @@ import {
UpdateControllerServiceRequest
} from '../../../../state/shared';
import { Router } from '@angular/router';
-import { selectSaving, selectStatus } from './management-controller-services.selectors';
+import { selectLoadedTimestamp, selectSaving } from './management-controller-services.selectors';
import { EnableControllerService } from '../../../../ui/common/controller-service/enable-controller-service/enable-controller-service.component';
import { DisableControllerService } from '../../../../ui/common/controller-service/disable-controller-service/disable-controller-service.component';
import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { ComponentType, LARGE_DIALOG, SMALL_DIALOG, XL_DIALOG, YesNoDialog } from '@nifi/shared';
+import { initialState } from './management-controller-services.reducer';
import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
import {
@@ -75,8 +76,8 @@ export class ManagementControllerServicesEffects {
loadManagementControllerServices$ = createEffect(() =>
this.actions$.pipe(
ofType(ManagementControllerServicesActions.loadManagementControllerServices),
- concatLatestFrom(() => this.store.select(selectStatus)),
- switchMap(([, status]) =>
+ concatLatestFrom(() => this.store.select(selectLoadedTimestamp)),
+ switchMap(([, loadedTimestamp]) =>
from(this.managementControllerServiceService.getControllerServices()).pipe(
map((response) =>
ManagementControllerServicesActions.loadManagementControllerServicesSuccess({
@@ -87,13 +88,31 @@ export class ManagementControllerServicesEffects {
})
),
catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
+ of(
+ ManagementControllerServicesActions.loadManagementControllerServicesError({
+ errorResponse,
+ loadedTimestamp,
+ status: loadedTimestamp !== initialState.loadedTimestamp ? 'success' : 'pending'
+ })
+ )
)
)
)
)
);
+ loadManagementControllerServicesError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ManagementControllerServicesActions.loadManagementControllerServicesError),
+ map((action) =>
+ this.errorHelper.handleLoadingError(
+ action.loadedTimestamp !== initialState.loadedTimestamp,
+ action.errorResponse
+ )
+ )
+ )
+ );
+
openNewControllerServiceDialog$ = createEffect(
() =>
this.actions$.pipe(
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.reducer.ts
index 8e533504a5..4915195286 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.reducer.ts
@@ -27,6 +27,7 @@ import {
inlineCreateControllerServiceSuccess,
loadManagementControllerServices,
loadManagementControllerServicesSuccess,
+ loadManagementControllerServicesError,
managementControllerServicesBannerApiError,
managementControllerServicesSnackbarApiError,
resetManagementControllerServicesState
@@ -55,6 +56,10 @@ export const managementControllerServicesReducer = createReducer(
loadedTimestamp: response.loadedTimestamp,
status: 'success' as const
})),
+ on(loadManagementControllerServicesError, (state, { status }) => ({
+ ...state,
+ status
+ })),
on(managementControllerServicesBannerApiError, managementControllerServicesSnackbarApiError, (state) => ({
...state,
saving: false
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.selectors.ts
index 0468358c58..c7ac2f26a8 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.selectors.ts
@@ -31,9 +31,9 @@ export const selectSaving = createSelector(
(state: ManagementControllerServicesState) => state.saving
);
-export const selectStatus = createSelector(
+export const selectLoadedTimestamp = createSelector(
selectManagementControllerServicesState,
- (state: ManagementControllerServicesState) => state.status
+ (state: ManagementControllerServicesState) => state.loadedTimestamp
);
export const selectControllerServiceIdFromRoute = createSelector(selectCurrentRoute, (route) => {
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.actions.ts
index 80faf24305..5652317803 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.actions.ts
@@ -43,8 +43,13 @@ export const loadParameterProvidersSuccess = createAction(
props<{ response: LoadParameterProvidersResponse }>()
);
-export const parameterProvidersBannerApiError = createAction(
+export const loadParameterProvidersError = createAction(
`${PARAMETER_PROVIDERS_PREFIX} Load Parameter Providers Error`,
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
+export const parameterProvidersBannerApiError = createAction(
+ `${PARAMETER_PROVIDERS_PREFIX} Parameter Providers Banner Api Error`,
props<{ error: string }>()
);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.spec.ts
new file mode 100644
index 0000000000..ea95c21a85
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.spec.ts
@@ -0,0 +1,204 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { Action } from '@ngrx/store';
+import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { HttpErrorResponse } from '@angular/common/http';
+
+import * as ParameterProviderActions from './parameter-providers.actions';
+import { ParameterProvidersEffects } from './parameter-providers.effects';
+import { ParameterProviderService } from '../../service/parameter-provider.service';
+import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { MatDialog } from '@angular/material/dialog';
+import { Router } from '@angular/router';
+import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
+import { Client } from '../../../../service/client.service';
+import { ExtensionTypesService } from '../../../../service/extension-types.service';
+import { initialParameterProvidersState } from './parameter-providers.reducer';
+
+describe('ParameterProvidersEffects', () => {
+ interface SetupOptions {
+ parameterProvidersState?: any;
+ }
+
+ const mockResponse = {
+ parameterProviders: [],
+ currentTime: '2023-01-01 12:00:00 EST'
+ };
+
+ async function setup({ parameterProvidersState = initialParameterProvidersState }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ ParameterProvidersEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ settings: {
+ parameterProviders: parameterProvidersState
+ }
+ }
+ }),
+ { provide: ParameterProviderService, useValue: { getParameterProviders: jest.fn() } },
+ { provide: ManagementControllerServiceService, useValue: {} },
+ { provide: ErrorHelper, useValue: { handleLoadingError: jest.fn() } },
+ { provide: MatDialog, useValue: { open: jest.fn(), closeAll: jest.fn() } },
+ { provide: Router, useValue: { navigate: jest.fn() } },
+ { provide: PropertyTableHelperService, useValue: {} },
+ { provide: Client, useValue: {} },
+ { provide: ExtensionTypesService, useValue: {} }
+ ]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const effects = TestBed.inject(ParameterProvidersEffects);
+ const parameterProviderService = TestBed.inject(ParameterProviderService);
+ const errorHelper = TestBed.inject(ErrorHelper);
+
+ return { effects, store, parameterProviderService, errorHelper };
+ }
+
+ let action$: ReplaySubject<Action>;
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>();
+ });
+
+ it('should create', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('Load Parameter Providers', () => {
+ it('should load parameter providers successfully', async () => {
+ const { effects, parameterProviderService } = await setup();
+
+ action$.next(ParameterProviderActions.loadParameterProviders());
+ jest.spyOn(parameterProviderService, 'getParameterProviders').mockReturnValueOnce(
+ of(mockResponse) as never
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadParameterProviders$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ ParameterProviderActions.loadParameterProvidersSuccess({
+ response: {
+ parameterProviders: mockResponse.parameterProviders,
+ loadedTimestamp: mockResponse.currentTime
+ }
+ })
+ );
+ });
+
+ it('should fail to load parameter providers on initial load with hasExistingData=false', async () => {
+ const { effects, parameterProviderService } = await setup();
+
+ action$.next(ParameterProviderActions.loadParameterProviders());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(parameterProviderService, 'getParameterProviders').mockImplementationOnce(() =>
+ throwError(() => error)
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadParameterProviders$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ ParameterProviderActions.loadParameterProvidersError({
+ errorResponse: error,
+ loadedTimestamp: initialParameterProvidersState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load parameter providers on refresh with hasExistingData=true', async () => {
+ const stateWithData = { ...initialParameterProvidersState, loadedTimestamp: '2023-01-01 11:00:00 EST' };
+ const { effects, parameterProviderService } = await setup({
+ parameterProvidersState: stateWithData
+ });
+
+ action$.next(ParameterProviderActions.loadParameterProviders());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(parameterProviderService, 'getParameterProviders').mockImplementationOnce(() =>
+ throwError(() => error)
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadParameterProviders$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ ParameterProviderActions.loadParameterProvidersError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+ });
+
+ describe('loadParameterProvidersError$', () => {
+ it('should handle parameter providers error for initial load', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = ParameterProviderActions.parameterProvidersBannerApiError({ error: 'Error message' });
+ action$.next(
+ ParameterProviderActions.loadParameterProvidersError({
+ errorResponse: error,
+ loadedTimestamp: initialParameterProvidersState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadParameterProvidersError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle parameter providers error for refresh', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = ParameterProviderActions.parameterProvidersBannerApiError({ error: 'Error message' });
+ action$.next(
+ ParameterProviderActions.loadParameterProvidersError({
+ errorResponse: error,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadParameterProvidersError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.ts
index 5bd82c3e6a..cfb2a545d1 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.ts
@@ -42,8 +42,8 @@ import {
} from 'rxjs';
import {
selectApplyParameterProviderParametersRequest,
- selectSaving,
- selectStatus
+ selectLoadedTimestamp,
+ selectSaving
} from './parameter-providers.selectors';
import {
selectExtensionTypesLoadingStatus,
@@ -59,6 +59,7 @@ import * as ErrorActions from '../../../../state/error/error.actions';
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { LARGE_DIALOG, SMALL_DIALOG, XL_DIALOG, YesNoDialog } from '@nifi/shared';
+import { initialParameterProvidersState } from './parameter-providers.reducer';
import {
resetPropertyVerificationState,
verifyProperties
@@ -86,8 +87,8 @@ export class ParameterProvidersEffects {
loadParameterProviders$ = createEffect(() =>
this.actions$.pipe(
ofType(loadParameterProviders),
- concatLatestFrom(() => this.store.select(selectStatus)),
- switchMap(([, status]) =>
+ concatLatestFrom(() => this.store.select(selectLoadedTimestamp)),
+ switchMap(([, loadedTimestamp]) =>
from(this.parameterProviderService.getParameterProviders()).pipe(
map((response) =>
ParameterProviderActions.loadParameterProvidersSuccess({
@@ -97,7 +98,30 @@ export class ParameterProvidersEffects {
}
})
),
- catchError((error: HttpErrorResponse) => of(this.errorHelper.handleLoadingError(status, error)))
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(
+ ParameterProviderActions.loadParameterProvidersError({
+ errorResponse,
+ loadedTimestamp,
+ status:
+ loadedTimestamp !== initialParameterProvidersState.loadedTimestamp
+ ? 'success'
+ : 'pending'
+ })
+ )
+ )
+ )
+ )
+ )
+ );
+
+ loadParameterProvidersError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ParameterProviderActions.loadParameterProvidersError),
+ map((action) =>
+ this.errorHelper.handleLoadingError(
+ action.loadedTimestamp !== initialParameterProvidersState.loadedTimestamp,
+ action.errorResponse
)
)
)
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.reducer.ts
index 624063548d..b705a8ff28 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.reducer.ts
@@ -28,6 +28,7 @@ import {
fetchParameterProviderParametersSuccess,
loadParameterProviders,
loadParameterProvidersSuccess,
+ loadParameterProvidersError,
parameterProvidersBannerApiError,
pollParameterProviderParametersUpdateRequestSuccess,
resetFetchedParameterProvider,
@@ -65,6 +66,11 @@ export const parameterProvidersReducer = createReducer(
status: 'success' as const
})),
+ on(loadParameterProvidersError, (state: ParameterProvidersState, { status }) => ({
+ ...state,
+ status
+ })),
+
on(parameterProvidersBannerApiError, (state: ParameterProvidersState) => ({
...state,
saving: false
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.selectors.ts
index 468d46eff5..c85c148309 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.selectors.ts
@@ -30,9 +30,9 @@ export const selectSaving = createSelector(
(state: ParameterProvidersState) => state.saving
);
-export const selectStatus = createSelector(
+export const selectLoadedTimestamp = createSelector(
selectParameterProvidersState,
- (state: ParameterProvidersState) => state.status
+ (state: ParameterProvidersState) => state.loadedTimestamp
);
export const selectParameterProviderIdFromRoute = createSelector(selectCurrentRoute, (route) => {
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.actions.ts
index 4adeac7e44..c6402ca8b4 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.actions.ts
@@ -37,8 +37,13 @@ export const loadRegistryClientsSuccess = createAction(
props<{ response: LoadRegistryClientsResponse }>()
);
+export const loadRegistryClientsError = createAction(
+ '[Registry Clients] Load Registry Clients Error',
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const registryClientsBannerApiError = createAction(
- '[Registry Clients] Load Registry Clients Banner Api Error',
+ '[Registry Clients] Registry Clients Banner Api Error',
props<{ error: string }>()
);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.spec.ts
new file mode 100644
index 0000000000..d7064a4149
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.spec.ts
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { Action } from '@ngrx/store';
+import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { HttpErrorResponse } from '@angular/common/http';
+
+import * as RegistryClientsActions from './registry-clients.actions';
+import { RegistryClientsEffects } from './registry-clients.effects';
+import { RegistryClientService } from '../../service/registry-client.service';
+import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { MatDialog } from '@angular/material/dialog';
+import { Router } from '@angular/router';
+import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
+import { Client } from '../../../../service/client.service';
+import { NiFiCommon } from '@nifi/shared';
+import { ClusterConnectionService } from '../../../../service/cluster-connection.service';
+import { ExtensionTypesService } from '../../../../service/extension-types.service';
+import { initialState } from './registry-clients.reducer';
+
+describe('RegistryClientsEffects', () => {
+ interface SetupOptions {
+ registryClientsState?: any;
+ }
+
+ const mockRegistryClientsResponse = {
+ registries: [],
+ currentTime: '2023-01-01 12:00:00 EST'
+ };
+
+ async function setup({ registryClientsState = initialState }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ RegistryClientsEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ settings: {
+ registryClients: registryClientsState
+ }
+ }
+ }),
+ {
+ provide: RegistryClientService,
+ useValue: {
+ getRegistryClients: jest.fn()
+ }
+ },
+ {
+ provide: ManagementControllerServiceService,
+ useValue: {}
+ },
+ {
+ provide: ErrorHelper,
+ useValue: {
+ handleLoadingError: jest.fn()
+ }
+ },
+ {
+ provide: MatDialog,
+ useValue: { open: jest.fn(), closeAll: jest.fn() }
+ },
+ {
+ provide: Router,
+ useValue: { navigate: jest.fn() }
+ },
+ { provide: PropertyTableHelperService, useValue: {} },
+ { provide: Client, useValue: {} },
+ { provide: NiFiCommon, useValue: {} },
+ { provide: ClusterConnectionService, useValue: {} },
+ { provide: ExtensionTypesService, useValue: {} }
+ ]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const effects = TestBed.inject(RegistryClientsEffects);
+ const registryClientService = TestBed.inject(RegistryClientService);
+ const errorHelper = TestBed.inject(ErrorHelper);
+
+ return { effects, store, registryClientService, errorHelper };
+ }
+
+ let action$: ReplaySubject<Action>;
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>();
+ });
+
+ it('should create', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('Load Registry Clients', () => {
+ it('should load registry clients successfully', async () => {
+ const { effects, registryClientService } = await setup();
+
+ action$.next(RegistryClientsActions.loadRegistryClients());
+ jest.spyOn(registryClientService, 'getRegistryClients').mockReturnValueOnce(
+ of(mockRegistryClientsResponse) as never
+ );
+ const result = await new Promise((resolve) =>
+ effects.loadRegistryClients$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ RegistryClientsActions.loadRegistryClientsSuccess({
+ response: {
+ registryClients: mockRegistryClientsResponse.registries,
+ loadedTimestamp: mockRegistryClientsResponse.currentTime
+ }
+ })
+ );
+ });
+
+ it('should fail to load registry clients on initial load with hasExistingData=false', async () => {
+ const { effects, registryClientService } = await setup();
+
+ action$.next(RegistryClientsActions.loadRegistryClients());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(registryClientService, 'getRegistryClients').mockImplementationOnce(() =>
+ throwError(() => error)
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadRegistryClients$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ RegistryClientsActions.loadRegistryClientsError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load registry clients on refresh with hasExistingData=true', async () => {
+ const stateWithData = {
+ ...initialState,
+ loadedTimestamp: '2023-01-01 11:00:00 EST'
+ };
+ const { effects, registryClientService } = await setup({
+ registryClientsState: stateWithData
+ });
+
+ action$.next(RegistryClientsActions.loadRegistryClients());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(registryClientService, 'getRegistryClients').mockImplementationOnce(() =>
+ throwError(() => error)
+ );
+
+ const result = await new Promise((resolve) =>
+ effects.loadRegistryClients$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ RegistryClientsActions.loadRegistryClientsError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+
+ it('should handle loadRegistryClientsError with pending status (no existing data)', async () => {
+ const { effects, errorHelper } = await setup();
+
+ action$.next(
+ RegistryClientsActions.loadRegistryClientsError({
+ errorResponse: new HttpErrorResponse({ status: 500 }),
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+
+ const errorAction = RegistryClientsActions.registryClientsBannerApiError({ error: 'Error loading' });
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadRegistryClientsError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, expect.any(HttpErrorResponse));
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle loadRegistryClientsError with success status (existing data)', async () => {
+ const { effects, errorHelper } = await setup();
+
+ action$.next(
+ RegistryClientsActions.loadRegistryClientsError({
+ errorResponse: new HttpErrorResponse({ status: 503 }),
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+
+ const errorAction = RegistryClientsActions.registryClientsBannerApiError({ error: 'Error loading' });
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadRegistryClientsError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, expect.any(HttpErrorResponse));
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.ts
index 9d5caebae9..3eb051afa0 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.ts
@@ -31,7 +31,7 @@ import { LARGE_DIALOG, SMALL_DIALOG, YesNoDialog } from '@nifi/shared';
import { Router } from '@angular/router';
import { RegistryClientService } from '../../service/registry-client.service';
import { CreateRegistryClient } from '../../ui/registry-clients/create-registry-client/create-registry-client.component';
-import { selectSaving, selectStatus } from './registry-clients.selectors';
+import { selectLoadedTimestamp, selectSaving } from './registry-clients.selectors';
import { EditRegistryClient } from '../../ui/registry-clients/edit-registry-client/edit-registry-client.component';
import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
import { EditRegistryClientRequest } from './index';
@@ -39,6 +39,7 @@ import { PropertyTableHelperService } from '../../../../service/property-table-h
import * as ErrorActions from '../../../../state/error/error.actions';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { HttpErrorResponse } from '@angular/common/http';
+import { initialState } from './registry-clients.reducer';
import { BackNavigation } from '../../../../state/navigation';
import { ErrorContextKey } from '../../../../state/error';
@@ -56,8 +57,8 @@ export class RegistryClientsEffects {
loadRegistryClients$ = createEffect(() =>
this.actions$.pipe(
ofType(RegistryClientsActions.loadRegistryClients),
- concatLatestFrom(() => this.store.select(selectStatus)),
- switchMap(([, status]) =>
+ concatLatestFrom(() => this.store.select(selectLoadedTimestamp)),
+ switchMap(([, loadedTimestamp]) =>
from(this.registryClientService.getRegistryClients()).pipe(
map((response) =>
RegistryClientsActions.loadRegistryClientsSuccess({
@@ -67,14 +68,30 @@ export class RegistryClientsEffects {
}
})
),
- catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
- )
+ catchError((errorResponse: HttpErrorResponse) => {
+ const status = loadedTimestamp !== initialState.loadedTimestamp ? 'success' : 'pending';
+ return of(
+ RegistryClientsActions.loadRegistryClientsError({
+ errorResponse,
+ loadedTimestamp,
+ status
+ })
+ );
+ })
)
)
)
);
+ loadRegistryClientsError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(RegistryClientsActions.loadRegistryClientsError),
+ switchMap((action) =>
+ of(this.errorHelper.handleLoadingError(action.status === 'success', action.errorResponse))
+ )
+ )
+ );
+
openNewRegistryClientDialog$ = createEffect(
() =>
this.actions$.pipe(
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.reducer.ts
index ba68c17737..a9f12a0178 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.reducer.ts
@@ -26,6 +26,7 @@ import {
deleteRegistryClient,
deleteRegistryClientSuccess,
loadRegistryClients,
+ loadRegistryClientsError,
loadRegistryClientsSuccess,
registryClientsBannerApiError,
registryClientsSnackbarApiError,
@@ -54,6 +55,10 @@ export const registryClientsReducer = createReducer(
loadedTimestamp: response.loadedTimestamp,
status: 'success' as const
})),
+ on(loadRegistryClientsError, (state, { status }) => ({
+ ...state,
+ status
+ })),
on(registryClientsBannerApiError, registryClientsSnackbarApiError, (state) => ({
...state,
saving: false
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.selectors.ts
index da098b31e8..e16fd28d81 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.selectors.ts
@@ -28,7 +28,10 @@ export const selectRegistryClientsState = createSelector(
export const selectSaving = createSelector(selectRegistryClientsState, (state: RegistryClientsState) => state.saving);
-export const selectStatus = createSelector(selectRegistryClientsState, (state: RegistryClientsState) => state.status);
+export const selectLoadedTimestamp = createSelector(
+ selectRegistryClientsState,
+ (state: RegistryClientsState) => state.loadedTimestamp
+);
export const selectRegistryClientIdFromRoute = createSelector(selectCurrentRoute, (route) => {
if (route) {
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.actions.ts
index 746baf6136..9cb54e2776 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.actions.ts
@@ -42,6 +42,11 @@ export const loadReportingTasksSuccess = createAction(
props<{ response: LoadReportingTasksResponse }>()
);
+export const loadReportingTasksError = createAction(
+ '[Reporting Tasks] Load Reporting Tasks Error',
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const openConfigureReportingTaskDialog = createAction(
'[Reporting Tasks] Open Reporting Task Dialog',
props<{ request: EditReportingTaskDialogRequest }>()
@@ -58,7 +63,7 @@ export const configureReportingTaskSuccess = createAction(
);
export const reportingTasksBannerApiError = createAction(
- '[Reporting Tasks] Load Reporting Tasks Banner Api Error',
+ '[Reporting Tasks] Reporting Tasks Banner Api Error',
props<{ error: string }>()
);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.effects.spec.ts
new file mode 100644
index 0000000000..391b44cec8
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.effects.spec.ts
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { Action } from '@ngrx/store';
+import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { HttpErrorResponse } from '@angular/common/http';
+
+import * as ReportingTaskActions from './reporting-tasks.actions';
+import { ReportingTasksEffects } from './reporting-tasks.effects';
+import { ReportingTaskService } from '../../service/reporting-task.service';
+import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { MatDialog } from '@angular/material/dialog';
+import { Router } from '@angular/router';
+import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
+import { ExtensionTypesService } from '../../../../service/extension-types.service';
+import { initialState } from './reporting-tasks.reducer';
+
+describe('ReportingTasksEffects', () => {
+ interface SetupOptions {
+ reportingTasksState?: any;
+ }
+
+ const mockResponse = {
+ reportingTasks: [],
+ currentTime: '2023-01-01 12:00:00 EST'
+ };
+
+ async function setup({ reportingTasksState = initialState }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ ReportingTasksEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ settings: {
+ reportingTasks: reportingTasksState
+ }
+ }
+ }),
+ { provide: ReportingTaskService, useValue: { getReportingTasks: jest.fn() } },
+ { provide: ManagementControllerServiceService, useValue: {} },
+ { provide: ErrorHelper, useValue: { handleLoadingError: jest.fn() } },
+ { provide: MatDialog, useValue: { open: jest.fn(), closeAll: jest.fn() } },
+ { provide: Router, useValue: { navigate: jest.fn() } },
+ { provide: PropertyTableHelperService, useValue: {} },
+ { provide: ExtensionTypesService, useValue: {} }
+ ]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const effects = TestBed.inject(ReportingTasksEffects);
+ const reportingTaskService = TestBed.inject(ReportingTaskService);
+ const errorHelper = TestBed.inject(ErrorHelper);
+
+ return { effects, store, reportingTaskService, errorHelper };
+ }
+
+ let action$: ReplaySubject<Action>;
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>();
+ });
+
+ it('should create', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('Load Reporting Tasks', () => {
+ it('should load reporting tasks successfully', async () => {
+ const { effects, reportingTaskService } = await setup();
+
+ action$.next(ReportingTaskActions.loadReportingTasks());
+ jest.spyOn(reportingTaskService, 'getReportingTasks').mockReturnValueOnce(of(mockResponse) as never);
+
+ const result = await new Promise((resolve) => effects.loadReportingTasks$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ ReportingTaskActions.loadReportingTasksSuccess({
+ response: {
+ reportingTasks: mockResponse.reportingTasks,
+ loadedTimestamp: mockResponse.currentTime
+ }
+ })
+ );
+ });
+
+ it('should fail to load reporting tasks on initial load with hasExistingData=false', async () => {
+ const { effects, reportingTaskService } = await setup();
+
+ action$.next(ReportingTaskActions.loadReportingTasks());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(reportingTaskService, 'getReportingTasks').mockImplementationOnce(() => throwError(() => error));
+
+ const result = await new Promise((resolve) => effects.loadReportingTasks$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ ReportingTaskActions.loadReportingTasksError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load reporting tasks on refresh with hasExistingData=true', async () => {
+ const stateWithData = {
+ ...initialState,
+ loadedTimestamp: '2023-01-01 11:00:00 EST'
+ };
+ const { effects, reportingTaskService } = await setup({
+ reportingTasksState: stateWithData
+ });
+
+ action$.next(ReportingTaskActions.loadReportingTasks());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(reportingTaskService, 'getReportingTasks').mockImplementationOnce(() => throwError(() => error));
+
+ const result = await new Promise((resolve) => effects.loadReportingTasks$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ ReportingTaskActions.loadReportingTasksError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+
+ it('should handle loadReportingTasksError with pending status (no existing data)', async () => {
+ const { effects, errorHelper } = await setup();
+
+ action$.next(
+ ReportingTaskActions.loadReportingTasksError({
+ errorResponse: new HttpErrorResponse({ status: 500 }),
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+
+ const errorAction = ReportingTaskActions.reportingTasksBannerApiError({ error: 'Error loading' });
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadReportingTasksError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, expect.any(HttpErrorResponse));
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle loadReportingTasksError with success status (existing data)', async () => {
+ const { effects, errorHelper } = await setup();
+
+ action$.next(
+ ReportingTaskActions.loadReportingTasksError({
+ errorResponse: new HttpErrorResponse({ status: 503 }),
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+
+ const errorAction = ReportingTaskActions.reportingTasksBannerApiError({ error: 'Error loading' });
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadReportingTasksError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, expect.any(HttpErrorResponse));
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.effects.ts
index d0755f7a86..e196c5cb4f 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.effects.ts
@@ -35,7 +35,8 @@ import { ManagementControllerServiceService } from '../../service/management-con
import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
import * as ErrorActions from '../../../../state/error/error.actions';
import { ErrorHelper } from '../../../../service/error-helper.service';
-import { selectStatus } from './reporting-tasks.selectors';
+import { selectLoadedTimestamp } from './reporting-tasks.selectors';
+import { initialState } from './reporting-tasks.reducer';
import { HttpErrorResponse } from '@angular/common/http';
import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
@@ -66,8 +67,8 @@ export class ReportingTasksEffects {
loadReportingTasks$ = createEffect(() =>
this.actions$.pipe(
ofType(ReportingTaskActions.loadReportingTasks),
- concatLatestFrom(() => this.store.select(selectStatus)),
- switchMap(([, status]) =>
+ concatLatestFrom(() => this.store.select(selectLoadedTimestamp)),
+ switchMap(([, loadedTimestamp]) =>
from(this.reportingTaskService.getReportingTasks()).pipe(
map((response) =>
ReportingTaskActions.loadReportingTasksSuccess({
@@ -77,14 +78,30 @@ export class ReportingTasksEffects {
}
})
),
- catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
- )
+ catchError((errorResponse: HttpErrorResponse) => {
+ const status = loadedTimestamp !== initialState.loadedTimestamp ? 'success' : 'pending';
+ return of(
+ ReportingTaskActions.loadReportingTasksError({
+ errorResponse,
+ loadedTimestamp,
+ status
+ })
+ );
+ })
)
)
)
);
+ loadReportingTasksError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ReportingTaskActions.loadReportingTasksError),
+ switchMap((action) =>
+ of(this.errorHelper.handleLoadingError(action.status === 'success', action.errorResponse))
+ )
+ )
+ );
+
openNewReportingTaskDialog$ = createEffect(
() =>
this.actions$.pipe(
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.reducer.ts
index a59b12d218..2157882615 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.reducer.ts
@@ -25,6 +25,7 @@ import {
deleteReportingTask,
deleteReportingTaskSuccess,
loadReportingTasks,
+ loadReportingTasksError,
loadReportingTasksSuccess,
reportingTasksBannerApiError,
reportingTasksSnackbarApiError,
@@ -56,6 +57,10 @@ export const reportingTasksReducer = createReducer(
loadedTimestamp: response.loadedTimestamp,
status: 'success' as const
})),
+ on(loadReportingTasksError, (state, { status }) => ({
+ ...state,
+ status
+ })),
on(reportingTasksBannerApiError, reportingTasksSnackbarApiError, (state) => ({
...state,
saving: false
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.selectors.ts
index 407364ab79..fd145a55d5 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.selectors.ts
@@ -27,7 +27,10 @@ export const selectReportingTasksState = createSelector(
export const selectSaving = createSelector(selectReportingTasksState, (state: ReportingTasksState) => state.saving);
-export const selectStatus = createSelector(selectReportingTasksState, (state: ReportingTasksState) => state.status);
+export const selectLoadedTimestamp = createSelector(
+ selectReportingTasksState,
+ (state: ReportingTasksState) => state.loadedTimestamp
+);
export const selectReportingTaskIdFromRoute = createSelector(selectCurrentRoute, (route) => {
if (route) {
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.actions.ts
index 65d00a7f60..204e4d8f74 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.actions.ts
@@ -38,6 +38,11 @@ export const loadSummaryListingSuccess = createAction(
props<{ response: SummaryListingResponse }>()
);
+export const loadSummaryListingError = createAction(
+ `${SUMMARY_LISTING_PREFIX} Load Summary Listing Error`,
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const selectProcessorStatus = createAction(
`${SUMMARY_LISTING_PREFIX} Select Processor Status`,
props<{ request: SelectProcessorStatusRequest }>()
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.effects.spec.ts
new file mode 100644
index 0000000000..31012ae4f8
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.effects.spec.ts
@@ -0,0 +1,251 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { SummaryListingEffects } from './summary-listing.effects';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { initialState } from './summary-listing.reducer';
+import { of, ReplaySubject, take, throwError } from 'rxjs';
+import { Action } from '@ngrx/store';
+import * as SummaryListingActions from './summary-listing.actions';
+import { ProcessGroupStatusService } from '../../service/process-group-status.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { HttpErrorResponse } from '@angular/common/http';
+import { Router } from '@angular/router';
+import { summaryFeatureKey } from '../index';
+import { summaryListingFeatureKey } from './index';
+
+describe('SummaryListingEffects', () => {
+ interface SetupOptions {
+ summaryListingState?: any;
+ }
+
+ const mockSummaryResponse = {
+ status: {
+ canRead: true,
+ processGroupStatus: {
+ id: 'root',
+ name: 'NiFi Flow',
+ statsLastRefreshed: '2023-01-01 12:00:00 EST',
+ aggregateSnapshot: {
+ id: 'root',
+ name: 'NiFi Flow',
+ flowFilesIn: 0,
+ bytesIn: 0,
+ input: '0 / 0 bytes',
+ flowFilesOut: 0,
+ bytesOut: 0,
+ output: '0 / 0 bytes',
+ flowFilesTransferred: 0,
+ bytesTransferred: 0,
+ transferred: '0 / 0 bytes',
+ bytesReceived: 0,
+ flowFilesReceived: 0,
+ received: '0 / 0 bytes',
+ bytesSent: 0,
+ flowFilesSent: 0,
+ sent: '0 / 0 bytes',
+ bytesRead: 0,
+ read: '0 bytes',
+ bytesWritten: 0,
+ written: '0 bytes',
+ activeThreadCount: 0,
+ terminatedThreadCount: 0,
+ processingNanos: 0,
+ statelessActiveThreadCount: 0,
+ processorStatusSnapshots: [],
+ connectionStatusSnapshots: [],
+ processGroupStatusSnapshots: [],
+ inputPortStatusSnapshots: [],
+ outputPortStatusSnapshots: [],
+ remoteProcessGroupStatusSnapshots: []
+ }
+ }
+ }
+ };
+
+ let action$: ReplaySubject<Action>;
+
+ async function setup({ summaryListingState = initialState }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ SummaryListingEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ [summaryFeatureKey]: {
+ [summaryListingFeatureKey]: summaryListingState || initialState
+ }
+ }
+ }),
+ {
+ provide: ProcessGroupStatusService,
+ useValue: {
+ getProcessGroupsStatus: jest.fn()
+ }
+ },
+ {
+ provide: Router,
+ useValue: {
+ navigate: jest.fn()
+ }
+ },
+ {
+ provide: ErrorHelper,
+ useValue: {
+ handleLoadingError: jest.fn(),
+ getErrorString: jest.fn()
+ }
+ }
+ ]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const summaryService = TestBed.inject(ProcessGroupStatusService) as jest.Mocked<ProcessGroupStatusService>;
+ const errorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+ const effects = TestBed.inject(SummaryListingEffects);
+
+ return { store, summaryService, errorHelper, effects };
+ }
+
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>(1);
+ });
+
+ afterEach(() => {
+ action$.complete();
+ });
+
+ it('should be created', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('loadSummaryListing', () => {
+ it('should load summary listing successfully', async () => {
+ const { effects, summaryService } = await setup();
+
+ action$.next(SummaryListingActions.loadSummaryListing({ recursive: false }));
+ jest.spyOn(summaryService, 'getProcessGroupsStatus').mockReturnValueOnce(
+ of(mockSummaryResponse.status) as never
+ );
+
+ const result = await new Promise((resolve) => effects.loadSummaryListing$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ SummaryListingActions.loadSummaryListingSuccess({
+ response: {
+ status: mockSummaryResponse.status
+ }
+ })
+ );
+ });
+
+ it('should fail to load summary listing on initial load with hasExistingData=false', async () => {
+ const { effects, summaryService } = await setup();
+
+ action$.next(SummaryListingActions.loadSummaryListing({ recursive: false }));
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(summaryService, 'getProcessGroupsStatus').mockImplementationOnce(() => throwError(() => error));
+
+ const result = await new Promise((resolve) => effects.loadSummaryListing$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ SummaryListingActions.loadSummaryListingError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load summary listing on refresh with hasExistingData=true', async () => {
+ const stateWithData = {
+ ...initialState,
+ loadedTimestamp: '2023-01-01 11:00:00 EST'
+ };
+ const { effects, summaryService } = await setup({ summaryListingState: stateWithData });
+
+ action$.next(SummaryListingActions.loadSummaryListing({ recursive: false }));
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(summaryService, 'getProcessGroupsStatus').mockImplementationOnce(() => throwError(() => error));
+
+ const result = await new Promise((resolve) => effects.loadSummaryListing$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ SummaryListingActions.loadSummaryListingError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+
+ it('should handle loadSummaryListingError with pending status (no existing data)', async () => {
+ const { effects, errorHelper } = await setup();
+
+ action$.next(
+ SummaryListingActions.loadSummaryListingError({
+ errorResponse: new HttpErrorResponse({ status: 500 }),
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+
+ const errorAction = SummaryListingActions.loadSummaryListingSuccess({
+ response: {
+ status: mockSummaryResponse.status
+ }
+ });
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadSummaryListingError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, expect.any(HttpErrorResponse));
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle loadSummaryListingError with success status (existing data)', async () => {
+ const { effects, errorHelper } = await setup();
+
+ action$.next(
+ SummaryListingActions.loadSummaryListingError({
+ errorResponse: new HttpErrorResponse({ status: 503 }),
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+
+ const errorAction = SummaryListingActions.loadSummaryListingSuccess({
+ response: {
+ status: mockSummaryResponse.status
+ }
+ });
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ const result = await new Promise((resolve) =>
+ effects.loadSummaryListingError$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, expect.any(HttpErrorResponse));
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.effects.ts
index 76e9af00a7..ed4c396ea4 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.effects.ts
@@ -27,8 +27,9 @@ import { catchError, filter, from, map, of, switchMap, tap } from 'rxjs';
import { Router } from '@angular/router';
import { ComponentType } from '@nifi/shared';
import { ErrorHelper } from '../../../../service/error-helper.service';
+import { initialState } from './summary-listing.reducer';
import { HttpErrorResponse } from '@angular/common/http';
-import { selectSelectedClusterNode, selectSummaryListingStatus } from './summary-listing.selectors';
+import { selectSelectedClusterNode, selectSummaryListingLoadedTimestamp } from './summary-listing.selectors';
import { LoadSummaryRequest } from './index';
@Injectable()
@@ -44,10 +45,10 @@ export class SummaryListingEffects {
ofType(SummaryListingActions.loadSummaryListing),
map((action) => action.recursive),
concatLatestFrom(() => [
- this.store.select(selectSummaryListingStatus),
+ this.store.select(selectSummaryListingLoadedTimestamp),
this.store.select(selectSelectedClusterNode)
]),
- switchMap(([recursive, listingStatus, selectedClusterNode]) => {
+ switchMap(([recursive, loadedTimestamp, selectedClusterNode]) => {
const request: LoadSummaryRequest = {
recursive
};
@@ -62,14 +63,30 @@ export class SummaryListingEffects {
}
})
),
- catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(listingStatus, errorResponse))
- )
+ catchError((errorResponse: HttpErrorResponse) => {
+ const status = loadedTimestamp !== initialState.loadedTimestamp ? 'success' : 'pending';
+ return of(
+ SummaryListingActions.loadSummaryListingError({
+ errorResponse,
+ loadedTimestamp,
+ status
+ })
+ );
+ })
);
})
)
);
+ loadSummaryListingError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(SummaryListingActions.loadSummaryListingError),
+ switchMap((action) =>
+ of(this.errorHelper.handleLoadingError(action.status === 'success', action.errorResponse))
+ )
+ )
+ );
+
selectProcessorStatus$ = createEffect(
() =>
this.actions$.pipe(
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.reducer.ts
index 0248543f7e..a27178c239 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/summary/state/summary-listing/summary-listing.reducer.ts
@@ -26,6 +26,7 @@ import {
} from '../index';
import {
loadSummaryListing,
+ loadSummaryListingError,
loadSummaryListingSuccess,
resetSummaryState,
selectClusterNode
@@ -96,6 +97,11 @@ export const summaryListingReducer = createReducer(
};
}),
+ on(loadSummaryListingError, (state, { status }) => ({
+ ...state,
+ status
+ })),
+
on(resetSummaryState, () => ({
...initialState
})),
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.actions.ts
index bc07f408c5..63a378d548 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.actions.ts
@@ -44,6 +44,11 @@ export const loadTenantsSuccess = createAction(
props<{ response: LoadTenantsSuccess }>()
);
+export const loadTenantsError = createAction(
+ `${USER_PREFIX} Load Tenants Error`,
+ props<{ errorResponse: any; loadedTimestamp: string; status: 'pending' | 'success' }>()
+);
+
export const usersApiSnackbarError = createAction(
`${USER_PREFIX} Users Api Snackbar Error`,
props<{ error: string }>()
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.effects.spec.ts
new file mode 100644
index 0000000000..108d54abaf
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.effects.spec.ts
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { Action } from '@ngrx/store';
+import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { HttpErrorResponse } from '@angular/common/http';
+
+import * as UserListingActions from './user-listing.actions';
+import { UserListingEffects } from './user-listing.effects';
+import { UsersService } from '../../service/users.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { MatDialog } from '@angular/material/dialog';
+import { Router } from '@angular/router';
+import { Client } from '../../../../service/client.service';
+import { NiFiCommon } from '@nifi/shared';
+import { initialState } from './user-listing.reducer';
+
+describe('UserListingEffects', () => {
+ interface SetupOptions {
+ userListingState?: any;
+ }
+
+ const mockUsersResponse = { users: [], generated: '2023-01-01 12:00:00 EST' };
+ const mockUserGroupsResponse = { userGroups: [] };
+
+ async function setup({ userListingState = initialState }: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ providers: [
+ UserListingEffects,
+ provideMockActions(() => action$),
+ provideMockStore({
+ initialState: {
+ users: {
+ users: userListingState
+ }
+ }
+ }),
+ { provide: UsersService, useValue: { getUsers: jest.fn(), getUserGroups: jest.fn() } },
+ { provide: ErrorHelper, useValue: { handleLoadingError: jest.fn() } },
+ { provide: MatDialog, useValue: { open: jest.fn(), closeAll: jest.fn() } },
+ { provide: Router, useValue: { navigate: jest.fn() } },
+ { provide: Client, useValue: {} },
+ { provide: NiFiCommon, useValue: {} }
+ ]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const effects = TestBed.inject(UserListingEffects);
+ const usersService = TestBed.inject(UsersService);
+ const errorHelper = TestBed.inject(ErrorHelper);
+
+ return { effects, store, usersService, errorHelper };
+ }
+
+ let action$: ReplaySubject<Action>;
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>();
+ });
+
+ it('should create', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('Load Tenants', () => {
+ it('should load tenants successfully', async () => {
+ const { effects, usersService } = await setup();
+
+ action$.next(UserListingActions.loadTenants());
+ jest.spyOn(usersService, 'getUsers').mockReturnValueOnce(of(mockUsersResponse) as never);
+ jest.spyOn(usersService, 'getUserGroups').mockReturnValueOnce(of(mockUserGroupsResponse) as never);
+
+ const result = await new Promise((resolve) => effects.loadTenants$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ UserListingActions.loadTenantsSuccess({
+ response: {
+ users: mockUsersResponse.users,
+ userGroups: mockUserGroupsResponse.userGroups,
+ loadedTimestamp: mockUsersResponse.generated
+ }
+ })
+ );
+ });
+
+ it('should fail to load tenants on initial load (no existing data)', async () => {
+ const { effects, usersService } = await setup();
+
+ action$.next(UserListingActions.loadTenants());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(usersService, 'getUsers').mockImplementationOnce(() => throwError(() => error));
+ jest.spyOn(usersService, 'getUserGroups').mockReturnValueOnce(of(mockUserGroupsResponse) as never);
+
+ const result = await new Promise((resolve) => effects.loadTenants$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ UserListingActions.loadTenantsError({
+ errorResponse: error,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+
+ it('should fail to load tenants on refresh (existing data)', async () => {
+ const stateWithData = { ...initialState, loadedTimestamp: '2023-01-01 11:00:00 EST' };
+ const { effects, usersService } = await setup({ userListingState: stateWithData });
+
+ action$.next(UserListingActions.loadTenants());
+ const error = new HttpErrorResponse({ status: 500 });
+ jest.spyOn(usersService, 'getUsers').mockImplementationOnce(() => throwError(() => error));
+ jest.spyOn(usersService, 'getUserGroups').mockReturnValueOnce(of(mockUserGroupsResponse) as never);
+
+ const result = await new Promise((resolve) => effects.loadTenants$.pipe(take(1)).subscribe(resolve));
+
+ expect(result).toEqual(
+ UserListingActions.loadTenantsError({
+ errorResponse: error,
+ loadedTimestamp: stateWithData.loadedTimestamp,
+ status: 'success'
+ })
+ );
+ });
+ });
+
+ describe('Load Tenants Error', () => {
+ it('should handle loadTenantsError with pending status', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = UserListingActions.usersApiBannerError({ error: 'Error loading' });
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ action$.next(
+ UserListingActions.loadTenantsError({
+ errorResponse: error,
+ loadedTimestamp: '',
+ status: 'pending'
+ })
+ );
+
+ const result = await new Promise((resolve) => effects.loadTenantsError$.pipe(take(1)).subscribe(resolve));
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle loadTenantsError with success status', async () => {
+ const { effects, errorHelper } = await setup();
+
+ const error = new HttpErrorResponse({ status: 500 });
+ const errorAction = UserListingActions.usersApiBannerError({ error: 'Error loading' });
+ jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+
+ action$.next(
+ UserListingActions.loadTenantsError({
+ errorResponse: error,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+
+ const result = await new Promise((resolve) => effects.loadTenantsError$.pipe(take(1)).subscribe(resolve));
+
+ expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
+ expect(result).toEqual(errorAction);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.effects.ts
index a8598f24df..f39a46e0d5 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.effects.ts
@@ -28,13 +28,14 @@ import { MatDialog } from '@angular/material/dialog';
import { UsersService } from '../../service/users.service';
import { YesNoDialog } from '@nifi/shared';
import { EditTenantDialog } from '../../../../ui/common/edit-tenant/edit-tenant-dialog.component';
-import { selectSaving, selectStatus, selectUserGroups, selectUsers } from './user-listing.selectors';
+import { selectLoadedTimestamp, selectSaving, selectUserGroups, selectUsers } from './user-listing.selectors';
import { EditTenantRequest, UserGroupEntity } from '../../../../state/shared';
import { Client } from '../../../../service/client.service';
import { LARGE_DIALOG, MEDIUM_DIALOG, SMALL_DIALOG, NiFiCommon } from '@nifi/shared';
import { UserAccessPolicies } from '../../ui/user-listing/user-access-policies/user-access-policies.component';
import * as ErrorActions from '../../../../state/error/error.actions';
import { ErrorHelper } from '../../../../service/error-helper.service';
+import { initialState } from './user-listing.reducer';
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorContextKey } from '../../../../state/error';
@@ -54,8 +55,8 @@ export class UserListingEffects {
loadTenants$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.loadTenants),
- concatLatestFrom(() => this.store.select(selectStatus)),
- switchMap(([, status]) =>
+ concatLatestFrom(() => this.store.select(selectLoadedTimestamp)),
+ switchMap(([, loadedTimestamp]) =>
combineLatest([this.usersService.getUsers(), this.usersService.getUserGroups()]).pipe(
map(([usersResponse, userGroupsResponse]) =>
UserListingActions.loadTenantsSuccess({
@@ -67,7 +68,13 @@ export class UserListingEffects {
})
),
catchError((errorResponse: HttpErrorResponse) =>
- of(this.errorHelper.handleLoadingError(status, errorResponse))
+ of(
+ UserListingActions.loadTenantsError({
+ errorResponse,
+ loadedTimestamp,
+ status: loadedTimestamp !== initialState.loadedTimestamp ? 'success' : 'pending'
+ })
+ )
)
)
)
@@ -86,6 +93,13 @@ export class UserListingEffects {
{ dispatch: false }
);
+ loadTenantsError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(UserListingActions.loadTenantsError),
+ map((action) => this.errorHelper.handleLoadingError(action.status === 'success', action.errorResponse))
+ )
+ );
+
openCreateTenantDialog$ = createEffect(
() =>
this.actions$.pipe(
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.reducer.ts
index 2e11869222..bbb790ae04 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.reducer.ts
@@ -23,6 +23,7 @@ import {
createUserGroup,
createUserGroupSuccess,
loadTenants,
+ loadTenantsError,
loadTenantsSuccess,
resetUsersState,
updateUser,
@@ -57,6 +58,10 @@ export const userListingReducer = createReducer(
loadedTimestamp: response.loadedTimestamp,
status: 'success' as const
})),
+ on(loadTenantsError, (state, { status }) => ({
+ ...state,
+ status
+ })),
on(createUser, updateUser, createUserGroup, updateUserGroup, (state) => ({
...state,
saving: true
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.selectors.ts
index 65f7433545..3c25d10de5 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/state/user-listing/user-listing.selectors.ts
@@ -24,7 +24,10 @@ export const selectUserListingState = createSelector(selectUserState, (state: Us
export const selectSaving = createSelector(selectUserListingState, (state: UserListingState) => state.saving);
-export const selectStatus = createSelector(selectUserListingState, (state: UserListingState) => state.status);
+export const selectLoadedTimestamp = createSelector(
+ selectUserListingState,
+ (state: UserListingState) => state.loadedTimestamp
+);
export const selectUsers = createSelector(selectUserListingState, (state: UserListingState) => state.users);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/error-helper.service.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/error-helper.service.spec.ts
new file mode 100644
index 0000000000..74b99fefa5
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/error-helper.service.spec.ts
@@ -0,0 +1,226 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { ErrorHelper } from './error-helper.service';
+import { HttpErrorResponse } from '@angular/common/http';
+import * as ErrorActions from '../state/error/error.actions';
+
+describe('ErrorHelper', () => {
+ let service: ErrorHelper;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(ErrorHelper);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('handleLoadingError', () => {
+ describe('when hasExistingData is false (initial load)', () => {
+ it('should return fullScreenError for any error status', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 500,
+ error: 'Server error'
+ });
+
+ const result = service.handleLoadingError(false, errorResponse);
+
+ expect(result.type).toBe(ErrorActions.fullScreenError.type);
+ });
+
+ it('should return fullScreenError for 403 status', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 403,
+ error: 'Forbidden'
+ });
+
+ const result = service.handleLoadingError(false, errorResponse);
+
+ expect(result.type).toBe(ErrorActions.fullScreenError.type);
+ });
+
+ it('should return fullScreenError for 401 status', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 401,
+ error: 'Unauthorized'
+ });
+
+ const result = service.handleLoadingError(false, errorResponse);
+
+ expect(result.type).toBe(ErrorActions.fullScreenError.type);
+ });
+ });
+
+ describe('when hasExistingData is true (refresh/reload)', () => {
+ it('should return snackBarError for 400 status', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 400,
+ error: 'Bad request'
+ });
+
+ const result = service.handleLoadingError(true, errorResponse);
+
+ expect(result.type).toBe(ErrorActions.snackBarError.type);
+ expect((result as any).error).toBe('Bad request');
+ });
+
+ it('should return snackBarError for 403 status', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 403,
+ error: 'Forbidden'
+ });
+
+ const result = service.handleLoadingError(true, errorResponse);
+
+ expect(result.type).toBe(ErrorActions.snackBarError.type);
+ });
+
+ it('should return snackBarError for 404 status', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 404,
+ error: 'Not found'
+ });
+
+ const result = service.handleLoadingError(true, errorResponse);
+
+ expect(result.type).toBe(ErrorActions.snackBarError.type);
+ });
+
+ it('should return snackBarError for 409 status', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 409,
+ error: 'Conflict'
+ });
+
+ const result = service.handleLoadingError(true, errorResponse);
+
+ expect(result.type).toBe(ErrorActions.snackBarError.type);
+ });
+
+ it('should return snackBarError for 413 status', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 413,
+ error: 'Payload too large'
+ });
+
+ const result = service.handleLoadingError(true, errorResponse);
+
+ expect(result.type).toBe(ErrorActions.snackBarError.type);
+ });
+
+ it('should return snackBarError for 503 status', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 503,
+ error: 'Service unavailable'
+ });
+
+ const result = service.handleLoadingError(true, errorResponse);
+
+ expect(result.type).toBe(ErrorActions.snackBarError.type);
+ });
+
+ it('should return fullScreenError for 401 status', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 401,
+ error: 'Unauthorized'
+ });
+
+ const result = service.handleLoadingError(true, errorResponse);
+
+ expect(result.type).toBe(ErrorActions.fullScreenError.type);
+ });
+
+ it('should return fullScreenError for 500 status', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 500,
+ error: 'Internal server error'
+ });
+
+ const result = service.handleLoadingError(true, errorResponse);
+
+ expect(result.type).toBe(ErrorActions.fullScreenError.type);
+ });
+
+ it('should return fullScreenError for 0 status (network error)', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 0,
+ error: null
+ });
+
+ const result = service.handleLoadingError(true, errorResponse);
+
+ expect(result.type).toBe(ErrorActions.fullScreenError.type);
+ });
+ });
+ });
+
+ describe('showErrorInContext', () => {
+ it('should return true for status codes that should show in context', () => {
+ expect(service.showErrorInContext(400)).toBe(true);
+ expect(service.showErrorInContext(403)).toBe(true);
+ expect(service.showErrorInContext(404)).toBe(true);
+ expect(service.showErrorInContext(409)).toBe(true);
+ expect(service.showErrorInContext(413)).toBe(true);
+ expect(service.showErrorInContext(503)).toBe(true);
+ });
+
+ it('should return false for status codes that should not show in context', () => {
+ expect(service.showErrorInContext(401)).toBe(false);
+ expect(service.showErrorInContext(500)).toBe(false);
+ expect(service.showErrorInContext(502)).toBe(false);
+ expect(service.showErrorInContext(0)).toBe(false);
+ });
+ });
+
+ describe('getErrorString', () => {
+ it('should return the error message from the response', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 500,
+ error: 'Server error message'
+ });
+
+ const result = service.getErrorString(errorResponse);
+
+ expect(result).toBe('Server error message');
+ });
+
+ it('should return error with prefix when provided', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 500,
+ error: 'Server error message'
+ });
+
+ const result = service.getErrorString(errorResponse, 'Failed to load');
+
+ expect(result).toBe('Failed to load - [Server error message]');
+ });
+
+ it('should handle status 0 (network error)', () => {
+ const errorResponse = new HttpErrorResponse({
+ status: 0,
+ error: null
+ });
+
+ const result = service.getErrorString(errorResponse);
+
+ expect(result).toBe('An unspecified error occurred.');
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/error-helper.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/error-helper.service.ts
index 07398f3b2f..290d24dfd2 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/error-helper.service.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/error-helper.service.ts
@@ -67,8 +67,8 @@ export class ErrorHelper {
return [400, 403, 404, 409, 413, 503].includes(status);
}
- handleLoadingError(status: string, errorResponse: HttpErrorResponse): Action {
- if (status === 'success') {
+ handleLoadingError(hasExistingData: boolean, errorResponse: HttpErrorResponse): Action {
+ if (hasExistingData) {
if (this.showErrorInContext(errorResponse.status)) {
return ErrorActions.snackBarError({ error: this.getErrorString(errorResponse) });
} else {