Skip to content

NIFI-15818: Implementing route-backed component selection so selecting a processor/connection/etc on the canvas updates the URL, and vice versa.#11138

Merged
rfellows merged 3 commits intoapache:mainfrom
mcgilman:NIFI-15818
Apr 14, 2026

Conversation

@mcgilman
Copy link
Copy Markdown
Contributor

Title: NIFI-15818: Route-backed component selection for the connector canvas

Body:

Summary

  • Implements route-backed component selection for the connector canvas so that selecting a component on the canvas updates the URL and deep-linking to a component URL centers the viewport on it
  • Adds selectComponents, deselectAllComponents, and navigateWithoutTransform actions with corresponding effects, reducer handlers, and component wiring
  • Fixes a corner case where dispatching selectComponents with an empty components array would navigate to an invalid route; it now correctly delegates to deselectAllComponents

Details

Selection routing: Clicking a single component routes to /connectors/:id/canvas/:processGroupId/:type/:componentId. Selecting multiple components routes to .../bulk/:id1,:id2,.... Clicking the canvas background deselects all and returns to the base canvas route. All selection-initiated navigations use replaceUrl: true to avoid polluting browser history.

skipTransform mechanism: When the user selects a component on the canvas, navigateWithoutTransform sets skipTransform: true in the reducer so the resulting route change does not auto-center the viewport. When a deep link is opened directly, skipTransform is false, so onCanvasInitialized calls centerOnSelection() to center the viewport on the selected component(s).

leaveProcessGroup$ update: Refactored to use the navigateWithoutTransform action (instead of direct router.navigate + setSkipTransform) after navigating to the parent process group and waiting for the flow to load. This auto-selects the child process group in the parent view, giving the user a visual reference of where they came from.

Test cleanup: Converted all effects tests from the new Promise<void>((resolve) => { setup().then(...) }) anti-pattern to async/await with firstValueFrom from RxJS.

@rfellows rfellows added the ui Pull requests for work relating to the user interface label Apr 14, 2026
Copy link
Copy Markdown
Contributor

@rfellows rfellows left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a few minor things...

Comment on lines +302 to +324
it('should not navigate when parent process group id is null', async () => {
const { effects, actions$, mockRouter } = await setup({
connectorId: 'conn-x',
parentProcessGroupId: null,
processGroupId: 'child-pg'
});
actions$(of(leaveProcessGroup()));

await firstValueFrom(effects.leaveProcessGroup$.pipe(() => of(undefined)));
expect(mockRouter.navigate).not.toHaveBeenCalled();
});

it('should not navigate when current process group id is null', async () => {
const { effects, actions$, mockRouter } = await setup({
connectorId: 'conn-x',
parentProcessGroupId: 'parent-pg',
processGroupId: null
});
actions$(of(leaveProcessGroup()));

await firstValueFrom(effects.leaveProcessGroup$.pipe(() => of(undefined)));
expect(mockRouter.navigate).not.toHaveBeenCalled();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In RxJS, .pipe() expects operator functions -- functions that take a source observable and return a new observable using RxJS operators like map, filter, switchMap, etc. But () => of(undefined) is a plain function that ignores its source argument entirely and just returns of(undefined).

So what actually happens:

  1. effects.leaveProcessGroup$ is created (the effect pipeline)
  2. .pipe(() => of(undefined)) wraps it, but the wrapper throws away the source and returns of(undefined)
  3. firstValueFrom subscribes and immediately gets undefined from of(undefined)
  4. The test then asserts mockRouter.navigate was not called -- which passes, but only because the effect was never actually driven

The filter at line 202-204 of the effects file is the logic being "tested" here:

            filter(
                ([, , parentProcessGroupId, currentProcessGroupId]) =>
                    parentProcessGroupId != null && currentProcessGroupId != null
            ),

These tests are supposed to prove that when parentProcessGroupId or processGroupId is null, the filter blocks navigation. But the tests pass regardless of whether that filter exists.

Recommendation:

The tricky part is that when the filter blocks, leaveProcessGroup$ simply never emits. Testing "nothing happened" is inherently awkward. Two reasonable approaches:

Option A -- Subscribe, flush, then assert (simplest):

it('should not navigate when parent process group id is null', async () => {
    const { effects, actions$, mockRouter } = await setup({
        connectorId: 'conn-x',
        parentProcessGroupId: null,
        processGroupId: 'child-pg'
    });
    actions$(of(leaveProcessGroup()));

    // Subscribe to drive the pipeline, then check nothing happened
    const results: any[] = [];
    effects.leaveProcessGroup$.subscribe((action) => results.push(action));

    expect(results).toEqual([]);
    expect(mockRouter.navigate).not.toHaveBeenCalled();
});

Option B -- Use firstValueFrom with a timeout that rejects (more explicit):

it('should not navigate when parent process group id is null', async () => {
    const { effects, actions$, mockRouter } = await setup({
        connectorId: 'conn-x',
        parentProcessGroupId: null,
        processGroupId: 'child-pg'
    });
    actions$(of(leaveProcessGroup()));

    await expect(
        firstValueFrom(effects.leaveProcessGroup$.pipe(timeout(50)))
    ).rejects.toThrow();
    expect(mockRouter.navigate).not.toHaveBeenCalled();
});

Option A is simpler because the source observable (of(leaveProcessGroup())) is synchronous, so the effect pipeline runs synchronously through the filter. By the time the next line executes, the filter has already blocked (or not). No async timing issues.

import * as ConnectorCanvasActions from '../../state/connector-canvas/connector-canvas.actions';
import * as ConnectorCanvasSelectors from '../../state/connector-canvas/connector-canvas.selectors';
import * as ConnectorCanvasEntityActions from '../../state/connector-canvas-entity/connector-canvas-entity.actions';
import { selectRouteParams } from '../../state/connector-canvas/connector-canvas.selectors';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already imported and available through ConnectorCanvasSelectors on line 34. However, looking at it a bit more closely... it looks like it can probably be imported from @nifi/shared. The import and re-export in the canvas selectors isn't needed.

import { ConnectorCanvasState, connectorCanvasFeatureKey } from './index';

const { selectRouteParams } = getRouterSelectors();
export const { selectRouteParams } = getRouterSelectors();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need export this here. In fact, it can be imported from @nifi/shared and not needed from router-store.

Copy link
Copy Markdown
Contributor

@rfellows rfellows left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thanks @mcgilman 👍

@rfellows rfellows merged commit 46cb14e into apache:main Apr 14, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui Pull requests for work relating to the user interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants