Browse Source

Feature/improve copy-to-clipboard feedback

pull/7338/head
roian6 6 days ago
committed by Thomas Kaul
parent
commit
053a993283
  1. 9
      libs/ui/src/lib/value/value.component.html
  2. 170
      libs/ui/src/lib/value/value.component.spec.ts
  3. 45
      libs/ui/src/lib/value/value.component.ts

9
libs/ui/src/lib/value/value.component.html

@ -13,13 +13,16 @@
@if (enableCopyToClipboardButton) { @if (enableCopyToClipboardButton) {
<button <button
class="ml-1 no-height no-min-width p-1" class="ml-1 no-height no-min-width p-1"
i18n-title
mat-button mat-button
title="Copy to clipboard"
type="button" type="button"
[attr.aria-label]="copyToClipboardTitle"
[title]="isCopied ? copiedTitle : copyToClipboardTitle"
(click)="onCopyValueToClipboard()" (click)="onCopyValueToClipboard()"
> >
<ion-icon class="text-muted" name="copy-outline" /> <ion-icon
class="text-muted"
[name]="isCopied ? 'checkmark-outline' : 'copy-outline'"
/>
</button> </button>
} }
</ng-template> </ng-template>

170
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<GfValueComponentType>;
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);
}));
});

45
libs/ui/src/lib/value/value.component.ts

@ -13,13 +13,14 @@ import {
input, input,
Input, Input,
OnChanges, OnChanges,
OnDestroy,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { copyOutline } from 'ionicons/icons'; import { checkmarkOutline, copyOutline } from 'ionicons/icons';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -32,7 +33,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
styleUrls: ['./value.component.scss'], styleUrls: ['./value.component.scss'],
templateUrl: './value.component.html' templateUrl: './value.component.html'
}) })
export class GfValueComponent implements AfterViewInit, OnChanges { export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() colorizeSign = false; @Input() colorizeSign = false;
@Input() deviceType: string; @Input() deviceType: string;
@Input() enableCopyToClipboardButton = false; @Input() enableCopyToClipboardButton = false;
@ -54,22 +55,29 @@ export class GfValueComponent implements AfterViewInit, OnChanges {
public absoluteValue = 0; public absoluteValue = 0;
public formattedValue = ''; public formattedValue = '';
public hasLabel = false; public hasLabel = false;
public isCopied = false;
public isNumber = false; public isNumber = false;
public isString = false; public isString = false;
public useAbsoluteValue = false; public useAbsoluteValue = false;
public readonly copiedTitle = $localize`Copied!`;
public readonly copyToClipboardTitle = $localize`Copy to clipboard`;
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private clipboard: Clipboard, private clipboard: Clipboard,
private snackBar: MatSnackBar private snackBar: MatSnackBar
) { ) {
addIcons({ addIcons({
checkmarkOutline,
copyOutline copyOutline
}); });
} }
public readonly precision = input<number>(); public readonly precision = input<number>();
private copyConfirmationTimeout?: ReturnType<typeof setTimeout>;
private readonly formatOptions = computed<Intl.NumberFormatOptions>(() => { private readonly formatOptions = computed<Intl.NumberFormatOptions>(() => {
const digits = this.hasPrecision ? this.precision() : 2; const digits = this.hasPrecision ? this.precision() : 2;
@ -175,18 +183,47 @@ export class GfValueComponent implements AfterViewInit, OnChanges {
} }
} }
public ngOnDestroy() {
this.clearCopyConfirmation();
}
public onCopyValueToClipboard() { 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( this.snackBar.open(
'✅ ' + $localize`${this.value} has been copied to the clipboard`, '✅ ' + $localize`${this.value} has been copied to the clipboard`,
undefined, 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() { private initializeVariables() {
this.absoluteValue = 0; this.absoluteValue = 0;
this.formattedValue = ''; this.formattedValue = '';

Loading…
Cancel
Save