NIFI-12401: Allow combo editor to reference parameters (#8068)

* NIFI-12401:
- Allow combo editor to reference parameters.

* NIFI-12401:
- Addressing review feedback.
- Handling corner cases where there is no parameter context and where there are no parameters in a bound parameter context.

* NIFI-12401:
- Fixing formatting issues.

This closes #8068
This commit is contained in:
Matt Gilman 2023-11-29 12:26:25 -05:00 committed by GitHub
parent 9d50c6dd53
commit ebfb5bc12e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 498 additions and 104 deletions

View File

@ -19,38 +19,35 @@
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/nifi'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
browsers: ['ChromeHeadless'],
restartOnFileChange: true
});
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/nifi'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }]
},
reporters: ['progress', 'kjhtml'],
browsers: ['ChromeHeadless'],
restartOnFileChange: true
});
};

View File

@ -43,11 +43,11 @@
"@angular-devkit/build-angular": "^16.2.0",
"@angular/cli": "~16.2.0",
"@angular/compiler-cli": "^16.2.0",
"@types/codemirror": "^5.60.13",
"@types/d3": "^7.4.0",
"@types/humanize-duration": "^3.27.1",
"@types/jasmine": "~4.3.0",
"@types/webfontloader": "^1.6.35",
"@types/codemirror": "^5.60.13",
"autoprefixer": "^10.4.15",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",

View File

@ -190,7 +190,7 @@
</a>
<ng-template #resultLink>
<a [routerLink]="['/process-groups', result.parentGroup.id, path, result.id]">
{{ result.name }}
{{ result.name ? result.name : result.id }}
</a>
</ng-template>
</li>

View File

@ -171,6 +171,8 @@ export interface ParameterContextReference {
export interface AffectedComponentEntity {
permissions: Permissions;
id: string;
revision: Revision;
bulletins: BulletinEntity[];
component: AffectedComponent;
processGroup: ProcessGroupName;
@ -289,8 +291,8 @@ export interface PropertyDescriptor {
name: string;
displayName: string;
description: string;
defaultValue: string;
allowableValues: AllowableValueEntity[];
defaultValue?: string;
allowableValues?: AllowableValueEntity[];
required: boolean;
sensitive: boolean;
dynamic: boolean;

View File

@ -24,7 +24,8 @@
formControlName="value"
[placeholder]="getComboPlaceholder()"
[panelClass]="'combo-panel'"
(mousedown)="preventDrag($event)">
(mousedown)="preventDrag($event)"
(selectionChange)="allowableValueChanged($event.value)">
<ng-container *ngFor="let allowableValue of allowableValues">
<ng-container *ngIf="allowableValue.description; else noDescription">
<mat-option
@ -49,6 +50,41 @@
</ng-container>
</mat-select>
</div>
<div *ngIf="showParameterAllowableValues">
<div *ngIf="!parametersLoaded; else showParameters">
<ngx-skeleton-loader count="1"></ngx-skeleton-loader>
</div>
<ng-template #showParameters>
<mat-select
class="combo"
formControlName="parameterReference"
[panelClass]="'combo-panel'"
(mousedown)="preventDrag($event)">
<ng-container *ngFor="let parameterAllowableValue of parameterAllowableValues">
<ng-container *ngIf="parameterAllowableValue.description; else noDescription">
<mat-option
[value]="parameterAllowableValue.id"
(mousedown)="preventDrag($event)"
nifiTooltip
[tooltipComponentType]="TextTip"
[tooltipInputData]="getAllowableValueOptionTipData(parameterAllowableValue)"
[delayClose]="false">
<span class="option-text" [class.unset]="parameterAllowableValue.value == null">{{
parameterAllowableValue.displayName
}}</span>
</mat-option>
</ng-container>
<ng-template #noDescription>
<mat-option [value]="parameterAllowableValue.id" (mousedown)="preventDrag($event)">
<span class="option-text" [class.unset]="parameterAllowableValue.value == null">{{
parameterAllowableValue.displayName
}}</span>
</mat-option>
</ng-template>
</ng-container>
</mat-select>
</ng-template>
</div>
<div class="flex justify-end items-center gap-x-2">
<button
color="accent"

View File

@ -18,19 +18,67 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComboEditor } from './combo-editor.component';
import { PropertyItem } from '../../property-table.component';
import { Parameter } from '../../../../../state/shared';
import { of } from 'rxjs';
describe('ComboEditor', () => {
let component: ComboEditor;
let fixture: ComponentFixture<ComboEditor>;
let item: PropertyItem | null = null;
let parameters: Parameter[] = [
{
name: 'one',
description: 'Description for one.',
sensitive: false,
value: 'value',
provided: false,
referencingComponents: [],
parameterContext: {
id: '95d4f3d2-018b-1000-b7c7-b830c49a8026',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: '95d4f3d2-018b-1000-b7c7-b830c49a8026',
name: 'params 1'
}
},
inherited: false
},
{
name: 'two',
description: 'Description for two.',
sensitive: false,
value: 'value',
provided: false,
referencingComponents: [],
parameterContext: {
id: '95d4f3d2-018b-1000-b7c7-b830c49a8026',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: '95d4f3d2-018b-1000-b7c7-b830c49a8026',
name: 'params 1'
}
},
inherited: false
}
];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ComboEditor]
});
fixture = TestBed.createComponent(ComboEditor);
component = fixture.componentInstance;
component.supportsParameters = false;
component.item = {
// re-establish the item before each test execution
item = {
property: 'Destination',
value: 'flowfile-attribute',
descriptor: {
@ -69,10 +117,114 @@ describe('ComboEditor', () => {
dirty: false,
type: 'required'
};
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
if (item) {
component.item = item;
fixture.detectChanges();
expect(component).toBeTruthy();
}
});
it('verify combo value', () => {
if (item) {
component.item = item;
fixture.detectChanges();
const formValue = component.comboEditorForm.get('value')?.value;
expect(component.itemLookup.get(formValue)?.value).toEqual(item.value);
expect(component.comboEditorForm.get('parameterReference')).toBeNull();
spyOn(component.ok, 'next');
component.okClicked();
expect(component.ok.next).toHaveBeenCalledWith(item.value);
}
});
it('verify combo not required with null value and default', () => {
if (item) {
item.value = null;
item.descriptor.required = false;
component.item = item;
fixture.detectChanges();
const formValue = component.comboEditorForm.get('value')?.value;
expect(component.itemLookup.get(formValue)?.value).toEqual(item.descriptor.defaultValue);
expect(component.comboEditorForm.get('parameterReference')).toBeNull();
spyOn(component.ok, 'next');
component.okClicked();
expect(component.ok.next).toHaveBeenCalledWith(item.descriptor.defaultValue);
}
});
it('verify combo not required with null value and no default', () => {
if (item) {
item.value = null;
item.descriptor.required = false;
item.descriptor.defaultValue = undefined;
component.item = item;
fixture.detectChanges();
const formValue = component.comboEditorForm.get('value')?.value;
expect(component.itemLookup.get(formValue)?.value).toEqual(item.value);
expect(component.comboEditorForm.get('parameterReference')).toBeNull();
spyOn(component.ok, 'next');
component.okClicked();
expect(component.ok.next).toHaveBeenCalledWith(item.value);
}
});
it('verify combo with parameter reference', () => {
if (item) {
item.value = '#{one}';
component.item = item;
component.getParameters = (sensitive: boolean) => {
return of(parameters);
};
fixture.detectChanges();
fixture.whenStable().then(() => {
const formValue = component.comboEditorForm.get('value')?.value;
expect(component.itemLookup.get(formValue)?.value).toEqual(item?.value);
expect(component.comboEditorForm.get('parameterReference')).toBeDefined();
const parameterReferenceValue = component.comboEditorForm.get('parameterReference')?.value;
expect(component.itemLookup.get(parameterReferenceValue)?.value).toEqual(item?.value);
spyOn(component.ok, 'next');
component.okClicked();
expect(component.ok.next).toHaveBeenCalledWith(item?.value);
});
}
});
it('verify combo with missing parameter reference', () => {
if (item) {
item.value = '#{three}';
component.item = item;
component.getParameters = (sensitive: boolean) => {
return of(parameters);
};
fixture.detectChanges();
fixture.whenStable().then(() => {
const formValue = component.comboEditorForm.get('value')?.value;
expect(component.itemLookup.get(formValue)?.value).toEqual('#{' + parameters[0].value + '}');
expect(component.comboEditorForm.get('parameterReference')).toBeDefined();
const parameterReferenceValue = component.comboEditorForm.get('parameterReference')?.value;
expect(component.itemLookup.get(parameterReferenceValue)?.value).toEqual(item?.value);
spyOn(component.ok, 'next');
component.okClicked();
expect(component.ok.next).toHaveBeenCalledWith('#{' + parameters[0].value + '}');
});
}
});
});

View File

@ -25,12 +25,14 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { NgForOf, NgIf, NgTemplateOutlet } from '@angular/common';
import { NifiTooltipDirective } from '../../../tooltips/nifi-tooltip.directive';
import { PropertyDescriptor, AllowableValue, TextTipInput } from '../../../../../state/shared';
import { AllowableValue, Parameter, PropertyDescriptor, TextTipInput } from '../../../../../state/shared';
import { MatOptionModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TextTip } from '../../../tooltips/text-tip/text-tip.component';
import { A11yModule } from '@angular/cdk/a11y';
import { Observable, take } from 'rxjs';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
export interface AllowableValueItem extends AllowableValue {
id: number;
@ -55,57 +57,32 @@ export interface AllowableValueItem extends AllowableValue {
NgForOf,
MatTooltipModule,
NgIf,
A11yModule
A11yModule,
NgxSkeletonLoaderModule
],
styleUrls: ['./combo-editor.component.scss']
})
export class ComboEditor {
@Input() set item(item: PropertyItem) {
this.itemLookup.clear();
if (item.value != null) {
this.configuredValue = item.value;
} else if (item.descriptor.defaultValue != null) {
this.configuredValue = item.descriptor.defaultValue;
}
this.descriptor = item.descriptor;
this.allowableValues = [];
this.sensitive = item.descriptor.sensitive;
let i: number = 0;
let selectedItem: AllowableValueItem | null = null;
if (!this.descriptor.required) {
const noValue: AllowableValueItem = {
id: i++,
displayName: 'No value',
value: null
};
this.itemLookup.set(noValue.id, noValue);
this.allowableValues.push(noValue);
if (noValue.value == item.value) {
selectedItem = noValue;
}
}
const allowableValueItems: AllowableValueItem[] = this.descriptor.allowableValues.map(
(allowableValueEntity) => {
const allowableValue: AllowableValueItem = {
...allowableValueEntity.allowableValue,
id: i++
};
this.itemLookup.set(allowableValue.id, allowableValue);
if (allowableValue.value == item.value) {
selectedItem = allowableValue;
}
return allowableValue;
}
);
this.allowableValues.push(...allowableValueItems);
if (selectedItem) {
// mat-select does not have good support for options with null value so we've
// introduced a mapping to work around the shortcoming
this.comboEditorForm.get('value')?.setValue(selectedItem.id);
}
this.itemSet = true;
this.initialAllowableValues();
}
@Input() set getParameters(getParameters: (sensitive: boolean) => Observable<Parameter[]>) {
this._getParameters = getParameters;
this.supportsParameters = getParameters != null;
this.initialAllowableValues();
}
@Input() supportsParameters: boolean = false;
@Output() ok: EventEmitter<any> = new EventEmitter<any>();
@Output() cancel: EventEmitter<void> = new EventEmitter<void>();
@ -113,21 +90,172 @@ export class ComboEditor {
protected readonly TextTip = TextTip;
itemLookup: Map<number, AllowableValueItem> = new Map<number, AllowableValueItem>();
referencesParametersId: number = -1;
configuredParameterId: number = -1;
comboEditorForm: FormGroup;
descriptor!: PropertyDescriptor;
allowableValues!: AllowableValueItem[];
showParameterAllowableValues: boolean = false;
parameterAllowableValues!: AllowableValueItem[];
sensitive: boolean = false;
supportsParameters: boolean = false;
parametersLoaded: boolean = false;
itemSet: boolean = false;
configuredValue: string | null = null;
_getParameters!: (sensitive: boolean) => Observable<Parameter[]>;
constructor(private formBuilder: FormBuilder) {
this.comboEditorForm = this.formBuilder.group({
value: new FormControl(null, Validators.required)
});
}
initialAllowableValues(): void {
if (this.itemSet) {
this.itemLookup.clear();
this.allowableValues = [];
this.referencesParametersId = -1;
let i: number = 0;
let selectedItem: AllowableValueItem | null = null;
if (!this.descriptor.required) {
const noValue: AllowableValueItem = {
id: i++,
displayName: 'No value',
value: null
};
this.itemLookup.set(noValue.id, noValue);
this.allowableValues.push(noValue);
if (noValue.value == this.configuredValue) {
selectedItem = noValue;
}
}
if (this.descriptor.allowableValues) {
const allowableValueItems: AllowableValueItem[] = this.descriptor.allowableValues.map(
(allowableValueEntity) => {
const allowableValue: AllowableValueItem = {
...allowableValueEntity.allowableValue,
id: i++
};
this.itemLookup.set(allowableValue.id, allowableValue);
if (allowableValue.value == this.configuredValue) {
selectedItem = allowableValue;
}
return allowableValue;
}
);
this.allowableValues.push(...allowableValueItems);
}
if (this.supportsParameters) {
this.parametersLoaded = false;
// parameters are supported so add the item to support showing
// and hiding the parameter options select
const referencesParameterOption: AllowableValueItem = {
id: i++,
displayName: 'Reference Parameter...',
value: null
};
this.allowableValues.push(referencesParameterOption);
this.itemLookup.set(referencesParameterOption.id, referencesParameterOption);
// record the item of the item to more easily identify this item
this.referencesParametersId = referencesParameterOption.id;
// if the current value references a parameter auto select the
// references parameter item
if (this.referencesParameter(this.configuredValue)) {
selectedItem = referencesParameterOption;
// trigger allowable value changed to show the parameters
this.allowableValueChanged(this.referencesParametersId);
}
this._getParameters(this.sensitive)
.pipe(take(1))
.subscribe((parameters) => {
if (parameters.length > 0) {
// capture the value of i which will be the id of the first
// parameter
this.configuredParameterId = i;
// create allowable values for each parameter
parameters.forEach((parameter) => {
const parameterItem: AllowableValueItem = {
id: i++,
displayName: parameter.name,
value: '#{' + parameter.name + '}',
description: parameter.description
};
this.parameterAllowableValues.push(parameterItem);
this.itemLookup.set(parameterItem.id, parameterItem);
// if the configured parameter is still available,
// capture the id, so we can auto select it
if (parameterItem.value === this.configuredValue) {
this.configuredParameterId = parameterItem.id;
}
});
// if combo still set to reference a parameter, set the default value
if (this.comboEditorForm.get('value')?.value == this.referencesParametersId) {
this.comboEditorForm.get('parameterReference')?.setValue(this.configuredParameterId);
}
}
this.parametersLoaded = true;
});
} else {
this.parameterAllowableValues = [];
}
if (selectedItem) {
// mat-select does not have good support for options with null value so we've
// introduced a mapping to work around the shortcoming
this.comboEditorForm.get('value')?.setValue(selectedItem.id);
}
}
}
referencesParameter(value: string | null): boolean {
if (value) {
return value.startsWith('#{') && value.endsWith('}');
}
return false;
}
preventDrag(event: MouseEvent): void {
event.stopPropagation();
}
allowableValueChanged(value: number): void {
this.showParameterAllowableValues = value === this.referencesParametersId;
if (this.showParameterAllowableValues) {
if (this.configuredParameterId === -1) {
this.comboEditorForm.addControl('parameterReference', new FormControl(null, Validators.required));
} else {
this.comboEditorForm.addControl(
'parameterReference',
new FormControl(this.configuredParameterId, Validators.required)
);
}
} else {
this.comboEditorForm.removeControl('parameterReference');
}
}
getComboPlaceholder(): string {
const valueControl: AbstractControl | null = this.comboEditorForm.get('value');
if (valueControl) {
@ -150,7 +278,21 @@ export class ComboEditor {
if (valueControl) {
const selectedItem: AllowableValueItem | undefined = this.itemLookup.get(valueControl.value);
if (selectedItem) {
this.ok.next(selectedItem.value);
// if the value currently references a parameter emit the parameter, get the parameter reference control and emit that value
if (selectedItem.id == this.referencesParametersId) {
const parameterReferenceControl: AbstractControl | null =
this.comboEditorForm.get('parameterReference');
if (parameterReferenceControl) {
const selectedParameterItem: AllowableValueItem | undefined = this.itemLookup.get(
parameterReferenceControl.value
);
if (selectedParameterItem) {
this.ok.next(selectedParameterItem.value);
}
}
} else {
this.ok.next(selectedItem.value);
}
}
}
}

View File

@ -19,6 +19,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NfEditor } from './nf-editor.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { PropertyItem } from '../../property-table.component';
describe('NfEditor', () => {
let component: NfEditor;
@ -30,10 +31,84 @@ describe('NfEditor', () => {
});
fixture = TestBed.createComponent(NfEditor);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('verify value set', () => {
const value: string = 'my-group-id';
const item: PropertyItem = {
property: 'group.id',
value,
descriptor: {
name: 'group.id',
displayName: 'Group ID',
description:
"A Group ID is used to identify consumers that are within the same consumer group. Corresponds to Kafka's 'group.id' property.",
required: true,
sensitive: false,
dynamic: false,
supportsEl: true,
expressionLanguageScope: 'Environment variables defined at JVM level and system properties',
dependencies: []
},
id: 3,
triggerEdit: false,
deleted: false,
added: false,
dirty: false,
type: 'required'
};
component.item = item;
fixture.detectChanges();
expect(component.nfEditorForm.get('value')?.value).toEqual(value);
expect(component.nfEditorForm.get('value')?.disabled).toBeFalse();
expect(component.nfEditorForm.get('setEmptyString')?.value).toBeFalse();
spyOn(component.ok, 'next');
component.okClicked();
expect(component.ok.next).toHaveBeenCalledWith(value);
});
it('verify empty value set', () => {
const value: string = '';
const item: PropertyItem = {
property: 'group.id',
value,
descriptor: {
name: 'group.id',
displayName: 'Group ID',
description:
"A Group ID is used to identify consumers that are within the same consumer group. Corresponds to Kafka's 'group.id' property.",
required: true,
sensitive: false,
dynamic: false,
supportsEl: true,
expressionLanguageScope: 'Environment variables defined at JVM level and system properties',
dependencies: []
},
id: 3,
triggerEdit: false,
deleted: false,
added: false,
dirty: false,
type: 'required'
};
component.item = item;
fixture.detectChanges();
expect(component.nfEditorForm.get('value')?.value).toEqual(value);
expect(component.nfEditorForm.get('value')?.disabled).toBeTruthy();
expect(component.nfEditorForm.get('setEmptyString')?.value).toBeTruthy();
spyOn(component.ok, 'next');
component.okClicked();
expect(component.ok.next).toHaveBeenCalledWith(value);
});
});

View File

@ -15,18 +15,7 @@
* limitations under the License.
*/
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Output,
Renderer2,
ViewChild,
ViewContainerRef
} from '@angular/core';
import { Component, EventEmitter, Input, OnDestroy, Output, Renderer2, ViewContainerRef } from '@angular/core';
import { PropertyItem } from '../../property-table.component';
import { CdkDrag, CdkDragHandle } from '@angular/cdk/drag-drop';
import { AbstractControl, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';

View File

@ -145,6 +145,7 @@
<combo-editor
*ngIf="hasAllowableValues(editorItem); else nfEditor"
[item]="editorItem"
[getParameters]="getParameters"
(ok)="savePropertyValue(editorItem, $event)"
(cancel)="closeEditor()"></combo-editor>
<ng-template #nfEditor>

View File

@ -347,8 +347,8 @@ export class PropertyTable implements AfterViewInit, ControlValueAccessor {
}
resolvePropertyValue(property: Property): string | null {
const allowableValues: AllowableValueEntity[] = property.descriptor.allowableValues;
if (this.nifiCommon.isEmpty(allowableValues)) {
const allowableValues: AllowableValueEntity[] | undefined = property.descriptor.allowableValues;
if (allowableValues == null || this.nifiCommon.isEmpty(allowableValues)) {
return property.value;
} else {
const allowableValue: AllowableValueEntity | undefined = allowableValues.find(
@ -392,7 +392,7 @@ export class PropertyTable implements AfterViewInit, ControlValueAccessor {
// TODO - add Input() for supportsGoTo? currently only false in summary table
const descriptor: PropertyDescriptor = item.descriptor;
if (item.value && descriptor.identifiesControllerService) {
if (item.value && descriptor.identifiesControllerService && descriptor.allowableValues) {
return descriptor.allowableValues.some(
(entity: AllowableValueEntity) => entity.allowableValue.value == item.value
);