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 a512a1651d9 NIFI-15941: Allow verification to be re-attempted even if 
there are API errors present. (#11251)
a512a1651d9 is described below

commit a512a1651d9335e36115e8ca0883d006e783e80e
Author: Matt Gilman <[email protected]>
AuthorDate: Thu May 14 09:34:32 2026 -0400

    NIFI-15941: Allow verification to be re-attempted even if there are API 
errors present. (#11251)
---
 .../connector-configuration-step.component.spec.ts | 156 +++++++++++++++++++++
 .../connector-configuration-step.component.ts      |  75 ++++++++--
 2 files changed, 221 insertions(+), 10 deletions(-)

diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.spec.ts
 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.spec.ts
index 47d6deed381..10b7b12c4eb 100644
--- 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.spec.ts
@@ -722,6 +722,162 @@ describe('SharedConnectorConfigurationStep', () => {
         });
     });
 
+    // ═══════════════════════════════════════════════════════
+    // Verify gate (canVerify / onVerify)
+    //
+    // API verification errors (key 'verificationError', sourced from
+    // subjectVerificationErrors) must NOT block re-submitting verify --
+    // only client-side validator failures (required, pattern, etc.) should.
+    // ═══════════════════════════════════════════════════════
+
+    describe('verify gate', () => {
+        it('canVerify is true on a clean form with no errors', async () => {
+            const stepConfig = makeStepConfig('test-step', [makeProp('host')]);
+            const { component } = await setup({ stepConfig });
+
+            expect(component.stepForm.valid).toBe(true);
+            expect(component.canVerify).toBe(true);
+        });
+
+        it('canVerify is true when only API verification errors are present', 
async () => {
+            const stepConfig = makeStepConfig('test-step', [makeProp('host')], 
{
+                host: { valueType: 'STRING_LITERAL' as const, value: 
'db.example.com' }
+            });
+            const { component, mockStore, fixture } = await setup({ stepConfig 
});
+            await fixture.whenStable();
+
+            mockStore.subjectVerificationErrors.set({ host: 'Connection 
refused' });
+            // toObservable subscription on subjectVerificationErrors triggers
+            // updateFormValidityForVerificationErrors which re-runs the 
validator.
+            await fixture.whenStable();
+
+            
expect(component.stepForm.get('host')?.hasError('verificationError')).toBe(true);
+            expect(component.stepForm.valid).toBe(false);
+            expect(component.canVerify).toBe(true);
+        });
+
+        it('canVerify is false when a required client validator fails', async 
() => {
+            const stepConfig = makeStepConfig('test-step', [makeProp('host', { 
required: true })]);
+            const { component } = await setup({ stepConfig });
+
+            component.stepForm.get('host')?.setValue('');
+
+            
expect(component.stepForm.get('host')?.hasError('required')).toBe(true);
+            expect(component.canVerify).toBe(false);
+        });
+
+        it('canVerify is true when an invisible (disabled) dependent property 
would otherwise be invalid', async () => {
+            const stepConfig = makeStepConfig('test-step', [
+                makeProp('mode'),
+                makeProp('advanced-setting', {
+                    required: true,
+                    dependencies: [{ propertyName: 'mode', dependentValues: 
['advanced'] }]
+                })
+            ]);
+            const { component } = await setup({ stepConfig });
+
+            // mode = '' so advanced-setting is hidden and therefore disabled.
+            
expect(component.stepForm.get('advanced-setting')?.disabled).toBe(true);
+
+            // The disabled, required-but-empty control must not block Verify 
(mirrors FormGroup.valid).
+            expect(component.canVerify).toBe(true);
+        });
+
+        it('canVerify is false when both client and API errors exist (client 
error wins)', async () => {
+            const stepConfig = makeStepConfig('test-step', [makeProp('host', { 
required: true }), makeProp('port')], {
+                port: { valueType: 'STRING_LITERAL' as const, value: '5432' }
+            });
+            const { component, mockStore, fixture } = await setup({ stepConfig 
});
+            await fixture.whenStable();
+
+            component.stepForm.get('host')?.setValue('');
+            mockStore.subjectVerificationErrors.set({ port: 'Port unreachable' 
});
+            await fixture.whenStable();
+
+            
expect(component.stepForm.get('host')?.hasError('required')).toBe(true);
+            
expect(component.stepForm.get('port')?.hasError('verificationError')).toBe(true);
+            expect(component.canVerify).toBe(false);
+        });
+
+        it('canVerify is false while a verify request is in flight', async () 
=> {
+            const stepConfig = makeStepConfig('test-step', [makeProp('host')]);
+            const { component, mockStore } = await setup({ stepConfig });
+
+            mockStore.verifying.set(true);
+
+            expect(component.canVerify).toBe(false);
+        });
+
+        it('onVerify dispatches verifyStep when only API verification errors 
remain', async () => {
+            const stepConfig = makeStepConfig('test-step', [makeProp('host')], 
{
+                host: { valueType: 'STRING_LITERAL' as const, value: 
'db.example.com' }
+            });
+            const { component, mockStore, fixture } = await setup({ stepConfig 
});
+            await fixture.whenStable();
+
+            mockStore.subjectVerificationErrors.set({ host: 'Connection 
refused' });
+            await fixture.whenStable();
+
+            component.onVerify();
+
+            expect(mockStore.verifyStep).toHaveBeenCalledTimes(1);
+            
expect(mockStore.verifyStep).toHaveBeenCalledWith(expect.objectContaining({ 
stepName: 'test-step' }));
+        });
+
+        it('onVerify does not dispatch verifyStep when a required client error 
exists', async () => {
+            const stepConfig = makeStepConfig('test-step', [makeProp('host', { 
required: true })]);
+            const { component, mockStore } = await setup({ stepConfig });
+
+            component.stepForm.get('host')?.setValue('');
+
+            component.onVerify();
+
+            expect(mockStore.verifyStep).not.toHaveBeenCalled();
+        });
+
+        it('clears stale verificationError from form controls when 
subjectVerificationErrors becomes empty', async () => {
+            const stepConfig = makeStepConfig('test-step', [makeProp('host')], 
{
+                host: { valueType: 'STRING_LITERAL' as const, value: 
'db.example.com' }
+            });
+            const { component, mockStore, fixture } = await setup({ stepConfig 
});
+            await fixture.whenStable();
+
+            // Simulate a previous failed verify that surfaced a subject error 
on host.
+            mockStore.subjectVerificationErrors.set({ host: 'Connection 
refused' });
+            await fixture.whenStable();
+            
expect(component.stepForm.get('host')?.hasError('verificationError')).toBe(true);
+
+            // Simulate a successful verify (or verify start): the store 
clears the signal.
+            mockStore.subjectVerificationErrors.set({});
+            await fixture.whenStable();
+
+            
expect(component.stepForm.get('host')?.hasError('verificationError')).toBe(false);
+            expect(component.stepForm.get('host')?.valid).toBe(true);
+        });
+
+        it('reconciles per-control verificationErrors when the signal 
transitions to a different set', async () => {
+            const stepConfig = makeStepConfig('test-step', [makeProp('host'), 
makeProp('port')], {
+                host: { valueType: 'STRING_LITERAL' as const, value: 
'db.example.com' },
+                port: { valueType: 'STRING_LITERAL' as const, value: '5432' }
+            });
+            const { component, mockStore, fixture } = await setup({ stepConfig 
});
+            await fixture.whenStable();
+
+            mockStore.subjectVerificationErrors.set({ host: 'Connection 
refused' });
+            await fixture.whenStable();
+            
expect(component.stepForm.get('host')?.hasError('verificationError')).toBe(true);
+            
expect(component.stepForm.get('port')?.hasError('verificationError')).toBe(false);
+
+            // A subsequent failed verify: host is fine now, port is failing 
instead.
+            mockStore.subjectVerificationErrors.set({ port: 'Port unreachable' 
});
+            await fixture.whenStable();
+
+            
expect(component.stepForm.get('host')?.hasError('verificationError')).toBe(false);
+            
expect(component.stepForm.get('port')?.hasError('verificationError')).toBe(true);
+            expect(component.canVerify).toBe(true);
+        });
+    });
+
     // ═══════════════════════════════════════════════════════
     // Store signal delegation
     // ═══════════════════════════════════════════════════════
diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.ts
 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.ts
index 9c473b59d7b..d4e1be451bb 100644
--- 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.ts
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.ts
@@ -60,6 +60,21 @@ import { ConnectorWizardStore } from 
'../connector-wizard.store';
 import { WizardContextBanner } from 
'../wizard-context-banner/wizard-context-banner.component';
 import { WizardStepDocumentationPanel } from 
'../wizard-step-documentation-panel/wizard-step-documentation-panel.component';
 
+/**
+ * Validation error key used to surface backend (API) field-level verification
+ * failures into Angular form errors. The `verifyStep` flow stores per-field
+ * messages in the wizard store under `subjectVerificationErrors`, and the
+ * `verificationErrorValidator` in this component projects those messages onto
+ * the corresponding controls under this key. The verify gate (`isClientValid`)
+ * intentionally ignores this key so the user can resubmit a verify request
+ * after addressing external conditions (network, permissions, etc.) without
+ * having to mutate form values.
+ *
+ * The matching template literal lives in 
connector-property-input.component.html
+ * (`parentControl?.hasError('verificationError')`); keep them in sync.
+ */
+const VERIFICATION_ERROR_KEY = 'verificationError';
+
 @Component({
     selector: 'shared-connector-configuration-step',
     standalone: true,
@@ -311,9 +326,9 @@ export class SharedConnectorConfigurationStep implements 
SaveableStep, OnInit, O
      * Reads directly from the store signal for synchronous, up-to-date access.
      */
     private verificationErrorValidator(propertyName: string): ValidatorFn {
-        return (): { verificationError: string } | null => {
+        return (): { [VERIFICATION_ERROR_KEY]: string } | null => {
             const errorMessage = 
this.subjectVerificationErrorsSignal()[propertyName];
-            return errorMessage ? { verificationError: errorMessage } : null;
+            return errorMessage ? { [VERIFICATION_ERROR_KEY]: errorMessage } : 
null;
         };
     }
 
@@ -701,20 +716,35 @@ export class SharedConnectorConfigurationStep implements 
SaveableStep, OnInit, O
     }
 
     /**
-     * Trigger revalidation on form controls that have verification errors.
-     * Called when verification errors change to ensure mat-error displays 
properly.
+     * Sync form-control validity with the wizard store's 
subjectVerificationErrors signal.
+     * Called whenever the signal changes (verify start, failed verify, 
successful verify,
+     * single-field clear). Two cases must be handled:
+     *   1. New errors: re-run validators on the affected controls so the 
verificationError
+     *      key gets attached. Mark them touched so mat-error displays.
+     *   2. Stale errors: re-run validators on any control that still carries a
+     *      verificationError but whose property is no longer in the signal -- 
this
+     *      clears the error after a successful verify (signal becomes {}) or 
after
+     *      verify start (signal is wiped) when the next response is success.
      */
     private updateFormValidityForVerificationErrors(): void {
         if (!this.stepForm) return;
 
-        
Object.keys(this.subjectVerificationErrorsSignal()).forEach((propertyName) => {
+        const currentErrors = this.subjectVerificationErrorsSignal();
+
+        Object.keys(this.stepForm.controls).forEach((propertyName) => {
             const control = this.stepForm.get(propertyName);
-            if (control) {
+            if (!control) return;
+
+            const hasNewError = !!currentErrors[propertyName];
+            const hasStaleError = control.hasError(VERIFICATION_ERROR_KEY);
+            if (!hasNewError && !hasStaleError) return;
+
+            if (hasNewError) {
                 // Mark as touched BEFORE updating validity so that ngDoCheck
                 // in ConnectorPropertyInput will sync errors to the internal 
control
                 control.markAsTouched();
-                control.updateValueAndValidity({ emitEvent: true });
             }
+            control.updateValueAndValidity({ emitEvent: true });
         });
 
         // Force change detection to ensure property inputs re-sync with 
parent control errors
@@ -722,10 +752,35 @@ export class SharedConnectorConfigurationStep implements 
SaveableStep, OnInit, O
     }
 
     /**
-     * Check if verify button can be enabled
+     * True when the form has no client-side validation errors. API 
verification
+     * errors (key 'verificationError') are intentionally ignored so the user 
can
+     * resubmit a verify request without first clearing prior backend failures,
+     * which are frequently caused by external conditions (network, 
permissions,
+     * etc.) that the user cannot resolve by editing form values.
+     *
+     * Disabled controls (e.g. dependency-hidden properties via
+     * computeAllPropertyVisibility) are skipped to mirror FormGroup.valid's
+     * behavior; Angular reports valid === false / errors === null on disabled
+     * controls, so without this guard a step with any hidden-via-dependency
+     * property would never enable Verify.
+     */
+    private isClientValid(): boolean {
+        return Object.keys(this.stepForm.controls).every((key) => {
+            const control = this.stepForm.get(key);
+            if (!control || control.disabled || control.valid) return true;
+            const errorKeys = Object.keys(control.errors ?? {});
+            return errorKeys.length > 0 && errorKeys.every((k) => k === 
VERIFICATION_ERROR_KEY);
+        });
+    }
+
+    /**
+     * Check if verify button can be enabled.
+     * Only client-side validation errors block verification; prior API 
verification
+     * errors are ignored so the user can re-submit verify after addressing 
external
+     * conditions (network, permissions, etc.) without modifying form values.
      */
     get canVerify(): boolean {
-        return this.stepForm.valid && !this.isStepSaving() && 
!this.isVerifying();
+        return this.isClientValid() && !this.isStepSaving() && 
!this.isVerifying();
     }
 
     /**
@@ -886,7 +941,7 @@ export class SharedConnectorConfigurationStep implements 
SaveableStep, OnInit, O
     onVerify(): void {
         const stepData = this.stepConfiguration?.();
         // If form is invalid, mark all fields as touched to show validation 
errors
-        if (!this.stepForm.valid || !stepData) {
+        if (!this.isClientValid() || !stepData) {
             this.markAllAsTouched();
             return;
         }

Reply via email to