diff --git a/docs/readme-generic-ui.md b/docs/readme-generic-ui.md
index fb5d0e2..45945ed 100644
--- a/docs/readme-generic-ui.md
+++ b/docs/readme-generic-ui.md
@@ -68,14 +68,18 @@ Each field definition supports the following properties:
- `"label"`: Display name for the group
- `"delimiter"`: String used to separate grouped values
- `"multiline"`: Boolean flag for multiline display of grouped values (default: true) When true, values are displayed on separate lines
-- `"labelDisplay"`: Boolean value for using the defaults or an object for customizing the visual appearance of field values:
- - `"backgroundColor"`: Background color for the value (CSS color value)
- - `"color"`: Text color for the value (CSS color value)
- - `"fontWeight"`: Font weight for the value (CSS font-weight value)
- - `"fontStyle"`: Font style for the value (CSS font-style value)
- - `"textDecoration"`: Text decoration for the value (CSS text-decoration value)
- - `"textTransform"`: Text transformation for the value (CSS text-transform value)
-- `"displayAsPlainText"`: Boolean valu that give you ability to render value as it is, without any built-in transformation.
+- `"uiSettings"`: Object for configuring UI-specific display settings:
+ - `"labelDisplay"`: Boolean value for using the defaults or an object for customizing the visual appearance of field values:
+ - `"backgroundColor"`: Background color for the value (CSS color value)
+ - `"color"`: Text color for the value (CSS color value)
+ - `"fontWeight"`: Font weight for the value (CSS font-weight value)
+ - `"fontStyle"`: Font style for the value (CSS font-style value)
+ - `"textDecoration"`: Text decoration for the value (CSS text-decoration value)
+ - `"textTransform"`: Text transformation for the value (CSS text-transform value)
+ - `"displayAs"`: Controls how the value is displayed:
+ - `'plainText'`: Render value as plain text without any built-in transformation
+ - `'secret'`: Render value as a secret with show/hide hover
+ - `"withCopyButton"`: Boolean flag to show a copy button next to the value for easy copying to clipboard
- `"dynamicValuesDefinition"`: Configuration for dynamic value loading:
- `"operation"`: GraphQL operation name
- `"gqlQuery"`: GraphQL query string
@@ -85,6 +89,13 @@ Each field definition supports the following properties:
#### Example Content Configuration for an Accounts Node
Below is an example content-configuration for an accounts node using the generic list view.
+This example demonstrates various features including:
+- **Secret fields**: The "Key" field in `listView` and "API Key" field in `detailView` use `displayAs: "secret"` to hide sensitive data with a toggle
+- **Copy buttons**: Multiple fields include `withCopyButton: true` for easy copying to clipboard
+- **Plain text display**: The "External URL" field uses `displayAs: "plainText"` to prevent automatic link formatting
+- **Custom styling**: The "Type" and "Display Name" fields use `labelDisplay` for visual customization
+- **Field grouping**: Contact information is grouped using the `group` property
+
```json
{
"name": "accounts",
@@ -134,17 +145,21 @@ Below is an example content-configuration for an accounts node using the generic
"propertyField": {
"key": "OPENAI_API_KEY",
"transform": ["uppercase", "encode"]
+ },
+ "uiSettings": {
+ "displayAs": "secret",
+ "withCopyButton": true,
+ "labelDisplay": {
+ "backgroundColor": "#e3f2fd",
+ "color": "#1976d2",
+ "fontWeight": "bold",
+ "textTransform": "uppercase"
+ }
}
},
{
"label": "Type",
"property": "spec.type",
- "labelDisplay": {
- "backgroundColor": "#e3f2fd",
- "color": "#1976d2",
- "fontWeight": "bold",
- "textTransform": "uppercase"
- }
},
{
"label": "Contact Info",
@@ -175,9 +190,34 @@ Below is an example content-configuration for an accounts node using the generic
{
"label": "Display Name",
"property": "spec.displayName",
- "labelDisplay": {
- "color": "#2e7d32",
- "fontWeight": "600"
+ "uiSettings": {
+ "labelDisplay": {
+ "color": "#2e7d32",
+ "fontWeight": "600"
+ }
+ }
+ },
+ {
+ "label": "API Key",
+ "property": "spec.credentials.apiKey",
+ "uiSettings": {
+ "displayAs": "secret",
+ "withCopyButton": true
+ }
+ },
+ {
+ "label": "Account ID",
+ "property": "metadata.uid",
+ "uiSettings": {
+ "withCopyButton": true
+ }
+ },
+ {
+ "label": "External URL",
+ "property": "spec.externalUrl",
+ "uiSettings": {
+ "displayAs": "plainText",
+ "withCopyButton": true
}
},
{
diff --git a/projects/lib/models/models/resource.ts b/projects/lib/models/models/resource.ts
index 1b5933e..acda89a 100644
--- a/projects/lib/models/models/resource.ts
+++ b/projects/lib/models/models/resource.ts
@@ -21,6 +21,12 @@ export interface PropertyField {
transform?: TransformType[];
}
+export interface UiSettings {
+ labelDisplay?: LabelDisplay | boolean;
+ displayAs?: 'plainText' | 'secret';
+ withCopyButton?: boolean;
+}
+
export interface FieldDefinition {
label?: string;
property: string | string[];
@@ -34,8 +40,7 @@ export interface FieldDefinition {
delimiter?: string;
multiline?: boolean;
};
- labelDisplay?: LabelDisplay | boolean;
- displayAsPlainText?: boolean;
+ uiSettings?: UiSettings;
dynamicValuesDefinition?: {
operation: string;
gqlQuery: string;
@@ -96,4 +101,4 @@ export interface UIDefinition {
detailView?: UiView;
}
-export type KubernetesScope = 'Cluster' | 'Namespaced';
+export type KubernetesScope = 'Cluster' | 'Namespaced';
\ No newline at end of file
diff --git a/projects/wc/_mocks_/ui5-mock.ts b/projects/wc/_mocks_/ui5-mock.ts
index f43a9ec..dde2109 100644
--- a/projects/wc/_mocks_/ui5-mock.ts
+++ b/projects/wc/_mocks_/ui5-mock.ts
@@ -1,6 +1,5 @@
import { Component } from '@angular/core';
-
@Component({ selector: 'ui5-component', template: '', standalone: true })
export class MockComponent {}
@@ -31,4 +30,14 @@ jest.mock('@ui5/webcomponents-ngx', () => {
BarComponent: MockComponent,
LinkComponent: MockComponent,
};
+});
+
+jest.mock('@ui5/webcomponents-icons/dist/copy.js', () => ({}), {
+ virtual: true,
+});
+jest.mock('@ui5/webcomponents-icons/dist/hide.js', () => ({}), {
+ virtual: true,
+});
+jest.mock('@ui5/webcomponents-icons/dist/show.js', () => ({}), {
+ virtual: true,
});
\ No newline at end of file
diff --git a/projects/wc/src/app/components/generic-ui/detail-view/detail-view.component.html b/projects/wc/src/app/components/generic-ui/detail-view/detail-view.component.html
index 33e4645..a3239dd 100644
--- a/projects/wc/src/app/components/generic-ui/detail-view/detail-view.component.html
+++ b/projects/wc/src/app/components/generic-ui/detail-view/detail-view.component.html
@@ -49,9 +49,10 @@
{{ field.label }}:
+ [fieldDefinition]="field"
+ [resource]="resource"
+ [LuigiClient]="LuigiClient()"
+ />
@if (!last && viewField.group.delimiter) {
{{ viewField.group.delimiter }}
@@ -62,9 +63,10 @@
{{ viewField.label }}
+ [fieldDefinition]="viewField"
+ [resource]="resource"
+ [LuigiClient]="LuigiClient()"
+ />
}
diff --git a/projects/wc/src/app/components/generic-ui/list-view/list-view.component.html b/projects/wc/src/app/components/generic-ui/list-view/list-view.component.html
index 4ad348e..d7d26a8 100644
--- a/projects/wc/src/app/components/generic-ui/list-view/list-view.component.html
+++ b/projects/wc/src/app/components/generic-ui/list-view/list-view.component.html
@@ -88,9 +88,9 @@
{{ field.label }}:
@if (!last && column.group.delimiter) {
@@ -101,9 +101,9 @@
} @else {
}
diff --git a/projects/wc/src/app/components/generic-ui/value-cell/label-value/label-value.component.html b/projects/wc/src/app/components/generic-ui/value-cell/label-value/label-value.component.html
deleted file mode 100644
index 7d589e9..0000000
--- a/projects/wc/src/app/components/generic-ui/value-cell/label-value/label-value.component.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
- {{ value() }}
-
diff --git a/projects/wc/src/app/components/generic-ui/value-cell/label-value/label-value.component.spec.ts b/projects/wc/src/app/components/generic-ui/value-cell/label-value/label-value.component.spec.ts
deleted file mode 100644
index 1b2c10a..0000000
--- a/projects/wc/src/app/components/generic-ui/value-cell/label-value/label-value.component.spec.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { LabelValue } from './label-value.component';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { LabelDisplay } from '@platform-mesh/portal-ui-lib/models/models';
-
-describe('LabelValue', () => {
- let component: LabelValue;
- let fixture: ComponentFixture;
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [LabelValue],
- }).compileComponents();
-
- fixture = TestBed.createComponent(LabelValue);
- component = fixture.componentInstance;
-
- // Set required inputs before detectChanges
- fixture.componentRef.setInput('value', 'Test Value');
- fixture.componentRef.setInput('labelDisplay', {
- backgroundColor: '#ffffff',
- color: '#000000',
- fontWeight: 'normal',
- fontStyle: 'normal',
- textDecoration: 'none',
- textTransform: 'none',
- } as LabelDisplay);
-
- fixture.detectChanges();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-});
\ No newline at end of file
diff --git a/projects/wc/src/app/components/generic-ui/value-cell/label-value/label-value.component.ts b/projects/wc/src/app/components/generic-ui/value-cell/label-value/label-value.component.ts
deleted file mode 100644
index c732c22..0000000
--- a/projects/wc/src/app/components/generic-ui/value-cell/label-value/label-value.component.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Component, input } from '@angular/core';
-import { LabelDisplay } from '@platform-mesh/portal-ui-lib/models/models';
-
-@Component({
- selector: 'wc-label-value',
- imports: [],
- templateUrl: './label-value.component.html',
- styleUrl: './label-value.component.scss',
-})
-export class LabelValue {
- value = input.required();
- labelDisplay = input.required();
-}
diff --git a/projects/wc/src/app/components/generic-ui/value-cell/secret-value/secret-value.component.html b/projects/wc/src/app/components/generic-ui/value-cell/secret-value/secret-value.component.html
new file mode 100644
index 0000000..05cf311
--- /dev/null
+++ b/projects/wc/src/app/components/generic-ui/value-cell/secret-value/secret-value.component.html
@@ -0,0 +1,9 @@
+
+
+ @if (isVisible()) {
+ {{ value() }}
+ } @else {
+ {{ maskedValue() }}
+ }
+
+
diff --git a/projects/wc/src/app/components/generic-ui/value-cell/secret-value/secret-value.component.scss b/projects/wc/src/app/components/generic-ui/value-cell/secret-value/secret-value.component.scss
new file mode 100644
index 0000000..aa75af2
--- /dev/null
+++ b/projects/wc/src/app/components/generic-ui/value-cell/secret-value/secret-value.component.scss
@@ -0,0 +1,10 @@
+.secret-value {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+
+ .masked {
+ transform: translateY(0.2em);
+ display: flex;
+ }
+}
diff --git a/projects/wc/src/app/components/generic-ui/value-cell/secret-value/secret-value.component.spec.ts b/projects/wc/src/app/components/generic-ui/value-cell/secret-value/secret-value.component.spec.ts
new file mode 100644
index 0000000..d8093fb
--- /dev/null
+++ b/projects/wc/src/app/components/generic-ui/value-cell/secret-value/secret-value.component.spec.ts
@@ -0,0 +1,162 @@
+import { SecretValueComponent } from './secret-value.component';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+describe('SecretValueComponent', () => {
+ let component: SecretValueComponent;
+ let fixture: ComponentFixture;
+
+ const makeComponent = (value: string) => {
+ fixture = TestBed.createComponent(SecretValueComponent);
+ component = fixture.componentInstance;
+
+ fixture.componentRef.setInput('value', value);
+
+ fixture.detectChanges();
+
+ return { component, fixture };
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [SecretValueComponent],
+ schemas: [],
+ });
+ });
+
+ it('should create', () => {
+ const { component } = makeComponent('test-secret');
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with isVisible as false', () => {
+ const { component } = makeComponent('test-secret');
+ expect(component.isVisible()).toBe(false);
+ });
+
+ it('should mask value with asterisks', () => {
+ const { component, fixture } = makeComponent('my-secret-password');
+ const compiled = fixture.nativeElement;
+
+ expect(component.maskedValue()).toBe(
+ '*'.repeat('my-secret-password'.length),
+ );
+ expect(compiled.querySelector('.masked')?.textContent).toBe(
+ '*'.repeat('my-secret-password'.length),
+ );
+ });
+
+ it('should mask empty string with default 8 asterisks', () => {
+ const { component, fixture } = makeComponent('');
+ const compiled = fixture.nativeElement;
+
+ expect(component.maskedValue()).toBe('*'.repeat(8));
+ expect(compiled.querySelector('.masked')?.textContent).toBe('*'.repeat(8));
+ });
+
+ it('should mask short value correctly', () => {
+ const { component, fixture } = makeComponent('abc');
+ const compiled = fixture.nativeElement;
+
+ expect(component.maskedValue()).toBe('***');
+ expect(compiled.querySelector('.masked')?.textContent).toBe('***');
+ });
+
+ it('should mask long value correctly', () => {
+ const longValue = 'a'.repeat(100);
+ const { component, fixture } = makeComponent(longValue);
+ const compiled = fixture.nativeElement;
+
+ expect(component.maskedValue()).toBe('*'.repeat(100));
+ expect(compiled.querySelector('.masked')?.textContent).toBe(
+ '*'.repeat(100),
+ );
+ });
+
+ it('should display masked value by default', () => {
+ const { fixture } = makeComponent('secret-value');
+ const compiled = fixture.nativeElement;
+
+ const maskedSpan = compiled.querySelector('.masked');
+ const originalSpan = compiled.querySelector('.original');
+
+ expect(maskedSpan).toBeTruthy();
+ expect(originalSpan).toBeFalsy();
+ });
+
+ it('should display original value when isVisible is true', () => {
+ const { component, fixture } = makeComponent('secret-value');
+
+ fixture.componentRef.setInput('isVisible', true);
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement;
+ const originalSpan = compiled.querySelector('.original');
+ const maskedSpan = compiled.querySelector('.masked');
+
+ expect(originalSpan).toBeTruthy();
+ expect(originalSpan?.textContent).toBe('secret-value');
+ expect(maskedSpan).toBeFalsy();
+ });
+
+ it('should switch from masked to original when isVisible changes', () => {
+ const { component, fixture } = makeComponent('secret-value');
+ const compiled = fixture.nativeElement;
+
+ expect(component.isVisible()).toBe(false);
+ expect(compiled.querySelector('.masked')).toBeTruthy();
+ expect(compiled.querySelector('.original')).toBeFalsy();
+
+ fixture.componentRef.setInput('isVisible', true);
+ fixture.detectChanges();
+
+ expect(component.isVisible()).toBe(true);
+ expect(compiled.querySelector('.original')).toBeTruthy();
+ expect(compiled.querySelector('.masked')).toBeFalsy();
+ });
+
+ it('should switch back to masked when isVisible changes to false', () => {
+ const { component, fixture } = makeComponent('secret-value');
+
+ fixture.componentRef.setInput('isVisible', true);
+ fixture.detectChanges();
+ expect(component.isVisible()).toBe(true);
+
+ fixture.componentRef.setInput('isVisible', false);
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement;
+ const maskedSpan = compiled.querySelector('.masked');
+ const originalSpan = compiled.querySelector('.original');
+
+ expect(component.isVisible()).toBe(false);
+ expect(maskedSpan).toBeTruthy();
+ expect(originalSpan).toBeFalsy();
+ });
+
+ it('should update masked value when input changes', () => {
+ const { component, fixture } = makeComponent('initial');
+ expect(component.maskedValue()).toBe('*'.repeat('initial'.length));
+
+ fixture.componentRef.setInput('value', 'updated-secret');
+ fixture.detectChanges();
+
+ expect(component.maskedValue()).toBe('*'.repeat('updated-secret'.length));
+ });
+
+ it('should maintain visibility state when input changes', () => {
+ const { component, fixture } = makeComponent('initial');
+
+ fixture.componentRef.setInput('isVisible', true);
+ fixture.detectChanges();
+
+ expect(component.isVisible()).toBe(true);
+
+ fixture.componentRef.setInput('value', 'updated-secret');
+ fixture.detectChanges();
+
+ expect(component.isVisible()).toBe(true);
+ const compiled = fixture.nativeElement;
+ const originalSpan = compiled.querySelector('.original');
+ expect(originalSpan?.textContent).toBe('updated-secret');
+ });
+});
diff --git a/projects/wc/src/app/components/generic-ui/value-cell/secret-value/secret-value.component.ts b/projects/wc/src/app/components/generic-ui/value-cell/secret-value/secret-value.component.ts
new file mode 100644
index 0000000..47907f8
--- /dev/null
+++ b/projects/wc/src/app/components/generic-ui/value-cell/secret-value/secret-value.component.ts
@@ -0,0 +1,14 @@
+import { Component, computed, input } from '@angular/core';
+
+@Component({
+ selector: 'wc-secret-value',
+ imports: [],
+ schemas: [],
+ templateUrl: './secret-value.component.html',
+ styleUrl: './secret-value.component.scss',
+})
+export class SecretValueComponent {
+ value = input.required();
+ isVisible = input(false);
+ maskedValue = computed(() => '*'.repeat(this.value().length || 8));
+}
diff --git a/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.html b/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.html
index 6603ce8..0680029 100644
--- a/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.html
+++ b/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.html
@@ -1,14 +1,33 @@
-@if (displayAsPlainText()) {
- {{ value() }}
-} @else {
- @if (isBoolLike()) {
+
+ @if (displayAs() === 'plainText') {
+ {{ value() }}
+ } @else if (displayAs() === 'secret') {
+
+ } @else if (isBoolLike()) {
} @else if (isUrlValue()) {
- } @else if (isLabelValue()) {
-
- }
- @else {
+ } @else {
{{ value() }}
}
+
+
+@if (displayAs() === 'secret') {
+
+}
+
+@if (withCopyButton()) {
+
+
}
diff --git a/projects/wc/src/app/components/generic-ui/value-cell/label-value/label-value.component.scss b/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.scss
similarity index 54%
rename from projects/wc/src/app/components/generic-ui/value-cell/label-value/label-value.component.scss
rename to projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.scss
index 8a134f1..7074408 100644
--- a/projects/wc/src/app/components/generic-ui/value-cell/label-value/label-value.component.scss
+++ b/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.scss
@@ -1,3 +1,8 @@
+:host {
+ display: flex;
+ align-items: center;
+}
+
.label-value {
background-color: #01B4FF;
color: #ffffff;
@@ -10,3 +15,13 @@
line-height: 1.4rem;
margin: 3px 0;
}
+
+.toggle-icon {
+ cursor: pointer;
+ transition: color 0.2s ease;
+ padding: 0.25rem;
+
+ &:hover {
+ color: var(--sapButton_Hover_TextColor, #0854a0);
+ }
+}
diff --git a/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.spec.ts b/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.spec.ts
index 4929a5c..bbcf47e 100644
--- a/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.spec.ts
+++ b/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.spec.ts
@@ -1,31 +1,49 @@
+import { BooleanValueComponent } from './boolean-value/boolean-value.component';
+import { LinkValueComponent } from './link-value/link-value.component';
+import { SecretValueComponent } from './secret-value/secret-value.component';
import { ValueCellComponent } from './value-cell.component';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { LuigiClient } from '@luigi-project/client/luigi-element';
+import {
+ FieldDefinition,
+ Resource,
+} from '@platform-mesh/portal-ui-lib/models/models';
describe('ValueCellComponent', () => {
let component: ValueCellComponent;
let fixture: ComponentFixture;
+ let mockLuigiClient: LuigiClient;
- const makeComponent = (value: unknown) => {
- fixture = TestBed.createComponent(ValueCellComponent);
- component = fixture.componentInstance;
-
- fixture.componentRef.setInput('value', value as any);
-
- fixture.detectChanges();
-
- return { component, fixture };
- };
+ const createMockLuigiClient = (showAlertSpy?: jest.Mock): LuigiClient =>
+ ({
+ uxManager: () => ({
+ showAlert: showAlertSpy || jest.fn(),
+ }),
+ }) as any;
- const makeComponentWithLabelDisplay = (
+ const makeComponent = (
value: unknown,
- labelDisplay: unknown,
+ fieldDefinition: Partial = {},
+ customLuigiClient?: LuigiClient,
) => {
+ mockLuigiClient = customLuigiClient || createMockLuigiClient();
fixture = TestBed.createComponent(ValueCellComponent);
component = fixture.componentInstance;
- fixture.componentRef.setInput('value', value as any);
- fixture.componentRef.setInput('labelDisplay', labelDisplay as any);
+ const resource: Resource = {
+ metadata: { name: 'test-resource' },
+ spec: { value },
+ } as any;
+
+ const field: FieldDefinition = {
+ property: 'spec.value',
+ ...fieldDefinition,
+ };
+
+ fixture.componentRef.setInput('resource', resource);
+ fixture.componentRef.setInput('fieldDefinition', field);
+ fixture.componentRef.setInput('LuigiClient', mockLuigiClient);
fixture.detectChanges();
@@ -35,7 +53,15 @@ describe('ValueCellComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ValueCellComponent],
- schemas: [CUSTOM_ELEMENTS_SCHEMA],
+ }).overrideComponent(ValueCellComponent, {
+ set: {
+ imports: [
+ BooleanValueComponent,
+ LinkValueComponent,
+ SecretValueComponent,
+ ],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
+ },
});
});
@@ -149,77 +175,262 @@ describe('ValueCellComponent', () => {
});
describe('labelDisplay functionality', () => {
- it('should render label-value component when labelDisplay is an object', () => {
+ it('should apply label styles when labelDisplay is an object', () => {
const labelDisplay = { backgroundColor: '#ffffff', color: '#000000' };
- const { fixture } = makeComponentWithLabelDisplay(
- 'test-value',
- labelDisplay,
- );
+ const { fixture } = makeComponent('test-value', {
+ uiSettings: { labelDisplay },
+ });
const compiled = fixture.nativeElement;
+ const span = compiled.querySelector('span');
- expect(compiled.querySelector('wc-label-value')).toBeTruthy();
- expect(component.isLabelValue()).toBe(true);
- expect(component.labelDisplayValue()).toEqual(labelDisplay);
+ expect(span.classList.contains('label-value')).toBe(true);
+ expect(component.labelDisplay()).toEqual(labelDisplay);
});
- it('should render label-value component when labelDisplay is true', () => {
- const { fixture } = makeComponentWithLabelDisplay('test-value', true);
+ it('should apply label-value class when labelDisplay is true', () => {
+ const { fixture } = makeComponent('test-value', {
+ uiSettings: { labelDisplay: true },
+ });
const compiled = fixture.nativeElement;
+ const span = compiled.querySelector('span');
- expect(compiled.querySelector('wc-label-value')).toBeTruthy();
- expect(component.isLabelValue()).toBe(true);
- expect(component.labelDisplayValue()).toEqual({});
+ expect(span.classList.contains('label-value')).toBe(true);
+ expect(component.labelDisplay()).toEqual({});
});
- it('should not render label-value component when labelDisplay is false', () => {
- const { fixture } = makeComponentWithLabelDisplay('test-value', false);
+ it('should not apply label-value class when labelDisplay is false', () => {
+ const { fixture } = makeComponent('test-value', {
+ uiSettings: { labelDisplay: false },
+ });
const compiled = fixture.nativeElement;
+ const span = compiled.querySelector('span');
- expect(compiled.querySelector('wc-label-value')).toBeFalsy();
- expect(component.isLabelValue()).toBe(false);
- expect(component.labelDisplayValue()).toBeUndefined();
+ expect(span.classList.contains('label-value')).toBe(false);
+ expect(component.labelDisplay()).toBeUndefined();
});
- it('should not render label-value component when labelDisplay is undefined', () => {
- const { fixture } = makeComponentWithLabelDisplay(
- 'test-value',
- undefined,
- );
+ it('should not apply label-value class when labelDisplay is undefined', () => {
+ const { fixture } = makeComponent('test-value', {
+ uiSettings: { labelDisplay: undefined },
+ });
+ const compiled = fixture.nativeElement;
+ const span = compiled.querySelector('span');
+
+ expect(span.classList.contains('label-value')).toBe(false);
+ expect(component.labelDisplay()).toBeUndefined();
+ });
+ });
+
+ describe('displayAs secret functionality', () => {
+ it('should render secret-value component when displayAs is secret', () => {
+ const { fixture } = makeComponent('secret-password', {
+ uiSettings: { displayAs: 'secret' },
+ });
+ const compiled = fixture.nativeElement;
+
+ expect(compiled.querySelector('wc-secret-value')).toBeTruthy();
+ expect(component.displayAs()).toBe('secret');
+ });
+
+ it('should not render secret-value component when displayAs is not secret', () => {
+ const { fixture } = makeComponent('plain-text', {
+ uiSettings: { displayAs: 'plainText' },
+ });
+ const compiled = fixture.nativeElement;
+
+ expect(compiled.querySelector('wc-secret-value')).toBeFalsy();
+ expect(component.displayAs()).toBe('plainText');
+ });
+
+ it('should not render secret-value component when displayAs is undefined', () => {
+ const { fixture } = makeComponent('plain-text');
+ const compiled = fixture.nativeElement;
+
+ expect(compiled.querySelector('wc-secret-value')).toBeFalsy();
+ expect(component.displayAs()).toBeUndefined();
+ });
+
+ it('should render toggle icon when displayAs is secret', () => {
+ const { fixture } = makeComponent('secret-password', {
+ uiSettings: { displayAs: 'secret' },
+ });
+ const compiled = fixture.nativeElement;
+
+ const toggleIcon = compiled.querySelector('ui5-icon.toggle-icon');
+ expect(toggleIcon).toBeTruthy();
+ });
+
+ it('should not render toggle icon when displayAs is not secret', () => {
+ const { fixture } = makeComponent('plain-text', {
+ uiSettings: { displayAs: 'plainText' },
+ });
+ const compiled = fixture.nativeElement;
+
+ const toggleIcon = compiled.querySelector('ui5-icon.toggle-icon');
+ expect(toggleIcon).toBeFalsy();
+ });
+
+ it('should initialize isVisible as false', () => {
+ const { component } = makeComponent('secret-password', {
+ uiSettings: { displayAs: 'secret' },
+ });
+
+ expect(component.isVisible()).toBe(false);
+ });
+
+ it('should toggle visibility when icon is clicked', () => {
+ const { component, fixture } = makeComponent('secret-password', {
+ uiSettings: { displayAs: 'secret' },
+ });
+ const compiled = fixture.nativeElement;
+
+ expect(component.isVisible()).toBe(false);
+
+ const icon = compiled.querySelector('ui5-icon.toggle-icon');
+ icon?.click();
+ fixture.detectChanges();
+
+ expect(component.isVisible()).toBe(true);
+ });
+
+ it('should toggle back to hidden when icon is clicked again', () => {
+ const { component, fixture } = makeComponent('secret-password', {
+ uiSettings: { displayAs: 'secret' },
+ });
+ const compiled = fixture.nativeElement;
+
+ const icon = compiled.querySelector('ui5-icon.toggle-icon');
+
+ icon?.click();
+ fixture.detectChanges();
+ expect(component.isVisible()).toBe(true);
+
+ icon?.click();
+ fixture.detectChanges();
+ expect(component.isVisible()).toBe(false);
+ });
+
+ it('should stop event propagation when toggle icon is clicked', () => {
+ const { component, fixture } = makeComponent('secret-password', {
+ uiSettings: { displayAs: 'secret' },
+ });
+
+ const event = new Event('click');
+ const stopPropagationSpy = jest.spyOn(event, 'stopPropagation');
+
+ component.toggleVisibility(event);
+ fixture.detectChanges();
+
+ expect(stopPropagationSpy).toHaveBeenCalled();
+ });
+
+ it('should pass isVisible state to secret-value component', () => {
+ const { component, fixture } = makeComponent('secret-password', {
+ uiSettings: { displayAs: 'secret' },
+ });
+ const compiled = fixture.nativeElement;
+
+ component.isVisible.set(true);
+ fixture.detectChanges();
+
+ const secretValueComponent = compiled.querySelector('wc-secret-value');
+ expect(secretValueComponent).toBeTruthy();
+ });
+ });
+
+ describe('withCopyButton functionality', () => {
+ it('should render copy button when withCopyButton is true', () => {
+ const { fixture } = makeComponent('test-value', {
+ uiSettings: { withCopyButton: true },
+ });
+ const compiled = fixture.nativeElement;
+
+ expect(compiled.querySelector('ui5-icon[name="copy"]')).toBeTruthy();
+ expect(component.withCopyButton()).toBe(true);
+ });
+
+ it('should not render copy button when withCopyButton is false', () => {
+ const { fixture } = makeComponent('test-value', {
+ uiSettings: { withCopyButton: false },
+ });
const compiled = fixture.nativeElement;
- expect(compiled.querySelector('wc-label-value')).toBeFalsy();
- expect(component.isLabelValue()).toBe(false);
- expect(component.labelDisplayValue()).toBeUndefined();
+ expect(compiled.querySelector('ui5-icon[name="copy"]')).toBeFalsy();
+ expect(component.withCopyButton()).toBe(false);
});
- it('should not render label-value component when labelDisplay is null', () => {
- const { fixture } = makeComponentWithLabelDisplay('test-value', null);
+ it('should not render copy button when withCopyButton is undefined', () => {
+ const { fixture } = makeComponent('test-value');
const compiled = fixture.nativeElement;
- expect(compiled.querySelector('wc-label-value')).toBeFalsy();
- expect(component.isLabelValue()).toBe(false);
- expect(component.labelDisplayValue()).toBeUndefined();
+ expect(compiled.querySelector('ui5-icon[name="copy"]')).toBeFalsy();
+ expect(component.withCopyButton()).toBeUndefined();
});
- it('should render label-value component when labelDisplay is a string', () => {
- const { fixture } = makeComponentWithLabelDisplay(
+ it('should copy value to clipboard when copy button is clicked', async () => {
+ const writeTextSpy = jest.fn().mockResolvedValue(undefined);
+ Object.assign(navigator, { clipboard: { writeText: writeTextSpy } });
+
+ const showAlertSpy = jest.fn();
+ const customLuigiClient = createMockLuigiClient(showAlertSpy);
+ const { fixture } = makeComponent(
'test-value',
- 'some-string',
+ { uiSettings: { withCopyButton: true } },
+ customLuigiClient,
);
+
+ const compiled = fixture.nativeElement;
+ const copyButton = compiled.querySelector('ui5-icon[name="copy"]');
+
+ const event = new Event('click');
+ copyButton.dispatchEvent(event);
+ fixture.detectChanges();
+
+ expect(writeTextSpy).toHaveBeenCalledWith('test-value');
+ expect(showAlertSpy).toHaveBeenCalledWith({
+ text: 'Copied to clipboard',
+ type: 'success',
+ closeAfter: 2000,
+ });
+ });
+
+ it('should stop event propagation when copy button is clicked', () => {
+ const { fixture } = makeComponent('test-value', {
+ uiSettings: { withCopyButton: true },
+ });
+ const compiled = fixture.nativeElement;
+ const copyButton = compiled.querySelector('ui5-icon[name="copy"]');
+
+ const event = new Event('click');
+ const stopPropagationSpy = jest.spyOn(event, 'stopPropagation');
+
+ component.copyValue(event);
+ fixture.detectChanges();
+
+ expect(stopPropagationSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('displayAs plainText functionality', () => {
+ it('should render plain text when displayAs is plainText', () => {
+ const { fixture } = makeComponent('test-value', {
+ uiSettings: { displayAs: 'plainText' },
+ });
const compiled = fixture.nativeElement;
- expect(compiled.querySelector('wc-label-value')).toBeTruthy();
- expect(component.isLabelValue()).toBe(true);
- expect(component.labelDisplayValue()).toEqual({});
+ expect(compiled.querySelector('wc-boolean-value')).toBeFalsy();
+ expect(compiled.querySelector('wc-link-value')).toBeFalsy();
+ expect(compiled.querySelector('wc-secret-value')).toBeFalsy();
+ expect(compiled.textContent.trim()).toContain('test-value');
});
- it('should render label-value component when labelDisplay is a number', () => {
- const { fixture } = makeComponentWithLabelDisplay('test-value', 42);
+ it('should not render plain text when displayAs is not plainText', () => {
+ const { fixture } = makeComponent('https://example.com', {
+ uiSettings: {},
+ });
const compiled = fixture.nativeElement;
- expect(compiled.querySelector('wc-label-value')).toBeTruthy();
- expect(component.isLabelValue()).toBe(true);
- expect(component.labelDisplayValue()).toEqual({});
+ expect(compiled.querySelector('wc-link-value')).toBeTruthy();
});
});
@@ -412,7 +623,7 @@ describe('ValueCellComponent', () => {
expect(component.isUrlValue()).toBe(false);
});
- it('should not treat "mailto:test@example.com" as valid URL for link component', () => {
+ it('should treat "mailto:test@example.com" as valid URL for link component', () => {
const { fixture } = makeComponent('mailto:test@example.com');
const compiled = fixture.nativeElement;
@@ -420,7 +631,7 @@ describe('ValueCellComponent', () => {
expect(component.isUrlValue()).toBe(true);
});
- it('should not treat "tel:+1234567890" as valid URL for link component', () => {
+ it('should treat "tel:+1234567890" as valid URL for link component', () => {
const { fixture } = makeComponent('tel:+1234567890');
const compiled = fixture.nativeElement;
@@ -510,49 +721,18 @@ describe('ValueCellComponent', () => {
expect(compiled.querySelector('wc-boolean-value')).toBeTruthy();
expect(compiled.querySelector('wc-link-value')).toBeFalsy();
- expect(compiled.querySelector('wc-label-value')).toBeFalsy();
- });
-
- it('should prioritize boolean over label when both are valid', () => {
- const { fixture } = makeComponentWithLabelDisplay('true', {
- backgroundColor: '#ffffff',
- });
- const compiled = fixture.nativeElement;
-
- expect(compiled.querySelector('wc-boolean-value')).toBeTruthy();
- expect(compiled.querySelector('wc-link-value')).toBeFalsy();
- expect(compiled.querySelector('wc-label-value')).toBeFalsy();
- });
-
- it('should prioritize URL over label when both are valid', () => {
- const { fixture } = makeComponentWithLabelDisplay('https://example.com', {
- backgroundColor: '#ffffff',
- });
- const compiled = fixture.nativeElement;
-
- expect(compiled.querySelector('wc-boolean-value')).toBeFalsy();
- expect(compiled.querySelector('wc-link-value')).toBeTruthy();
- expect(compiled.querySelector('wc-label-value')).toBeFalsy();
- });
-
- it('should render label when boolean and URL are not valid but labelDisplay is provided', () => {
- const { fixture } = makeComponentWithLabelDisplay('some-text', {
- backgroundColor: '#ffffff',
- });
- const compiled = fixture.nativeElement;
-
- expect(compiled.querySelector('wc-boolean-value')).toBeFalsy();
- expect(compiled.querySelector('wc-link-value')).toBeFalsy();
- expect(compiled.querySelector('wc-label-value')).toBeTruthy();
});
it('should render plain text when no special rendering is needed', () => {
- const { fixture } = makeComponentWithLabelDisplay('some-text', false);
+ const { fixture } = makeComponent('some-text', {
+ uiSettings: { labelDisplay: false },
+ });
const compiled = fixture.nativeElement;
+ const span = compiled.querySelector('span');
expect(compiled.querySelector('wc-boolean-value')).toBeFalsy();
expect(compiled.querySelector('wc-link-value')).toBeFalsy();
- expect(compiled.querySelector('wc-label-value')).toBeFalsy();
+ expect(span.classList.contains('label-value')).toBe(false);
expect(compiled.textContent.trim()).toBe('some-text');
});
});
diff --git a/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.ts b/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.ts
index 2b33a2b..2b90e57 100644
--- a/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.ts
+++ b/projects/wc/src/app/components/generic-ui/value-cell/value-cell.component.ts
@@ -1,35 +1,65 @@
import { BooleanValueComponent } from './boolean-value/boolean-value.component';
-import { LabelValue } from './label-value/label-value.component';
import { LinkValueComponent } from './link-value/link-value.component';
+import { SecretValueComponent } from './secret-value/secret-value.component';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
+ signal,
} from '@angular/core';
-import { LabelDisplay } from '@platform-mesh/portal-ui-lib/models/models';
+import { LuigiClient } from '@luigi-project/client/luigi-element';
+import {
+ FieldDefinition,
+ LabelDisplay,
+} from '@platform-mesh/portal-ui-lib/models/models';
+import { Resource } from '@platform-mesh/portal-ui-lib/models/models/resource';
+import { getResourceValueByJsonPath } from '@platform-mesh/portal-ui-lib/utils/utils';
+import '@ui5/webcomponents-icons/dist/copy.js';
+import '@ui5/webcomponents-icons/dist/hide.js';
+import '@ui5/webcomponents-icons/dist/show.js';
+import { IconComponent } from '@ui5/webcomponents-ngx';
@Component({
selector: 'value-cell',
standalone: true,
- imports: [BooleanValueComponent, LinkValueComponent, LabelValue],
+ imports: [
+ IconComponent,
+ BooleanValueComponent,
+ LinkValueComponent,
+ SecretValueComponent,
+ ],
templateUrl: './value-cell.component.html',
+ styleUrls: ['./value-cell.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ValueCellComponent {
- value = input();
- labelDisplay = input();
- displayAsPlainText = input(false);
+ fieldDefinition = input.required();
+ resource = input.required();
+ LuigiClient = input.required();
+
+ value = computed(() =>
+ getResourceValueByJsonPath(this.resource(), this.fieldDefinition()),
+ );
+
+ uiSettings = computed(() => this.fieldDefinition().uiSettings);
+ displayAs = computed(() => this.uiSettings()?.displayAs);
+ withCopyButton = computed(() => this.uiSettings()?.withCopyButton);
+ labelDisplay = computed(() =>
+ this.normalizeLabelDisplay(this.uiSettings()?.labelDisplay),
+ );
- isLabelValue = computed(() => this.labelDisplayValue() !== undefined);
isBoolLike = computed(() => this.boolValue() !== undefined);
isUrlValue = computed(() => this.checkValidUrl(this.stringValue()));
boolValue = computed(() => this.normalizeBoolean(this.value()));
stringValue = computed(() => this.normalizeString(this.value()));
- labelDisplayValue = computed(() =>
- this.normalizeLabelDisplay(this.labelDisplay()),
- );
+ isVisible = signal(false);
+
+ toggleVisibility(e: Event): void {
+ e.stopPropagation();
+ this.isVisible.set(!this.isVisible());
+ }
private normalizeBoolean(value: unknown): boolean | undefined {
const normalizedValue = value?.toString()?.toLowerCase();
@@ -74,4 +104,14 @@ export class ValueCellComponent {
return undefined;
}
+
+ public copyValue(event: Event) {
+ event.stopPropagation();
+ navigator.clipboard.writeText(this.value() || '');
+ this.LuigiClient().uxManager().showAlert({
+ text: 'Copied to clipboard',
+ type: 'success',
+ closeAfter: 2000,
+ });
+ }
}