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:
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user