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:
@@ -1,13 +0,0 @@
|
||||
export class Clipboard {
|
||||
public static copyText(text: string): void {
|
||||
const el = document.createElement('textarea');
|
||||
el.value = text;
|
||||
el.setAttribute('readonly', ''); // to avoid focus
|
||||
el.style.position = 'absolute';
|
||||
el.style.left = '-9999px';
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hook
|
||||
import { IApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { useClipboard } from '../components/Shared/Hooks/Clipboard/UseClipboard';
|
||||
import { useCurrentCode } from '../components/Shared/Hooks/UseCurrentCode';
|
||||
|
||||
export function provideDependencies(
|
||||
context: IApplicationContext,
|
||||
@@ -23,6 +25,12 @@ export function provideDependencies(
|
||||
const { events } = api.inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||
return useCollectionState(context, events);
|
||||
});
|
||||
registerTransient(InjectionKeys.useClipboard, () => useClipboard());
|
||||
registerTransient(InjectionKeys.useCurrentCode, () => {
|
||||
const { events } = api.inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||
const state = api.inject(InjectionKeys.useCollectionState)();
|
||||
return useCurrentCode(state, events);
|
||||
});
|
||||
}
|
||||
|
||||
export interface VueDependencyInjectionApi {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<IconButton
|
||||
text="Copy"
|
||||
@click="copyCode"
|
||||
icon-name="copy"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, inject,
|
||||
} from 'vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import IconButton from './IconButton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
IconButton,
|
||||
},
|
||||
setup() {
|
||||
const { copyText } = inject(InjectionKeys.useClipboard)();
|
||||
const { currentCode } = inject(InjectionKeys.useCurrentCode)();
|
||||
|
||||
async function copyCode() {
|
||||
await copyText(currentCode.value);
|
||||
}
|
||||
|
||||
return {
|
||||
copyCode,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<IconButton
|
||||
v-if="canRun"
|
||||
text="Run"
|
||||
@click="executeCode"
|
||||
icon-name="play"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, computed, inject,
|
||||
} from 'vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { CodeRunner } from '@/infrastructure/CodeRunner';
|
||||
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import IconButton from './IconButton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
IconButton,
|
||||
},
|
||||
setup() {
|
||||
const { currentState, currentContext } = inject(InjectionKeys.useCollectionState)();
|
||||
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
|
||||
|
||||
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
|
||||
|
||||
async function executeCode() {
|
||||
await runCode(currentContext);
|
||||
}
|
||||
|
||||
return {
|
||||
isDesktopVersion: isDesktop,
|
||||
canRun,
|
||||
executeCode,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function getCanRunState(
|
||||
selectedOs: OperatingSystem,
|
||||
isDesktopVersion: boolean,
|
||||
hostOs: OperatingSystem,
|
||||
): boolean {
|
||||
const isRunningOnSelectedOs = selectedOs === hostOs;
|
||||
return isDesktopVersion && isRunningOnSelectedOs;
|
||||
}
|
||||
|
||||
async function runCode(context: IReadOnlyApplicationContext) {
|
||||
const runner = new CodeRunner();
|
||||
await runner.runCode(
|
||||
/* code: */ context.state.code.current,
|
||||
/* appName: */ context.app.info.name,
|
||||
/* fileExtension: */ context.state.collection.scripting.fileExtension,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
@@ -1,15 +1,17 @@
|
||||
<template>
|
||||
<button
|
||||
class="button"
|
||||
type="button"
|
||||
@click="onClicked"
|
||||
>
|
||||
<AppIcon
|
||||
class="button__icon"
|
||||
:icon="iconName"
|
||||
/>
|
||||
<div class="button__text">{{text}}</div>
|
||||
</button>
|
||||
<div class="button-wrapper">
|
||||
<button
|
||||
class="button"
|
||||
type="button"
|
||||
@click="onClicked"
|
||||
>
|
||||
<AppIcon
|
||||
class="button__icon"
|
||||
:icon="iconName"
|
||||
/>
|
||||
<div class="button__text">{{text}}</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -49,10 +51,20 @@ export default defineComponent({
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
.button-wrapper {
|
||||
position: relative;
|
||||
height: 70px;
|
||||
.button {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
background-color: $color-secondary;
|
||||
color: $color-on-secondary;
|
||||
@@ -70,19 +82,17 @@ export default defineComponent({
|
||||
|
||||
@include clickable;
|
||||
|
||||
width: 10%;
|
||||
min-width: 90px;
|
||||
@include hover-or-touch {
|
||||
background: $color-surface;
|
||||
box-shadow: 0px 2px 10px 5px $color-secondary;
|
||||
.button__text {
|
||||
display: block;
|
||||
}
|
||||
.button__icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@include hover-or-touch('>&__text') {
|
||||
display: block;
|
||||
}
|
||||
@include hover-or-touch('>&__icon') {
|
||||
display: none;
|
||||
}
|
||||
&__text {
|
||||
.button__text {
|
||||
display: none;
|
||||
font-family: $font-artistic;
|
||||
font-size: 1.5em;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div>
|
||||
<IconButton
|
||||
:text="isDesktopVersion ? 'Save' : 'Download'"
|
||||
@click="saveCode"
|
||||
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
|
||||
/>
|
||||
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
|
||||
<InstructionList :data="instructions" />
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, ref, computed, inject,
|
||||
} from 'vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
|
||||
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import IconButton from '../IconButton.vue';
|
||||
import InstructionList from './Instructions/InstructionList.vue';
|
||||
import { IInstructionListData } from './Instructions/InstructionListData';
|
||||
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
IconButton,
|
||||
InstructionList,
|
||||
ModalDialog,
|
||||
},
|
||||
setup() {
|
||||
const { currentState } = inject(InjectionKeys.useCollectionState)();
|
||||
const { isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
|
||||
|
||||
const areInstructionsVisible = ref(false);
|
||||
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
|
||||
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
|
||||
currentState.value.collection.os,
|
||||
fileName.value,
|
||||
));
|
||||
|
||||
function saveCode() {
|
||||
saveCodeToDisk(fileName.value, currentState.value);
|
||||
areInstructionsVisible.value = true;
|
||||
}
|
||||
|
||||
return {
|
||||
isDesktopVersion: isDesktop,
|
||||
instructions,
|
||||
fileName,
|
||||
areInstructionsVisible,
|
||||
saveCode,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function getDownloadInstructions(
|
||||
os: OperatingSystem,
|
||||
fileName: string,
|
||||
): IInstructionListData | undefined {
|
||||
if (!hasInstructions(os)) {
|
||||
return undefined;
|
||||
}
|
||||
return getInstructions(os, fileName);
|
||||
}
|
||||
|
||||
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
|
||||
const content = state.code.current;
|
||||
const type = getType(state.collection.scripting.language);
|
||||
SaveFileDialog.saveFile(content, fileName, type);
|
||||
}
|
||||
|
||||
function getType(language: ScriptingLanguage) {
|
||||
switch (language) {
|
||||
case ScriptingLanguage.batchfile:
|
||||
return FileType.BatchFile;
|
||||
case ScriptingLanguage.shellscript:
|
||||
return FileType.ShellScript;
|
||||
default:
|
||||
throw new Error('unknown file type');
|
||||
}
|
||||
}
|
||||
function buildFileName(scripting: IScriptingDefinition) {
|
||||
const fileName = 'privacy-script';
|
||||
if (scripting.fileExtension) {
|
||||
return `${fileName}.${scripting.fileExtension}`;
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
</script>
|
||||
@@ -16,10 +16,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, shallowRef } from 'vue';
|
||||
import { Clipboard } from '@/infrastructure/Clipboard';
|
||||
import { defineComponent, shallowRef, inject } from 'vue';
|
||||
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
|
||||
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -27,9 +27,11 @@ export default defineComponent({
|
||||
AppIcon,
|
||||
},
|
||||
setup() {
|
||||
const { copyText } = inject(InjectionKeys.useClipboard)();
|
||||
|
||||
const codeElement = shallowRef<HTMLElement | undefined>();
|
||||
|
||||
function copyCode() {
|
||||
async function copyCode() {
|
||||
const element = codeElement.value;
|
||||
if (!element) {
|
||||
throw new Error('Code element could not be found.');
|
||||
@@ -38,7 +40,7 @@ export default defineComponent({
|
||||
if (!code) {
|
||||
throw new Error('Code element does not contain any text.');
|
||||
}
|
||||
Clipboard.copyText(code);
|
||||
await copyText(code);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1,168 +1,36 @@
|
||||
<template>
|
||||
<div class="container" v-if="hasCode">
|
||||
<IconButton
|
||||
v-if="canRun"
|
||||
text="Run"
|
||||
v-on:click="executeCode"
|
||||
icon-name="play"
|
||||
/>
|
||||
<IconButton
|
||||
:text="isDesktopVersion ? 'Save' : 'Download'"
|
||||
v-on:click="saveCode"
|
||||
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
|
||||
/>
|
||||
<IconButton
|
||||
text="Copy"
|
||||
v-on:click="copyCode"
|
||||
icon-name="copy"
|
||||
/>
|
||||
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
|
||||
<InstructionList :data="instructions" />
|
||||
</ModalDialog>
|
||||
<CodeRunButton class="code-button" />
|
||||
<CodeSaveButton class="code-button" />
|
||||
<CodeCopyButton class="code-button" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, ref, computed, inject,
|
||||
defineComponent, computed, inject,
|
||||
} from 'vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
|
||||
import { Clipboard } from '@/infrastructure/Clipboard';
|
||||
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { CodeRunner } from '@/infrastructure/CodeRunner';
|
||||
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import InstructionList from './Instructions/InstructionList.vue';
|
||||
import IconButton from './IconButton.vue';
|
||||
import { IInstructionListData } from './Instructions/InstructionListData';
|
||||
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
|
||||
import CodeRunButton from './CodeRunButton.vue';
|
||||
import CodeCopyButton from './CodeCopyButton.vue';
|
||||
import CodeSaveButton from './Save/CodeSaveButton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
IconButton,
|
||||
InstructionList,
|
||||
ModalDialog,
|
||||
CodeRunButton,
|
||||
CodeCopyButton,
|
||||
CodeSaveButton,
|
||||
},
|
||||
setup() {
|
||||
const {
|
||||
currentState, currentContext, onStateChange,
|
||||
} = inject(InjectionKeys.useCollectionState)();
|
||||
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
|
||||
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||
const { currentCode } = inject(InjectionKeys.useCurrentCode)();
|
||||
|
||||
const areInstructionsVisible = ref(false);
|
||||
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
|
||||
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
|
||||
const hasCode = ref(false);
|
||||
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
|
||||
currentState.value.collection.os,
|
||||
fileName.value,
|
||||
));
|
||||
|
||||
async function copyCode() {
|
||||
const code = await getCurrentCode();
|
||||
Clipboard.copyText(code.current);
|
||||
}
|
||||
|
||||
function saveCode() {
|
||||
saveCodeToDisk(fileName.value, currentState.value);
|
||||
areInstructionsVisible.value = true;
|
||||
}
|
||||
|
||||
async function executeCode() {
|
||||
await runCode(currentContext);
|
||||
}
|
||||
|
||||
onStateChange((newState) => {
|
||||
updateCurrentCode(newState.code.current);
|
||||
subscribeToCodeChanges(newState.code);
|
||||
}, { immediate: true });
|
||||
|
||||
function subscribeToCodeChanges(code: IApplicationCode) {
|
||||
events.unsubscribeAllAndRegister([
|
||||
code.changed.on((newCode) => updateCurrentCode(newCode.code)),
|
||||
]);
|
||||
}
|
||||
|
||||
function updateCurrentCode(code: string) {
|
||||
hasCode.value = code && code.length > 0;
|
||||
}
|
||||
|
||||
async function getCurrentCode(): Promise<IApplicationCode> {
|
||||
const { code } = currentContext.state;
|
||||
return code;
|
||||
}
|
||||
const hasCode = computed<boolean>(() => currentCode.value.length > 0);
|
||||
|
||||
return {
|
||||
isDesktopVersion: isDesktop,
|
||||
canRun,
|
||||
hasCode,
|
||||
instructions,
|
||||
fileName,
|
||||
areInstructionsVisible,
|
||||
copyCode,
|
||||
saveCode,
|
||||
executeCode,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function getDownloadInstructions(
|
||||
os: OperatingSystem,
|
||||
fileName: string,
|
||||
): IInstructionListData | undefined {
|
||||
if (!hasInstructions(os)) {
|
||||
return undefined;
|
||||
}
|
||||
return getInstructions(os, fileName);
|
||||
}
|
||||
|
||||
function getCanRunState(
|
||||
selectedOs: OperatingSystem,
|
||||
isDesktopVersion: boolean,
|
||||
hostOs: OperatingSystem,
|
||||
): boolean {
|
||||
const isRunningOnSelectedOs = selectedOs === hostOs;
|
||||
return isDesktopVersion && isRunningOnSelectedOs;
|
||||
}
|
||||
|
||||
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
|
||||
const content = state.code.current;
|
||||
const type = getType(state.collection.scripting.language);
|
||||
SaveFileDialog.saveFile(content, fileName, type);
|
||||
}
|
||||
|
||||
function getType(language: ScriptingLanguage) {
|
||||
switch (language) {
|
||||
case ScriptingLanguage.batchfile:
|
||||
return FileType.BatchFile;
|
||||
case ScriptingLanguage.shellscript:
|
||||
return FileType.ShellScript;
|
||||
default:
|
||||
throw new Error('unknown file type');
|
||||
}
|
||||
}
|
||||
function buildFileName(scripting: IScriptingDefinition) {
|
||||
const fileName = 'privacy-script';
|
||||
if (scripting.fileExtension) {
|
||||
return `${fileName}.${scripting.fileExtension}`;
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
async function runCode(context: IReadOnlyApplicationContext) {
|
||||
const runner = new CodeRunner();
|
||||
await runner.runCode(
|
||||
/* code: */ context.state.code.current,
|
||||
/* appName: */ context.app.info.name,
|
||||
/* fileExtension: */ context.state.collection.scripting.fileExtension,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -170,8 +38,10 @@ async function runCode(context: IReadOnlyApplicationContext) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 30px;
|
||||
}
|
||||
.container > * + * {
|
||||
margin-left: 30px;
|
||||
.code-button {
|
||||
width: 10%;
|
||||
min-width: 90px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Clipboard } from './Clipboard';
|
||||
|
||||
export type NavigatorClipboard = typeof globalThis.navigator.clipboard;
|
||||
|
||||
export class BrowserClipboard implements Clipboard {
|
||||
constructor(
|
||||
private readonly navigatorClipboard: NavigatorClipboard = globalThis.navigator.clipboard,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
public async copyText(text: string): Promise<void> {
|
||||
await this.navigatorClipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface Clipboard {
|
||||
copyText(text: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { FunctionKeys } from '@/TypeHelpers';
|
||||
import { BrowserClipboard } from './BrowserClipboard';
|
||||
import { Clipboard } from './Clipboard';
|
||||
|
||||
export function useClipboard(clipboard: Clipboard = new BrowserClipboard()) {
|
||||
// Bind functions for direct use from destructured assignments such as `const { .. } = ...`.
|
||||
const functionKeys: readonly FunctionKeys<Clipboard>[] = ['copyText'];
|
||||
functionKeys.forEach((functionName) => {
|
||||
const fn = clipboard[functionName];
|
||||
clipboard[functionName] = fn.bind(clipboard);
|
||||
});
|
||||
return clipboard;
|
||||
}
|
||||
32
src/presentation/components/Shared/Hooks/UseCurrentCode.ts
Normal file
32
src/presentation/components/Shared/Hooks/UseCurrentCode.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ref } from 'vue';
|
||||
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
|
||||
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
|
||||
import { useCollectionState } from './UseCollectionState';
|
||||
|
||||
export function useCurrentCode(
|
||||
state: ReturnType<typeof useCollectionState>,
|
||||
events: IEventSubscriptionCollection,
|
||||
) {
|
||||
const { onStateChange } = state;
|
||||
|
||||
const currentCode = ref<string>('');
|
||||
|
||||
onStateChange((newState) => {
|
||||
updateCurrentCode(newState.code.current);
|
||||
subscribeToCodeChanges(newState.code);
|
||||
}, { immediate: true });
|
||||
|
||||
function subscribeToCodeChanges(code: IApplicationCode) {
|
||||
events.unsubscribeAllAndRegister([
|
||||
code.changed.on((newCode) => updateCurrentCode(newCode.code)),
|
||||
]);
|
||||
}
|
||||
|
||||
function updateCurrentCode(newCode: string) {
|
||||
currentCode.value = newCode;
|
||||
}
|
||||
|
||||
return {
|
||||
currentCode,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div v-html="svgContent" class="inline-icon" />
|
||||
<div
|
||||
class="inline-icon"
|
||||
v-html="svgContent"
|
||||
@click="onClicked"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -18,10 +22,19 @@ export default defineComponent({
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
emits: [
|
||||
'click',
|
||||
],
|
||||
setup(props, { emit }) {
|
||||
const useSvgLoaderHook = inject('useSvgLoaderHook', useSvgLoader);
|
||||
|
||||
const { svgContent } = useSvgLoaderHook(() => props.icon);
|
||||
return { svgContent };
|
||||
|
||||
function onClicked() {
|
||||
emit('click');
|
||||
}
|
||||
|
||||
return { svgContent, onClicked };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
import { useRuntimeEnvironment } from '@/presentation/components/Shared/Hooks/UseRuntimeEnvironment';
|
||||
import type { useAutoUnsubscribedEvents } from './components/Shared/Hooks/UseAutoUnsubscribedEvents';
|
||||
import type { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import type { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
import type { useRuntimeEnvironment } from '@/presentation/components/Shared/Hooks/UseRuntimeEnvironment';
|
||||
import type { useClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/UseClipboard';
|
||||
import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
|
||||
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
export const InjectionKeys = {
|
||||
@@ -9,6 +11,8 @@ export const InjectionKeys = {
|
||||
useApplication: defineSingletonKey<ReturnType<typeof useApplication>>('useApplication'),
|
||||
useRuntimeEnvironment: defineSingletonKey<ReturnType<typeof useRuntimeEnvironment>>('useRuntimeEnvironment'),
|
||||
useAutoUnsubscribedEvents: defineTransientKey<ReturnType<typeof useAutoUnsubscribedEvents>>('useAutoUnsubscribedEvents'),
|
||||
useClipboard: defineTransientKey<ReturnType<typeof useClipboard>>('useClipboard'),
|
||||
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
|
||||
};
|
||||
|
||||
function defineSingletonKey<T>(key: string): InjectionKey<T> {
|
||||
|
||||
@@ -14,6 +14,8 @@ describe('DependencyProvider', () => {
|
||||
useApplication: createSingletonTests(),
|
||||
useRuntimeEnvironment: createSingletonTests(),
|
||||
useAutoUnsubscribedEvents: createTransientTests(),
|
||||
useClipboard: createTransientTests(),
|
||||
useCurrentCode: createTransientTests(),
|
||||
};
|
||||
Object.entries(testCases).forEach(([key, runTests]) => {
|
||||
describe(`Key: "${key}"`, () => {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import CodeCopyButton from '@/presentation/components/Code/CodeButtons/CodeCopyButton.vue';
|
||||
import { ClipboardStub } from '@tests/unit/shared/Stubs/ClipboardStub';
|
||||
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
|
||||
import { UseClipboardStub } from '@tests/unit/shared/Stubs/UseClipboardStub';
|
||||
import { UseCurrentCodeStub } from '@tests/unit/shared/Stubs/UseCurrentCodeStub';
|
||||
|
||||
const COMPONENT_ICON_BUTTON_WRAPPER_NAME = 'IconButton';
|
||||
|
||||
describe('CodeCopyButton', () => {
|
||||
it('copies current code when clicked', async () => {
|
||||
// arrange
|
||||
const expectedCode = 'code to be copied';
|
||||
const clipboard = new ClipboardStub();
|
||||
const wrapper = mountComponent({
|
||||
clipboard,
|
||||
currentCode: expectedCode,
|
||||
});
|
||||
|
||||
// act
|
||||
await wrapper.trigger('click');
|
||||
|
||||
// assert
|
||||
const calls = clipboard.callHistory;
|
||||
expect(calls).to.have.lengthOf(1);
|
||||
const call = calls.find((c) => c.methodName === 'copyText');
|
||||
expect(call).toBeDefined();
|
||||
const [copiedText] = call.args;
|
||||
expect(copiedText).to.equal(expectedCode);
|
||||
});
|
||||
});
|
||||
|
||||
function mountComponent(options?: {
|
||||
clipboard?: Clipboard,
|
||||
currentCode?: string,
|
||||
}) {
|
||||
return shallowMount(CodeCopyButton, {
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useClipboard as symbol]: () => (
|
||||
options?.clipboard
|
||||
? new UseClipboardStub(options.clipboard)
|
||||
: new UseClipboardStub()
|
||||
).get(),
|
||||
[InjectionKeys.useCurrentCode as symbol]: () => (
|
||||
options.currentCode === undefined
|
||||
? new UseCurrentCodeStub()
|
||||
: new UseCurrentCodeStub().withCurrentCode(options.currentCode)
|
||||
).get(),
|
||||
},
|
||||
stubs: {
|
||||
[COMPONENT_ICON_BUTTON_WRAPPER_NAME]: {
|
||||
name: COMPONENT_ICON_BUTTON_WRAPPER_NAME,
|
||||
template: '<div @click="handleClick()" />',
|
||||
emits: ['click'],
|
||||
setup: (_, { emit }) => ({
|
||||
handleClick: () => emit('click'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { IInstructionsBuilderData, InstructionsBuilder, InstructionStepBuilderType } from '@/presentation/components/Code/CodeButtons/Instructions/Data/InstructionsBuilder';
|
||||
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/Instructions/InstructionListData';
|
||||
import { IInstructionInfo, IInstructionListStep } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListData';
|
||||
|
||||
describe('InstructionsBuilder', () => {
|
||||
describe('withStep', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe } from 'vitest';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { MacOsInstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Instructions/Data/MacOsInstructionsBuilder';
|
||||
import { MacOsInstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/MacOsInstructionsBuilder';
|
||||
import { runOsSpecificInstructionBuilderTests } from './OsSpecificInstructionBuilderTestRunner';
|
||||
|
||||
describe('MacOsInstructionsBuilder', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Instructions/Data/InstructionsBuilder';
|
||||
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
|
||||
|
||||
interface ITestData {
|
||||
readonly factory: () => InstructionsBuilder;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { getInstructions, hasInstructions } from '@/presentation/components/Code/CodeButtons/Instructions/InstructionListDataFactory';
|
||||
import { getInstructions, hasInstructions } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListDataFactory';
|
||||
import { getEnumValues } from '@/application/Common/Enum';
|
||||
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Instructions/Data/InstructionsBuilder';
|
||||
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
|
||||
|
||||
describe('InstructionListDataFactory', () => {
|
||||
const supportedOsList = [OperatingSystem.macOS];
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
|
||||
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
|
||||
import { IEventSource } from '@/infrastructure/Events/IEventSource';
|
||||
import { EventSource } from '@/infrastructure/Events/EventSource';
|
||||
import { EventSourceStub } from './EventSourceStub';
|
||||
|
||||
export class ApplicationCodeStub implements IApplicationCode {
|
||||
public changed: IEventSource<ICodeChangedEvent> = new EventSource<ICodeChangedEvent>();
|
||||
public changed = new EventSourceStub<ICodeChangedEvent>();
|
||||
|
||||
public current = '';
|
||||
|
||||
public triggerCodeChange(event: ICodeChangedEvent): this {
|
||||
this.changed.notify(event);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCurrentCode(currentCode: string): this {
|
||||
this.current = currentCode;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ApplicationCodeStub } from './ApplicationCodeStub';
|
||||
import { CategoryStub } from './CategoryStub';
|
||||
|
||||
export class CategoryCollectionStateStub implements ICategoryCollectionState {
|
||||
public readonly code: IApplicationCode = new ApplicationCodeStub();
|
||||
public code: IApplicationCode = new ApplicationCodeStub();
|
||||
|
||||
public filter: IUserFilter = new UserFilterStub();
|
||||
|
||||
@@ -39,6 +39,11 @@ export class CategoryCollectionStateStub implements ICategoryCollectionState {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCode(code: IApplicationCode): this {
|
||||
this.code = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withOs(os: OperatingSystem): this {
|
||||
if (this.collection instanceof CategoryCollectionStub) {
|
||||
this.collection = this.collection.withOs(os);
|
||||
|
||||
14
tests/unit/shared/Stubs/ClipboardStub.ts
Normal file
14
tests/unit/shared/Stubs/ClipboardStub.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class ClipboardStub
|
||||
extends StubWithObservableMethodCalls<Clipboard>
|
||||
implements Clipboard {
|
||||
public copyText(text: string): Promise<void> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'copyText',
|
||||
args: [text],
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
26
tests/unit/shared/Stubs/CodeChangedEventStub.ts
Normal file
26
tests/unit/shared/Stubs/CodeChangedEventStub.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
|
||||
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
|
||||
export class CodeChangedEventStub implements ICodeChangedEvent {
|
||||
public code: string;
|
||||
|
||||
public addedScripts: readonly IScript[];
|
||||
|
||||
public removedScripts: readonly IScript[];
|
||||
|
||||
public changedScripts: readonly IScript[];
|
||||
|
||||
public isEmpty(): boolean {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
public getScriptPositionInCode(): ICodePosition {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
public withCode(code: string): this {
|
||||
this.code = code;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
17
tests/unit/shared/Stubs/UseClipboardStub.ts
Normal file
17
tests/unit/shared/Stubs/UseClipboardStub.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/UseClipboard';
|
||||
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
import { ClipboardStub } from './ClipboardStub';
|
||||
|
||||
export class UseClipboardStub
|
||||
extends StubWithObservableMethodCalls<ReturnType<typeof useClipboard>> {
|
||||
constructor(private readonly clipboard: Clipboard = new ClipboardStub()) {
|
||||
super();
|
||||
}
|
||||
|
||||
public get(): ReturnType<typeof useClipboard> {
|
||||
const { clipboard } = this;
|
||||
clipboard.copyText = clipboard.copyText.bind(clipboard);
|
||||
return this.clipboard;
|
||||
}
|
||||
}
|
||||
17
tests/unit/shared/Stubs/UseCurrentCodeStub.ts
Normal file
17
tests/unit/shared/Stubs/UseCurrentCodeStub.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ref } from 'vue';
|
||||
import { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
|
||||
|
||||
export class UseCurrentCodeStub {
|
||||
public currentCodeRef = ref('');
|
||||
|
||||
public withCurrentCode(code: string): this {
|
||||
this.currentCodeRef.value = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
public get(): ReturnType<typeof useCurrentCode> {
|
||||
return {
|
||||
currentCode: this.currentCodeRef,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user