This is an automated email from the ASF dual-hosted git repository.
mcgilman 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 a2eb2c59a33 NIFI-15855 - Connector wizard - secret and boolean
property handling (#11248)
a2eb2c59a33 is described below
commit a2eb2c59a33fdb9f33aba98ec2a65f786a0ab869
Author: Rob Fellows <[email protected]>
AuthorDate: Fri May 15 11:08:12 2026 -0400
NIFI-15855 - Connector wizard - secret and boolean property handling
(#11248)
* NIFI-15855 - Connector wizard - secret and boolean property handling
* Address review feedback:
---
.../connector-property-input.component.html | 18 +-
.../connector-property-input.component.spec.ts | 372 ++++++++++++++++++++-
.../connector-property-input.component.ts | 165 ++++++++-
3 files changed, 536 insertions(+), 19 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.html
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.html
index b9583944126..7b87d9f6d2a 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.html
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.html
@@ -18,12 +18,14 @@
@if (prop) {
@switch (prop.type) {
@case ('BOOLEAN') {
- <mat-checkbox [formControl]="formControl"
data-qa="property-input-boolean">
- <span class="text-sm">{{ prop.name }}</span>
+ <div class="flex flex-col">
+ <mat-slide-toggle [formControl]="formControl"
labelPosition="before" data-qa="property-input-boolean">
+ <span class="text-sm">{{ prop.name }}</span>
+ </mat-slide-toggle>
@if (prop.description) {
<span class="text-xs tertiary-color block">{{
prop.description }}</span>
}
- </mat-checkbox>
+ </div>
}
@default {
@if (shouldUseAssetUpload()) {
@@ -79,14 +81,18 @@
[searchPlaceholder]="'Search'"
[allowClear]="!prop.required"
[hint]="prop.description || ''"
- [showHint]="!!prop.description"
+ [showHint]="!!prop.description && !hasSecretsError()"
[validationError]="getValidationErrorMessage()"
+ [loadError]="hasSecretsError()"
+ [loadErrorMessage]="'Failed to load secrets'"
data-qa="property-input-select">
</searchable-select>
- @if (isDynamicValuesLoading()) {
+ @if (isDynamicValuesLoading() || isSecretsLoading()) {
<div class="flex items-center gap-2 mt-1"
data-qa="property-input-loading">
<mat-progress-spinner diameter="14"
mode="indeterminate"></mat-progress-spinner>
- <span class="text-xs tertiary-color">Loading
values...</span>
+ <span class="text-xs tertiary-color">
+ {{ isSecretsLoading() ? 'Loading secrets...' :
'Loading values...' }}
+ </span>
</div>
}
</div>
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
index b7bdddd9ba6..bbbf71841ae 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
@@ -26,8 +26,10 @@ import { ConnectorPropertyInput } from
'./connector-property-input.component';
import {
AllowableValue,
AssetInfo,
+ buildSecretKey,
ConnectorPropertyDescriptor,
PropertyAllowableValuesState,
+ Secret,
UploadProgressInfo
} from '../../types';
@@ -65,6 +67,18 @@ function makeProgress(overrides: Partial<UploadProgressInfo>
= {}): UploadProgre
};
}
+function makeSecret(overrides: Partial<Secret> = {}): Secret {
+ return {
+ name: 'my-secret',
+ fullyQualifiedName: 'group-a.my-secret',
+ providerId: 'provider-1',
+ providerName: 'Vault',
+ groupName: 'group-a',
+ description: 'A secret',
+ ...overrides
+ };
+}
+
/**
* Host fixture that owns the parent FormControl and reactively passes signal
inputs
* to ConnectorPropertyInput. Use setters on the returned harness to drive
updates.
@@ -79,6 +93,9 @@ function makeProgress(overrides: Partial<UploadProgressInfo>
= {}): UploadProgre
[dynamicAllowableValuesState]="dynamicAllowableValuesState()"
[currentAssets]="currentAssets()"
[assetUploadProgress]="assetUploadProgress()"
+ [availableSecrets]="availableSecrets()"
+ [secretsLoading]="secretsLoading()"
+ [secretsError]="secretsError()"
(requestAllowableValues)="onRequestAllowableValues()"
(assetFilesSelected)="onAssetFilesSelected($event)"
(assetDeleteRequested)="onAssetDeleteRequested($event)"
@@ -87,11 +104,14 @@ function makeProgress(overrides:
Partial<UploadProgressInfo> = {}): UploadProgre
`
})
class HostComponent {
- control = new FormControl<string | string[] | null>(null);
+ control = new FormControl<string | string[] | boolean | null>(null);
property: WritableSignal<ConnectorPropertyDescriptor> = signal(makeProp());
dynamicAllowableValuesState: WritableSignal<PropertyAllowableValuesState |
null> = signal(null);
currentAssets: WritableSignal<AssetInfo[]> = signal([]);
assetUploadProgress: WritableSignal<UploadProgressInfo[]> = signal([]);
+ availableSecrets: WritableSignal<Secret[] | null> = signal(null);
+ secretsLoading: WritableSignal<boolean> = signal(false);
+ secretsError: WritableSignal<string | null> = signal(null);
requestSpy = vi.fn();
assetFilesSelectedSpy = vi.fn();
assetDeleteRequestedSpy = vi.fn();
@@ -127,9 +147,12 @@ async function setup(
options: {
property?: ConnectorPropertyDescriptor;
dynamicState?: PropertyAllowableValuesState | null;
- initialValue?: string | string[] | null;
+ initialValue?: string | string[] | boolean | null;
currentAssets?: AssetInfo[];
assetUploadProgress?: UploadProgressInfo[];
+ availableSecrets?: Secret[] | null;
+ secretsLoading?: boolean;
+ secretsError?: string | null;
} = {}
) {
await TestBed.configureTestingModule({
@@ -154,6 +177,15 @@ async function setup(
if (options.assetUploadProgress !== undefined) {
host.assetUploadProgress.set(options.assetUploadProgress);
}
+ if (options.availableSecrets !== undefined) {
+ host.availableSecrets.set(options.availableSecrets);
+ }
+ if (options.secretsLoading !== undefined) {
+ host.secretsLoading.set(options.secretsLoading);
+ }
+ if (options.secretsError !== undefined) {
+ host.secretsError.set(options.secretsError);
+ }
fixture.detectChanges();
await fixture.whenStable();
@@ -330,13 +362,90 @@ describe('ConnectorPropertyInput', () => {
});
describe('boolean rendering', () => {
- it('renders a checkbox for BOOLEAN properties', async () => {
+ it('renders a mat-slide-toggle (not a checkbox) for BOOLEAN
properties', async () => {
const { fixture } = await setup({
property: makeProp({ type: 'BOOLEAN' })
});
- const checkbox =
fixture.debugElement.query(By.css('[data-qa="property-input-boolean"]'));
- expect(checkbox).toBeTruthy();
+ const toggle =
fixture.debugElement.query(By.css('[data-qa="property-input-boolean"]'));
+ const checkbox =
fixture.debugElement.query(By.css('mat-checkbox'));
+
+ expect(toggle).toBeTruthy();
+
expect(toggle.nativeElement.tagName.toLowerCase()).toBe('mat-slide-toggle');
+ expect(checkbox).toBeNull();
+ });
+
+ it('reflects the form value in the toggle checked state', async () => {
+ const { fixture, host } = await setup({
+ property: makeProp({ type: 'BOOLEAN' }),
+ initialValue: true
+ });
+
+ const toggle =
fixture.debugElement.query(By.css('[data-qa="property-input-boolean"]'));
+ expect(toggle.componentInstance.checked).toBe(true);
+
+ host.control.setValue(false);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+
+ expect(toggle.componentInstance.checked).toBe(false);
+ });
+
+ it('propagates the form value through the [formControl] binding
(parent <-> toggle round-trip)', async () => {
+ const { fixture, host } = await setup({
+ property: makeProp({ type: 'BOOLEAN' })
+ });
+
+ const toggleDebug =
fixture.debugElement.query(By.css('[data-qa="property-input-boolean"]'));
+
+ // Parent -> child
+ host.control.setValue(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+ expect(toggleDebug.componentInstance.checked).toBe(true);
+
+ // Toggle -> parent: invoke the underlying input element's click,
+ // which is the public surface MatSlideToggle exposes for
user-driven changes.
+ const input: HTMLInputElement | null = (toggleDebug.nativeElement
as HTMLElement).querySelector(
+ 'button[role="switch"], input[type="checkbox"]'
+ );
+ expect(input).not.toBeNull();
+ input!.click();
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+
+ expect(host.control.value).toBe(false);
+ });
+
+ it('coerces string "true" to true via writeValue', async () => {
+ const { fixture, host } = await setup({
+ property: makeProp({ type: 'BOOLEAN' })
+ });
+
+ host.control.setValue('true' as unknown as boolean);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+
+ const toggle =
fixture.debugElement.query(By.css('[data-qa="property-input-boolean"]'));
+ expect(toggle.componentInstance.checked).toBe(true);
+ });
+
+ it('coerces non-true values to false via writeValue', async () => {
+ const { fixture, host } = await setup({
+ property: makeProp({ type: 'BOOLEAN' })
+ });
+
+ host.control.setValue('false' as unknown as boolean);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+
+ const toggle =
fixture.debugElement.query(By.css('[data-qa="property-input-boolean"]'));
+ expect(toggle.componentInstance.checked).toBe(false);
});
});
@@ -489,6 +598,259 @@ describe('ConnectorPropertyInput', () => {
});
});
+ describe('SECRET rendering', () => {
+ it('renders a searchable-select for a SECRET property even when no
secrets are loaded yet', async () => {
+ const { fixture, inputComponent } = await setup({
+ property: makeProp({ type: 'SECRET' })
+ });
+
+ const select =
fixture.debugElement.query(By.css('[data-qa="property-input-select"]'));
+ expect(select).toBeTruthy();
+ expect(inputComponent.shouldUseSelect()).toBe(true);
+ });
+
+ it('builds options from availableSecrets using the composite key as
the option value', async () => {
+ const secret = makeSecret({
+ name: 'Prod DB Password',
+ fullyQualifiedName: 'group-a.prod-db',
+ providerId: 'vault-1',
+ providerName: 'Vault',
+ groupName: 'group-a',
+ description: 'Production DB password'
+ });
+ const { inputComponent } = await setup({
+ property: makeProp({ type: 'SECRET' }),
+ availableSecrets: [secret]
+ });
+
+ expect(inputComponent.selectOptions).toEqual([
+ {
+ value: buildSecretKey('vault-1', 'Vault',
'group-a.prod-db'),
+ label: 'Prod DB Password',
+ description: 'Production DB password',
+ group: 'Vault'
+ }
+ ]);
+ });
+
+ it('uses the bare provider name as the group when a provider owns a
single group', async () => {
+ const secrets = [
+ makeSecret({ name: 'secret-1', providerName: 'Vault',
groupName: 'group-a' }),
+ makeSecret({
+ name: 'secret-2',
+ fullyQualifiedName: 'group-a.secret-2',
+ providerName: 'Vault',
+ groupName: 'group-a'
+ })
+ ];
+ const { inputComponent } = await setup({
+ property: makeProp({ type: 'SECRET' }),
+ availableSecrets: secrets
+ });
+
+ expect(inputComponent.selectOptions.map((o) =>
o.group)).toEqual(['Vault', 'Vault']);
+ });
+
+ it('formats the group as "Provider - Group" when a provider owns
multiple groups', async () => {
+ const secrets = [
+ makeSecret({ name: 'secret-1', providerName: 'Vault',
groupName: 'group-a' }),
+ makeSecret({
+ name: 'secret-2',
+ fullyQualifiedName: 'group-b.secret-2',
+ providerName: 'Vault',
+ groupName: 'group-b'
+ })
+ ];
+ const { inputComponent } = await setup({
+ property: makeProp({ type: 'SECRET' }),
+ availableSecrets: secrets
+ });
+
+ expect(inputComponent.selectOptions.map((o) =>
o.group)).toEqual(['Vault - group-a', 'Vault - group-b']);
+ });
+
+ it('appends a disabled "(no longer available)" option when the saved
value is missing from the loaded secrets', async () => {
+ const orphanKey = buildSecretKey('old-provider', 'OldVault',
'group-x.gone');
+ const { inputComponent } = await setup({
+ property: makeProp({ type: 'SECRET' }),
+ initialValue: orphanKey,
+ availableSecrets: [makeSecret()]
+ });
+
+ const orphan = inputComponent.selectOptions.find((o) => o.value
=== orphanKey);
+ expect(orphan).toBeTruthy();
+ expect(orphan?.disabled).toBe(true);
+ expect(orphan?.label).toBe('group-x.gone (no longer available)');
+ expect(orphan?.group).toBe('OldVault');
+ });
+
+ it('renders the saved value as an orphan placeholder while secrets are
still loading (no "(no longer available)" suffix)', async () => {
+ const orphanKey = buildSecretKey('provider-1', 'Vault',
'group-a.secret-1');
+ const { inputComponent } = await setup({
+ property: makeProp({ type: 'SECRET' }),
+ initialValue: orphanKey,
+ availableSecrets: null,
+ secretsLoading: true
+ });
+
+ const orphan = inputComponent.selectOptions.find((o) => o.value
=== orphanKey);
+ expect(orphan).toBeTruthy();
+ expect(orphan?.disabled).toBe(true);
+ expect(orphan?.label).toBe('group-a.secret-1');
+ expect(orphan?.group).toBe('Vault');
+ });
+
+ it('shows the "(no longer available)" suffix once loading completes
and the secret is absent', async () => {
+ const orphanKey = buildSecretKey('provider-1', 'Vault',
'group-a.secret-1');
+ const { fixture, host, inputComponent } = await setup({
+ property: makeProp({ type: 'SECRET' }),
+ initialValue: orphanKey,
+ availableSecrets: null,
+ secretsLoading: true
+ });
+
+ let orphan = inputComponent.selectOptions.find((o) => o.value ===
orphanKey);
+ expect(orphan?.label).toBe('group-a.secret-1');
+
+ host.secretsLoading.set(false);
+ host.availableSecrets.set([]);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+
+ orphan = inputComponent.selectOptions.find((o) => o.value ===
orphanKey);
+ expect(orphan?.label).toBe('group-a.secret-1 (no longer
available)');
+ });
+
+ it('rewrites the form value to the current composite key after a
provider rename', async () => {
+ const savedKey = buildSecretKey('provider-1', 'OldVault',
'group-a.secret-1');
+ const renamedSecret = makeSecret({
+ providerId: 'provider-1',
+ providerName: 'NewVault',
+ fullyQualifiedName: 'group-a.secret-1',
+ groupName: 'group-a',
+ name: 'secret-1'
+ });
+ const expectedKey = buildSecretKey('provider-1', 'NewVault',
'group-a.secret-1');
+
+ const { fixture, host } = await setup({
+ property: makeProp({ type: 'SECRET' }),
+ initialValue: savedKey,
+ availableSecrets: [renamedSecret]
+ });
+
+ // afterNextRender defers the setValue to the next render; flush
via detectChanges + whenStable.
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+
+ expect(host.control.value).toBe(expectedKey);
+ });
+
+ it('flips the searchable-select into loadError mode and suppresses the
description hint when secretsError is set', async () => {
+ const { fixture, inputComponent } = await setup({
+ property: makeProp({ type: 'SECRET', description: 'A
description' }),
+ secretsError: 'Backend rejected the secrets request'
+ });
+
+ const select =
fixture.debugElement.query(By.css('[data-qa="property-input-select"]'));
+ expect(select.componentInstance.loadError()).toBe(true);
+ expect(select.componentInstance.loadErrorMessage()).toBe('Failed
to load secrets');
+ expect(select.componentInstance.showHint()).toBe(false);
+ expect(inputComponent.hasSecretsError()).toBe(true);
+ });
+
+ it('does not flip into loadError mode when secretsError is the empty
string', async () => {
+ const { fixture } = await setup({
+ property: makeProp({ type: 'SECRET' }),
+ secretsError: ''
+ });
+
+ const select =
fixture.debugElement.query(By.css('[data-qa="property-input-select"]'));
+ expect(select.componentInstance.loadError()).toBe(false);
+ expect(select.componentInstance.loadErrorMessage()).toBe('Failed
to load secrets');
+ });
+
+ it('shows the inline loading spinner with "Loading secrets..." while
secretsLoading is true', async () => {
+ const { fixture } = await setup({
+ property: makeProp({ type: 'SECRET' }),
+ secretsLoading: true
+ });
+
+ const spinner =
fixture.debugElement.query(By.css('[data-qa="property-input-loading"]'));
+ expect(spinner).toBeTruthy();
+ expect((spinner.nativeElement as
HTMLElement).textContent).toContain('Loading secrets...');
+ });
+
+ it('hides the loading spinner once secretsLoading flips back to
false', async () => {
+ const { fixture, host } = await setup({
+ property: makeProp({ type: 'SECRET' }),
+ secretsLoading: true
+ });
+
+
expect(fixture.debugElement.query(By.css('[data-qa="property-input-loading"]'))).toBeTruthy();
+
+ host.secretsLoading.set(false);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+
+
expect(fixture.debugElement.query(By.css('[data-qa="property-input-loading"]'))).toBeNull();
+ });
+
+ it('returns the right placeholder text for each SECRET state', async
() => {
+ const { inputComponent, host, fixture } = await setup({
+ property: makeProp({ type: 'SECRET' }),
+ secretsLoading: true
+ });
+ expect(inputComponent.getSelectPlaceholder()).toBe('Loading
secrets...');
+
+ host.secretsLoading.set(false);
+ host.secretsError.set('boom');
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+ expect(inputComponent.getSelectPlaceholder()).toBe('Failed to load
secrets');
+
+ host.secretsError.set(null);
+ host.availableSecrets.set([]);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+ expect(inputComponent.getSelectPlaceholder()).toBe('No secrets
available');
+
+ host.availableSecrets.set([makeSecret()]);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+ expect(inputComponent.getSelectPlaceholder()).toBe('Select a
secret');
+ });
+
+ it('removes the orphan option from selectOptions after the user
selects a real secret', async () => {
+ const orphanKey = buildSecretKey('old-provider', 'OldVault',
'group-x.gone');
+ const realSecret = makeSecret();
+ const realKey = buildSecretKey(
+ realSecret.providerId,
+ realSecret.providerName,
+ realSecret.fullyQualifiedName
+ );
+ const { fixture, host, inputComponent } = await setup({
+ property: makeProp({ type: 'SECRET' }),
+ initialValue: orphanKey,
+ availableSecrets: [realSecret]
+ });
+
+ expect(inputComponent.selectOptions.some((o) => o.value ===
orphanKey && o.disabled)).toBe(true);
+
+ host.control.setValue(realKey);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+
+ expect(inputComponent.selectOptions.some((o) => o.value ===
orphanKey)).toBe(false);
+ });
+ });
+
describe('asset rendering', () => {
it('renders an asset-upload (single) for an ASSET property and hides
the default text input', async () => {
const { fixture, inputComponent } = await setup({
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
index 27d7c161cda..682256ff96d 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
@@ -15,17 +15,31 @@
* limitations under the License.
*/
-import { Component, DestroyRef, DoCheck, effect, inject, input, OnInit, output
} from '@angular/core';
+import {
+ afterNextRender,
+ Component,
+ DestroyRef,
+ DoCheck,
+ effect,
+ inject,
+ Injector,
+ input,
+ OnInit,
+ output,
+ runInInjectionContext
+} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ControlValueAccessor, FormControl, NgControl, ReactiveFormsModule }
from '@angular/forms';
import { MatError, MatFormField, MatHint, MatLabel } from
'@angular/material/form-field';
import { MatInput } from '@angular/material/input';
-import { MatCheckbox } from '@angular/material/checkbox';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
+import { MatSlideToggle } from '@angular/material/slide-toggle';
import {
AllowableValue,
AssetInfo,
+ buildSecretKey,
ConnectorPropertyDescriptor,
+ parseSecretKey,
PropertyAllowableValuesState,
SearchableSelectOption,
Secret,
@@ -37,12 +51,13 @@ import { AssetUpload } from
'../asset-upload/asset-upload.component';
/**
* Form control for a single connector property.
* Renders different input types based on the property descriptor:
- * STRING/INTEGER/DOUBLE/FLOAT -> text input, BOOLEAN -> checkbox,
+ * STRING/INTEGER/DOUBLE/FLOAT -> text input, BOOLEAN -> slide toggle,
* STRING_LIST without allowable values -> textarea (comma-separated),
* allowable values (static or fetched) -> searchable-select
* (multi-select when the property type is STRING_LIST),
- * ASSET / ASSET_LIST -> asset-upload (drop zone + uploaded list + progress).
- * SECRET handling is deferred to a follow-up PR.
+ * ASSET / ASSET_LIST -> asset-upload (drop zone + uploaded list + progress),
+ * SECRET -> searchable-select populated from availableSecrets (composite key
+ * value, provider/group grouping, orphan handling, provider-rename auto-fix).
*
* Uses an internal FormControl bound to the actual input elements so that
* mat-form-field can detect error state. Validation state is synced from
@@ -58,8 +73,8 @@ import { AssetUpload } from
'../asset-upload/asset-upload.component';
MatLabel,
MatHint,
MatInput,
- MatCheckbox,
MatProgressSpinner,
+ MatSlideToggle,
SearchableSelect,
AssetUpload
],
@@ -68,6 +83,7 @@ import { AssetUpload } from
'../asset-upload/asset-upload.component';
export class ConnectorPropertyInput implements ControlValueAccessor, DoCheck,
OnInit {
private ngControl = inject(NgControl, { optional: true, self: true });
private destroyRef = inject(DestroyRef);
+ private injector = inject(Injector);
readonly property = input.required<ConnectorPropertyDescriptor>();
readonly dynamicAllowableValuesState = input<PropertyAllowableValuesState
| null>(null);
@@ -114,6 +130,9 @@ export class ConnectorPropertyInput implements
ControlValueAccessor, DoCheck, On
effect(() => {
const prop = this.property();
this.dynamicAllowableValuesState();
+ this.availableSecrets();
+ this.secretsLoading();
+ this.secretsError();
this.selectOptions = this.computeSelectOptions();
const currentName = prop?.name ?? null;
@@ -123,6 +142,29 @@ export class ConnectorPropertyInput implements
ControlValueAccessor, DoCheck, On
}
this.lastSeenPropertyName = currentName;
});
+
+ // Reconcile form value with current secret identity after secrets
load.
+ // If the saved composite key matches a current secret by providerId +
fullyQualifiedName
+ // but the providerName has changed, rewrite the form value to the
current key.
+ // afterNextRender defers the setValue past the current
change-detection cycle.
+ // Multiple rapid re-runs are safe: the rewrite is idempotent.
+ effect(() => {
+ const secrets = this.availableSecrets();
+ if (this.property()?.type !== 'SECRET' || !secrets) return;
+ const currentValue = this.formControl.value as string | null;
+ if (!currentValue) return;
+ const parsed = parseSecretKey(currentValue);
+ const matching = secrets.find(
+ (s) => s.providerId === parsed.providerId &&
s.fullyQualifiedName === parsed.fullyQualifiedName
+ );
+ if (!matching) return;
+ const expected = buildSecretKey(matching.providerId,
matching.providerName, matching.fullyQualifiedName);
+ if (currentValue !== expected) {
+ runInInjectionContext(this.injector, () => {
+ afterNextRender(() => this.formControl.setValue(expected,
{ emitEvent: true }));
+ });
+ }
+ });
}
ngOnInit(): void {
@@ -136,6 +178,9 @@ export class ConnectorPropertyInput implements
ControlValueAccessor, DoCheck, On
normalized = value === true || value === 'true';
}
this.formControl.setValue(normalized, { emitEvent: false });
+ if (this.property()?.type === 'SECRET') {
+ this.selectOptions = this.computeSelectOptions();
+ }
}
registerOnChange(fn: (value: unknown) => void): void {
@@ -143,6 +188,9 @@ export class ConnectorPropertyInput implements
ControlValueAccessor, DoCheck, On
this.valueChangesSubscribed = true;
this.formControl.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((value)
=> {
fn(value);
+ if (this.property()?.type === 'SECRET') {
+ this.selectOptions = this.computeSelectOptions();
+ }
});
}
}
@@ -276,6 +324,12 @@ export class ConnectorPropertyInput implements
ControlValueAccessor, DoCheck, On
return false;
}
+ // SECRET always uses the select. Loading shows an inline spinner;
load errors
+ // disable the select via the searchable-select loadError input.
+ if (prop.type === 'SECRET') {
+ return true;
+ }
+
if (this.isDynamicValuesFetchFailed() ||
this.isDynamicValuesFetchEmpty()) {
return false;
}
@@ -339,10 +393,38 @@ export class ConnectorPropertyInput implements
ControlValueAccessor, DoCheck, On
return this.property()?.type === 'STRING_LIST' &&
this.shouldUseSelect();
}
+ /**
+ * True when a SECRET property's secrets list is currently being loaded.
+ */
+ isSecretsLoading(): boolean {
+ return this.property()?.type === 'SECRET' && this.secretsLoading();
+ }
+
+ /**
+ * True when a SECRET property's secrets fetch reported an error.
+ */
+ hasSecretsError(): boolean {
+ return this.property()?.type === 'SECRET' && !!this.secretsError();
+ }
+
/**
* Placeholder text for the searchable-select dropdown.
*/
getSelectPlaceholder(): string {
+ const prop = this.property();
+ if (prop?.type === 'SECRET') {
+ if (this.secretsLoading()) {
+ return 'Loading secrets...';
+ }
+ if (this.secretsError()) {
+ return 'Failed to load secrets';
+ }
+ const list = this.availableSecrets();
+ if (list && list.length === 0) {
+ return 'No secrets available';
+ }
+ return 'Select a secret';
+ }
if (this.isDynamicValuesLoading()) {
return 'Loading values...';
}
@@ -402,8 +484,18 @@ export class ConnectorPropertyInput implements
ControlValueAccessor, DoCheck, On
}
/**
- * Compute SearchableSelectOptions from whichever source is available:
- * dynamic values when successfully fetched, otherwise static values.
+ * Pure computation of SearchableSelectOptions from whichever source is
available.
+ * Has no side effects; all reactive updates (provider-rename rewrite,
orphan
+ * clearance on value change) are handled by separate effects and
subscriptions.
+ *
+ * - SECRET: options come from availableSecrets, valued by a composite key
+ * (providerId::providerName::fullyQualifiedName) and grouped by provider
+ * (or "Provider - Group" when a provider owns multiple groups). Saved
+ * values that no longer match an available secret are surfaced as
disabled
+ * options; the "(no longer available)" suffix is suppressed while
secrets
+ * are still loading so as not to alarm the user prematurely.
+ * - Otherwise: dynamic values when successfully fetched, falling back to
+ * the descriptor's static allowableValues.
*/
private computeSelectOptions(): SearchableSelectOption<string>[] {
const prop = this.property();
@@ -411,6 +503,10 @@ export class ConnectorPropertyInput implements
ControlValueAccessor, DoCheck, On
return [];
}
+ if (prop.type === 'SECRET') {
+ return this.computeSecretSelectOptions();
+ }
+
let allowableValues: AllowableValue[] = [];
const state = this.dynamicAllowableValuesState();
@@ -426,6 +522,59 @@ export class ConnectorPropertyInput implements
ControlValueAccessor, DoCheck, On
}));
}
+ private computeSecretSelectOptions(): SearchableSelectOption<string>[] {
+ const options: SearchableSelectOption<string>[] = [];
+ const secrets: Secret[] | null = this.availableSecrets();
+
+ if (secrets && secrets.length > 0) {
+ const providerGroups = new Map<string, Set<string>>();
+ for (const s of secrets) {
+ if (!providerGroups.has(s.providerName)) {
+ providerGroups.set(s.providerName, new Set());
+ }
+ providerGroups.get(s.providerName)!.add(s.groupName);
+ }
+
+ for (const s of secrets) {
+ const groupCount = providerGroups.get(s.providerName)?.size ??
1;
+ const group = groupCount > 1 ? `${s.providerName} -
${s.groupName}` : s.providerName;
+ options.push({
+ value: buildSecretKey(s.providerId, s.providerName,
s.fullyQualifiedName),
+ label: s.name,
+ description: s.description,
+ group
+ });
+ }
+ }
+
+ const currentValue = this.formControl.value as string | null;
+ if (currentValue && secrets) {
+ const parsed = parseSecretKey(currentValue);
+ const matching = secrets.find(
+ (s) => s.providerId === parsed.providerId &&
s.fullyQualifiedName === parsed.fullyQualifiedName
+ );
+ if (!matching) {
+ options.push({
+ value: currentValue,
+ label: `${parsed.fullyQualifiedName} (no longer
available)`,
+ disabled: true,
+ group: parsed.providerName
+ });
+ }
+ } else if (currentValue) {
+ const parsed = parseSecretKey(currentValue);
+ const loadCompleted = !this.secretsLoading() &&
!this.secretsError();
+ options.push({
+ value: currentValue,
+ label: loadCompleted ? `${parsed.fullyQualifiedName} (no
longer available)` : parsed.fullyQualifiedName,
+ disabled: true,
+ group: parsed.providerName
+ });
+ }
+
+ return options;
+ }
+
private syncValidationState(): void {
if (!this.parentControl) {
return;