This is an automated email from the ASF dual-hosted git repository.

mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new e5d42421e64 NIFI-15927 improvements to connector property group card 
(#11237)
e5d42421e64 is described below

commit e5d42421e644bb3b2efba46dcc0902b5d4ad59a1
Author: Scott Aslan <[email protected]>
AuthorDate: Fri May 15 13:35:13 2026 -0400

    NIFI-15927 improvements to connector property group card (#11237)
    
    * NIFI-15927 improvements to connector property group card
    
    * address review feedback
    
    * adjust card title typography
    
    * restore mat-card subtitle overrides, address unit test concerns
    
    * prettier and font weight 400
---
 .../libs/shared/src/assets/styles/_app.scss        |  27 +-
 .../property-group-card.component.html             |  50 +++
 .../property-group-card.component.spec.ts          | 377 +++++++++++++++++++++
 .../property-group-card.component.ts               |  36 +-
 4 files changed, 445 insertions(+), 45 deletions(-)

diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss 
b/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss
index c74845bf33d..9fb0e785334 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss
@@ -306,20 +306,6 @@
             line-height: normal;
         }
 
-        .mat-mdc-card-title {
-            font-size: 18px;
-            letter-spacing: normal;
-            line-height: 28px;
-            font-weight: 700;
-        }
-
-        .mat-mdc-card-subtitle {
-            font-size: var(--mat-sys-body-medium-size);
-            letter-spacing: normal;
-            line-height: 20px;
-            font-weight: 400;
-        }
-
         .mat-mdc-option {
             font-size: var(--mat-sys-body-medium-size);
             letter-spacing: normal;
@@ -799,4 +785,17 @@
         pointer-events: auto;
         cursor: not-allowed;
     }
+
+    // mat-card
+    @include mat.card-overrides(
+        (
+            title-text-size: 16px,
+            title-text-line-height: 20px,
+            title-text-weight: 600,
+            subtitle-text-size: 14px,
+            subtitle-text-line-height: 20px,
+            subtitle-text-tracking: normal,
+            subtitle-text-weight: 400
+        )
+    );
 }
diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/property-group-card/property-group-card.component.html
 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/property-group-card/property-group-card.component.html
new file mode 100644
index 00000000000..e5735b3e094
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/property-group-card/property-group-card.component.html
@@ -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.
+-->
+
+<mat-card appearance="outlined">
+    @if (!hideGroupName()) {
+        <mat-card-header>
+            <mat-card-title data-qa="property-group-name">
+                {{ propertyGroup().propertyGroupName }}
+            </mat-card-title>
+        </mat-card-header>
+    }
+    <mat-card-content>
+        <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 
xl:grid-cols-4 gap-x-6 gap-y-4">
+            @for (propertyName of getPropertyNames(); track propertyName) {
+                <div class="min-w-0 flex flex-col gap-y-1">
+                    <div class="flex items-center pt-1">
+                        <mat-label ellipsisTooltip>{{ propertyName 
}}</mat-label>
+                        @if (isRequired(propertyName)) {
+                            <span class="error-color ml-1 shrink-0">*</span>
+                        }
+                    </div>
+                    @if (hasValue(propertyName)) {
+                        <span class="tertiary-color truncate" 
ellipsisTooltip>{{
+                            getDisplayValueForProperty(propertyName)
+                        }}</span>
+                    } @else {
+                        <span class="unset neutral-color">No value set</span>
+                    }
+                    @if (getFieldError(propertyName); as errorMessage) {
+                        <mat-error class="error-color">{{ errorMessage 
}}</mat-error>
+                    }
+                </div>
+            }
+        </div>
+    </mat-card-content>
+</mat-card>
diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/property-group-card/property-group-card.component.spec.ts
 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/property-group-card/property-group-card.component.spec.ts
new file mode 100644
index 00000000000..b0b39c907eb
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/property-group-card/property-group-card.component.spec.ts
@@ -0,0 +1,377 @@
+/*
+ * 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 { TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { PropertyGroupCard } from './property-group-card.component';
+import { ConfigVerificationResult, PropertyGroupConfiguration } from 
'../../types';
+
+describe('PropertyGroupCard', () => {
+    function makeGroup(overrides: Partial<PropertyGroupConfiguration> = {}): 
PropertyGroupConfiguration {
+        return {
+            propertyGroupName: 'Connection',
+            propertyDescriptors: {
+                Host: { name: 'Host', type: 'STRING', required: true },
+                Port: { name: 'Port', type: 'STRING', required: false }
+            },
+            propertyValues: {
+                Host: { value: 'localhost', valueType: 'STRING_LITERAL' },
+                Port: { value: '5432', valueType: 'STRING_LITERAL' }
+            },
+            ...overrides
+        };
+    }
+
+    interface SetupOptions {
+        propertyGroup?: PropertyGroupConfiguration;
+        hideGroupName?: boolean;
+        verificationErrors?: ConfigVerificationResult[];
+    }
+
+    async function setup(options: SetupOptions = {}) {
+        await TestBed.configureTestingModule({
+            imports: [PropertyGroupCard, NoopAnimationsModule]
+        }).compileComponents();
+
+        const fixture = TestBed.createComponent(PropertyGroupCard);
+        const component = fixture.componentInstance;
+
+        fixture.componentRef.setInput('propertyGroup', options.propertyGroup 
?? makeGroup());
+        if (options.hideGroupName !== undefined) 
fixture.componentRef.setInput('hideGroupName', options.hideGroupName);
+        if (options.verificationErrors !== undefined)
+            fixture.componentRef.setInput('verificationErrors', 
options.verificationErrors);
+
+        fixture.detectChanges();
+
+        const queryAll = (qa: string) => 
fixture.debugElement.queryAll(By.css(`[data-qa="${qa}"]`));
+        const query = (selector: string) => 
fixture.debugElement.query(By.css(selector));
+
+        return { fixture, component, queryAll, query };
+    }
+
+    describe('creation', () => {
+        it('should create', async () => {
+            const { component } = await setup();
+            expect(component).toBeTruthy();
+        });
+    });
+
+    describe('group name header', () => {
+        it('should display the group name by default', async () => {
+            const { query } = await setup();
+            const title = query('mat-card-title');
+            expect(title).toBeTruthy();
+            expect(title.nativeElement.textContent.trim()).toBe('Connection');
+        });
+
+        it('should hide the group name when hideGroupName is true', async () 
=> {
+            const { query } = await setup({ hideGroupName: true });
+            expect(query('mat-card-title')).toBeFalsy();
+        });
+    });
+
+    describe('getPropertyNames', () => {
+        it('should return all property names from descriptors', async () => {
+            const { component } = await setup();
+            expect(component.getPropertyNames()).toEqual(['Host', 'Port']);
+        });
+
+        it('should return empty array when no descriptors exist', async () => {
+            const { component } = await setup({
+                propertyGroup: makeGroup({ propertyDescriptors: {} })
+            });
+            expect(component.getPropertyNames()).toEqual([]);
+        });
+    });
+
+    describe('getDescriptor', () => {
+        it('should return the descriptor for a known property', async () => {
+            const { component } = await setup();
+            const descriptor = component.getDescriptor('Host');
+            expect(descriptor).toBeDefined();
+            expect(descriptor!.name).toBe('Host');
+            expect(descriptor!.required).toBe(true);
+        });
+
+        it('should return undefined for an unknown property', async () => {
+            const { component } = await setup();
+            expect(component.getDescriptor('NonExistent')).toBeUndefined();
+        });
+    });
+
+    describe('isRequired', () => {
+        it('should return true for required properties', async () => {
+            const { component } = await setup();
+            expect(component.isRequired('Host')).toBe(true);
+        });
+
+        it('should return false for optional properties', async () => {
+            const { component } = await setup();
+            expect(component.isRequired('Port')).toBe(false);
+        });
+
+        it('should return false for unknown properties', async () => {
+            const { component } = await setup();
+            expect(component.isRequired('NonExistent')).toBe(false);
+        });
+    });
+
+    describe('hasValue', () => {
+        it('should return true for STRING_LITERAL with a value', async () => {
+            const { component } = await setup();
+            expect(component.hasValue('Host')).toBe(true);
+        });
+
+        it('should return false when no value reference exists', async () => {
+            const { component } = await setup({
+                propertyGroup: makeGroup({
+                    propertyValues: {}
+                })
+            });
+            expect(component.hasValue('Host')).toBe(false);
+        });
+
+        it('should return false for STRING_LITERAL with null value', async () 
=> {
+            const { component } = await setup({
+                propertyGroup: makeGroup({
+                    propertyValues: {
+                        Host: { value: null, valueType: 'STRING_LITERAL' }
+                    }
+                })
+            });
+            expect(component.hasValue('Host')).toBe(false);
+        });
+
+        it('should return false for STRING_LITERAL with empty string', async 
() => {
+            const { component } = await setup({
+                propertyGroup: makeGroup({
+                    propertyValues: {
+                        Host: { value: '', valueType: 'STRING_LITERAL' }
+                    }
+                })
+            });
+            expect(component.hasValue('Host')).toBe(false);
+        });
+
+        it('should return true for SECRET_REFERENCE regardless of value', 
async () => {
+            const { component } = await setup({
+                propertyGroup: makeGroup({
+                    propertyValues: {
+                        Host: { valueType: 'SECRET_REFERENCE' }
+                    }
+                })
+            });
+            expect(component.hasValue('Host')).toBe(true);
+        });
+
+        it('should return true for ASSET_REFERENCE with entries', async () => {
+            const { component } = await setup({
+                propertyGroup: makeGroup({
+                    propertyValues: {
+                        Host: {
+                            valueType: 'ASSET_REFERENCE',
+                            assetReferences: [{ id: 'asset-1', name: 
'cert.pem' }]
+                        }
+                    }
+                })
+            });
+            expect(component.hasValue('Host')).toBe(true);
+        });
+
+        it('should return false for ASSET_REFERENCE with empty array', async 
() => {
+            const { component } = await setup({
+                propertyGroup: makeGroup({
+                    propertyValues: {
+                        Host: { valueType: 'ASSET_REFERENCE', assetReferences: 
[] }
+                    }
+                })
+            });
+            expect(component.hasValue('Host')).toBe(false);
+        });
+    });
+
+    describe('getDisplayValueForProperty', () => {
+        it('should return the string value for STRING_LITERAL', async () => {
+            const { component } = await setup();
+            
expect(component.getDisplayValueForProperty('Host')).toBe('localhost');
+        });
+
+        it('should return masked text for SECRET_REFERENCE', async () => {
+            const { component } = await setup({
+                propertyGroup: makeGroup({
+                    propertyValues: {
+                        Host: { valueType: 'SECRET_REFERENCE' }
+                    }
+                })
+            });
+            
expect(component.getDisplayValueForProperty('Host')).toBe('••••••••');
+        });
+
+        it('should return comma-separated asset names for ASSET_REFERENCE', 
async () => {
+            const { component } = await setup({
+                propertyGroup: makeGroup({
+                    propertyValues: {
+                        Host: {
+                            valueType: 'ASSET_REFERENCE',
+                            assetReferences: [
+                                { id: 'a1', name: 'cert.pem' },
+                                { id: 'a2', name: 'key.pem' }
+                            ]
+                        }
+                    }
+                })
+            });
+            
expect(component.getDisplayValueForProperty('Host')).toBe('cert.pem, key.pem');
+        });
+
+        it('should fall back to asset id when name is missing', async () => {
+            const { component } = await setup({
+                propertyGroup: makeGroup({
+                    propertyValues: {
+                        Host: {
+                            valueType: 'ASSET_REFERENCE',
+                            assetReferences: [{ id: 'a1' }]
+                        }
+                    }
+                })
+            });
+            expect(component.getDisplayValueForProperty('Host')).toBe('a1');
+        });
+
+        it('should return empty string when no value reference exists', async 
() => {
+            const { component } = await setup({
+                propertyGroup: makeGroup({ propertyValues: {} })
+            });
+            expect(component.getDisplayValueForProperty('Host')).toBe('');
+        });
+    });
+
+    describe('fieldErrors computed signal', () => {
+        it('should return empty object when no verification errors', async () 
=> {
+            const { component } = await setup();
+            expect(component.fieldErrors()).toEqual({});
+        });
+
+        it('should map errors to matching property names', async () => {
+            const { component } = await setup({
+                verificationErrors: [
+                    { outcome: 'FAILED', verificationStepName: 'Check', 
subject: 'Host', explanation: 'Invalid host' }
+                ]
+            });
+            expect(component.fieldErrors()).toEqual({ Host: 'Invalid host' });
+        });
+
+        it('should ignore errors whose subject is not in the property group', 
async () => {
+            const { component } = await setup({
+                verificationErrors: [
+                    {
+                        outcome: 'FAILED',
+                        verificationStepName: 'Check',
+                        subject: 'UnknownProp',
+                        explanation: 'Bad value'
+                    }
+                ]
+            });
+            expect(component.fieldErrors()).toEqual({});
+        });
+
+        it('should ignore errors without a subject', async () => {
+            const { component } = await setup({
+                verificationErrors: [
+                    { outcome: 'FAILED', verificationStepName: 'Check', 
explanation: 'General failure' }
+                ]
+            });
+            expect(component.fieldErrors()).toEqual({});
+        });
+    });
+
+    describe('getFieldError', () => {
+        it('should return the error message for a property with a field 
error', async () => {
+            const { component } = await setup({
+                verificationErrors: [
+                    {
+                        outcome: 'FAILED',
+                        verificationStepName: 'Check',
+                        subject: 'Port',
+                        explanation: 'Port out of range'
+                    }
+                ]
+            });
+            expect(component.getFieldError('Port')).toBe('Port out of range');
+        });
+
+        it('should return null for a property without a field error', async () 
=> {
+            const { component } = await setup();
+            expect(component.getFieldError('Host')).toBeNull();
+        });
+    });
+
+    describe('template rendering', () => {
+        it('should render a row for each property', async () => {
+            const { query } = await setup();
+            const labels = 
query('mat-card-content').queryAll(By.css('mat-label'));
+            expect(labels.length).toBe(2);
+            const texts = labels.map((el) => 
el.nativeElement.textContent.trim());
+            expect(texts).toContain('Host');
+            expect(texts).toContain('Port');
+        });
+
+        it('should show required asterisk for required properties', async () 
=> {
+            const { query } = await setup();
+            const content = query('mat-card-content');
+            const asterisks = content.queryAll(By.css('span.error-color'));
+            expect(asterisks.length).toBe(1);
+            expect(asterisks[0].nativeElement.textContent.trim()).toBe('*');
+        });
+
+        it('should show "No value set" for properties without values', async 
() => {
+            const { query } = await setup({
+                propertyGroup: makeGroup({ propertyValues: {} })
+            });
+            const unsetLabels = 
query('mat-card-content').queryAll(By.css('.unset'));
+            expect(unsetLabels.length).toBe(2);
+            expect(unsetLabels[0].nativeElement.textContent.trim()).toBe('No 
value set');
+        });
+
+        it('should display the property value for properties with values', 
async () => {
+            const { query } = await setup();
+            const content = query('mat-card-content');
+            const valueSpans = content.queryAll(By.css('.tertiary-color'));
+            const texts = valueSpans.map((el) => 
el.nativeElement.textContent.trim());
+            expect(texts).toContain('localhost');
+            expect(texts).toContain('5432');
+        });
+
+        it('should render mat-error for properties with verification errors', 
async () => {
+            const { query } = await setup({
+                verificationErrors: [
+                    { outcome: 'FAILED', verificationStepName: 'Check', 
subject: 'Host', explanation: 'Cannot resolve' }
+                ]
+            });
+            const errors = 
query('mat-card-content').queryAll(By.css('mat-error'));
+            expect(errors.length).toBe(1);
+            expect(errors[0].nativeElement.textContent.trim()).toBe('Cannot 
resolve');
+        });
+
+        it('should not render mat-error when there are no verification 
errors', async () => {
+            const { query } = await setup();
+            const errors = 
query('mat-card-content').queryAll(By.css('mat-error'));
+            expect(errors.length).toBe(0);
+        });
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/property-group-card/property-group-card.component.ts
 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/property-group-card/property-group-card.component.ts
index 25751472c8a..188d73ba8be 100644
--- 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/property-group-card/property-group-card.component.ts
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/property-group-card/property-group-card.component.ts
@@ -16,8 +16,9 @@
  */
 
 import { Component, computed, input } from '@angular/core';
-import { MatCard } from '@angular/material/card';
-import { MatError } from '@angular/material/form-field';
+import { MatCard, MatCardHeader, MatCardTitle, MatCardContent } from 
'@angular/material/card';
+import { MatLabel, MatError } from '@angular/material/form-field';
+import { EllipsisTooltipDirective } from 
'../../directives/ellipsis-tooltip/ellipsis-tooltip.directive';
 import {
     AssetReference,
     ConfigVerificationResult,
@@ -31,35 +32,8 @@ import {
  */
 @Component({
     selector: 'property-group-card',
-    standalone: true,
-    imports: [MatCard, MatError],
-    template: `
-        <mat-card appearance="outlined" class="p-4">
-            @if (!hideGroupName()) {
-                <h4 class="font-semibold mb-4">{{ 
propertyGroup().propertyGroupName }}</h4>
-            }
-            <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 
xl:grid-cols-4 gap-x-6 gap-y-4">
-                @for (propertyName of getPropertyNames(); track propertyName) {
-                    <div class="flex flex-col gap-y-1">
-                        <div class="flex items-center pt-1">
-                            <span class="text-sm tertiary-color leading-5">{{ 
propertyName }}</span>
-                            @if (isRequired(propertyName)) {
-                                <span class="error-color ml-1 text-sm">*</span>
-                            }
-                        </div>
-                        @if (hasValue(propertyName)) {
-                            <span class="text-sm">{{ 
getDisplayValueForProperty(propertyName) }}</span>
-                        } @else {
-                            <span class="unset neutral-color text-sm">No value 
set</span>
-                        }
-                        @if (getFieldError(propertyName); as errorMessage) {
-                            <mat-error class="error-color text-xs">{{ 
errorMessage }}</mat-error>
-                        }
-                    </div>
-                }
-            </div>
-        </mat-card>
-    `
+    imports: [MatCard, MatCardHeader, MatCardTitle, MatCardContent, MatLabel, 
MatError, EllipsisTooltipDirective],
+    templateUrl: './property-group-card.component.html'
 })
 export class PropertyGroupCard {
     readonly propertyGroup = input.required<PropertyGroupConfiguration>();

Reply via email to