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,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>

View File

@@ -0,0 +1,82 @@
<template>
<span class="code-wrapper">
<span class="dollar">$</span>
<code ref="codeElement"><slot /></code>
<TooltipWrapper>
<AppIcon
class="copy-button"
icon="copy"
@click="copyCode"
/>
<template v-slot:tooltip>
Copy
</template>
</TooltipWrapper>
</span>
</template>
<script lang="ts">
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: {
TooltipWrapper,
AppIcon,
},
setup() {
const { copyText } = inject(InjectionKeys.useClipboard)();
const codeElement = shallowRef<HTMLElement | undefined>();
async function copyCode() {
const element = codeElement.value;
if (!element) {
throw new Error('Code element could not be found.');
}
const code = element.textContent;
if (!code) {
throw new Error('Code element does not contain any text.');
}
await copyText(code);
}
return {
copyCode,
codeElement,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.code-wrapper {
display:flex;
white-space: nowrap;
justify-content: space-between;
font-family: $font-normal;
background-color: $color-primary-darker;
color: $color-on-primary;
align-items: center;
padding: 0.2rem;
.dollar {
margin-right: 0.5rem;
font-size: 0.8rem;
user-select: none;
}
.copy-button {
margin-left: 1rem;
@include clickable;
@include hover-or-touch {
color: $color-primary;
}
}
code {
font-size: 1rem;
}
}
</style>

View File

@@ -0,0 +1,30 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { IInstructionListData, IInstructionListStep } from '../InstructionListData';
export interface IInstructionsBuilderData {
readonly fileName: string;
}
export type InstructionStepBuilderType = (data: IInstructionsBuilderData) => IInstructionListStep;
export class InstructionsBuilder {
private readonly stepBuilders = new Array<InstructionStepBuilderType>();
constructor(private readonly os: OperatingSystem) {
}
public withStep(stepBuilder: InstructionStepBuilderType) {
if (!stepBuilder) { throw new Error('missing stepBuilder'); }
this.stepBuilders.push(stepBuilder);
return this;
}
public build(data: IInstructionsBuilderData): IInstructionListData {
if (!data) { throw new Error('missing data'); }
return {
operatingSystem: this.os,
steps: this.stepBuilders.map((stepBuilder) => stepBuilder(data)),
};
}
}

View File

@@ -0,0 +1,93 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { InstructionsBuilder } from './InstructionsBuilder';
export class LinuxInstructionsBuilder extends InstructionsBuilder {
constructor() {
super(OperatingSystem.Linux);
super
.withStep(() => ({
action: {
instruction: 'Download the file.',
details: 'You should have already been prompted to save the script file.'
+ '<br/>If this was not the case or you did not save the script when prompted,'
+ '<br/>please try to download your script file again.',
},
}))
.withStep(() => ({
action: {
instruction: 'Open terminal.',
details:
'Opening terminal changes based on the distro you run.'
+ '<br/>You may search for "Terminal" in your application launcher to find it.'
+ '<br/>'
+ '<br/>Alternatively use terminal shortcut for your distro if it has one by default:'
+ '<ul>'
+ '<li><code>Ctrl-Alt-T</code>: Ubuntu, CentOS, Linux Mint, Elementary OS, ubermix, Kali…</li>'
+ '<li><code>Super-T</code>: Pop!_OS…</li>'
+ '<li><code>Alt-T</code>: Parrot OS…</li>'
+ '<li><code>Ctrl-Alt-Insert</code>: Bodhi Linux…</li>'
+ '</ul>'
+ '<br/>'
,
},
}))
.withStep(() => ({
action: {
instruction: 'Navigate to the folder where you downloaded the file e.g.:',
},
code: {
instruction: 'cd ~/Downloads',
details: 'Press on <code>enter/return</code> key after running the command.'
+ '<br/>If the file is not downloaded on Downloads folder,'
+ '<br/>change <code>Downloads</code> to path where the file is downloaded.'
+ '<br/>'
+ '<br/>This command means:'
+ '<ul>'
+ '<li><code>cd</code> will change the current folder.</li>'
+ '<li><code>~</code> is the user home directory.</li>'
+ '</ul>',
},
}))
.withStep((data) => ({
action: {
instruction: 'Give the file execute permissions:',
},
code: {
instruction: `chmod +x ${data.fileName}`,
details: 'Press on <code>enter/return</code> key after running the command.<br/>'
+ 'It will make the file executable. <br/>'
+ 'If you use desktop environment you can alternatively (instead of running the command):'
+ '<ol>'
+ '<li>Locate the file using your file manager.</li>'
+ '<li>Right click on the file, select "Properties".</li>'
+ '<li>Go to "Permissions" and check "Allow executing file as program".</li>'
+ '</ol>'
+ '<br/>These GUI steps and name of options may change depending on your file manager.'
,
},
}))
.withStep((data) => ({
action: {
instruction: 'Execute the file:',
},
code: {
instruction: `./${data.fileName}`,
details:
'If you have desktop environment, instead of running this command you can alternatively:'
+ '<ol>'
+ '<li>Locate the file using your file manager.</li>'
+ '<li>Right click on the file, select "Run as program".</li>'
+ '</ol>'
,
},
}))
.withStep(() => ({
action: {
instruction: 'If asked, enter your administrator password.',
details: 'As you type, your password will be hidden but the keys are still registered, so keep typing.'
+ '<br/>Press on <code>enter/return</code> key after typing your password.'
+ '<br/>Administrator privileges are required to configure OS.',
},
}));
}
}

View File

@@ -0,0 +1,70 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { InstructionsBuilder } from './InstructionsBuilder';
export class MacOsInstructionsBuilder extends InstructionsBuilder {
constructor() {
super(OperatingSystem.macOS);
super
.withStep(() => ({
action: {
instruction: 'Download the file.',
details: 'You should have already been prompted to save the script file.'
+ '<br/>If this was not the case or you did not save the script when prompted,'
+ '<br/>please try to download your script file again.'
,
},
}))
.withStep(() => ({
action: {
instruction: 'Open terminal.',
details: 'Type Terminal into Spotlight or open it from the Applications -> Utilities folder.',
},
}))
.withStep(() => ({
action: {
instruction: 'Navigate to the folder where you downloaded the file e.g.:',
},
code: {
instruction: 'cd ~/Downloads',
details: 'Press on <code>enter/return</code> key after running the command.'
+ '<br/>If the file is not downloaded on Downloads folder,'
+ '<br/>change <code>Downloads</code> to path where the file is downloaded.'
+ '<br/>'
+ '<br/>This command means:'
+ '<ul>'
+ '<li><code>cd</code> will change the current folder.</li>'
+ '<li><code>~</code> is the user home directory.</li>'
+ '</ul>',
},
}))
.withStep((data) => ({
action: {
instruction: 'Give the file execute permissions:',
},
code: {
instruction: `chmod +x ${data.fileName}`,
details: 'Press on <code>enter/return</code> key after running the command.<br/>'
+ 'It will make the file executable.'
,
},
}))
.withStep((data) => ({
action: {
instruction: 'Execute the file:',
},
code: {
instruction: `./${data.fileName}`,
details: 'Alternatively you can locate the file in <strong>Finder</strong> and double click on it.',
},
}))
.withStep(() => ({
action: {
instruction: 'If asked, enter your administrator password.',
details: 'As you type, your password will be hidden but the keys are still registered, so keep typing.'
+ '<br/>Press on <code>enter/return</code> key after typing your password.'
+ '<br/>Administrator privileges are required to configure OS.'
,
},
}));
}
}

View File

@@ -0,0 +1,135 @@
<template>
<div class="instructions">
<p>
You have two alternatives to apply your selection.
</p>
<hr />
<p>
<strong>1. The easy alternative</strong>. Run your script without any manual steps by
<a :href="macOsDownloadUrl">downloading desktop version</a> of {{ appName }} on the
{{ osName }} system you wish to configure, and then click on the Run button. This is
recommended for most users.
</p>
<hr />
<p>
<strong>2. The hard (manual) alternative</strong>. This requires you to do additional manual
steps. If you are unsure how to follow the instructions, hover on information
(<AppIcon icon="circle-info" />)
icons near the steps, or follow the easy alternative described above.
</p>
<p>
<ol>
<li
v-for='(step, index) in data.steps'
v-bind:key="index"
class="step"
>
<div class="step__action">
<span>{{ step.action.instruction }}</span>
<TooltipWrapper v-if="step.action.details">
<AppIcon
class="explanation"
icon="circle-info"
/>
<template v-slot:tooltip>
<div v-html="step.action.details" />
</template>
</TooltipWrapper>
</div>
<div v-if="step.code" class="step__code">
<CodeInstruction>{{ step.code.instruction }}</CodeInstruction>
<TooltipWrapper v-if="step.code.details">
<AppIcon
class="explanation"
icon="circle-info"
/>
<template v-slot:tooltip>
<div v-html="step.code.details" />
</template>
</TooltipWrapper>
</div>
</li>
</ol>
</p>
</div>
</template>
<script lang="ts">
import {
defineComponent, PropType, computed,
inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import CodeInstruction from './CodeInstruction.vue';
import { IInstructionListData } from './InstructionListData';
export default defineComponent({
components: {
CodeInstruction,
TooltipWrapper,
AppIcon,
},
props: {
data: {
type: Object as PropType<IInstructionListData>,
required: true,
},
},
setup(props) {
const { info } = inject(InjectionKeys.useApplication);
const appName = computed<string>(() => info.name);
const macOsDownloadUrl = computed<string>(
() => info.getDownloadUrl(OperatingSystem.macOS),
);
const osName = computed<string>(() => {
if (!props.data) {
throw new Error('missing data');
}
return renderOsName(props.data.operatingSystem);
});
return {
appName,
macOsDownloadUrl,
osName,
};
},
});
function renderOsName(os: OperatingSystem): string {
switch (os) {
case OperatingSystem.Windows: return 'Windows';
case OperatingSystem.macOS: return 'macOS';
case OperatingSystem.Linux: return 'Linux';
default: throw new RangeError(`Cannot render os name: ${OperatingSystem[os]}`);
}
}
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.step {
margin: 10px 0;
&__action {
display: flex;
flex-direction: row;
align-items: center;
}
&__code {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 0.5em;
}
}
.explanation {
margin-left: 0.5em;
}
</style>

View File

@@ -0,0 +1,16 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
export interface IInstructionListData {
readonly operatingSystem: OperatingSystem;
readonly steps: readonly IInstructionListStep[];
}
export interface IInstructionListStep {
readonly action: IInstructionInfo;
readonly code?: IInstructionInfo;
}
export interface IInstructionInfo {
readonly instruction: string;
readonly details?: string;
}

View File

@@ -0,0 +1,23 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { InstructionsBuilder } from './Data/InstructionsBuilder';
import { MacOsInstructionsBuilder } from './Data/MacOsInstructionsBuilder';
import { IInstructionListData } from './InstructionListData';
import { LinuxInstructionsBuilder } from './Data/LinuxInstructionsBuilder';
const builders = new Map<OperatingSystem, InstructionsBuilder>([
[OperatingSystem.macOS, new MacOsInstructionsBuilder()],
[OperatingSystem.Linux, new LinuxInstructionsBuilder()],
]);
export function hasInstructions(os: OperatingSystem) {
return builders.has(os);
}
export function getInstructions(
os: OperatingSystem,
fileName: string,
): IInstructionListData {
return builders
.get(os)
.build({ fileName });
}