Fix unresponsive copy button on instructions modal

This commit fixes the bug where the "Copy" button does not copy when
clicked on download instructions modal (on Linux and macOS).

This commit also introduces several improvements to the UI components
related to copy action and their interaction with the clipboard feature.

It adds more tests to avoid regression of the bugs and improves
maintainability, testability and adherence to Vue's reactive principles.

Changes include:

- Fix non-responsive copy button in the download instructions modal by
  triggering a `click` event in `AppIcon.vue`.
- Improve `TheCodeButtons.vue`:
  - Remove redundant `getCurrentCode` function.
  - Separate components for each button for better separation of
    concerns and higher testability.
  - Use the `gap` property in the flexbox layout, replacing the less
    explicit sibling combinator approach.
- Add `useClipboard` compositional hook for more idiomatic Vue approach
  to interacting with the clipboard.
- Add `useCurrentCode` compositional hook to handle current code state
  more effectively with unified logic.
- Abstract clipboard operations to an interface to isolate
  responsibilities.
- Switch clipboard implementation to the `navigator.clipboard` API,
  moving away from the deprecated `document.execCommand`.
- Move clipboard logic to the presentation layer to conform to
  separation of concerns and domain-driven design principles.
- Improve `IconButton.vue` component to increase reusability with
  consistent sizing.
This commit is contained in:
undergroundwires
2023-11-06 21:55:43 +01:00
parent b2ffc90da7
commit 8ccaec7af6
37 changed files with 881 additions and 206 deletions

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { BrowserClipboard, NavigatorClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/BrowserClipboard';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
describe('BrowserClipboard', () => {
describe('writeText', () => {
it('calls navigator clipboard with the correct text', async () => {
// arrange
const expectedText = 'test text';
const navigatorClipboard = new NavigatorClipboardStub();
const clipboard = new BrowserClipboard(navigatorClipboard);
// act
await clipboard.copyText(expectedText);
// assert
const calls = navigatorClipboard.callHistory;
expect(calls).to.have.lengthOf(1);
const call = calls.find((c) => c.methodName === 'writeText');
expect(call).toBeDefined();
const [actualText] = call.args;
expect(actualText).to.equal(expectedText);
});
it('throws when navigator clipboard fails', async () => {
// arrange
const expectedError = 'internalError';
const navigatorClipboard = new NavigatorClipboardStub();
navigatorClipboard.writeText = () => {
throw new Error(expectedError);
};
const clipboard = new BrowserClipboard(navigatorClipboard);
// act
const act = () => clipboard.copyText('unimportant-text');
// assert
await expectThrowsAsync(act, expectedError);
});
});
});
class NavigatorClipboardStub
extends StubWithObservableMethodCalls<NavigatorClipboard>
implements NavigatorClipboard {
writeText(data: string): Promise<void> {
this.registerMethodCall({
methodName: 'writeText',
args: [data],
});
return Promise.resolve();
}
read(): Promise<ClipboardItems> {
throw new Error('Method not implemented.');
}
readText(): Promise<string> {
throw new Error('Method not implemented.');
}
write(): Promise<void> {
throw new Error('Method not implemented.');
}
addEventListener(): void {
throw new Error('Method not implemented.');
}
dispatchEvent(): boolean {
throw new Error('Method not implemented.');
}
removeEventListener(): void {
throw new Error('Method not implemented.');
}
}

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { useClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/UseClipboard';
import { BrowserClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/BrowserClipboard';
import { ClipboardStub } from '@tests/unit/shared/Stubs/ClipboardStub';
import { FunctionKeys } from '@/TypeHelpers';
describe('useClipboard', () => {
it(`returns an instance of ${BrowserClipboard.name}`, () => {
// arrange
const expectedType = BrowserClipboard;
// act
const clipboard = useClipboard();
// assert
expect(clipboard).to.be.instanceOf(expectedType);
});
it('does not create a new instance if one is provided', () => {
// arrange
const expectedClipboard = new ClipboardStub();
// act
const actualClipboard = useClipboard(expectedClipboard);
// assert
expect(actualClipboard).to.equal(expectedClipboard);
});
describe('supports object destructuring', () => {
type ClipboardFunction = FunctionKeys<ReturnType<typeof useClipboard>>;
const testScenarios: {
readonly [FunctionName in ClipboardFunction]:
Parameters<ReturnType<typeof useClipboard>[FunctionName]>;
} = {
copyText: ['text-arg'],
};
Object.entries(testScenarios).forEach(([functionName, testFunctionArgs]) => {
describe(functionName, () => {
it('binds the method to the instance', () => {
// arrange
const expectedArgs = testFunctionArgs;
const clipboardStub = new ClipboardStub();
// act
const clipboard = useClipboard(clipboardStub);
const { [functionName as ClipboardFunction]: testFunction } = clipboard;
// assert
testFunction(...expectedArgs);
const call = clipboardStub.callHistory.find((c) => c.methodName === functionName);
expect(call).toBeDefined();
expect(call.args).to.deep.equal(expectedArgs);
});
it('ensures method retains the clipboard instance context', () => {
// arrange
const clipboardStub = new ClipboardStub();
const expectedThisContext = clipboardStub;
let actualThisContext: typeof expectedThisContext | undefined;
// eslint-disable-next-line func-names
clipboardStub[functionName] = function () {
// eslint-disable-next-line @typescript-eslint/no-this-alias
actualThisContext = this;
};
// act
const clipboard = useClipboard(clipboardStub);
const { [functionName as ClipboardFunction]: testFunction } = clipboard;
// assert
testFunction(...testFunctionArgs);
expect(expectedThisContext).to.equal(actualThisContext);
});
});
});
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
import { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
import { ApplicationCodeStub } from '@tests/unit/shared/Stubs/ApplicationCodeStub';
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
import { EventSubscriptionCollectionStub } from '@tests/unit/shared/Stubs/EventSubscriptionCollectionStub';
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
import { CodeChangedEventStub } from '@tests/unit/shared/Stubs/CodeChangedEventStub';
describe('useCurrentCode', () => {
describe('currentCode', () => {
it('gets code from initial state', () => {
// arrange
const expectedCode = 'initial code';
const { codeStub, collectionStateStub } = createStubs();
codeStub.withCurrentCode(expectedCode);
const useCollectionStateStub = new UseCollectionStateStub();
const { currentCode } = new UseCurrentCodeBuilder()
.withUseCollectionState(useCollectionStateStub)
.build();
// act
useCollectionStateStub.triggerOnStateChange({
newState: collectionStateStub,
immediateOnly: true, // set initial state
});
// assert
const actualCode = currentCode.value;
expect(actualCode).to.equal(expectedCode);
});
it('updates code state code is changed', () => {
// arrange
const initialCode = 'initial code';
const expectedCode = 'changed code';
const {
codeStub: initialCodeStub,
collectionStateStub: initialCollectionStateStub,
} = createStubs();
initialCodeStub.withCurrentCode(initialCode);
const useCollectionStateStub = new UseCollectionStateStub();
const { currentCode } = new UseCurrentCodeBuilder()
.withUseCollectionState(useCollectionStateStub)
.build();
useCollectionStateStub.triggerOnStateChange({
newState: initialCollectionStateStub,
immediateOnly: true, // set initial state
});
const {
codeStub: changedCodeStub,
collectionStateStub: changedStateStub,
} = createStubs();
changedCodeStub.withCurrentCode(expectedCode);
// act
useCollectionStateStub.triggerOnStateChange({
newState: changedStateStub,
immediateOnly: true, // update state
});
// assert
const actualCode = currentCode.value;
expect(actualCode).to.equal(expectedCode);
});
it('updates code when code is changed', () => {
// arrange
const expectedCode = 'changed code';
const { codeStub, collectionStateStub } = createStubs();
const { currentCode } = new UseCurrentCodeBuilder()
.withCollectionState(collectionStateStub)
.build();
// act
codeStub.triggerCodeChange(new CodeChangedEventStub().withCode(expectedCode));
// assert
const actualCode = currentCode.value;
expect(expectedCode).to.equal(actualCode);
});
it('registers event subscription on creation', () => {
// arrange
const eventsStub = new EventSubscriptionCollectionStub();
const stateStub = new UseCollectionStateStub();
// act
new UseCurrentCodeBuilder()
.withUseCollectionState(stateStub)
.withEvents(eventsStub)
.build();
// assert
const calls = eventsStub.callHistory;
expect(calls).has.lengthOf(1);
const call = calls.find((c) => c.methodName === 'unsubscribeAllAndRegister');
expect(call).toBeDefined();
});
});
});
function createStubs() {
const codeStub = new ApplicationCodeStub();
const collectionStateStub = new CategoryCollectionStateStub().withCode(codeStub);
const useStateStub = new UseCollectionStateStub()
.withState(collectionStateStub);
return {
codeStub,
useStateStub,
collectionStateStub,
};
}
class UseCurrentCodeBuilder {
private useCollectionState: UseCollectionStateStub = new UseCollectionStateStub();
private events: IEventSubscriptionCollection = new EventSubscriptionCollectionStub();
public withUseCollectionState(useCollectionState: UseCollectionStateStub): this {
this.useCollectionState = useCollectionState;
return this;
}
public withCollectionState(collectionState: CategoryCollectionStateStub): this {
return this.withUseCollectionState(
this.useCollectionState.withState(collectionState),
);
}
public withEvents(events: IEventSubscriptionCollection): this {
this.events = events;
return this;
}
public build(): ReturnType<typeof useCurrentCode> {
return useCurrentCode(this.useCollectionState.get(), this.events);
}
}

View File

@@ -55,19 +55,31 @@ describe('AppIcon.vue', () => {
`Expected:\n\n${expectedSvg}\n\nActual:\n\n${actualSvg}`,
);
});
it('emits `click` event when clicked', async () => {
// arrange
const wrapper = mountComponent();
// act
await wrapper.trigger('click');
// assert
expect(wrapper.emitted().click).to.have.lengthOf(1);
});
});
function mountComponent(options: {
readonly iconPropValue: IconName,
readonly loader: UseSvgLoaderStub,
function mountComponent(options?: {
readonly iconPropValue?: IconName,
readonly loader?: UseSvgLoaderStub,
}) {
const iconName = options?.iconPropValue ?? 'globe';
const loaderStub = options?.loader ?? new UseSvgLoaderStub().withSvgIcon(iconName, '<svg />');
return shallowMount(AppIcon, {
props: {
icon: options.iconPropValue,
icon: iconName,
},
global: {
provide: {
useSvgLoaderHook: options.loader.get(),
useSvgLoaderHook: loaderStub.get(),
},
},
});