diff --git a/libs/ui/src/lib/value/value.component.html b/libs/ui/src/lib/value/value.component.html index d5476f42d..15a8e371e 100644 --- a/libs/ui/src/lib/value/value.component.html +++ b/libs/ui/src/lib/value/value.component.html @@ -13,13 +13,16 @@ @if (enableCopyToClipboardButton) { } diff --git a/libs/ui/src/lib/value/value.component.spec.ts b/libs/ui/src/lib/value/value.component.spec.ts new file mode 100644 index 000000000..3bdceddb4 --- /dev/null +++ b/libs/ui/src/lib/value/value.component.spec.ts @@ -0,0 +1,170 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { Component, Input } from '@angular/core'; +import { + ComponentFixture, + TestBed, + fakeAsync, + flush, + tick +} from '@angular/core/testing'; +import '@angular/localize/init'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { By } from '@angular/platform-browser'; + +import type { GfValueComponent as GfValueComponentType } from './value.component'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'ion-icon', + standalone: true, + template: '' +}) +class MockIonIcon { + @Input() name = ''; +} + +jest.mock('@ionic/angular/standalone', () => ({ IonIcon: MockIonIcon })); + +const { GfValueComponent } = + require('./value.component') as typeof import('./value.component'); + +describe('GfValueComponent', () => { + let clipboard: { copy: jest.Mock }; + let fixture: ComponentFixture; + let snackBar: { dismiss: jest.Mock; open: jest.Mock }; + + const getCopyButton = () => + fixture.nativeElement.querySelector( + 'button[aria-label]' + ) as HTMLButtonElement; + + const getCopyIcon = () => + fixture.debugElement.query(By.directive(MockIonIcon)) + .componentInstance as MockIonIcon; + + beforeEach(async () => { + clipboard = { copy: jest.fn().mockReturnValue(true) }; + snackBar = { dismiss: jest.fn(), open: jest.fn() }; + + await TestBed.configureTestingModule({ + imports: [GfValueComponent], + providers: [ + { provide: Clipboard, useValue: clipboard }, + { provide: MatSnackBar, useValue: snackBar } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(GfValueComponent); + fixture.componentRef.setInput('enableCopyToClipboardButton', true); + fixture.componentRef.setInput('locale', 'en-US'); + fixture.componentRef.setInput('value', 42); + fixture.detectChanges(); + }); + + it('shows confirmation immediately after copying', () => { + getCopyButton().click(); + fixture.detectChanges(); + + expect(clipboard.copy).toHaveBeenCalledWith('42'); + expect(snackBar.open).toHaveBeenCalledTimes(1); + expect(snackBar.open).toHaveBeenCalledWith( + expect.any(String), + undefined, + expect.objectContaining({ politeness: 'polite' }) + ); + expect(getCopyButton().title).toBe('Copied!'); + expect(getCopyIcon().name).toBe('checkmark-outline'); + }); + + it('does not show confirmation when copying fails', () => { + clipboard.copy.mockReturnValue(false); + + getCopyButton().click(); + fixture.detectChanges(); + + expect(getCopyButton().title).toBe('Copy to clipboard'); + expect(getCopyIcon().name).toBe('copy-outline'); + expect(snackBar.dismiss).toHaveBeenCalledTimes(1); + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it('clears active confirmation when a repeated copy fails', fakeAsync(() => { + getCopyButton().click(); + fixture.detectChanges(); + + clipboard.copy.mockReturnValue(false); + + getCopyButton().click(); + fixture.detectChanges(); + + expect(clipboard.copy).toHaveBeenCalledTimes(2); + expect(snackBar.dismiss).toHaveBeenCalledTimes(1); + expect(snackBar.open).toHaveBeenCalledTimes(1); + expect(getCopyButton().title).toBe('Copy to clipboard'); + expect(getCopyIcon().name).toBe('copy-outline'); + + tick(2000); + + expect(getCopyButton().title).toBe('Copy to clipboard'); + expect(getCopyIcon().name).toBe('copy-outline'); + })); + + it('keeps the copy action accessible during confirmation', () => { + getCopyButton().click(); + fixture.detectChanges(); + + expect(getCopyButton().getAttribute('aria-label')).toBe( + 'Copy to clipboard' + ); + expect(getCopyButton().disabled).toBe(false); + expect(snackBar.open).toHaveBeenCalledTimes(1); + }); + + it('restores the copy affordance after two seconds', fakeAsync(() => { + getCopyButton().click(); + fixture.detectChanges(); + + tick(1999); + fixture.detectChanges(); + + expect(getCopyButton().title).toBe('Copied!'); + expect(getCopyIcon().name).toBe('checkmark-outline'); + + tick(1); + fixture.detectChanges(); + + expect(getCopyButton().title).toBe('Copy to clipboard'); + expect(getCopyIcon().name).toBe('copy-outline'); + })); + + it('restarts the confirmation timer when copied again', fakeAsync(() => { + getCopyButton().click(); + tick(1999); + + getCopyButton().click(); + fixture.detectChanges(); + + expect(clipboard.copy).toHaveBeenCalledTimes(2); + expect(snackBar.open).toHaveBeenCalledTimes(2); + + tick(1); + fixture.detectChanges(); + + expect(getCopyButton().title).toBe('Copied!'); + expect(getCopyIcon().name).toBe('checkmark-outline'); + + tick(1999); + fixture.detectChanges(); + + expect(getCopyButton().title).toBe('Copy to clipboard'); + expect(getCopyIcon().name).toBe('copy-outline'); + })); + + it('cancels the pending confirmation timer when destroyed', fakeAsync(() => { + getCopyButton().click(); + + fixture.destroy(); + + expect(flush()).toBe(0); + })); +}); diff --git a/libs/ui/src/lib/value/value.component.ts b/libs/ui/src/lib/value/value.component.ts index dffcb89e7..87dd5efca 100644 --- a/libs/ui/src/lib/value/value.component.ts +++ b/libs/ui/src/lib/value/value.component.ts @@ -13,13 +13,14 @@ import { input, Input, OnChanges, + OnDestroy, ViewChild } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatSnackBar } from '@angular/material/snack-bar'; import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; -import { copyOutline } from 'ionicons/icons'; +import { checkmarkOutline, copyOutline } from 'ionicons/icons'; import { isNumber } from 'lodash'; import ms from 'ms'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @@ -32,7 +33,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; styleUrls: ['./value.component.scss'], templateUrl: './value.component.html' }) -export class GfValueComponent implements AfterViewInit, OnChanges { +export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { @Input() colorizeSign = false; @Input() deviceType: string; @Input() enableCopyToClipboardButton = false; @@ -54,22 +55,29 @@ export class GfValueComponent implements AfterViewInit, OnChanges { public absoluteValue = 0; public formattedValue = ''; public hasLabel = false; + public isCopied = false; public isNumber = false; public isString = false; public useAbsoluteValue = false; + public readonly copiedTitle = $localize`Copied!`; + public readonly copyToClipboardTitle = $localize`Copy to clipboard`; + public constructor( private changeDetectorRef: ChangeDetectorRef, private clipboard: Clipboard, private snackBar: MatSnackBar ) { addIcons({ + checkmarkOutline, copyOutline }); } public readonly precision = input(); + private copyConfirmationTimeout?: ReturnType; + private readonly formatOptions = computed(() => { const digits = this.hasPrecision ? this.precision() : 2; @@ -175,18 +183,47 @@ export class GfValueComponent implements AfterViewInit, OnChanges { } } + public ngOnDestroy() { + this.clearCopyConfirmation(); + } + public onCopyValueToClipboard() { - this.clipboard.copy(String(this.value)); + this.clearCopyConfirmation(); + + const hasCopied = this.clipboard.copy(String(this.value)); + + if (!hasCopied) { + this.snackBar.dismiss(); + + return; + } + + this.isCopied = true; + this.copyConfirmationTimeout = setTimeout(() => { + this.copyConfirmationTimeout = undefined; + this.isCopied = false; + this.changeDetectorRef.markForCheck(); + }, ms('2 seconds')); this.snackBar.open( '✅ ' + $localize`${this.value} has been copied to the clipboard`, undefined, { - duration: ms('3 seconds') + duration: ms('3 seconds'), + politeness: 'polite' } ); } + private clearCopyConfirmation() { + if (this.copyConfirmationTimeout) { + clearTimeout(this.copyConfirmationTimeout); + this.copyConfirmationTimeout = undefined; + } + + this.isCopied = false; + } + private initializeVariables() { this.absoluteValue = 0; this.formattedValue = '';