[NIFI-13226] - Support changing color of processors and labels (#8836)

* [NIFI-13226] - Support changing color of processors and labels

* style the processor icon for better support cross-themes

This closes #8836
This commit is contained in:
Rob Fellows 2024-05-16 17:38:15 -04:00 committed by GitHub
parent 15ac906725
commit 60112f242c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 530 additions and 24 deletions

View File

@ -29,6 +29,7 @@ import {
navigateToEditComponent,
navigateToEditCurrentProcessGroup,
navigateToManageComponentPolicies,
openChangeColorDialog,
paste,
reloadFlow,
selectComponents,
@ -38,6 +39,7 @@ import {
stopCurrentProcessGroup
} from '../state/flow/flow.actions';
import {
ChangeColorRequest,
CopyComponentRequest,
DeleteComponentRequest,
DisableComponentRequest,
@ -422,6 +424,34 @@ export class CanvasActionsService {
})
);
}
},
changeColor: {
id: 'changeColor',
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.isColorable(selection);
},
action: (selection: d3.Selection<any, any, any, any>) => {
const changeColorRequests: ChangeColorRequest[] = [];
selection.each((d) => {
let color = null;
if (d.component.style) {
color = d.component.style['background-color'] || null;
}
changeColorRequests.push({
id: d.id,
uri: d.uri,
type: d.type,
color,
style: d.component.style || null,
revision: this.client.getRevision(d)
});
});
this.store.dispatch(
openChangeColorDialog({
request: changeColorRequests
})
);
}
}
};

View File

@ -1161,15 +1161,10 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
}
},
{
condition: (selection: any) => {
// TODO - isColorable
return false;
},
condition: this.canvasActionsService.getConditionFunction('changeColor'),
clazz: 'fa fa-paint-brush',
text: 'Change Color',
action: () => {
// TODO - fillColor
}
action: this.canvasActionsService.getActionFunction('changeColor')
},
{
condition: (selection: d3.Selection<any, any, any, any>) => {

View File

@ -2028,4 +2028,30 @@ export class CanvasUtils {
return this.isConnection(selection) || this.isLabel(selection);
}
public isColorable(selection: d3.Selection<any, any, any, any>) {
if (selection.empty()) {
return false;
}
// require read and write permissions
if (!this.canRead(selection) || !this.canModify(selection)) {
return false;
}
// determine if the current selection is entirely processors or labels
const selectedProcessors = selection.filter((d, index, nodes) => {
const processor = d3.select(nodes[index]);
return this.isProcessor(processor) && this.canModify(processor);
});
const selectedLabels = selection.filter((d, index, nodes) => {
const label = d3.select(nodes[index]);
return this.isLabel(label) && this.canModify(label);
});
const allProcessors = selectedProcessors.size() === selection.size();
const allLabels = selectedLabels.size() === selection.size();
return allProcessors || allLabels;
}
}

View File

@ -648,9 +648,12 @@ export class ProcessorManager {
});
}
} else {
// undo changes made above
processor.select('text.processor-icon').attr('class', () => {
return 'processor-icon accent-color';
});
processor.select('rect.processor-icon-container').style('fill', null);
processor.select('rect.border').style('stroke', null);
}
}

View File

@ -18,6 +18,7 @@
import { createAction, props } from '@ngrx/store';
import {
CenterComponentRequest,
ChangeColorRequest,
ChangeVersionDialogRequest,
ComponentEntity,
ConfirmStopVersionControlRequest,
@ -863,3 +864,8 @@ export const openChangeProcessorVersionDialog = createAction(
`${CANVAS_PREFIX} Open Change Processor Version Dialog`,
props<{ request: FetchComponentVersionsRequest }>()
);
export const openChangeColorDialog = createAction(
`${CANVAS_PREFIX} Open Change Color Dialog`,
props<{ request: ChangeColorRequest[] }>()
);

View File

@ -135,6 +135,7 @@ import { EditLabel } from '../../ui/canvas/items/label/edit-label/edit-label.com
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';
@Injectable()
export class FlowEffects {
@ -3955,4 +3956,75 @@ export class FlowEffects {
),
{ dispatch: false }
);
changeColor$ = createEffect(
() =>
this.actions$.pipe(
ofType(FlowActions.openChangeColorDialog),
map((action) => action.request),
tap((request) => {
const dialogRef = this.dialog.open(ChangeColorDialog, {
...SMALL_DIALOG,
data: request
});
dialogRef.componentInstance.changeColor.pipe(take(1)).subscribe((requests) => {
requests.forEach((request) => {
const style = { ...request.style } || {};
if (request.type === ComponentType.Processor) {
if (request.color) {
style['background-color'] = request.color;
} else {
// for processors, removing the background-color from the style map effectively unsets it
delete style['background-color'];
}
this.store.dispatch(
FlowActions.updateProcessor({
request: {
id: request.id,
type: ComponentType.Processor,
errorStrategy: 'snackbar',
uri: request.uri,
payload: {
revision: request.revision,
component: {
id: request.id,
style
}
}
}
})
);
} else if (request.type === ComponentType.Label) {
if (request.color) {
style['background-color'] = request.color;
} else {
// for labels, removing the setting the background-color style effectively unsets it
style['background-color'] = null;
}
this.store.dispatch(
FlowActions.updateComponent({
request: {
id: request.id,
type: ComponentType.Label,
errorStrategy: 'snackbar',
uri: request.uri,
payload: {
revision: request.revision,
component: {
id: request.id,
style
}
}
}
})
);
}
});
dialogRef.close();
});
})
),
{ dispatch: false }
);
}

View File

@ -854,3 +854,12 @@ export interface MoveToFrontRequest {
revision: Revision;
zIndex: number;
}
export interface ChangeColorRequest {
id: string;
uri: string;
type: ComponentType;
color: string | null;
revision: Revision;
style: any | null;
}

View File

@ -0,0 +1,66 @@
/*!
* 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($nifi-theme) {
// Get the color config from the theme.
$nifi-theme-color-config: mat.get-color-config($nifi-theme);
$nifi-theme-surface-palette: map.get($nifi-theme-color-config, 'primary');
$is-dark: map-get($nifi-theme-color-config, is-dark);
$nifi-theme-surface-palette-lighter: mat.get-color-from-palette($nifi-theme-surface-palette, lighter);
$nifi-theme-surface-palette-darker: mat.get-color-from-palette($nifi-theme-surface-palette, darker);
$nifi-theme-surface-palette-darker-contrast: mat.get-color-from-palette(
$nifi-theme-surface-palette,
darker-contrast
);
$nifi-theme-surface-palette-lighter-contrast: mat.get-color-from-palette(
$nifi-theme-surface-palette,
lighter-contrast
);
$alternate-surface: if(
$is-dark,
rgba($nifi-theme-surface-palette-darker-contrast, 0.28),
rgba($nifi-theme-surface-palette-lighter-contrast, 0.2)
);
.preview {
.processor {
background-color: if($is-dark, $nifi-theme-surface-palette-darker, $nifi-theme-surface-palette-lighter);
}
.odd {
background-color: rgba(
if($is-dark, $nifi-theme-surface-palette-lighter, $nifi-theme-surface-palette-darker),
0.025
);
}
.even {
background-color: if($is-dark, $nifi-theme-surface-palette-darker, $nifi-theme-surface-palette-lighter);
}
.row-border {
border-top: 1px solid $alternate-surface;
}
}
}

View File

@ -0,0 +1,65 @@
<!--
~ 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>Change Color</h2>
<form [formGroup]="changeColorForm">
<mat-dialog-content>
<div class="flex flex-col">
<mat-form-field>
<mat-label>Color</mat-label>
<input matInput type="color" formControlName="color" (change)="colorChanged()" />
</mat-form-field>
<mat-checkbox color="primary" [checked]="noColor" formControlName="noColor" (change)="noColorChanged()"
>Use default color</mat-checkbox
>
</div>
<div class="pt-4 preview">
<div class="value">Preview</div>
@if (type === ComponentType.Processor) {
<div class="processor border drop-shadow-lg" [style.border-color]="color">
<div class="flex flex-col">
<div class="flex gap-x-2 items-center">
<div class="logo flex flex-col items-center" [style.background-color]="color">
<i
class="icon accent-color icon-processor p-2"
[style.color]="contrastColor"></i>
</div>
<div class="flex flex-col flex-1">
<div class="context-name w-full">Processor Name</div>
</div>
</div>
</div>
<div class="odd h-4"></div>
<div class="row-border even h-4"></div>
<div class="row-border odd h-4"></div>
<div class="row-border even h-4"></div>
</div>
} @else if (type === ComponentType.Label) {
<div
class="label border h-36 p-2 font-bold"
[style.background-color]="color"
[style.color]="contrastColor">
Label Value
</div>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button mat-button [disabled]="changeColorForm.invalid" (click)="applyClicked()" color="primary">Apply</button>
</mat-dialog-actions>
</form>

View File

@ -0,0 +1,25 @@
/*!
* 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.
*/
.preview {
.processor {
.logo {
.icon {
font-size: 32px;
}
}
}
}

View File

@ -0,0 +1,52 @@
/*
* 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 { ChangeColorDialog } from './change-color-dialog.component';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../state/flow/flow.reducer';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('ChangeColorDialog', () => {
let component: ChangeColorDialog;
let fixture: ComponentFixture<ChangeColorDialog>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ChangeColorDialog, NoopAnimationsModule],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
request: []
}
},
{ provide: MatDialogRef, useValue: null },
provideMockStore({ initialState })
]
}).compileComponents();
fixture = TestBed.createComponent(ChangeColorDialog);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,156 @@
/*
* 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 {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle
} from '@angular/material/dialog';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButton } from '@angular/material/button';
import { NifiSpinnerDirective } from '../../../../../ui/common/spinner/nifi-spinner.directive';
import { ChangeColorRequest } from '../../../state/flow';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { ComponentContext } from '../../../../../ui/common/component-context/component-context.component';
import { ComponentType } from '../../../../../state/shared';
import { CloseOnEscapeDialog } from '../../../../../ui/common/close-on-escape-dialog/close-on-escape-dialog.component';
import { ComponentTypeNamePipe } from '../../../../../pipes/component-type-name.pipe';
import { CanvasUtils } from '../../../service/canvas-utils.service';
import { NiFiCommon } from '../../../../../service/nifi-common.service';
import { MatCheckbox } from '@angular/material/checkbox';
@Component({
selector: 'change-component-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogTitle,
ReactiveFormsModule,
MatDialogContent,
MatButton,
MatDialogActions,
MatDialogClose,
NifiSpinnerDirective,
MatFormField,
MatLabel,
MatInput,
ComponentContext,
ComponentTypeNamePipe,
MatCheckbox
],
templateUrl: './change-color-dialog.component.html',
styleUrl: './change-color-dialog.component.scss'
})
export class ChangeColorDialog extends CloseOnEscapeDialog {
color: string | null = null;
noColor: boolean = true;
contrastColor: string | null = null;
type: ComponentType | null = null;
changeColorForm: FormGroup;
private _data: ChangeColorRequest[] = [];
private DEFAULT_LABEL_COLOR = '#fff7d7';
@Output() changeColor = new EventEmitter<ChangeColorRequest[]>();
constructor(
@Inject(MAT_DIALOG_DATA) private data: ChangeColorRequest[],
private canvasUtils: CanvasUtils,
private nifiCommon: NiFiCommon,
private formBuilder: FormBuilder
) {
super();
this._data = data;
let isDefaultColor = true;
if (data.length > 0) {
this.color = data[0].color;
if (this.color !== null) {
const hex = this.nifiCommon.substringAfterLast(this.color, '#');
this.contrastColor = this.canvasUtils.determineContrastColor(hex);
this.noColor = false;
isDefaultColor = false;
}
this.type = data[0].type;
if (this.type === ComponentType.Label && this.color === null) {
this.color = this.DEFAULT_LABEL_COLOR;
const hex = this.nifiCommon.substringAfterLast(this.color, '#');
this.contrastColor = this.canvasUtils.determineContrastColor(hex);
isDefaultColor = true;
}
}
this.changeColorForm = formBuilder.group({
color: [this.color],
noColor: [isDefaultColor]
});
}
colorChanged(): void {
this.color = this.changeColorForm.get('color')?.value;
if (this.color !== null) {
const hex = this.nifiCommon.substringAfterLast(this.color, '#');
this.contrastColor = this.canvasUtils.determineContrastColor(hex);
this.noColor = false;
} else {
this.contrastColor = null;
}
}
noColorChanged(): void {
const noColorChecked = this.changeColorForm.get('noColor')?.value;
if (noColorChecked) {
this.noColor = true;
if (this.type === ComponentType.Label) {
this.color = this.DEFAULT_LABEL_COLOR;
const hex = this.nifiCommon.substringAfterLast(this.color, '#');
this.contrastColor = this.canvasUtils.determineContrastColor(hex);
} else {
this.color = null;
this.contrastColor = null;
}
} else {
this.noColor = false;
this.color = this.changeColorForm.get('color')?.value;
if (this.color === null) {
if (this.type === ComponentType.Label) {
this.color = this.DEFAULT_LABEL_COLOR;
} else {
this.color = '#000000'; // default for the color picker
}
}
const hex = this.nifiCommon.substringAfterLast(this.color, '#');
this.contrastColor = this.canvasUtils.determineContrastColor(hex);
}
}
applyClicked() {
const result: ChangeColorRequest[] = this._data.map((changeColorRequest) => {
return {
...changeColorRequest,
color: this.noColor ? null : this.color
};
});
this.changeColor.next(result);
}
protected readonly ComponentType = ComponentType;
}

View File

@ -119,16 +119,15 @@
(click)="group(selection)">
<i class="ml-1 icon icon-group"></i>
</button>
<!-- TODO - Add support for coloring processors and labels -->
<!-- <button-->
<!-- mat-icon-button-->
<!-- color="primary"-->
<!-- class="mr-2"-->
<!-- type="button"-->
<!-- [disabled]="!canColor(selection)"-->
<!-- (click)="color(selection)">-->
<!-- <i class="fa fa-paint-brush"></i>-->
<!-- </button>-->
<button
mat-icon-button
color="primary"
class="mr-2"
type="button"
[disabled]="!canColor(selection)"
(click)="color(selection)">
<i class="fa fa-paint-brush"></i>
</button>
<button
mat-icon-button
color="primary"

View File

@ -254,12 +254,11 @@ export class OperationControl {
}
canColor(selection: d3.Selection<any, any, any, any>): boolean {
// TODO
return false;
return this.canvasActionsService.getConditionFunction('changeColor')(selection);
}
color(selection: d3.Selection<any, any, any, any>): void {
// TODO
this.canvasActionsService.getActionFunction('changeColor')(selection);
}
canDelete(selection: d3.Selection<any, any, any, any>): boolean {

View File

@ -22,7 +22,7 @@ import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { ComponentType } from '../../../../../../../state/shared';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../../state/flow/flow.reducer';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { CreateComponentRequest } from '../../../../../state/flow';
describe('CreateRemoteProcessGroup', () => {
@ -43,7 +43,7 @@ describe('CreateRemoteProcessGroup', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CreateRemoteProcessGroup, BrowserAnimationsModule],
imports: [CreateRemoteProcessGroup, NoopAnimationsModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }, provideMockStore({ initialState })]
});
fixture = TestBed.createComponent(CreateRemoteProcessGroup);

View File

@ -19,7 +19,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EditRemoteProcessGroup } from './edit-remote-process-group.component';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ComponentType } from '../../../../../../../state/shared';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../../state/flow/flow.reducer';
@ -71,7 +71,7 @@ describe('EditRemoteProcessGroup', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [EditRemoteProcessGroup, BrowserAnimationsModule],
imports: [EditRemoteProcessGroup, NoopAnimationsModule],
providers: [
{
provide: MAT_DIALOG_DATA,

View File

@ -40,6 +40,7 @@
@use 'app/ui/common/status-history/status-history.component-theme' as status-history;
@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;
// Plus imports for other components in your app.
@use 'assets/fonts/flowfont/flowfont.css';
@ -91,6 +92,7 @@
@include status-history.generate-theme($nifi-theme-light);
@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);
.dark-theme {
// Include the dark theme color styles.
@ -119,4 +121,5 @@
@include status-history.generate-theme($nifi-theme-dark);
@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);
}