Browse Source

Feature/improve copy-to-clipboard feedback

pull/7338/head
roian6 5 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) {
<button
class="ml-1 no-height no-min-width p-1"
i18n-title
mat-button
title="Copy to clipboard"
type="button"
[attr.aria-label]="copyToClipboardTitle"
[title]="isCopied ? copiedTitle : copyToClipboardTitle"
(click)="onCopyValueToClipboard()"
>
<ion-icon class="text-muted" name="copy-outline" />
<ion-icon
class="text-muted"
[name]="isCopied ? 'checkmark-outline' : 'copy-outline'"
/>
</button>
}
</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,
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<number>();
private copyConfirmationTimeout?: ReturnType<typeof setTimeout>;
private readonly formatOptions = computed<Intl.NumberFormatOptions>(() => {
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 = '';

Loading…
Cancel
Save