[NIFI-13247] - Property Verification (#8857)

* [NIFI-13247] - Property Verification
* Added map-table providing a property-table-like component for simple key/value pairs.

* add missing license headers

* address review feedback

* update to leverage themed code-mirror styles

This closes #8857
This commit is contained in:
Rob Fellows 2024-05-22 14:49:28 -04:00 committed by GitHub
parent 05d0d36e70
commit 7951b4be80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 2807 additions and 90 deletions

View File

@ -46,6 +46,7 @@ import { MatSnackBarModule } from '@angular/material/snack-bar';
import { PipesModule } from './pipes/pipes.module';
import { DocumentationEffects } from './state/documentation/documentation.effects';
import { ClusterSummaryEffects } from './state/cluster-summary/cluster-summary.effects';
import { PropertyVerificationEffects } from './state/property-verification/property-verification.effects';
import { loadingInterceptor } from './service/interceptors/loading.interceptor';
import { LoginConfigurationEffects } from './state/login-configuration/login-configuration.effects';
@ -77,7 +78,8 @@ import { LoginConfigurationEffects } from './state/login-configuration/login-con
SystemDiagnosticsEffects,
ComponentStateEffects,
DocumentationEffects,
ClusterSummaryEffects
ClusterSummaryEffects,
PropertyVerificationEffects
),
StoreDevtoolsModule.instrument({
maxAge: 25,

View File

@ -54,6 +54,15 @@ import { LARGE_DIALOG, SMALL_DIALOG, XL_DIALOG } from '../../../../index';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog';
import { FlowService } from '../../service/flow.service';
import {
resetPropertyVerificationState,
verifyProperties
} from '../../../../state/property-verification/property-verification.actions';
import {
selectPropertyVerificationResults,
selectPropertyVerificationStatus
} from '../../../../state/property-verification/property-verification.selectors';
import { VerifyPropertiesRequestContext } from '../../../../state/property-verification';
@Injectable()
export class ControllerServicesEffects {
@ -270,7 +279,7 @@ export class ControllerServicesEffects {
const serviceId: string = request.id;
const editDialogReference = this.dialog.open(EditControllerService, {
...LARGE_DIALOG,
...XL_DIALOG,
data: request,
id: serviceId
});
@ -280,6 +289,23 @@ export class ControllerServicesEffects {
editDialogReference.componentInstance.createNewProperty =
this.propertyTableHelperService.createNewProperty(request.id, this.controllerServiceService);
editDialogReference.componentInstance.verify
.pipe(takeUntil(editDialogReference.afterClosed()))
.subscribe((verificationRequest: VerifyPropertiesRequestContext) => {
this.store.dispatch(
verifyProperties({
request: verificationRequest
})
);
});
editDialogReference.componentInstance.propertyVerificationResults$ = this.store.select(
selectPropertyVerificationResults
);
editDialogReference.componentInstance.propertyVerificationStatus$ = this.store.select(
selectPropertyVerificationStatus
);
const goTo = (commands: string[], destination: string): void => {
if (editDialogReference.componentInstance.editControllerServiceForm.dirty) {
const saveChangesDialogReference = this.dialog.open(YesNoDialog, {
@ -375,6 +401,9 @@ export class ControllerServicesEffects {
});
editDialogReference.afterClosed().subscribe((response) => {
this.store.dispatch(ErrorActions.clearBannerErrors());
this.store.dispatch(resetPropertyVerificationState());
if (response != 'ROUTED') {
this.store.dispatch(
ControllerServicesActions.selectControllerService({

View File

@ -136,6 +136,15 @@ import { ErrorHelper } from '../../../../service/error-helper.service';
import { selectConnectedStateChanged } from '../../../../state/cluster-summary/cluster-summary.selectors';
import { resetConnectedStateChanged } from '../../../../state/cluster-summary/cluster-summary.actions';
import { ChangeColorDialog } from '../../ui/canvas/change-color-dialog/change-color-dialog.component';
import {
resetPropertyVerificationState,
verifyProperties
} from '../../../../state/property-verification/property-verification.actions';
import {
selectPropertyVerificationResults,
selectPropertyVerificationStatus
} from '../../../../state/property-verification/property-verification.selectors';
import { VerifyPropertiesRequestContext } from '../../../../state/property-verification';
@Injectable()
export class FlowEffects {
@ -1291,7 +1300,7 @@ export class FlowEffects {
const processorId: string = request.entity.id;
const editDialogReference = this.dialog.open(EditProcessor, {
...LARGE_DIALOG,
...XL_DIALOG,
data: request,
id: processorId
});
@ -1301,6 +1310,23 @@ export class FlowEffects {
editDialogReference.componentInstance.createNewProperty =
this.propertyTableHelperService.createNewProperty(processorId, this.flowService);
editDialogReference.componentInstance.verify
.pipe(takeUntil(editDialogReference.afterClosed()))
.subscribe((verificationRequest: VerifyPropertiesRequestContext) => {
this.store.dispatch(
verifyProperties({
request: verificationRequest
})
);
});
editDialogReference.componentInstance.propertyVerificationResults$ = this.store.select(
selectPropertyVerificationResults
);
editDialogReference.componentInstance.propertyVerificationStatus$ = this.store.select(
selectPropertyVerificationStatus
);
const goTo = (commands: string[], destination: string): void => {
if (editDialogReference.componentInstance.editProcessorForm.dirty) {
const saveChangesDialogReference = this.dialog.open(YesNoDialog, {
@ -1380,7 +1406,7 @@ export class FlowEffects {
editDialogReference.afterClosed().subscribe((response) => {
this.store.dispatch(ErrorActions.clearBannerErrors());
this.store.dispatch(resetPropertyVerificationState());
if (response != 'ROUTED') {
this.store.dispatch(
FlowActions.selectComponents({

View File

@ -80,10 +80,7 @@ describe('EditLabel', () => {
isDisconnectionAcknowledged: jest.fn()
}
},
{
provide: MatDialogRef,
useValue: null
}
{ provide: MatDialogRef, useValue: null }
]
});
fixture = TestBed.createComponent(EditLabel);

View File

@ -47,10 +47,7 @@ describe('CreatePort', () => {
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
provideMockStore({ initialState }),
{
provide: MatDialogRef,
useValue: null
}
{ provide: MatDialogRef, useValue: null }
]
});
fixture = TestBed.createComponent(CreatePort);

View File

@ -106,10 +106,7 @@ describe('EditPort', () => {
isDisconnectionAcknowledged: jest.fn()
}
},
{
provide: MatDialogRef,
useValue: null
}
{ provide: MatDialogRef, useValue: null }
]
});
fixture = TestBed.createComponent(EditPort);

View File

@ -159,10 +159,7 @@ describe('CreateProcessGroup', () => {
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
provideMockStore({ initialState }),
{
provide: MatDialogRef,
useValue: null
}
{ provide: MatDialogRef, useValue: null }
]
});
fixture = TestBed.createComponent(CreateProcessGroup);

View File

@ -63,10 +63,7 @@ describe('CreateProcessor', () => {
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
provideMockStore({ initialState }),
{
provide: MatDialogRef,
useValue: null
}
{ provide: MatDialogRef, useValue: null }
]
});
fixture = TestBed.createComponent(CreateProcessor);

View File

@ -236,8 +236,9 @@
</mat-tab>
<mat-tab label="Properties">
<mat-dialog-content>
<div class="dialog-tab-content">
<div class="dialog-tab-content flex gap-x-3">
<property-table
class="w-2/3"
formControlName="properties"
[createNewProperty]="createNewProperty"
[createNewService]="createNewService"
@ -249,6 +250,12 @@
[supportsSensitiveDynamicProperties]="
request.entity.component.supportsSensitiveDynamicProperties
"></property-table>
<property-verification
class="w-1/3"
[disabled]="readonly"
[isVerifying]="(propertyVerificationStatus$ | async) === 'loading'"
[results]="propertyVerificationResults$ | async"
(verify)="verifyClicked(request.entity)"></property-verification>
</div>
</mat-dialog-content>
</mat-tab>

View File

@ -25,7 +25,7 @@ import { AsyncPipe } from '@angular/common';
import { MatTabsModule } from '@angular/material/tabs';
import { MatOptionModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import {
InlineServiceCreationRequest,
InlineServiceCreationResponse,
@ -50,6 +50,12 @@ import { ClusterConnectionService } from '../../../../../../../service/cluster-c
import { CanvasUtils } from '../../../../../service/canvas-utils.service';
import { ConvertToParameterResponse } from '../../../../../service/parameter-helper.service';
import { CloseOnEscapeDialog } from '../../../../../../../ui/common/close-on-escape-dialog/close-on-escape-dialog.component';
import { PropertyVerification } from '../../../../../../../ui/common/property-verification/property-verification.component';
import {
ConfigVerificationResult,
ModifiedProperties,
VerifyPropertiesRequestContext
} from '../../../../../../../state/property-verification';
@Component({
selector: 'edit-processor',
@ -70,7 +76,8 @@ import { CloseOnEscapeDialog } from '../../../../../../../ui/common/close-on-esc
NifiTooltipDirective,
RunDurationSlider,
RelationshipSettings,
ErrorBanner
ErrorBanner,
PropertyVerification
],
styleUrls: ['./edit-processor.component.scss']
})
@ -86,6 +93,11 @@ export class EditProcessor extends CloseOnEscapeDialog {
) => Observable<ConvertToParameterResponse>;
@Input() goToService!: (serviceId: string) => void;
@Input() saving$!: Observable<boolean>;
@Input() propertyVerificationResults$!: Observable<ConfigVerificationResult[]>;
@Input() propertyVerificationStatus$: Observable<'pending' | 'loading' | 'success'> = of('pending');
@Output() verify: EventEmitter<VerifyPropertiesRequestContext> = new EventEmitter<VerifyPropertiesRequestContext>();
@Output() editProcessor: EventEmitter<UpdateProcessorRequest> = new EventEmitter<UpdateProcessorRequest>();
protected readonly TextTip = TextTip;
@ -321,9 +333,7 @@ export class EditProcessor extends CloseOnEscapeDialog {
const propertyControl: AbstractControl | null = this.editProcessorForm.get('properties');
if (propertyControl && propertyControl.dirty) {
const properties: Property[] = propertyControl.value;
const values: { [key: string]: string | null } = {};
properties.forEach((property) => (values[property.property] = property.value));
payload.component.config.properties = values;
payload.component.config.properties = this.getModifiedProperties();
payload.component.config.sensitiveDynamicPropertyNames = properties
.filter((property) => property.descriptor.dynamic && property.descriptor.sensitive)
.map((property) => property.descriptor.name);
@ -345,7 +355,25 @@ export class EditProcessor extends CloseOnEscapeDialog {
});
}
private getModifiedProperties(): ModifiedProperties {
const propertyControl: AbstractControl | null = this.editProcessorForm.get('properties');
if (propertyControl && propertyControl.dirty) {
const properties: Property[] = propertyControl.value;
const values: { [key: string]: string | null } = {};
properties.forEach((property) => (values[property.property] = property.value));
return values;
}
return {};
}
override isDirty(): boolean {
return this.editProcessorForm.dirty;
}
verifyClicked(entity: any): void {
this.verify.next({
entity,
properties: this.getModifiedProperties()
});
}
}

View File

@ -47,10 +47,7 @@ describe('CreateRemoteProcessGroup', () => {
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
provideMockStore({ initialState }),
{
provide: MatDialogRef,
useValue: null
}
{ provide: MatDialogRef, useValue: null }
]
});
fixture = TestBed.createComponent(CreateRemoteProcessGroup);

View File

@ -38,9 +38,18 @@ import * as ErrorActions from '../../../../state/error/error.actions';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { selectStatus } from './flow-analysis-rules.selectors';
import { HttpErrorResponse } from '@angular/common/http';
import { LARGE_DIALOG, SMALL_DIALOG } from '../../../../index';
import { LARGE_DIALOG, SMALL_DIALOG, XL_DIALOG } from '../../../../index';
import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
import {
resetPropertyVerificationState,
verifyProperties
} from '../../../../state/property-verification/property-verification.actions';
import {
selectPropertyVerificationResults,
selectPropertyVerificationStatus
} from '../../../../state/property-verification/property-verification.selectors';
import { VerifyPropertiesRequestContext } from '../../../../state/property-verification';
@Injectable()
export class FlowAnalysisRulesEffects {
@ -254,7 +263,7 @@ export class FlowAnalysisRulesEffects {
const ruleId: string = request.id;
const editDialogReference = this.dialog.open(EditFlowAnalysisRule, {
...LARGE_DIALOG,
...XL_DIALOG,
data: request,
id: ruleId
});
@ -264,6 +273,23 @@ export class FlowAnalysisRulesEffects {
editDialogReference.componentInstance.createNewProperty =
this.propertyTableHelperService.createNewProperty(request.id, this.flowAnalysisRuleService);
editDialogReference.componentInstance.verify
.pipe(takeUntil(editDialogReference.afterClosed()))
.subscribe((verificationRequest: VerifyPropertiesRequestContext) => {
this.store.dispatch(
verifyProperties({
request: verificationRequest
})
);
});
editDialogReference.componentInstance.propertyVerificationResults$ = this.store.select(
selectPropertyVerificationResults
);
editDialogReference.componentInstance.propertyVerificationStatus$ = this.store.select(
selectPropertyVerificationStatus
);
const goTo = (commands: string[], destination: string): void => {
if (editDialogReference.componentInstance.editFlowAnalysisRuleForm.dirty) {
const saveChangesDialogReference = this.dialog.open(YesNoDialog, {
@ -317,6 +343,7 @@ export class FlowAnalysisRulesEffects {
editDialogReference.afterClosed().subscribe((response) => {
this.store.dispatch(ErrorActions.clearBannerErrors());
this.store.dispatch(resetPropertyVerificationState());
if (response != 'ROUTED') {
this.store.dispatch(

View File

@ -47,6 +47,15 @@ import { ErrorHelper } from '../../../../service/error-helper.service';
import { LARGE_DIALOG, SMALL_DIALOG, XL_DIALOG } from '../../../../index';
import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
import {
resetPropertyVerificationState,
verifyProperties
} from '../../../../state/property-verification/property-verification.actions';
import {
selectPropertyVerificationResults,
selectPropertyVerificationStatus
} from '../../../../state/property-verification/property-verification.selectors';
import { VerifyPropertiesRequestContext } from '../../../../state/property-verification';
@Injectable()
export class ManagementControllerServicesEffects {
@ -98,7 +107,6 @@ export class ManagementControllerServicesEffects {
});
dialogReference.componentInstance.saving$ = this.store.select(selectSaving);
dialogReference.componentInstance.createControllerService
.pipe(take(1))
.subscribe((controllerServiceType) => {
@ -224,12 +232,13 @@ export class ManagementControllerServicesEffects {
const serviceId: string = request.id;
const editDialogReference = this.dialog.open(EditControllerService, {
...LARGE_DIALOG,
...XL_DIALOG,
data: request,
id: serviceId
});
editDialogReference.componentInstance.saving$ = this.store.select(selectSaving);
editDialogReference.componentInstance.supportsParameters = false;
editDialogReference.componentInstance.createNewProperty =
this.propertyTableHelperService.createNewProperty(
@ -237,6 +246,23 @@ export class ManagementControllerServicesEffects {
this.managementControllerServiceService
);
editDialogReference.componentInstance.verify
.pipe(takeUntil(editDialogReference.afterClosed()))
.subscribe((verificationRequest: VerifyPropertiesRequestContext) => {
this.store.dispatch(
verifyProperties({
request: verificationRequest
})
);
});
editDialogReference.componentInstance.propertyVerificationResults$ = this.store.select(
selectPropertyVerificationResults
);
editDialogReference.componentInstance.propertyVerificationStatus$ = this.store.select(
selectPropertyVerificationStatus
);
const goTo = (commands: string[], destination: string): void => {
if (editDialogReference.componentInstance.editControllerServiceForm.dirty) {
const saveChangesDialogReference = this.dialog.open(YesNoDialog, {
@ -306,6 +332,7 @@ export class ManagementControllerServicesEffects {
editDialogReference.afterClosed().subscribe((response) => {
this.store.dispatch(ErrorActions.clearBannerErrors());
this.store.dispatch(resetPropertyVerificationState());
if (response != 'ROUTED') {
this.store.dispatch(

View File

@ -57,6 +57,15 @@ import * as ErrorActions from '../../../../state/error/error.actions';
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { LARGE_DIALOG, SMALL_DIALOG, XL_DIALOG } from '../../../../index';
import {
resetPropertyVerificationState,
verifyProperties
} from '../../../../state/property-verification/property-verification.actions';
import {
selectPropertyVerificationResults,
selectPropertyVerificationStatus
} from '../../../../state/property-verification/property-verification.selectors';
import { VerifyPropertiesRequestContext } from '../../../../state/property-verification';
@Injectable()
export class ParameterProvidersEffects {
@ -300,13 +309,30 @@ export class ParameterProvidersEffects {
tap((request) => {
const id = request.id;
const editDialogReference = this.dialog.open(EditParameterProvider, {
...LARGE_DIALOG,
...XL_DIALOG,
data: request,
id
});
editDialogReference.componentInstance.saving$ = this.store.select(selectSaving);
editDialogReference.componentInstance.verify
.pipe(takeUntil(editDialogReference.afterClosed()))
.subscribe((verificationRequest: VerifyPropertiesRequestContext) => {
this.store.dispatch(
verifyProperties({
request: verificationRequest
})
);
});
editDialogReference.componentInstance.propertyVerificationResults$ = this.store.select(
selectPropertyVerificationResults
);
editDialogReference.componentInstance.propertyVerificationStatus$ = this.store.select(
selectPropertyVerificationStatus
);
const goTo = (commands: string[], destination: string) => {
// confirm navigating away while changes are unsaved
if (editDialogReference.componentInstance.editParameterProviderForm.dirty) {
@ -369,6 +395,7 @@ export class ParameterProvidersEffects {
editDialogReference.afterClosed().subscribe((response) => {
this.store.dispatch(ErrorActions.clearBannerErrors());
this.store.dispatch(resetPropertyVerificationState());
if (response !== 'ROUTED') {
this.store.dispatch(

View File

@ -38,9 +38,18 @@ import * as ErrorActions from '../../../../state/error/error.actions';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { selectStatus } from './reporting-tasks.selectors';
import { HttpErrorResponse } from '@angular/common/http';
import { LARGE_DIALOG, SMALL_DIALOG } from '../../../../index';
import { LARGE_DIALOG, SMALL_DIALOG, XL_DIALOG } from '../../../../index';
import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
import {
resetPropertyVerificationState,
verifyProperties
} from '../../../../state/property-verification/property-verification.actions';
import {
selectPropertyVerificationResults,
selectPropertyVerificationStatus
} from '../../../../state/property-verification/property-verification.selectors';
import { VerifyPropertiesRequestContext } from '../../../../state/property-verification';
@Injectable()
export class ReportingTasksEffects {
@ -266,7 +275,7 @@ export class ReportingTasksEffects {
const taskId: string = request.id;
const editDialogReference = this.dialog.open(EditReportingTask, {
...LARGE_DIALOG,
...XL_DIALOG,
data: request,
id: taskId
});
@ -276,6 +285,23 @@ export class ReportingTasksEffects {
editDialogReference.componentInstance.createNewProperty =
this.propertyTableHelperService.createNewProperty(request.id, this.reportingTaskService);
editDialogReference.componentInstance.verify
.pipe(takeUntil(editDialogReference.afterClosed()))
.subscribe((verificationRequest: VerifyPropertiesRequestContext) => {
this.store.dispatch(
verifyProperties({
request: verificationRequest
})
);
});
editDialogReference.componentInstance.propertyVerificationResults$ = this.store.select(
selectPropertyVerificationResults
);
editDialogReference.componentInstance.propertyVerificationStatus$ = this.store.select(
selectPropertyVerificationStatus
);
const goTo = (commands: string[], destination: string): void => {
if (editDialogReference.componentInstance.editReportingTaskForm.dirty) {
const saveChangesDialogReference = this.dialog.open(YesNoDialog, {
@ -329,6 +355,7 @@ export class ReportingTasksEffects {
editDialogReference.afterClosed().subscribe((response) => {
this.store.dispatch(ErrorActions.clearBannerErrors());
this.store.dispatch(resetPropertyVerificationState());
if (response != 'ROUTED') {
this.store.dispatch(

View File

@ -87,14 +87,22 @@
</mat-tab>
<mat-tab label="Properties">
<mat-dialog-content>
<div class="dialog-tab-content">
<div class="dialog-tab-content flex gap-x-3">
<property-table
class="w-2/3"
formControlName="properties"
[createNewProperty]="createNewProperty"
[createNewService]="createNewService"
[propertyHistory]="request.history"
[supportsParameters]="false"
[goToService]="goToService">
</property-table>
<property-verification
class="w-1/3"
[disabled]="readonly"
[isVerifying]="(propertyVerificationStatus$ | async) === 'loading'"
[results]="propertyVerificationResults$ | async"
(verify)="verifyClicked(request.flowAnalysisRule)"></property-verification>
</div>
</mat-dialog-content>
</mat-tab>

View File

@ -25,7 +25,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { MatOptionModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
import { AbstractControl, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { Client } from '../../../../../service/client.service';
import {
InlineServiceCreationRequest,
@ -47,6 +47,12 @@ import { FlowAnalysisRuleTable } from '../flow-analysis-rule-table/flow-analysis
import { ErrorBanner } from '../../../../../ui/common/error-banner/error-banner.component';
import { ClusterConnectionService } from '../../../../../service/cluster-connection.service';
import { CloseOnEscapeDialog } from '../../../../../ui/common/close-on-escape-dialog/close-on-escape-dialog.component';
import {
ConfigVerificationResult,
ModifiedProperties,
VerifyPropertiesRequestContext
} from '../../../../../state/property-verification';
import { PropertyVerification } from '../../../../../ui/common/property-verification/property-verification.component';
@Component({
selector: 'edit-flow-analysis-rule',
@ -66,7 +72,8 @@ import { CloseOnEscapeDialog } from '../../../../../ui/common/close-on-escape-di
MatTooltipModule,
NifiTooltipDirective,
FlowAnalysisRuleTable,
ErrorBanner
ErrorBanner,
PropertyVerification
],
styleUrls: ['./edit-flow-analysis-rule.component.scss']
})
@ -75,6 +82,10 @@ export class EditFlowAnalysisRule extends CloseOnEscapeDialog {
@Input() createNewService!: (request: InlineServiceCreationRequest) => Observable<InlineServiceCreationResponse>;
@Input() saving$!: Observable<boolean>;
@Input() goToService!: (serviceId: string) => void;
@Input() propertyVerificationResults$!: Observable<ConfigVerificationResult[]>;
@Input() propertyVerificationStatus$: Observable<'pending' | 'loading' | 'success'> = of('pending');
@Output() verify: EventEmitter<VerifyPropertiesRequestContext> = new EventEmitter<VerifyPropertiesRequestContext>();
@Output() editFlowAnalysisRule: EventEmitter<UpdateFlowAnalysisRuleRequest> =
new EventEmitter<UpdateFlowAnalysisRuleRequest>();
@ -144,9 +155,7 @@ export class EditFlowAnalysisRule extends CloseOnEscapeDialog {
const propertyControl: AbstractControl | null = this.editFlowAnalysisRuleForm.get('properties');
if (propertyControl && propertyControl.dirty) {
const properties: Property[] = propertyControl.value;
const values: { [key: string]: string | null } = {};
properties.forEach((property) => (values[property.property] = property.value));
payload.component.properties = values;
payload.component.properties = this.getModifiedProperties();
payload.component.sensitiveDynamicPropertyNames = properties
.filter((property) => property.descriptor.dynamic && property.descriptor.sensitive)
.map((property) => property.descriptor.name);
@ -160,7 +169,25 @@ export class EditFlowAnalysisRule extends CloseOnEscapeDialog {
protected readonly TextTip = TextTip;
private getModifiedProperties(): ModifiedProperties {
const propertyControl: AbstractControl | null = this.editFlowAnalysisRuleForm.get('properties');
if (propertyControl && propertyControl.dirty) {
const properties: Property[] = propertyControl.value;
const values: { [key: string]: string | null } = {};
properties.forEach((property) => (values[property.property] = property.value));
return values;
}
return {};
}
override isDirty(): boolean {
return this.editFlowAnalysisRuleForm.dirty;
}
verifyClicked(entity: FlowAnalysisRuleEntity): void {
this.verify.next({
entity,
properties: this.getModifiedProperties()
});
}
}

View File

@ -81,13 +81,21 @@
</mat-tab>
<mat-tab label="Properties">
<mat-dialog-content>
<div class="dialog-tab-content">
<div class="dialog-tab-content flex gap-x-3">
<property-table
class="w-2/3"
formControlName="properties"
[createNewProperty]="createNewProperty"
[createNewService]="createNewService"
[propertyHistory]="request.history"
[supportsParameters]="false"
[goToService]="goToService"></property-table>
<property-verification
class="w-1/3"
[disabled]="readonly"
[isVerifying]="(propertyVerificationStatus$ | async) === 'loading'"
[results]="propertyVerificationResults$ | async"
(verify)="verifyClicked(request.parameterProvider)"></property-verification>
</div>
</mat-dialog-content>
</mat-tab>

View File

@ -21,7 +21,7 @@ import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { MatButtonModule } from '@angular/material/button';
import { NifiSpinnerDirective } from '../../../../../ui/common/spinner/nifi-spinner.directive';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import {
InlineServiceCreationRequest,
InlineServiceCreationResponse,
@ -47,6 +47,12 @@ import { ClusterConnectionService } from '../../../../../service/cluster-connect
import { TextTip } from '../../../../../ui/common/tooltips/text-tip/text-tip.component';
import { NifiTooltipDirective } from '../../../../../ui/common/tooltips/nifi-tooltip.directive';
import { CloseOnEscapeDialog } from '../../../../../ui/common/close-on-escape-dialog/close-on-escape-dialog.component';
import {
ConfigVerificationResult,
ModifiedProperties,
VerifyPropertiesRequestContext
} from '../../../../../state/property-verification';
import { PropertyVerification } from '../../../../../ui/common/property-verification/property-verification.component';
@Component({
selector: 'edit-parameter-provider',
@ -64,7 +70,8 @@ import { CloseOnEscapeDialog } from '../../../../../ui/common/close-on-escape-di
PropertyTable,
ErrorBanner,
CommonModule,
NifiTooltipDirective
NifiTooltipDirective,
PropertyVerification
],
templateUrl: './edit-parameter-provider.component.html',
styleUrls: ['./edit-parameter-provider.component.scss']
@ -75,7 +82,10 @@ export class EditParameterProvider extends CloseOnEscapeDialog {
@Input() goToService!: (serviceId: string) => void;
@Input() goToReferencingParameterContext!: (parameterContextId: string) => void;
@Input() saving$!: Observable<boolean>;
@Input() propertyVerificationResults$!: Observable<ConfigVerificationResult[]>;
@Input() propertyVerificationStatus$: Observable<'pending' | 'loading' | 'success'> = of('pending');
@Output() verify: EventEmitter<VerifyPropertiesRequestContext> = new EventEmitter<VerifyPropertiesRequestContext>();
@Output() editParameterProvider: EventEmitter<UpdateParameterProviderRequest> =
new EventEmitter<UpdateParameterProviderRequest>();
@ -132,9 +142,7 @@ export class EditParameterProvider extends CloseOnEscapeDialog {
const propertyControl: AbstractControl | null = this.editParameterProviderForm.get('properties');
if (propertyControl && propertyControl.dirty) {
const properties: Property[] = propertyControl.value;
const values: { [key: string]: string | null } = {};
properties.forEach((property) => (values[property.property] = property.value));
payload.component.properties = values;
payload.component.properties = this.getModifiedProperties();
payload.component.sensitiveDynamicPropertyNames = properties
.filter((property) => property.descriptor.dynamic && property.descriptor.sensitive)
.map((property) => property.descriptor.name);
@ -154,7 +162,25 @@ export class EditParameterProvider extends CloseOnEscapeDialog {
protected readonly TextTip = TextTip;
private getModifiedProperties(): ModifiedProperties {
const propertyControl: AbstractControl | null = this.editParameterProviderForm.get('properties');
if (propertyControl && propertyControl.dirty) {
const properties: Property[] = propertyControl.value;
const values: { [key: string]: string | null } = {};
properties.forEach((property) => (values[property.property] = property.value));
return values;
}
return {};
}
override isDirty(): boolean {
return this.editParameterProviderForm.dirty;
}
verifyClicked(entity: ParameterProviderEntity): void {
this.verify.next({
entity,
properties: this.getModifiedProperties()
});
}
}

View File

@ -64,6 +64,7 @@
[createNewProperty]="createNewProperty"
[createNewService]="createNewService"
[goToService]="goToService"
[supportsParameters]="false"
[supportsSensitiveDynamicProperties]="
request.registryClient.component.supportsSensitiveDynamicProperties
"></property-table>

View File

@ -112,17 +112,25 @@
</mat-tab>
<mat-tab label="Properties">
<mat-dialog-content>
<div class="dialog-tab-content">
<div class="dialog-tab-content flex gap-x-3">
<property-table
class="w-2/3"
formControlName="properties"
[createNewProperty]="createNewProperty"
[createNewService]="createNewService"
[goToService]="goToService"
[propertyHistory]="request.history"
[supportsParameters]="false"
[supportsSensitiveDynamicProperties]="
request.reportingTask.component.supportsSensitiveDynamicProperties
">
</property-table>
<property-verification
class="w-1/3"
[disabled]="readonly"
[isVerifying]="(propertyVerificationStatus$ | async) === 'loading'"
[results]="propertyVerificationResults$ | async"
(verify)="verifyClicked(request.reportingTask)"></property-verification>
</div>
</mat-dialog-content>
</mat-tab>

View File

@ -26,7 +26,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { MatOptionModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
import { AbstractControl, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { Client } from '../../../../../service/client.service';
import {
ControllerServiceReferencingComponent,
@ -49,6 +49,12 @@ import { TextTip } from '../../../../../ui/common/tooltips/text-tip/text-tip.com
import { ErrorBanner } from '../../../../../ui/common/error-banner/error-banner.component';
import { ClusterConnectionService } from '../../../../../service/cluster-connection.service';
import { CloseOnEscapeDialog } from '../../../../../ui/common/close-on-escape-dialog/close-on-escape-dialog.component';
import {
ConfigVerificationResult,
ModifiedProperties,
VerifyPropertiesRequestContext
} from '../../../../../state/property-verification';
import { PropertyVerification } from '../../../../../ui/common/property-verification/property-verification.component';
@Component({
selector: 'edit-reporting-task',
@ -69,7 +75,8 @@ import { CloseOnEscapeDialog } from '../../../../../ui/common/close-on-escape-di
NifiSpinnerDirective,
MatTooltipModule,
NifiTooltipDirective,
ErrorBanner
ErrorBanner,
PropertyVerification
],
styleUrls: ['./edit-reporting-task.component.scss']
})
@ -79,6 +86,10 @@ export class EditReportingTask extends CloseOnEscapeDialog {
@Input() goToService!: (serviceId: string) => void;
@Input() goToReferencingComponent!: (component: ControllerServiceReferencingComponent) => void;
@Input() saving$!: Observable<boolean>;
@Input() propertyVerificationResults$!: Observable<ConfigVerificationResult[]>;
@Input() propertyVerificationStatus$: Observable<'pending' | 'loading' | 'success'> = of('pending');
@Output() verify: EventEmitter<VerifyPropertiesRequestContext> = new EventEmitter<VerifyPropertiesRequestContext>();
@Output() editReportingTask: EventEmitter<UpdateReportingTaskRequest> =
new EventEmitter<UpdateReportingTaskRequest>();
@ -184,9 +195,7 @@ export class EditReportingTask extends CloseOnEscapeDialog {
const propertyControl: AbstractControl | null = this.editReportingTaskForm.get('properties');
if (propertyControl && propertyControl.dirty) {
const properties: Property[] = propertyControl.value;
const values: { [key: string]: string | null } = {};
properties.forEach((property) => (values[property.property] = property.value));
payload.component.properties = values;
payload.component.properties = this.getModifiedProperties();
payload.component.sensitiveDynamicPropertyNames = properties
.filter((property) => property.descriptor.dynamic && property.descriptor.sensitive)
.map((property) => property.descriptor.name);
@ -221,4 +230,22 @@ export class EditReportingTask extends CloseOnEscapeDialog {
override isDirty(): boolean {
return this.editReportingTaskForm.dirty;
}
private getModifiedProperties(): ModifiedProperties {
const propertyControl: AbstractControl | null = this.editReportingTaskForm.get('properties');
if (propertyControl && propertyControl.dirty) {
const properties: Property[] = propertyControl.value;
const values: { [key: string]: string | null } = {};
properties.forEach((property) => (values[property.property] = property.value));
return values;
}
return {};
}
verifyClicked(entity: ReportingTaskEntity): void {
this.verify.next({
entity,
properties: this.getModifiedProperties()
});
}
}

View File

@ -55,10 +55,7 @@ describe('UserAccessPolicies', () => {
imports: [UserAccessPolicies, NoopAnimationsModule],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
{
provide: MatDialogRef,
useValue: null
}
{ provide: MatDialogRef, useValue: null }
]
});
fixture = TestBed.createComponent(UserAccessPolicies);

View File

@ -0,0 +1,61 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { MapTableEntry } from '../state/shared';
import { Observable, of, switchMap, takeUntil } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { SMALL_DIALOG } from '../index';
import {
MapTableEntryData,
NewMapTableEntryDialog
} from '../ui/common/new-map-table-entry-dialog/new-map-table-entry-dialog.component';
@Injectable({
providedIn: 'root'
})
export class MapTableHelperService {
constructor(private dialog: MatDialog) {}
/**
* Returns a function that can be used to pass into a MapTable to support creating a new entry.
*
* @param entryType string representing the type of Map Table Entry
*/
createNewEntry(entryType: string): (existingEntries: string[]) => Observable<MapTableEntry> {
return (existingEntries: string[]) => {
const dialogRef = this.dialog.open(NewMapTableEntryDialog, {
...SMALL_DIALOG,
data: {
existingEntries,
entryTypeLabel: entryType
} as MapTableEntryData
});
return dialogRef.componentInstance.newEntry.pipe(
takeUntil(dialogRef.afterClosed()),
switchMap((name: string) => {
dialogRef.close();
const newEntry: MapTableEntry = {
name,
value: null
};
return of(newEntry);
})
);
};
}
}

View File

@ -0,0 +1,71 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Client } from './client.service';
import {
ConfigurationAnalysisResponse,
InitiateVerificationRequest,
PropertyVerificationResponse,
VerifyPropertiesRequestContext
} from '../state/property-verification';
import { Observable } from 'rxjs';
import { NiFiCommon } from './nifi-common.service';
@Injectable({ providedIn: 'root' })
export class PropertyVerificationService {
private static readonly API: string = '../nifi-api';
constructor(
private httpClient: HttpClient,
private client: Client,
private nifiCommon: NiFiCommon
) {}
getAnalysis(request: VerifyPropertiesRequestContext): Observable<ConfigurationAnalysisResponse> {
const body = {
configurationAnalysis: {
componentId: request.entity.id,
properties: request.properties
}
};
return this.httpClient.post(
`${this.nifiCommon.stripProtocol(request.entity.uri)}/config/analysis`,
body
) as Observable<ConfigurationAnalysisResponse>;
}
initiatePropertyVerification(request: InitiateVerificationRequest): Observable<PropertyVerificationResponse> {
return this.httpClient.post(
`${this.nifiCommon.stripProtocol(request.uri)}/config/verification-requests`,
request.request
) as Observable<PropertyVerificationResponse>;
}
getPropertyVerificationRequest(requestId: string, uri: string): Observable<PropertyVerificationResponse> {
return this.httpClient.get(
`${this.nifiCommon.stripProtocol(uri)}/config/verification-requests/${requestId}`
) as Observable<PropertyVerificationResponse>;
}
deletePropertyVerificationRequest(requestId: string, uri: string) {
return this.httpClient.delete(
`${this.nifiCommon.stripProtocol(uri)}/config/verification-requests/${requestId}`
);
}
}

View File

@ -41,6 +41,8 @@ import { clusterSummaryFeatureKey, ClusterSummaryState } from './cluster-summary
import { clusterSummaryReducer } from './cluster-summary/cluster-summary.reducer';
import { loginConfigurationFeatureKey, LoginConfigurationState } from './login-configuration';
import { loginConfigurationReducer } from './login-configuration/login-configuration.reducer';
import { propertyVerificationFeatureKey, PropertyVerificationState } from './property-verification';
import { propertyVerificationReducer } from './property-verification/property-verification.reducer';
export interface NiFiState {
[DEFAULT_ROUTER_FEATURENAME]: RouterReducerState;
@ -56,6 +58,7 @@ export interface NiFiState {
[componentStateFeatureKey]: ComponentStateState;
[documentationFeatureKey]: DocumentationState;
[clusterSummaryFeatureKey]: ClusterSummaryState;
[propertyVerificationFeatureKey]: PropertyVerificationState;
}
export const rootReducers: ActionReducerMap<NiFiState> = {
@ -71,5 +74,6 @@ export const rootReducers: ActionReducerMap<NiFiState> = {
[systemDiagnosticsFeatureKey]: systemDiagnosticsReducer,
[componentStateFeatureKey]: componentStateReducer,
[documentationFeatureKey]: documentationReducer,
[clusterSummaryFeatureKey]: clusterSummaryReducer
[clusterSummaryFeatureKey]: clusterSummaryReducer,
[propertyVerificationFeatureKey]: propertyVerificationReducer
};

View File

@ -0,0 +1,93 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const propertyVerificationFeatureKey = 'propertyVerification';
export enum Outcome {
SUCCESSFUL = 'SUCCESSFUL',
FAILED = 'FAILED',
SKIPPED = 'SKIPPED'
}
export interface ModifiedProperties {
[key: string]: string | null;
}
export interface VerifyPropertiesRequestContext {
entity: any;
properties: ModifiedProperties;
}
export interface ConfigurationAnalysisResponse {
requestContext: VerifyPropertiesRequestContext;
configurationAnalysis: ConfigurationAnalysis;
}
export interface ConfigurationAnalysis {
componentId: string;
properties: any;
referencedAttributes: any;
supportsVerification: boolean;
}
export interface ConfigVerificationResult {
outcome: Outcome;
verificationStepName: string;
explanation: string;
}
export interface PropertyVerificationRequest {
complete: boolean;
componentId: string;
lastUpdated: string;
percentCompleted: number;
properties: any;
requestId: string;
uri: string;
state: string;
results?: ConfigVerificationResult[];
failureReason?: string;
}
export interface PropertyVerificationResponse {
request: PropertyVerificationRequest;
}
export interface VerifyConfigRequest {
componentId: string;
properties: any;
attributes: any;
results?: ConfigVerificationResult[];
}
export interface VerifyConfigRequestEntity {
request: VerifyConfigRequest;
}
export interface InitiateVerificationRequest {
uri: string;
request: VerifyConfigRequestEntity;
}
export interface PropertyVerificationState {
activeRequest: PropertyVerificationRequest | null;
requestContext: VerifyPropertiesRequestContext | null;
configurationAnalysis: ConfigurationAnalysis | null;
results: ConfigVerificationResult[];
attributes: { [key: string]: string } | null;
status: 'pending' | 'loading' | 'success';
}

View File

@ -0,0 +1,69 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createAction, props } from '@ngrx/store';
import { ConfigurationAnalysisResponse, PropertyVerificationResponse, VerifyPropertiesRequestContext } from './index';
const VERIFICATION_STATE_PREFIX = '[Property Verification State]';
export const verifyProperties = createAction(
`${VERIFICATION_STATE_PREFIX} Verify Properties`,
props<{ request: VerifyPropertiesRequestContext }>()
);
export const getConfigurationAnalysisSuccess = createAction(
`${VERIFICATION_STATE_PREFIX} Get Configuration Analysis`,
props<{ response: ConfigurationAnalysisResponse }>()
);
export const startPollingPropertyVerification = createAction(
`${VERIFICATION_STATE_PREFIX} Start Polling Property Verification`
);
export const pollPropertyVerification = createAction(`${VERIFICATION_STATE_PREFIX} Poll Property Verification`);
export const pollPropertyVerificationSuccess = createAction(
`${VERIFICATION_STATE_PREFIX} Poll Property Verification Success`,
props<{ response: PropertyVerificationResponse }>()
);
export const stopPollingPropertyVerification = createAction(
`${VERIFICATION_STATE_PREFIX} Stop Polling Property Verification`
);
export const propertyVerificationComplete = createAction(`${VERIFICATION_STATE_PREFIX} Property Verification Complete`);
export const verifyPropertiesSuccess = createAction(
`${VERIFICATION_STATE_PREFIX} Verify Properties Success`,
props<{ response: PropertyVerificationResponse }>()
);
export const verifyPropertiesComplete = createAction(
`${VERIFICATION_STATE_PREFIX} Verify Properties Complete`,
props<{ response: PropertyVerificationResponse }>()
);
export const openPropertyVerificationProgressDialog = createAction(
`${VERIFICATION_STATE_PREFIX} Open Property Verification Progress`
);
export const resetPropertyVerificationState = createAction(`${VERIFICATION_STATE_PREFIX} Reset`);
export const initiatePropertyVerification = createAction(
`${VERIFICATION_STATE_PREFIX} Initiate Property Verification`,
props<{ response: ConfigurationAnalysisResponse }>()
);

View File

@ -0,0 +1,294 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { ConfigurationAnalysisResponse, PropertyVerificationState } from './index';
import { Store } from '@ngrx/store';
import { MatDialog } from '@angular/material/dialog';
import { ErrorHelper } from '../../service/error-helper.service';
import * as VerificationActions from './property-verification.actions';
import * as ErrorActions from '../error/error.actions';
import { asyncScheduler, catchError, filter, from, interval, map, of, switchMap, take, takeUntil, tap } from 'rxjs';
import { PropertyVerificationService } from '../../service/property-verification.service';
import { HttpErrorResponse } from '@angular/common/http';
import { concatLatestFrom } from '@ngrx/operators';
import { isDefinedAndNotNull, MapTableEntry } from '../shared';
import {
selectActivePropertyVerificationRequest,
selectPropertyVerificationAttributes,
selectPropertyVerificationRequestContext
} from './property-verification.selectors';
import { PropertyVerificationProgress } from '../../ui/common/property-verification/common/property-verification-progress/property-verification-progress.component';
import { MEDIUM_DIALOG, SMALL_DIALOG } from '../../index';
import { ReferencedAttributesDialog } from '../../ui/common/property-verification/common/referenced-attributes-dialog/referenced-attributes-dialog.component';
import { PropertyTableHelperService } from '../../service/property-table-helper.service';
import { MapTableHelperService } from '../../service/map-table-helper.service';
@Injectable()
export class PropertyVerificationEffects {
constructor(
private actions$: Actions,
private store: Store<PropertyVerificationState>,
private dialog: MatDialog,
private errorHelper: ErrorHelper,
private propertyVerificationService: PropertyVerificationService,
private propertyTableHelperService: PropertyTableHelperService,
private mapTableHelperService: MapTableHelperService
) {}
verifyProperties$ = createEffect(() =>
this.actions$.pipe(
ofType(VerificationActions.verifyProperties),
map((action) => action.request),
switchMap((request) => {
// get the configuration analysis
return from(this.propertyVerificationService.getAnalysis(request)).pipe(
map((response) =>
VerificationActions.getConfigurationAnalysisSuccess({
response: {
requestContext: request,
configurationAnalysis: response.configurationAnalysis
}
})
),
catchError((errorResponse: HttpErrorResponse) =>
of(ErrorActions.snackBarError({ error: this.errorHelper.getErrorString(errorResponse) }))
)
);
})
)
);
getConfigurationAnalysisSuccess_noExtraVerification$ = createEffect(() =>
this.actions$.pipe(
ofType(VerificationActions.getConfigurationAnalysisSuccess),
map((action) => action.response),
filter((response) => !response.configurationAnalysis.supportsVerification),
switchMap((response) => {
return of(VerificationActions.initiatePropertyVerification({ response }));
})
)
);
getConfigurationAnalysisSuccess_extraVerification$ = createEffect(
() =>
this.actions$.pipe(
ofType(VerificationActions.getConfigurationAnalysisSuccess),
map((action) => action.response),
filter((response) => response.configurationAnalysis.supportsVerification),
concatLatestFrom(() => this.store.select(selectPropertyVerificationAttributes)),
tap(([response, previousAttributes]) => {
let referencedAttributes: MapTableEntry[] = [];
if (previousAttributes) {
referencedAttributes = Object.entries(previousAttributes).map(([key, value]) => {
return {
name: key,
value: value
} as MapTableEntry;
});
}
const dialogRef = this.dialog.open(ReferencedAttributesDialog, {
...MEDIUM_DIALOG,
data: {
attributes: referencedAttributes
}
});
dialogRef.componentInstance.createNew = this.mapTableHelperService.createNewEntry('Attribute');
dialogRef.componentInstance.verify
.pipe(takeUntil(dialogRef.afterClosed()))
.subscribe((formData) => {
const attributesArray: MapTableEntry[] = formData.attributes || [];
const attributesMap: { [key: string]: string | null } = {};
attributesArray.forEach((entry: MapTableEntry) => {
attributesMap[entry.name] = entry.value;
});
const responseWithAttributes: ConfigurationAnalysisResponse = {
...response,
configurationAnalysis: {
...response.configurationAnalysis,
referencedAttributes: attributesMap
}
};
this.store.dispatch(
VerificationActions.initiatePropertyVerification({ response: responseWithAttributes })
);
});
})
),
{ dispatch: false }
);
initiatePropertyVerification$ = createEffect(() =>
this.actions$.pipe(
ofType(VerificationActions.initiatePropertyVerification),
map((action) => action.response),
switchMap((response) => {
this.store.dispatch(VerificationActions.openPropertyVerificationProgressDialog());
// if the component does not support additional verification there is no need to prompt for attribute values
return from(
this.propertyVerificationService
.initiatePropertyVerification({
request: {
request: {
properties: response.configurationAnalysis.properties,
componentId: response.configurationAnalysis.componentId,
attributes: response.configurationAnalysis.referencedAttributes
}
},
uri: response.requestContext.entity.uri
})
.pipe(
map((response) => {
return VerificationActions.verifyPropertiesSuccess({ response });
}),
catchError((errorResponse: HttpErrorResponse) =>
of(
ErrorActions.snackBarError({
error: this.errorHelper.getErrorString(errorResponse)
})
)
)
)
);
})
)
);
openPropertyVerificationProgressDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(VerificationActions.openPropertyVerificationProgressDialog),
tap(() => {
const dialogRef = this.dialog.open(PropertyVerificationProgress, {
...SMALL_DIALOG
});
const verificationRequest$ = this.store
.select(selectActivePropertyVerificationRequest)
.pipe(isDefinedAndNotNull());
dialogRef.componentInstance.verificationRequest$ = verificationRequest$;
verificationRequest$
.pipe(
takeUntil(dialogRef.afterClosed()),
isDefinedAndNotNull(),
filter((request) => request.complete)
)
.subscribe((request) => {
if (request.failureReason) {
this.store.dispatch(ErrorActions.snackBarError({ error: request.failureReason }));
}
// close the dialog now that it is complete
dialogRef.close();
});
dialogRef.componentInstance.stopVerification.pipe(take(1)).subscribe(() => {
this.store.dispatch(VerificationActions.stopPollingPropertyVerification());
});
})
),
{ dispatch: false }
);
verifyPropertiesSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(VerificationActions.verifyPropertiesSuccess),
map((action) => action.response),
filter((response) => !response.request.complete),
switchMap(() => {
return of(VerificationActions.startPollingPropertyVerification());
})
)
);
startPollingPropertyVerification$ = createEffect(() =>
this.actions$.pipe(
ofType(VerificationActions.startPollingPropertyVerification),
switchMap(() =>
interval(2000, asyncScheduler).pipe(
takeUntil(this.actions$.pipe(ofType(VerificationActions.stopPollingPropertyVerification)))
)
),
switchMap(() => of(VerificationActions.pollPropertyVerification()))
)
);
pollPropertyVerification$ = createEffect(() =>
this.actions$.pipe(
ofType(VerificationActions.pollPropertyVerification),
concatLatestFrom(() => [
this.store.select(selectPropertyVerificationRequestContext).pipe(isDefinedAndNotNull()),
this.store.select(selectActivePropertyVerificationRequest).pipe(isDefinedAndNotNull())
]),
switchMap(([, requestContext, verifyRequest]) => {
return from(
this.propertyVerificationService
.getPropertyVerificationRequest(verifyRequest.requestId, requestContext.entity.uri)
.pipe(
map((response) => VerificationActions.pollPropertyVerificationSuccess({ response })),
catchError((errorResponse: HttpErrorResponse) => {
this.store.dispatch(VerificationActions.stopPollingPropertyVerification());
return of(
ErrorActions.snackBarError({
error: this.errorHelper.getErrorString(errorResponse)
})
);
})
)
);
})
)
);
pollPropertyVerificationSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(VerificationActions.pollPropertyVerificationSuccess),
map((action) => action.response),
filter((response) => response.request.complete),
switchMap(() => of(VerificationActions.stopPollingPropertyVerification()))
)
);
stopPollingPropertyVerification$ = createEffect(() =>
this.actions$.pipe(
ofType(VerificationActions.stopPollingPropertyVerification),
concatLatestFrom(() => [
this.store.select(selectPropertyVerificationRequestContext).pipe(isDefinedAndNotNull()),
this.store.select(selectActivePropertyVerificationRequest).pipe(isDefinedAndNotNull())
]),
switchMap(([, requestContext, verifyRequest]) =>
from(
this.propertyVerificationService.deletePropertyVerificationRequest(
verifyRequest.requestId,
requestContext.entity.uri
)
).pipe(
map(() => VerificationActions.propertyVerificationComplete()),
catchError((errorResponse: HttpErrorResponse) =>
of(
ErrorActions.snackBarError({
error: this.errorHelper.getErrorString(errorResponse)
})
)
)
)
)
)
);
}

View File

@ -0,0 +1,75 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createReducer, on } from '@ngrx/store';
import { PropertyVerificationState } from './index';
import {
getConfigurationAnalysisSuccess,
initiatePropertyVerification,
pollPropertyVerificationSuccess,
propertyVerificationComplete,
resetPropertyVerificationState,
verifyPropertiesSuccess
} from './property-verification.actions';
import { produce } from 'immer';
export const initialPropertyVerificationState: PropertyVerificationState = {
results: [],
status: 'pending',
requestContext: null,
activeRequest: null,
configurationAnalysis: null,
attributes: null
};
export const propertyVerificationReducer = createReducer(
initialPropertyVerificationState,
on(initiatePropertyVerification, (state, { response }) => {
return produce(state, (draftState) => {
draftState.status = 'loading';
draftState.configurationAnalysis = response.configurationAnalysis;
draftState.requestContext = response.requestContext;
if (response.configurationAnalysis.supportsVerification) {
// preserve the most recent attributes used to verify component
draftState.attributes = response.configurationAnalysis.referencedAttributes;
}
});
}),
on(getConfigurationAnalysisSuccess, (state: PropertyVerificationState, { response }) => ({
...state,
configurationAnalysis: response.configurationAnalysis,
requestContext: response.requestContext
})),
on(verifyPropertiesSuccess, pollPropertyVerificationSuccess, (state: PropertyVerificationState, { response }) => ({
...state,
activeRequest: response.request,
results: response.request.results || []
})),
on(propertyVerificationComplete, (state) => ({
...state,
activeRequest: null,
status: 'success' as const
})),
on(resetPropertyVerificationState, (state) => ({
...initialPropertyVerificationState,
attributes: state.attributes // preserve attributes
}))
);

View File

@ -0,0 +1,47 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { propertyVerificationFeatureKey, PropertyVerificationState } from './index';
export const selectPropertyVerificationState =
createFeatureSelector<PropertyVerificationState>(propertyVerificationFeatureKey);
export const selectPropertyVerificationRequestContext = createSelector(
selectPropertyVerificationState,
(state: PropertyVerificationState) => state.requestContext
);
export const selectActivePropertyVerificationRequest = createSelector(
selectPropertyVerificationState,
(state: PropertyVerificationState) => state.activeRequest
);
export const selectPropertyVerificationResults = createSelector(
selectPropertyVerificationState,
(state: PropertyVerificationState) => state.results
);
export const selectPropertyVerificationStatus = createSelector(
selectPropertyVerificationState,
(state: PropertyVerificationState) => state.status
);
export const selectPropertyVerificationAttributes = createSelector(
selectPropertyVerificationState,
(state: PropertyVerificationState) => state.attributes
);

View File

@ -703,3 +703,8 @@ export interface OpenChangeComponentVersionDialogRequest {
fetchRequest: FetchComponentVersionsRequest;
componentVersions: DocumentedType[];
}
export interface MapTableEntry {
name: string;
value: string | null;
}

View File

@ -117,8 +117,9 @@
</mat-tab>
<mat-tab label="Properties">
<mat-dialog-content>
<div class="dialog-tab-content">
<div class="dialog-tab-content flex gap-x-3">
<property-table
class="w-2/3"
formControlName="properties"
[createNewProperty]="createNewProperty"
[createNewService]="createNewService"
@ -127,9 +128,16 @@
[convertToParameter]="convertToParameter"
[goToService]="goToService"
[propertyHistory]="request.history"
[supportsParameters]="supportsParameters"
[supportsSensitiveDynamicProperties]="
request.controllerService.component.supportsSensitiveDynamicProperties
"></property-table>
<property-verification
class="w-1/3"
[disabled]="readonly"
[isVerifying]="(propertyVerificationStatus$ | async) === 'loading'"
[results]="propertyVerificationResults$ | async"
(verify)="verifyClicked(request.controllerService)"></property-verification>
</div>
</mat-dialog-content>
</mat-tab>

View File

@ -39,7 +39,7 @@ import { MatOptionModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
import { PropertyTable } from '../../property-table/property-table.component';
import { ControllerServiceApi } from '../controller-service-api/controller-service-api.component';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { ControllerServiceReferences } from '../controller-service-references/controller-service-references.component';
import { NifiSpinnerDirective } from '../../spinner/nifi-spinner.directive';
import { ErrorBanner } from '../../error-banner/error-banner.component';
@ -48,6 +48,12 @@ import { TextTip } from '../../tooltips/text-tip/text-tip.component';
import { NifiTooltipDirective } from '../../tooltips/nifi-tooltip.directive';
import { ConvertToParameterResponse } from '../../../../pages/flow-designer/service/parameter-helper.service';
import { CloseOnEscapeDialog } from '../../close-on-escape-dialog/close-on-escape-dialog.component';
import { PropertyVerification } from '../../property-verification/property-verification.component';
import {
ConfigVerificationResult,
ModifiedProperties,
VerifyPropertiesRequestContext
} from '../../../../state/property-verification';
@Component({
selector: 'edit-controller-service',
@ -68,7 +74,8 @@ import { CloseOnEscapeDialog } from '../../close-on-escape-dialog/close-on-escap
AsyncPipe,
NifiSpinnerDirective,
ErrorBanner,
NifiTooltipDirective
NifiTooltipDirective,
PropertyVerification
],
styleUrls: ['./edit-controller-service.component.scss']
})
@ -85,6 +92,11 @@ export class EditControllerService extends CloseOnEscapeDialog {
@Input() goToService!: (serviceId: string) => void;
@Input() goToReferencingComponent!: (component: ControllerServiceReferencingComponent) => void;
@Input() saving$!: Observable<boolean>;
@Input() supportsParameters: boolean = true;
@Input() propertyVerificationResults$!: Observable<ConfigVerificationResult[]>;
@Input() propertyVerificationStatus$: Observable<'pending' | 'loading' | 'success'> = of('pending');
@Output() verify: EventEmitter<VerifyPropertiesRequestContext> = new EventEmitter<VerifyPropertiesRequestContext>();
@Output() editControllerService: EventEmitter<UpdateControllerServiceRequest> =
new EventEmitter<UpdateControllerServiceRequest>();
@ -167,9 +179,7 @@ export class EditControllerService extends CloseOnEscapeDialog {
const propertyControl: AbstractControl | null = this.editControllerServiceForm.get('properties');
if (propertyControl && propertyControl.dirty) {
const properties: Property[] = propertyControl.value;
const values: { [key: string]: string | null } = {};
properties.forEach((property) => (values[property.property] = property.value));
payload.component.properties = values;
payload.component.properties = this.getModifiedProperties();
payload.component.sensitiveDynamicPropertyNames = properties
.filter((property) => property.descriptor.dynamic && property.descriptor.sensitive)
.map((property) => property.descriptor.name);
@ -181,9 +191,27 @@ export class EditControllerService extends CloseOnEscapeDialog {
});
}
private getModifiedProperties(): ModifiedProperties {
const propertyControl: AbstractControl | null = this.editControllerServiceForm.get('properties');
if (propertyControl && propertyControl.dirty) {
const properties: Property[] = propertyControl.value;
const values: { [key: string]: string | null } = {};
properties.forEach((property) => (values[property.property] = property.value));
return values;
}
return {};
}
protected readonly TextTip = TextTip;
override isDirty(): boolean {
return this.editControllerServiceForm.dirty;
}
verifyClicked(entity: ControllerServiceEntity): void {
this.verify.next({
entity,
properties: this.getModifiedProperties()
});
}
}

View File

@ -28,12 +28,7 @@ describe('ExtensionCreation', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ExtensionCreation, NoopAnimationsModule],
providers: [
{
provide: MatDialogRef,
useValue: null
}
]
providers: [{ provide: MatDialogRef, useValue: null }]
});
fixture = TestBed.createComponent(ExtensionCreation);
component = fixture.componentInstance;

View File

@ -0,0 +1,32 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@use 'sass:map';
@use '@angular/material' as mat;
@mixin generate-theme($material-theme, $nifi-theme) {
.text-editor {
@include mat.button-density(-1);
.editor {
&.blank {
border-color: var(--mdc-outlined-text-field-disabled-label-text-color);
cursor: not-allowed !important;
}
}
}
}

View File

@ -0,0 +1,71 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<div
class="text-editor mat-elevation-z8 p-4 h-full"
[style.width.px]="width"
cdkDrag
resizable
(resized)="resized($event)">
<form class="h-full" [formGroup]="textEditorForm" cdkTrapFocus [cdkTrapFocusAutoCapture]="!readonly">
<div class="flex flex-col gap-y-3 h-full">
<div class="flex flex-col gap-y-0.5 flex-1">
<div class="editor flex-1" [class.blank]="blank">
<ngx-codemirror
formControlName="value"
[options]="getOptions()"
(mousedown)="preventDrag($event)"
(codeMirrorLoaded)="codeMirrorLoaded($event)"></ngx-codemirror>
</div>
@if (!readonly) {
<mat-checkbox
color="primary"
formControlName="setEmptyString"
(mousedown)="preventDrag($event)"
(change)="setEmptyStringChanged()"
>Set empty string
</mat-checkbox>
}
</div>
<div class="flex justify-end items-center gap-x-2">
@if (readonly) {
<button
mat-button
type="button"
color="primary"
(mousedown)="preventDrag($event)"
(click)="cancelClicked()">
Close
</button>
} @else {
<button mat-button type="button" (mousedown)="preventDrag($event)" (click)="cancelClicked()">
Cancel
</button>
<button
[disabled]="!textEditorForm.dirty || textEditorForm.invalid"
(mousedown)="preventDrag($event)"
type="button"
color="primary"
(click)="okClicked()"
mat-button>
Ok
</button>
}
</div>
</div>
</form>
</div>

View File

@ -0,0 +1,28 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@use '@angular/material' as mat;
.text-editor {
@include mat.button-density(-1);
min-height: 220px;
min-width: 245px;
max-height: 100vh;
max-width: 100vw;
cursor: move;
}

View File

@ -0,0 +1,38 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TextEditor } from './text-editor.component';
describe('TextEditor', () => {
let component: TextEditor;
let fixture: ComponentFixture<TextEditor>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TextEditor]
}).compileComponents();
fixture = TestBed.createComponent(TextEditor);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,174 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Input, Output, Renderer2, ViewContainerRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MapTableItem } from '../../map-table.component';
import { AbstractControl, FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { Editor } from 'codemirror';
import { CdkDrag } from '@angular/cdk/drag-drop';
import { CdkTrapFocus } from '@angular/cdk/a11y';
import { CodemirrorModule } from '@ctrl/ngx-codemirror';
import { MatButton } from '@angular/material/button';
import { MatCheckbox } from '@angular/material/checkbox';
import { NifiTooltipDirective } from '../../../tooltips/nifi-tooltip.directive';
import { Resizable } from '../../../resizable/resizable.component';
@Component({
selector: 'text-editor',
standalone: true,
imports: [
CommonModule,
CdkDrag,
CdkTrapFocus,
CodemirrorModule,
MatButton,
MatCheckbox,
NifiTooltipDirective,
ReactiveFormsModule,
Resizable
],
templateUrl: './text-editor.component.html',
styleUrl: './text-editor.component.scss'
})
export class TextEditor {
@Input() set item(item: MapTableItem) {
this.textEditorForm.get('value')?.setValue(item.entry.value);
const isEmptyString: boolean = item.entry.value === '';
this.textEditorForm.get('setEmptyString')?.setValue(isEmptyString);
this.setEmptyStringChanged();
this.itemSet = true;
}
@Input() width!: number;
@Input() readonly: boolean = false;
@Output() ok: EventEmitter<string | null> = new EventEmitter<string | null>();
@Output() cancel: EventEmitter<void> = new EventEmitter<void>();
textEditorForm: FormGroup;
editor!: Editor;
blank = false;
itemSet = false;
constructor(
private formBuilder: FormBuilder,
private viewContainerRef: ViewContainerRef,
private renderer: Renderer2
) {
this.textEditorForm = this.formBuilder.group({
value: new FormControl(''),
setEmptyString: new FormControl(false)
});
}
cancelClicked(): void {
this.cancel.next();
}
okClicked(): void {
const valueControl: AbstractControl | null = this.textEditorForm.get('value');
const emptyStringChecked: AbstractControl | null = this.textEditorForm.get('setEmptyString');
if (valueControl && emptyStringChecked) {
const value = valueControl.value;
if (value === '') {
if (emptyStringChecked.value) {
this.ok.next('');
} else {
this.ok.next(null);
}
} else {
this.ok.next(value);
}
}
}
setEmptyStringChanged(): void {
const emptyStringChecked: AbstractControl | null = this.textEditorForm.get('setEmptyString');
if (emptyStringChecked) {
this.blank = emptyStringChecked.value;
if (emptyStringChecked.value) {
this.textEditorForm.get('value')?.setValue('');
this.textEditorForm.get('value')?.disable();
if (this.editor) {
this.editor.setOption('readOnly', 'nocursor');
}
} else {
this.textEditorForm.get('value')?.enable();
if (this.editor) {
this.editor.setOption('readOnly', false);
}
}
}
}
resized(event: any): void {
// Note: We calculate the height of the codemirror to fit into an `.editor` overlay. The
// height of the codemirror needs to be set in order to handle large amounts of text in the codemirror editor.
// The height of the codemirror should be the height of the `.editor` overlay minus the 112px of spacing
// needed to display the 'Set Empty String' checkbox, the action buttons,
// and the resize handle. If the amount of spacing needed for additional UX is needed for the `.editor` is
// changed then this value should also be updated.
this.editor.setSize('100%', event.height - 112);
}
preventDrag(event: MouseEvent): void {
event.stopPropagation();
}
codeMirrorLoaded(codeEditor: any): void {
this.editor = codeEditor.codeMirror;
// The `.text-editor` minimum height is set to 220px. This is the height of the `.editor` overlay. The
// height of the codemirror needs to be set in order to handle large amounts of text in the codemirror editor.
// The height of the codemirror should be the height of the `.editor` overlay minus the 112px of spacing
// needed to display the 'Set Empty String' checkbox, the action buttons,
// and the resize handle so the initial height of the codemirror when opening should be 108px for a 220px tall
// `.editor` overlay. If the initial height of that overlay changes then this initial height should also be
// updated.
this.editor.setSize('100%', 108);
if (!this.readonly) {
this.editor.focus();
this.editor.execCommand('selectAll');
}
// disabling of the input through the form isn't supported until codemirror
// has loaded so we must disable again if the value is an empty string
if (this.textEditorForm.get('setEmptyString')?.value) {
this.textEditorForm.get('value')?.disable();
this.editor.setOption('readOnly', 'nocursor');
}
}
getOptions(): any {
return {
readOnly: this.readonly,
lineNumbers: true,
matchBrackets: true,
theme: 'nifi',
extraKeys: {
Enter: () => {
if (this.textEditorForm.dirty && this.textEditorForm.valid) {
this.okClicked();
}
}
}
};
}
}

View File

@ -0,0 +1,139 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<div class="map-table flex flex-col h-full gap-y-3">
<div class="flex justify-between items-center">
<div class="font-bold flex-1">
<ng-content></ng-content>
</div>
@if (!isDisabled) {
<div>
<button mat-icon-button color="primary" type="button" (click)="newEntryClicked()">
<i class="fa fa-plus"></i>
</button>
</div>
}
</div>
<div class="listing-table flex-1 relative">
<div class="absolute inset-0 overflow-y-auto overflow-x-hidden">
<table mat-table #mapTable [dataSource]="dataSource">
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let item">
<div class="flex justify-between items-center">
<div
class="whitespace-nowrap overflow-hidden text-ellipsis leading-normal"
[title]="item.entry.name">
{{ item.entry.name }}
</div>
</div>
</td>
</ng-container>
<!-- Value Column -->
<ng-container matColumnDef="value">
<th mat-header-cell *matHeaderCellDef>Value</th>
<td mat-cell *matCellDef="let item">
<div
[id]="formatId(item)"
class="pointer"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
(click)="openEditor(trigger, item, $event)">
@if (isNull(item.entry.value)) {
<div class="unset surface-color">No value set</div>
} @else {
<ng-container
*ngTemplateOutlet="
isEmptyString(item.entry.value) ? blank : nonBlank;
context: { $implicit: item.entry.value }
"></ng-container>
<ng-template #blank>
<div class="empty surface-color">Empty string set</div>
</ng-template>
<ng-template #nonBlank let-value>
<div class="flex justify-between items-center">
<div
class="whitespace-nowrap overflow-hidden text-ellipsis leading-normal"
[title]="value">
{{ value }}
</div>
@if (hasExtraWhitespace(value)) {
<div
class="fa fa-info primary-color"
nifiTooltip
[tooltipComponentType]="TextTip"
tooltipInputData="The specified value contains leading and/or trailing whitespace character(s). This could produce unexpected results if it was not intentional."></div>
}
</div>
</ng-template>
}
</div>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let item">
<div class="flex items-center justify-end gap-x-2">
@if (!isDisabled) {
<button
mat-icon-button
type="button"
[matMenuTriggerFor]="actionMenu"
class="h-16 w-16 flex items-center justify-center icon global-menu">
<i class="fa fa-ellipsis-v"></i>
</button>
}
<mat-menu #actionMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="deleteProperty(item)">
<i class="fa fa-trash primary-color mr-2"></i>
Delete
</button>
</mat-menu>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr
mat-row
*matRowDef="let row; let even = even; columns: displayedColumns"
(click)="select(row)"
[class.selected]="isSelected(row)"
[class.even]="even"></tr>
</table>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="editorTrigger"
[cdkConnectedOverlayPositions]="editorPositions"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
[cdkConnectedOverlayOpen]="editorOpen"
(detach)="closeEditor()">
<text-editor
[item]="editorItem"
[width]="editorWidth"
[readonly]="isDisabled"
(ok)="saveValue(editorItem, $event)"
(cancel)="closeEditor()"></text-editor>
</ng-template>
</div>
</div>
</div>

View File

@ -0,0 +1,16 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

View File

@ -0,0 +1,38 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MapTable } from './map-table.component';
describe('EditableMapTable', () => {
let component: MapTable;
let fixture: ComponentFixture<MapTable>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MapTable]
}).compileComponents();
fixture = TestBed.createComponent(MapTable);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,392 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AfterViewInit,
ChangeDetectorRef,
Component,
DestroyRef,
forwardRef,
inject,
Input,
QueryList,
ViewChildren
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Observable, take } from 'rxjs';
import { MapTableEntry } from '../../../state/shared';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable,
MatTableDataSource
} from '@angular/material/table';
import {
CdkConnectedOverlay,
CdkOverlayOrigin,
ConnectionPositionPair,
OriginConnectionPosition,
OverlayConnectionPosition
} from '@angular/cdk/overlay';
import { NiFiCommon } from '../../../service/nifi-common.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { ComboEditor } from '../property-table/editors/combo-editor/combo-editor.component';
import { MatIconButton } from '@angular/material/button';
import { NifiTooltipDirective } from '../tooltips/nifi-tooltip.directive';
import { TextTip } from '../tooltips/text-tip/text-tip.component';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { NfEditor } from '../property-table/editors/nf-editor/nf-editor.component';
import { TextEditor } from './editors/text-editor/text-editor.component';
export interface MapTableItem {
entry: MapTableEntry;
id: number;
triggerEdit: boolean;
deleted: boolean;
dirty: boolean;
added: boolean;
}
@Component({
selector: 'map-table',
standalone: true,
imports: [
CommonModule,
CdkConnectedOverlay,
CdkOverlayOrigin,
ComboEditor,
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatIconButton,
MatTable,
MatHeaderCellDef,
NifiTooltipDirective,
MatMenuTrigger,
MatMenu,
MatMenuItem,
MatRow,
MatHeaderRow,
NfEditor,
MatRowDef,
MatHeaderRowDef,
TextEditor
],
templateUrl: './map-table.component.html',
styleUrl: './map-table.component.scss',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MapTable),
multi: true
}
]
})
export class MapTable implements AfterViewInit, ControlValueAccessor {
@Input() createNew!: (existingEntries: string[]) => Observable<MapTableEntry>;
@Input() reportChangesOnly: boolean = false;
private destroyRef = inject(DestroyRef);
itemLookup: Map<string, MapTableItem> = new Map<string, MapTableItem>();
displayedColumns: string[] = ['name', 'value', 'actions'];
dataSource: MatTableDataSource<MapTableItem> = new MatTableDataSource<MapTableItem>();
selectedItem!: MapTableItem;
@ViewChildren('trigger') valueTriggers!: QueryList<CdkOverlayOrigin>;
isDisabled = false;
isTouched = false;
onTouched!: () => void;
onChange!: (entries: MapTableEntry[]) => void;
editorOpen = false;
editorTrigger: any = null;
editorItem!: MapTableItem;
editorWidth = 0;
editorOffsetX = 0;
editorOffsetY = 0;
private originPos: OriginConnectionPosition = {
originX: 'center',
originY: 'center'
};
private editorOverlayPos: OverlayConnectionPosition = {
overlayX: 'center',
overlayY: 'center'
};
public editorPositions: ConnectionPositionPair[] = [];
constructor(
private changeDetector: ChangeDetectorRef,
private nifiCommon: NiFiCommon
) {}
ngAfterViewInit(): void {
this.initFilter();
this.valueTriggers.changes.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
const item: MapTableItem | undefined = this.dataSource.data.find((item) => item.triggerEdit);
if (item) {
const valueTrigger: CdkOverlayOrigin | undefined = this.valueTriggers.find(
(valueTrigger: CdkOverlayOrigin) => {
return this.formatId(item) == valueTrigger.elementRef.nativeElement.getAttribute('id');
}
);
if (valueTrigger) {
// scroll into view
valueTrigger.elementRef.nativeElement.scrollIntoView({ block: 'center', behavior: 'instant' });
window.setTimeout(function () {
// trigger a click to start editing the new item
valueTrigger.elementRef.nativeElement.click();
}, 0);
}
item.triggerEdit = false;
}
});
}
initFilter(): void {
this.dataSource.filterPredicate = (data: MapTableItem) => this.isVisible(data);
this.dataSource.filter = ' ';
}
isVisible(item: MapTableItem): boolean {
return !item.deleted;
}
registerOnChange(onChange: (entries: MapTableEntry[]) => void): void {
this.onChange = onChange;
}
registerOnTouched(onTouch: () => void): void {
this.onTouched = onTouch;
}
setDisabledState(isDisabled: boolean): void {
this.isDisabled = isDisabled;
}
writeValue(entries: MapTableEntry[]): void {
this.itemLookup.clear();
let i = 0;
let items: MapTableItem[] = [];
if (entries) {
items = entries.map((entry) => {
// create the property item
const item: MapTableItem = {
entry,
id: i++,
triggerEdit: false,
deleted: false,
added: false,
dirty: false
};
// store the entry item in a map for an efficient lookup later
this.itemLookup.set(entry.name, item);
return item;
});
}
this.setItems(items);
}
private setItems(items: MapTableItem[]): void {
this.dataSource = new MatTableDataSource<MapTableItem>(items);
this.initFilter();
}
newEntryClicked(): void {
// filter out deleted properties in case the user needs to re-add one
const existingEntries: string[] = this.dataSource.data
.filter((item) => !item.deleted)
.map((item) => item.entry.name);
// create the new property
this.createNew(existingEntries)
.pipe(take(1))
.subscribe((entry) => {
const currentItems: MapTableItem[] = this.dataSource.data;
const itemIndex: number = currentItems.findIndex(
(existingItem: MapTableItem) => existingItem.entry.name == entry.name
);
if (itemIndex > -1) {
const currentItem: MapTableItem = currentItems[itemIndex];
const updatedItem: MapTableItem = {
...currentItem,
entry,
triggerEdit: true,
deleted: false,
added: true,
dirty: true
};
this.itemLookup.set(entry.name, updatedItem);
// if the user had previously deleted the entry, replace the matching entry item
currentItems[itemIndex] = updatedItem;
} else {
const i: number = currentItems.length;
const item: MapTableItem = {
entry,
id: i,
triggerEdit: true,
deleted: false,
added: true,
dirty: true
};
this.itemLookup.set(entry.name, item);
// if this is a new entry, add it to the list
this.setItems([...currentItems, item]);
}
this.handleChanged();
});
}
formatId(item: MapTableItem): string {
return 'entry-' + item.id;
}
isNull(value: string): boolean {
return value == null;
}
isEmptyString(value: string): boolean {
return value == '';
}
hasExtraWhitespace(value: string): boolean {
return this.nifiCommon.hasLeadTrailWhitespace(value);
}
openEditor(editorTrigger: any, item: MapTableItem, event: MouseEvent): void {
if (event.target) {
const target: HTMLElement = event.target as HTMLElement;
// find the table cell regardless of the target of the click
const td: HTMLElement | null = target.closest('td');
if (td) {
const { width } = td.getBoundingClientRect();
this.editorPositions.pop();
this.editorItem = item;
this.editorTrigger = editorTrigger;
this.editorOpen = true;
this.editorWidth = width + 100;
this.editorOffsetX = 8;
this.editorOffsetY = 80;
this.editorPositions.push(
new ConnectionPositionPair(
this.originPos,
this.editorOverlayPos,
this.editorOffsetX,
this.editorOffsetY
)
);
}
}
}
deleteProperty(item: MapTableItem): void {
if (!item.deleted) {
item.entry.value = null;
item.deleted = true;
item.dirty = true;
this.handleChanged();
}
}
saveValue(item: MapTableItem, newValue: string | null): void {
if (item.entry.value != newValue) {
item.entry.value = newValue;
item.dirty = true;
this.handleChanged();
}
this.closeEditor();
}
private handleChanged() {
// this is needed to trigger the filter to be reapplied
this.dataSource._updateChangeSubscription();
this.changeDetector.markForCheck();
// mark the component as touched if not already
if (!this.isTouched) {
this.isTouched = true;
this.onTouched();
}
// emit the changes
this.onChange(this.serializeEntries());
}
private serializeEntries(): MapTableEntry[] {
const items: MapTableItem[] = this.dataSource.data;
if (this.reportChangesOnly) {
// only include dirty items
return items
.filter((item) => item.dirty)
.filter((item) => !(item.added && item.deleted))
.map((item) => item.entry);
} else {
// return all the items, even untouched ones. no need to return the deleted items though
return items.filter((item) => !item.deleted).map((item) => item.entry);
}
}
closeEditor(): void {
this.editorOpen = false;
}
select(item: MapTableItem): void {
this.selectedItem = item;
}
isSelected(item: MapTableItem): boolean {
if (this.selectedItem) {
return item.entry.name == this.selectedItem.entry.name;
}
return false;
}
protected readonly TextTip = TextTip;
}

View File

@ -0,0 +1,41 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<h2 mat-dialog-title>Add {{ data.entryTypeLabel || 'Entry' }}</h2>
<form class="new-entry-form" [formGroup]="newEntryForm">
<mat-dialog-content>
<div class="mb-2">
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput formControlName="name" type="text" />
@if (name.invalid) {
<mat-error>{{ getNameErrorMessage() }}</mat-error>
}
</mat-form-field>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button
mat-button
[disabled]="!newEntryForm.dirty || newEntryForm.invalid"
(click)="addClicked()"
color="primary">
Ok
</button>
</mat-dialog-actions>
</form>

View File

@ -0,0 +1,30 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@use '@angular/material' as mat;
.new-entry-form {
@include mat.button-density(-1);
.mat-mdc-form-field {
width: 100%;
}
.mat-mdc-form-field-error {
font-size: 12px;
}
}

View File

@ -0,0 +1,48 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MapTableEntryData, NewMapTableEntryDialog } from './new-map-table-entry-dialog.component';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('NewMapTableEntryDialog', () => {
let component: NewMapTableEntryDialog;
let fixture: ComponentFixture<NewMapTableEntryDialog>;
const data: MapTableEntryData = {
entryTypeLabel: '',
existingEntries: []
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NewMapTableEntryDialog, NoopAnimationsModule],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: MatDialogRef, useValue: null }
]
}).compileComponents();
fixture = TestBed.createComponent(NewMapTableEntryDialog);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,118 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Inject, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
AbstractControl,
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
Validators
} from '@angular/forms';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle
} from '@angular/material/dialog';
import { CloseOnEscapeDialog } from '../close-on-escape-dialog/close-on-escape-dialog.component';
import { MatButton } from '@angular/material/button';
import { MatError, MatFormField, MatLabel } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
export interface MapTableEntryData {
existingEntries: string[];
entryTypeLabel?: string;
}
@Component({
selector: 'new-map-table-entry-dialog',
standalone: true,
imports: [
CommonModule,
MatButton,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle,
MatError,
MatFormField,
MatInput,
MatLabel,
MatRadioButton,
MatRadioGroup,
ReactiveFormsModule
],
templateUrl: './new-map-table-entry-dialog.component.html',
styleUrl: './new-map-table-entry-dialog.component.scss'
})
export class NewMapTableEntryDialog extends CloseOnEscapeDialog {
@Output() newEntry: EventEmitter<string> = new EventEmitter<string>();
newEntryForm: FormGroup;
name: FormControl;
constructor(
private formBuilder: FormBuilder,
@Inject(MAT_DIALOG_DATA) public data: MapTableEntryData
) {
super();
this.name = new FormControl(null, [
Validators.required,
this.existingEntryValidator(this.data.existingEntries)
]);
this.newEntryForm = formBuilder.group({
name: this.name
});
}
private existingEntryValidator(existingEntries: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (value === '') {
return null;
}
if (existingEntries.includes(value)) {
return {
existingEntry: true
};
}
return null;
};
}
getNameErrorMessage(): string {
if (this.name) {
if (this.name.hasError('required')) {
return 'Name is required.';
}
return this.name.hasError('existingEntry') ? 'Name already exists.' : '';
}
return '';
}
addClicked() {
this.newEntry.next(this.name.value);
}
}

View File

@ -75,7 +75,7 @@ export class NfEditor implements OnDestroy {
this.loadParameters();
}
@Input() set parameters(parameters: Parameter[]) {
@Input() set parameters(parameters: Parameter[] | null) {
this._parameters = parameters;
this.getParametersSet = true;
@ -99,7 +99,7 @@ export class NfEditor implements OnDestroy {
blank = false;
mode!: string;
_parameters!: Parameter[];
_parameters!: Parameter[] | null;
editor!: Editor;
@ -128,6 +128,7 @@ export class NfEditor implements OnDestroy {
this.editor.setSize('100%', 108);
if (!this.readonly) {
this.editor.focus();
this.editor.execCommand('selectAll');
}

View File

@ -20,7 +20,6 @@
<div class="font-bold">Required field</div>
@if (!isDisabled) {
<div>
<!-- TODO Property Verification -->
<button mat-icon-button color="primary" type="button" (click)="newPropertyClicked()">
<i class="fa fa-plus"></i>
</button>
@ -181,7 +180,7 @@
@if (hasAllowableValues(editorItem)) {
<combo-editor
[item]="editorItem"
[parameters]="editorParameters"
[parameters]="editorParameters || []"
[width]="editorWidth"
[readonly]="isDisabled"
(ok)="savePropertyValue(editorItem, $event)"

View File

@ -114,6 +114,7 @@ export class PropertyTable implements AfterViewInit, ControlValueAccessor {
@Input() goToService!: (serviceId: string) => void;
@Input() supportsSensitiveDynamicProperties = false;
@Input() propertyHistory: ComponentHistory | undefined;
@Input() supportsParameters: boolean = true;
private static readonly PARAM_REF_REGEX: RegExp = /#{[a-zA-Z0-9-_. ]+}/;
@ -137,7 +138,7 @@ export class PropertyTable implements AfterViewInit, ControlValueAccessor {
editorOpen = false;
editorTrigger: any = null;
editorItem!: PropertyItem;
editorParameters: Parameter[] = [];
editorParameters: Parameter[] | null = [];
editorWidth = 0;
editorOffsetX = 0;
editorOffsetY = 0;
@ -319,7 +320,10 @@ export class PropertyTable implements AfterViewInit, ControlValueAccessor {
this.initFilter();
}
private getParametersForItem(propertyItem: PropertyItem): Parameter[] {
private getParametersForItem(propertyItem: PropertyItem): Parameter[] | null {
if (!this.supportsParameters) {
return null;
}
if (this.parameterContext?.permissions.canRead) {
return this.parameterContext.component.parameters
.map((parameterEntity) => parameterEntity.parameter)

View File

@ -0,0 +1,44 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<h2 mat-dialog-title>Verifying Properties</h2>
<div class="change-version-progress">
<mat-dialog-content>
<div class="flex flex-col gap-y-4">
@if (verificationRequest$ | async; as verificationRequest) {
<div class="accent-color font-medium">
@if (verificationRequest.complete) {
Complete
} @else {
{{ verificationRequest.state }}
}
</div>
<div class="w-full flex flex-col items-center">
<mat-progress-bar
mode="determinate"
[value]="verificationRequest.percentCompleted"></mat-progress-bar>
<div class="accent-color font-medium">{{ verificationRequest.percentCompleted }}%</div>
</div>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (verificationRequest$ | async; as verificationRequest) {
<button mat-button mat-dialog-close (click)="stop(verificationRequest)" color="primary">Stop</button>
}
</mat-dialog-actions>
</div>

View File

@ -0,0 +1,16 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

View File

@ -0,0 +1,38 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PropertyVerificationProgress } from './property-verification-progress.component';
describe('PropertyVerificationProgress', () => {
let component: PropertyVerificationProgress;
let fixture: ComponentFixture<PropertyVerificationProgress>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PropertyVerificationProgress]
}).compileComponents();
fixture = TestBed.createComponent(PropertyVerificationProgress);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,48 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatButton } from '@angular/material/button';
import { MatDialogActions, MatDialogClose, MatDialogContent, MatDialogTitle } from '@angular/material/dialog';
import { MatProgressBar } from '@angular/material/progress-bar';
import { PropertyVerificationRequest } from '../../../../../state/property-verification';
import { Observable, of } from 'rxjs';
@Component({
selector: 'property-verification-progress',
standalone: true,
imports: [
CommonModule,
MatButton,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle,
MatProgressBar
],
templateUrl: './property-verification-progress.component.html',
styleUrl: './property-verification-progress.component.scss'
})
export class PropertyVerificationProgress {
@Input() verificationRequest$: Observable<PropertyVerificationRequest | null> = of(null);
@Output() stopVerification = new EventEmitter<PropertyVerificationRequest>();
stop(request: PropertyVerificationRequest) {
this.stopVerification.next(request);
}
}

View File

@ -0,0 +1,48 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<h2 mat-dialog-title>Referenced Attributes</h2>
<form class="referenced-attributes-form" [formGroup]="referencedAttributesForm">
<mat-dialog-content>
<div class="dialog-content max-h-96">
<map-table formControlName="attributes" [createNew]="createNew" [reportChangesOnly]="false">
<div class="flex flex-row justify-between items-center">
<div>
Enter Attribute Values
<i
class="fa fa-info-circle"
nifiTooltip
[tooltipComponentType]="TextTip"
tooltipInputData="Supply attribute values that should be considered when performing property verification."></i>
</div>
<button
mat-icon-button
[disabled]="isEmpty()"
color="primary"
type="button"
(click)="clearAttributesClicked()">
<i class="fa fa-eraser"></i>
</button>
</div>
</map-table>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button mat-button mat-dialog-close cdkFocusInitial (click)="verifyClicked()" color="primary">Verify</button>
</mat-dialog-actions>
</form>

View File

@ -0,0 +1,16 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

View File

@ -0,0 +1,46 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReferencedAttributesDialog, ReferencedAttributesDialogData } from './referenced-attributes-dialog.component';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
describe('ReferencedAttributesDialog', () => {
let component: ReferencedAttributesDialog;
let fixture: ComponentFixture<ReferencedAttributesDialog>;
const data: ReferencedAttributesDialogData = {
attributes: []
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReferencedAttributesDialog],
providers: [
{ provide: MatDialogRef, useValue: null },
{ provide: MAT_DIALOG_DATA, useValue: data }
]
}).compileComponents();
fixture = TestBed.createComponent(ReferencedAttributesDialog);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,91 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Inject, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle
} from '@angular/material/dialog';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { Observable } from 'rxjs';
import { MapTableEntry } from '../../../../../state/shared';
import { MatButton, MatIconButton } from '@angular/material/button';
import { NifiSpinnerDirective } from '../../../spinner/nifi-spinner.directive';
import { MapTable } from '../../../map-table/map-table.component';
import { NifiTooltipDirective } from '../../../tooltips/nifi-tooltip.directive';
import { TextTip } from '../../../tooltips/text-tip/text-tip.component';
import { CloseOnEscapeDialog } from '../../../close-on-escape-dialog/close-on-escape-dialog.component';
export interface ReferencedAttributesDialogData {
attributes: MapTableEntry[];
}
@Component({
selector: 'referenced-attributes-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogTitle,
ReactiveFormsModule,
MatDialogContent,
MatDialogActions,
MatButton,
MatDialogClose,
NifiSpinnerDirective,
MapTable,
NifiTooltipDirective,
MatIconButton
],
templateUrl: './referenced-attributes-dialog.component.html',
styleUrl: './referenced-attributes-dialog.component.scss'
})
export class ReferencedAttributesDialog extends CloseOnEscapeDialog {
referencedAttributesForm: FormGroup;
@Input() createNew!: (existingEntries: string[]) => Observable<MapTableEntry>;
@Output() verify = new EventEmitter<any>();
constructor(
private formBuilder: FormBuilder,
@Inject(MAT_DIALOG_DATA) private data: ReferencedAttributesDialogData
) {
super();
const attributes: MapTableEntry[] = data.attributes || [];
this.referencedAttributesForm = this.formBuilder.group({
attributes: new FormControl(attributes)
});
}
verifyClicked() {
this.verify.next(this.referencedAttributesForm.value);
}
clearAttributesClicked() {
this.referencedAttributesForm.reset();
}
isEmpty() {
const attributes = this.referencedAttributesForm.get('attributes')?.value || [];
return attributes.length === 0;
}
protected readonly TextTip = TextTip;
}

View File

@ -0,0 +1,60 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<div class="property-verification flex flex-col h-full gap-y-3">
<div class="flex justify-between items-center">
<div class="font-bold">Verification</div>
<div>
<button mat-icon-button color="primary" type="button" [disabled]="disabled" (click)="verifyClicked()">
<i class="fa fa-check"></i>
</button>
</div>
</div>
<div class="h-full border p-2 overflow-y-auto">
@if (disabled) {
<div class="verification-disabled unset">Property verification is disabled</div>
} @else if (isVerifying) {
<div class="empty">Verifying properties...</div>
} @else if (results.length > 0) {
<div class="verification-results flex flex-col gap-y-2">
@for (result of results; track results) {
<div>
<div class="flex gap-x-1">
@switch (result.outcome) {
@case (Outcome.SUCCESSFUL) {
<div class="fa fa-check success-color text-lg"></div>
}
@case (Outcome.FAILED) {
<div class="fa fa-times warn-color text-lg"></div>
}
@case (Outcome.SKIPPED) {
<div class="fa fa-exclamation warn-color-lighter text-lg"></div>
}
}
<div class="flex flex-col">
<div class="font-bold">{{ result.verificationStepName }}</div>
<div class="verification-explanation text-xs opacity-80">{{ result.explanation }}</div>
</div>
</div>
</div>
}
</div>
} @else {
<div class="empty">Click the button above to verify this component.</div>
}
</div>
</div>

View File

@ -0,0 +1,22 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.property-verification {
.verification-explanation {
word-break: break-word;
}
}

View File

@ -0,0 +1,38 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PropertyVerification } from './property-verification.component';
describe('PropertyVerification', () => {
let component: PropertyVerification;
let fixture: ComponentFixture<PropertyVerification>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PropertyVerification]
}).compileComponents();
fixture = TestBed.createComponent(PropertyVerification);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,50 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconButton } from '@angular/material/button';
import { ConfigVerificationResult, Outcome } from '../../../state/property-verification';
@Component({
selector: 'property-verification',
standalone: true,
imports: [CommonModule, MatIconButton],
templateUrl: './property-verification.component.html',
styleUrl: './property-verification.component.scss'
})
export class PropertyVerification {
private _results: ConfigVerificationResult[] | null = null;
@Input() set results(results: ConfigVerificationResult[] | null) {
this._results = results;
}
get results(): ConfigVerificationResult[] {
return this._results || [];
}
@Input() isVerifying = false;
@Input() disabled = false;
@Output() verify: EventEmitter<void> = new EventEmitter<void>();
verifyClicked(): void {
this.verify.next();
}
protected readonly Outcome = Outcome;
}

View File

@ -33,10 +33,7 @@ describe('StatusHistory', () => {
providers: [
{ provide: MAT_DIALOG_DATA, useValue: {} },
provideMockStore({ initialState }),
{
provide: MatDialogRef,
useValue: null
}
{ provide: MatDialogRef, useValue: null }
]
});
fixture = TestBed.createComponent(StatusHistory);

View File

@ -31,10 +31,7 @@ describe('SystemDiagnosticsDialog', () => {
imports: [SystemDiagnosticsDialog],
providers: [
provideMockStore({ initialState: initialSystemDiagnosticsState }),
{
provide: MatDialogRef,
useValue: null
}
{ provide: MatDialogRef, useValue: null }
]
});
fixture = TestBed.createComponent(SystemDiagnosticsDialog);

View File

@ -42,6 +42,7 @@
@use 'app/ui/common/tooltips/property-hint-tip/property-hint-tip.component-theme' as property-hint-tip;
@use 'app/pages/summary/ui/processor-status-listing/processor-status-table/processor-status-table.component-theme' as processor-status-table;
@use 'app/pages/flow-designer/ui/canvas/change-color-dialog/change-color-dialog.component-theme' as change-color-dialog;
@use 'app/ui/common/map-table/editors/text-editor/text-editor.component-theme' as text-editor;
// Plus imports for other components in your app.
@use 'assets/fonts/flowfont/flowfont.css';
@ -96,6 +97,7 @@
@include property-hint-tip.generate-theme($material-theme-light, $nifi-theme-light);
@include processor-status-table.generate-theme($nifi-theme-light);
@include change-color-dialog.generate-theme($nifi-theme-light);
@include text-editor.generate-theme($material-theme-light, $nifi-theme-light);
.dark-theme {
// Include the dark theme color styles.
@ -126,4 +128,5 @@
@include property-hint-tip.generate-theme($material-theme-dark, $nifi-theme-dark);
@include processor-status-table.generate-theme($nifi-theme-dark);
@include change-color-dialog.generate-theme($nifi-theme-dark);
@include text-editor.generate-theme($material-theme-dark, $nifi-theme-dark);
}