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;
}