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,96 @@
import { describe, it, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import CodeInstruction from '@/presentation/components/Code/CodeButtons/Save/Instructions/CodeInstruction.vue';
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
import { UseClipboardStub } from '@tests/unit/shared/Stubs/UseClipboardStub';
import { ClipboardStub } from '@tests/unit/shared/Stubs/ClipboardStub';
const DOM_SELECTOR_CODE_SLOT = 'code';
const DOM_SELECTOR_COPY_BUTTON = '.copy-button';
const COMPONENT_TOOLTIP_WRAPPER_NAME = 'TooltipWrapper';
describe('CodeInstruction.vue', () => {
it('renders a slot content inside a <code> element', () => {
// arrange
const expectedSlotContent = 'Example Code';
const wrapper = mountComponent({
slotContent: expectedSlotContent,
});
// act
const codeSlot = wrapper.find(DOM_SELECTOR_CODE_SLOT);
const actualContent = codeSlot.text();
// assert
expect(actualContent).to.equal(expectedSlotContent);
});
describe('copy', () => {
it('calls copyText when the copy button is clicked', async () => {
// arrange
const expectedCode = 'Code to be copied';
const clipboardStub = new ClipboardStub();
const wrapper = mountComponent({
clipboard: clipboardStub,
});
wrapper.vm.codeElement = { textContent: expectedCode } as HTMLElement;
// act
const copyButton = wrapper.find(DOM_SELECTOR_COPY_BUTTON);
await copyButton.trigger('click');
// assert
const calls = clipboardStub.callHistory;
expect(calls).to.have.lengthOf(1);
const call = calls.find((c) => c.methodName === 'copyText');
expect(call).toBeDefined();
const [actualCode] = call.args;
expect(actualCode).to.equal(expectedCode);
});
it('throws an error when codeElement is not found during copy', async () => {
// arrange
const expectedError = 'Code element could not be found.';
const wrapper = mountComponent();
wrapper.vm.codeElement = undefined;
// act
const act = () => wrapper.vm.copyCode();
// assert
await expectThrowsAsync(act, expectedError);
});
it('throws an error when codeElement has no textContent during copy', async () => {
// arrange
const expectedError = 'Code element does not contain any text.';
const wrapper = mountComponent();
wrapper.vm.codeElement = { textContent: '' } as HTMLElement;
// act
const act = () => wrapper.vm.copyCode();
// assert
await expectThrowsAsync(act, expectedError);
});
});
});
function mountComponent(options?: {
readonly clipboard?: Clipboard,
readonly slotContent?: string,
}) {
return shallowMount(CodeInstruction, {
global: {
provide: {
[InjectionKeys.useClipboard as symbol]:
() => {
if (options?.clipboard) {
return new UseClipboardStub(options.clipboard).get();
}
return new UseClipboardStub().get();
},
},
stubs: {
[COMPONENT_TOOLTIP_WRAPPER_NAME]: {
name: COMPONENT_TOOLTIP_WRAPPER_NAME,
template: '<slot />',
},
},
},
slots: {
default: options?.slotContent ?? 'Stubbed slot content',
},
});
}

View File

@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { IInstructionsBuilderData, InstructionsBuilder, InstructionStepBuilderType } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { IInstructionInfo, IInstructionListStep } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListData';
describe('InstructionsBuilder', () => {
describe('withStep', () => {
describe('throws when step is missing', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing stepBuilder';
const data = absentValue;
const sut = new InstructionsBuilder(OperatingSystem.Linux);
// act
const act = () => sut.withStep(data);
// assert
expect(act).to.throw(expectedError);
});
});
});
describe('build', () => {
it('builds with given data', () => {
// arrange
const expectedData = createMockData();
const actualData = Array<IInstructionsBuilderData>();
const builder = new InstructionsBuilder(OperatingSystem.Android);
const steps: readonly InstructionStepBuilderType[] = [createMockStep(), createMockStep()]
.map((step) => (data) => {
actualData.push(data);
return step;
});
for (const step of steps) {
builder.withStep(step);
}
// act
builder.build(expectedData);
// assert
expect(actualData.every((data) => data === expectedData));
});
it('builds with every step', () => {
// arrange
const expectedSteps = [
createMockStep('first'),
createMockStep('second'),
createMockStep('third'),
];
const builder = new InstructionsBuilder(OperatingSystem.Android);
const steps: readonly InstructionStepBuilderType[] = expectedSteps.map((step) => () => step);
for (const step of steps) {
builder.withStep(step);
}
// act
const data = builder.build(createMockData());
// assert
const actualSteps = data.steps;
expect(actualSteps).to.have.members(expectedSteps);
});
it('builds with expected OS', () => {
// arrange
const expected = OperatingSystem.Linux;
const sut = new InstructionsBuilder(expected);
// act
const actual = sut.build(createMockData()).operatingSystem;
// assert
expect(true);
expect(actual).to.equal(expected);
});
describe('throws when data is missing', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing data';
const data = absentValue;
const sut = new InstructionsBuilder(OperatingSystem.Linux);
// act
const act = () => sut.build(data);
// assert
expect(act).to.throw(expectedError);
});
});
});
});
function createMockData(): IInstructionsBuilderData {
return {
fileName: 'instructions-file',
};
}
function createMockStep(identifier = 'mock step'): IInstructionListStep {
return {
action: createMockInfo(`${identifier} | action`),
code: createMockInfo(`${identifier} | code`),
};
}
function createMockInfo(identifier = 'mock info'): IInstructionInfo {
return {
instruction: `${identifier} | mock instruction`,
details: `${identifier} | mock details`,
};
}

View File

@@ -0,0 +1,11 @@
import { describe } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { MacOsInstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/MacOsInstructionsBuilder';
import { runOsSpecificInstructionBuilderTests } from './OsSpecificInstructionBuilderTestRunner';
describe('MacOsInstructionsBuilder', () => {
runOsSpecificInstructionBuilderTests({
factory: () => new MacOsInstructionsBuilder(),
os: OperatingSystem.macOS,
});
});

View File

@@ -0,0 +1,28 @@
import { it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
interface ITestData {
readonly factory: () => InstructionsBuilder;
readonly os: OperatingSystem;
}
export function runOsSpecificInstructionBuilderTests(data: ITestData) {
it('builds multiple steps', () => {
// arrange
const sut = data.factory();
// act
const result = sut.build({ fileName: 'test.file' });
// assert
expect(result.steps).to.have.length.greaterThan(0);
});
it(`operatingSystem return ${OperatingSystem[data.os]}`, () => {
// arrange
const expected = data.os;
const sut = data.factory();
// act
const result = sut.build({ fileName: 'test.file' });
// assert
expect(result.operatingSystem).to.equal(expected);
});
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { getInstructions, hasInstructions } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListDataFactory';
import { getEnumValues } from '@/application/Common/Enum';
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
describe('InstructionListDataFactory', () => {
const supportedOsList = [OperatingSystem.macOS];
describe('hasInstructions', () => {
it('return true if OS is supported', () => {
// arrange
const expected = true;
// act
const actualResults = supportedOsList.map((os) => hasInstructions(os));
// assert
expect(actualResults.every((result) => result === expected));
});
it('return false if OS is not supported', () => {
// arrange
const expected = false;
const unsupportedOses = getEnumValues(OperatingSystem)
.filter((value) => !supportedOsList.includes(value));
// act
const actualResults = unsupportedOses.map((os) => hasInstructions(os));
// assert
expect(actualResults.every((result) => result === expected));
});
});
describe('getInstructions', () => {
it('returns expected if os is supported', () => {
// arrange
const fileName = 'test.file';
// act
const actualResults = supportedOsList.map((os) => getInstructions(os, fileName));
// assert
expect(actualResults.every((result) => result instanceof InstructionsBuilder));
});
});
});