diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss b/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss index 5c91e1ee9d43..c74845bf33d3 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss +++ b/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss @@ -793,4 +793,10 @@ ) ); } + + // buttons + .mdc-button:disabled { + pointer-events: auto; + cursor: not-allowed; + } } diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-configuration-summary-step/connector-configuration-summary-step.component.html b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-configuration-summary-step/connector-configuration-summary-step.component.html index 021564da348d..f820085d9788 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-configuration-summary-step/connector-configuration-summary-step.component.html +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-configuration-summary-step/connector-configuration-summary-step.component.html @@ -185,6 +185,7 @@ [disabled]="applyDisabled()" [matTooltipDisabled]="applyTooltip() === ''" [matTooltip]="applyTooltip()" + [matTooltipShowDelay]="500" matTooltipPosition="left" (click)="onConfirm()" data-qa="apply-button" diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-configuration-summary-step/connector-configuration-summary-step.component.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-configuration-summary-step/connector-configuration-summary-step.component.spec.ts index a2c0b6bbab4c..c044cbb63ef6 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-configuration-summary-step/connector-configuration-summary-step.component.spec.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-configuration-summary-step/connector-configuration-summary-step.component.spec.ts @@ -1045,6 +1045,48 @@ describe('ConnectorConfigurationSummaryStep', () => { const applyButton = fixture.debugElement.query(By.css('[data-qa="apply-button"]')); expect(applyButton.nativeElement.disabled).toBe(true); }); + + it('should disable apply when applyAllowed is false even with verificationPassed=true', () => { + fixture.componentRef.setInput('verificationPassed', true); + fixture.componentRef.setInput('applyAllowed', false); + fixture.componentRef.setInput('applyDisabledReason', 'No pending changes'); + fixture.detectChanges(); + + const applyButton = fixture.debugElement.query(By.css('[data-qa="apply-button"]')); + expect(applyButton.nativeElement.disabled).toBe(true); + }); + + it('should surface the applyDisabledReason as the apply tooltip when not allowed', () => { + fixture.componentRef.setInput('verificationPassed', true); + fixture.componentRef.setInput('applyAllowed', false); + fixture.componentRef.setInput('applyDisabledReason', 'No pending changes'); + fixture.detectChanges(); + + expect(component.applyTooltip()).toBe('No pending changes'); + }); + + it('should keep the verify-required tooltip when apply is allowed but verification has not passed', () => { + fixture.componentRef.setInput('verificationPassed', null); + fixture.componentRef.setInput('applyAllowed', true); + fixture.componentRef.setInput('applyDisabledReason', ''); + fixture.detectChanges(); + + expect(component.applyTooltip()).toBe('Run verification before applying'); + }); + + it('should fall through to the verify-required tooltip when applyAllowed=false but no reason is provided', () => { + fixture.componentRef.setInput('verificationPassed', null); + fixture.componentRef.setInput('applyAllowed', false); + fixture.componentRef.setInput('applyDisabledReason', ''); + fixture.detectChanges(); + + expect(component.applyTooltip()).toBe('Run verification before applying'); + }); + + it('should default applyAllowed to true so existing callers are unaffected', () => { + expect(component.applyAllowed()).toBe(true); + expect(component.applyDisabledReason()).toBe(''); + }); }); describe('Step verification status icons', () => { diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-configuration-summary-step/connector-configuration-summary-step.component.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-configuration-summary-step/connector-configuration-summary-step.component.ts index b8b61c019bf6..eb89c778d7d0 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-configuration-summary-step/connector-configuration-summary-step.component.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-configuration-summary-step/connector-configuration-summary-step.component.ts @@ -58,6 +58,18 @@ export class ConnectorConfigurationSummaryStep { stepVerificationResults = input<{ [stepName: string]: ConfigVerificationResult[] }>({}); stepNameMapping = input<{ [displayName: string]: string[] }>({}); verifyAllError = input(null); + /** + * Whether the backend permits applying updates. Defaults to true so callers that + * have not yet adopted the input continue to behave as before. When false (e.g. the + * connector reports `APPLY_UPDATES` as not allowed because there are no pending + * changes or the connector is mid-update), the Apply button is disabled. + */ + applyAllowed = input(true); + /** + * Backend-supplied reason describing why apply is not allowed. Surfaced verbatim as + * the Apply button tooltip when {@link applyAllowed} is false. May be empty. + */ + applyDisabledReason = input(''); // Signal outputs confirm = output(); @@ -66,10 +78,27 @@ export class ConnectorConfigurationSummaryStep { verify = output(); applyDisabled = computed(() => { - return this.loading() || this.applying() || this.verifying() || this.verificationPassed() !== true; + return ( + this.loading() || + this.applying() || + this.verifying() || + this.verificationPassed() !== true || + !this.applyAllowed() + ); }); + /** + * The Apply button's tooltip. Returns one of: + * - the backend-supplied {@link applyDisabledReason} verbatim when apply is not + * allowed; takes precedence so the user sees the authoritative reason + * (e.g. "No pending changes", "Connector is updating"); + * - the verify-required hint when verification has not succeeded; + * - empty string otherwise. + */ applyTooltip = computed(() => { + if (!this.applyAllowed() && this.applyDisabledReason()) { + return this.applyDisabledReason(); + } if (this.verificationPassed() !== true) { return 'Run verification before applying'; } diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.component.html b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.component.html index 82738740164e..5fc28f4016b7 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.component.html +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.component.html @@ -81,6 +81,8 @@ [stepVerificationResults]="wizardStore.stepVerificationResults()" [verifyAllError]="wizardStore.verifyAllError()" [stepNameMapping]="stepNameMapping()" + [applyAllowed]="wizardStore.applyUpdatesAllowed()" + [applyDisabledReason]="wizardStore.applyUpdatesDisabledReason()" (confirm)="onApplyConfiguration()" (dismiss)="navigateBack.emit()" (previous)="onPreviousStep()" diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.component.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.component.spec.ts index 33afe98aed9c..3e30ce55635a 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.component.spec.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.component.spec.ts @@ -53,6 +53,8 @@ function createMockStore(state: MockStoreState = {}) { currentVerifyingStepName: signal(null), stepVerificationResults: signal>({}), verifyAllError: signal(null), + applyUpdatesAllowed: signal(false), + applyUpdatesDisabledReason: signal(''), bannerErrors: signal([]), initializeWithConnector: vi.fn(), loadSecrets: vi.fn(), diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.store.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.store.spec.ts index cfc8d788fd88..3bc2dfba6a1a 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.store.spec.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.store.spec.ts @@ -263,6 +263,63 @@ describe('StandardConnectorWizardStore', () => { }); }); + // ═══════════════════════════════════════════════════════ + // applyUpdatesAllowed / applyUpdatesDisabledReason + // ═══════════════════════════════════════════════════════ + + describe('applyUpdatesAllowed / applyUpdatesDisabledReason', () => { + it('returns false with empty reason when no connector is set', () => { + const { store } = setup(); + expect(store.applyUpdatesAllowed()).toBe(false); + expect(store.applyUpdatesDisabledReason()).toBe(''); + }); + + it('returns false with empty reason when APPLY_UPDATES action is missing', () => { + const { store } = setup(); + store.initializeWithConnector(makeConnector()); + expect(store.applyUpdatesAllowed()).toBe(false); + expect(store.applyUpdatesDisabledReason()).toBe(''); + }); + + it('reflects allowed=true when backend permits APPLY_UPDATES', () => { + const { store } = setup(); + const connector = makeConnector(); + connector.component.availableActions = [ + { name: 'APPLY_UPDATES', description: 'Apply updates', allowed: true } + ]; + store.initializeWithConnector(connector); + expect(store.applyUpdatesAllowed()).toBe(true); + expect(store.applyUpdatesDisabledReason()).toBe(''); + }); + + it('surfaces the backend reason when APPLY_UPDATES is not allowed', () => { + const { store } = setup(); + const connector = makeConnector(); + connector.component.availableActions = [ + { + name: 'APPLY_UPDATES', + description: 'Apply updates', + allowed: false, + reasonNotAllowed: 'No pending changes' + } + ]; + store.initializeWithConnector(connector); + expect(store.applyUpdatesAllowed()).toBe(false); + expect(store.applyUpdatesDisabledReason()).toBe('No pending changes'); + }); + + it('returns empty reason when APPLY_UPDATES is not allowed but no reason is provided', () => { + const { store } = setup(); + const connector = makeConnector(); + connector.component.availableActions = [ + { name: 'APPLY_UPDATES', description: 'Apply updates', allowed: false } + ]; + store.initializeWithConnector(connector); + expect(store.applyUpdatesAllowed()).toBe(false); + expect(store.applyUpdatesDisabledReason()).toBe(''); + }); + }); + // ═══════════════════════════════════════════════════════ // Per-step signal factories // ═══════════════════════════════════════════════════════ diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.store.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.store.ts index 0873276932e0..79ec58f01fc7 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.store.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.store.ts @@ -69,6 +69,8 @@ export abstract class ConnectorWizardStore { abstract readonly verificationPassed: Signal; abstract readonly currentVerifyingStepName: Signal; abstract readonly verifyAllError: Signal; + abstract readonly applyUpdatesAllowed: Signal; + abstract readonly applyUpdatesDisabledReason: Signal; // --------------- Per-step signal factories --------------- abstract stepConfiguration(stepName: string): Signal; diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/with-connector-wizard.feature.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/with-connector-wizard.feature.ts index 90883231f195..6cae43e97da4 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/with-connector-wizard.feature.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/with-connector-wizard.feature.ts @@ -68,6 +68,7 @@ import { initialConnectorWizardState } from './connector-wizard.types'; import { getVisibleStepNames } from './step-dependency.utils'; +import { getConnectorAction } from '../../utils/connector-permissions.utils'; /** * Reusable SignalStore feature encapsulating all shared connector wizard logic. @@ -90,7 +91,28 @@ export function withConnectorWizard() { allStepsVerifying: computed(() => allStepsVerification().verifying), verificationPassed: computed(() => allStepsVerification().passed), currentVerifyingStepName: computed(() => allStepsVerification().currentStepName), - verifyAllError: computed(() => allStepsVerification().error) + verifyAllError: computed(() => allStepsVerification().error), + /** + * Whether the backend currently permits applying the connector's working + * configuration. Derived from the `APPLY_UPDATES` action on the connector + * entity. The backend reports `allowed: false` with reason + * `"No pending changes"` when there is nothing to apply, and with reason + * `"Connector is updating"` while a previous apply is still in progress. + */ + applyUpdatesAllowed: computed(() => { + const c = connector(); + if (!c) return false; + return getConnectorAction(c, 'APPLY_UPDATES')?.allowed ?? false; + }), + /** + * Backend-supplied reason describing why apply is not allowed. Empty when + * apply is allowed or the action is missing entirely. + */ + applyUpdatesDisabledReason: computed(() => { + const c = connector(); + if (!c) return ''; + return getConnectorAction(c, 'APPLY_UPDATES')?.reasonNotAllowed ?? ''; + }) }) ),