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