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, + }); + } }