Refactor Vue components using Composition API #230
- Migrate `StatefulVue`:
- Introduce `UseCollectionState` that replaces its behavior and acts
as a shared state store.
- Add more encapsulated, granular functions based on read or write
access to state in CollectionState.
- Some linting rules get activates due to new code-base compability to
modern parses, fix linting errors.
- Rename Dialog to ModalDialog as after refactoring,
eslintvue/no-reserved-component-names does not allow name Dialog.
- To comply with `vue/multi-word-component-names`, rename:
- `Code` -> `CodeInstruction`
- `Handle` -> `SliderHandle`
- `Documentable` -> `DocumentableNode`
- `Node` -> `NodeContent`
- `INode` -> `INodeContent`
- `Responsive` -> `SizeObserver`
- Remove `vue-property-decorator` and `vue-class-component`
dependencies.
- Refactor `watch` with computed properties when possible for cleaner
code.
- Introduce `UseApplication` to reduce repeated code in new components
that use `computed` more heavily than before.
- Change TypeScript target to `es2017` to allow top level async calls
for getting application context/state/instance to simplify the code by
removing async calls. However, mocha (unit and integration) tests do
not run with top level awaits, so a workaround is used.
This commit is contained in:
@@ -27,6 +27,8 @@ Application layer depends on and consumes domain layer. [Presentation layer](./p
|
||||
|
||||
State handling uses an event-driven subscription model to signal state changes and special functions to register changes. It does not depend on third party packages.
|
||||
|
||||
The presentation layer can read and modify state through the context. State changes trigger events that components can subscribe to for reactivity.
|
||||
|
||||
Each layer treat application layer differently.
|
||||
|
||||

|
||||
@@ -45,7 +47,7 @@ Each layer treat application layer differently.
|
||||
- So state is mutable, and fires related events when mutated.
|
||||
- 📖 Read more: [application.md | Application state](./application.md#application-state).
|
||||
|
||||
It's comparable with flux ([`redux`](https://redux.js.org/)) or flux-like ([`vuex`](https://vuex.vuejs.org/)) patterns. Flux component "view" is [presentation layer](./presentation.md) in Vue. Flux functions "dispatcher", "store" and "action creation" functions lie in the [application layer](./application.md). A difference is that application state in privacy.sexy is mutable and lies in single flux "store" that holds app state and logic. The "actions" mutate the state directly which in turns act as dispatcher to notify its own event subscriptions (callbacks).
|
||||
It's comparable with `flux`, `vuex`, and `pinia`. A difference is that mutable application layer state in privacy.sexy is mutable and lies in single "store" that holds app state and logic. The "actions" mutate the state directly which in turns act as dispatcher to notify its own event subscriptions (callbacks).
|
||||
|
||||
## AWS infrastructure
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Presentation layer
|
||||
|
||||
Presentation layer consists of UI-related code. It uses Vue.js as JavaScript framework and includes Vue.js components. It also includes [Electron](https://www.electronjs.org/) to provide functionality to desktop application.
|
||||
The presentation layer handles UI concerns using Vue as JavaScript framework and Electron to provide desktop functionality.
|
||||
|
||||
It's designed event-driven from bottom to top. It listens user events (from top) and state events (from bottom) to update state or the GUI.
|
||||
It reflects the [application state](./application.md#application-state) and allows user interactions to modify it. Components manage their own local UI state.
|
||||
|
||||
The presentation layer uses an event-driven architecture for bidirectional reactivity between the application state and UI. State change events flow bottom-up to trigger UI updates, while user events flow top-down through components, some ultimately modifying the application state.
|
||||
|
||||
📖 Refer to [architecture.md (Layered Application)](./architecture.md#layered-application) to read more about the layered architecture.
|
||||
|
||||
@@ -12,6 +14,7 @@ It's designed event-driven from bottom to top. It listens user events (from top)
|
||||
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue global objects including components and plugins.
|
||||
- [**`components/`**](./../src/presentation/components/): Contains all Vue components and their helper classes.
|
||||
- [**`Shared/`**](./../src/presentation/components/Shared): Contains Vue components and component helpers that other components share.
|
||||
- [**`hooks`**](../src/presentation/components/Shared/Hooks): Shared hooks for state access
|
||||
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets that webpack will process.
|
||||
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts
|
||||
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles used throughout different components.
|
||||
@@ -32,7 +35,7 @@ Add visual clues for clickable items. It should be as clear as possible that the
|
||||
|
||||
## Application data
|
||||
|
||||
Components (should) use [ApplicationFactory](./../src/application/ApplicationFactory.ts) singleton to reach the application domain to avoid [parsing and compiling](./application.md#parsing-and-compiling) the application again.
|
||||
Components (should) use [`UseApplication`](./../src/presentation/components/Shared/Hooks/UseApplication.ts) to reach the application domain to avoid [parsing and compiling](./application.md#parsing-and-compiling) the application again.
|
||||
|
||||
[Application.ts](../src/domain/Application.ts) is an immutable domain model that represents application state. It includes:
|
||||
|
||||
@@ -43,32 +46,33 @@ You can read more about how application layer provides application data to he pr
|
||||
|
||||
## Application state
|
||||
|
||||
Inheritance of a Vue components marks whether it uses application state . Components that does not handle application state extends `Vue`. Stateful components mutate or/and react to state changes (such as user selection or search queries) in [ApplicationContext](./../src/application/Context/ApplicationContext.ts) extend [`StatefulVue`](./../src/presentation/components/Shared/StatefulVue.ts) class to access the context / state.
|
||||
This project uses a singleton instance of the application state, making it available to all Vue components.
|
||||
|
||||
[`StatefulVue`](./../src/presentation/components/Shared/StatefulVue.ts) functions include:
|
||||
The decision to not use third-party state management libraries like [`vuex`](https://web.archive.org/web/20230801191617/https://vuex.vuejs.org/) or [`pinia`](https://web.archive.org/web/20230801191743/https://pinia.vuejs.org/) was made to promote code independence and enhance portability.
|
||||
|
||||
- Creating a singleton of the state and makes it available to presentation layer as single source of truth.
|
||||
- Providing virtual abstract `handleCollectionState` callback that it calls when
|
||||
- the Vue loads the component,
|
||||
- and also every time when state changes.
|
||||
- Providing `events` member to make lifecycling of state subscriptions events easier because it ensures that components unsubscribe from listening to state events when
|
||||
- the component is no longer used (destroyed),
|
||||
- an if [ApplicationContext](./../src/application/Context/ApplicationContext.ts) changes the active [collection](./collection-files.md) to a different one.
|
||||
Stateful components can mutate and/or react to state changes (e.g., user selection, search queries) in the [ApplicationContext](./../src/application/Context/ApplicationContext.ts). Vue components import [`CollectionState.ts`](./../src/presentation/components/Shared/CollectionState.ts) to access both the application context and the state.
|
||||
|
||||
📖 Refer to [architecture.md | Application State](./architecture.md#application-state) to get an overview of event handling and [application.md | Application State](./presentation.md#application-state) for deeper look into how the application layer manages state.
|
||||
[`CollectionState.ts`](./../src/presentation/components/Shared/CollectionState.ts) provides several functionalities including:
|
||||
|
||||
- **Singleton State Instance**: It creates a singleton instance of the state, which is shared across the presentation layer. The singleton instance ensures that there's a single source of truth for the application's state.
|
||||
- **State Change Callback and Lifecycle Management**: It offers a mechanism to register callbacks, which will be invoked when the state initializes or mutates. It ensures that components unsubscribe from state events when they are no longer in use or when [ApplicationContext](./../src/application/Context/ApplicationContext.ts) switches the active [collection](./collection-files.md).
|
||||
- **State Access and Modification**: It provides functions to read and mutate for accessing and modifying the state, encapsulating the details of these operations.
|
||||
- **Event Subscription Lifecycle Management**: Includes an `events` member that simplifies state subscription lifecycle events. This ensures that components unsubscribe from state events when they are no longer in use, or when [ApplicationContext](./../src/application/Context/ApplicationContext.ts) switches the active [collection](./collection-files.md).
|
||||
|
||||
📖 Refer to [architecture.md | Application State](./architecture.md#application-state) for an overview of event handling and [application.md | Application State](./presentation.md#application-state) for an in-depth understanding of state management in the application layer.
|
||||
|
||||
## Modals
|
||||
|
||||
[Dialog.vue](./../src/presentation/components/Shared/Dialog.vue) is a shared component that other components used to show modal windows.
|
||||
[ModalDialog.vue](./../src/presentation/components/Shared/ModalDialog.vue) is a shared component utilized for rendering modal windows.
|
||||
|
||||
You can use it by wrapping the content inside of its `slot` and call `.show()` function on its reference. For example:
|
||||
Use the component by wrapping the desired content within its slot and calling the .show() function on its reference, as shown below:
|
||||
|
||||
```html
|
||||
<Dialog ref="testDialog">
|
||||
```html
|
||||
<ModalDialog ref="testDialog">
|
||||
<div>Hello world</div>
|
||||
</Dialog>
|
||||
</ModalDialog>
|
||||
<div @click="$refs.testDialog.show()">Show dialog</div>
|
||||
```
|
||||
```
|
||||
|
||||
## Sass naming convention
|
||||
|
||||
|
||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -6,7 +6,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "privacy.sexy",
|
||||
"version": "0.11.4",
|
||||
"version": "0.12.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||
@@ -26,9 +26,7 @@
|
||||
"npm": "^9.8.1",
|
||||
"v-tooltip": "2.1.3",
|
||||
"vue": "^2.7.14",
|
||||
"vue-class-component": "^7.2.6",
|
||||
"vue-js-modal": "^2.0.1",
|
||||
"vue-property-decorator": "^9.1.2"
|
||||
"vue-js-modal": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.3.2",
|
||||
@@ -25610,14 +25608,6 @@
|
||||
"csstype": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-class-component": {
|
||||
"version": "7.2.6",
|
||||
"resolved": "https://registry.npmjs.org/vue-class-component/-/vue-class-component-7.2.6.tgz",
|
||||
"integrity": "sha512-+eaQXVrAm/LldalI272PpDe3+i4mPis0ORiMYxF6Ae4hyuCh15W8Idet7wPUEs4N4YptgFHGys4UrgNQOMyO6w==",
|
||||
"peerDependencies": {
|
||||
"vue": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-cli-plugin-electron-builder": {
|
||||
"version": "3.0.0-alpha.4",
|
||||
"resolved": "https://registry.npmmirror.com/vue-cli-plugin-electron-builder/-/vue-cli-plugin-electron-builder-3.0.0-alpha.4.tgz",
|
||||
@@ -25886,15 +25876,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-property-decorator": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-property-decorator/-/vue-property-decorator-9.1.2.tgz",
|
||||
"integrity": "sha512-xYA8MkZynPBGd/w5QFJ2d/NM0z/YeegMqYTphy7NJQXbZcuU6FC6AOdUAcy4SXP+YnkerC6AfH+ldg7PDk9ESQ==",
|
||||
"peerDependencies": {
|
||||
"vue": "*",
|
||||
"vue-class-component": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-resize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-1.0.1.tgz",
|
||||
@@ -45848,12 +45829,6 @@
|
||||
"csstype": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"vue-class-component": {
|
||||
"version": "7.2.6",
|
||||
"resolved": "https://registry.npmjs.org/vue-class-component/-/vue-class-component-7.2.6.tgz",
|
||||
"integrity": "sha512-+eaQXVrAm/LldalI272PpDe3+i4mPis0ORiMYxF6Ae4hyuCh15W8Idet7wPUEs4N4YptgFHGys4UrgNQOMyO6w==",
|
||||
"requires": {}
|
||||
},
|
||||
"vue-cli-plugin-electron-builder": {
|
||||
"version": "3.0.0-alpha.4",
|
||||
"resolved": "https://registry.npmmirror.com/vue-cli-plugin-electron-builder/-/vue-cli-plugin-electron-builder-3.0.0-alpha.4.tgz",
|
||||
@@ -46055,12 +46030,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"vue-property-decorator": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-property-decorator/-/vue-property-decorator-9.1.2.tgz",
|
||||
"integrity": "sha512-xYA8MkZynPBGd/w5QFJ2d/NM0z/YeegMqYTphy7NJQXbZcuU6FC6AOdUAcy4SXP+YnkerC6AfH+ldg7PDk9ESQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"vue-resize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-1.0.1.tgz",
|
||||
|
||||
@@ -42,9 +42,7 @@
|
||||
"npm": "^9.8.1",
|
||||
"v-tooltip": "2.1.3",
|
||||
"vue": "^2.7.14",
|
||||
"vue-class-component": "^7.2.6",
|
||||
"vue-js-modal": "^2.0.1",
|
||||
"vue-property-decorator": "^9.1.2"
|
||||
"vue-js-modal": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.3.2",
|
||||
@@ -85,10 +83,10 @@
|
||||
"sass-loader": "^13.3.2",
|
||||
"svgexport": "^0.4.2",
|
||||
"ts-loader": "^9.4.4",
|
||||
"tslib": "~2.4.0",
|
||||
"typescript": "~4.6.2",
|
||||
"vue-cli-plugin-electron-builder": "^3.0.0-alpha.4",
|
||||
"yaml-lint": "^1.7.0",
|
||||
"tslib": "~2.4.0"
|
||||
"yaml-lint": "^1.7.0"
|
||||
},
|
||||
"overrides": {
|
||||
"vue-cli-plugin-electron-builder": {
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent } from 'vue';
|
||||
import TheHeader from '@/presentation/components/TheHeader.vue';
|
||||
import TheFooter from '@/presentation/components/TheFooter/TheFooter.vue';
|
||||
import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeButtons.vue';
|
||||
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
|
||||
import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
TheHeader,
|
||||
TheCodeButtons,
|
||||
@@ -26,10 +26,8 @@ import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
|
||||
TheSearchBar,
|
||||
TheFooter,
|
||||
},
|
||||
})
|
||||
export default class App extends Vue {
|
||||
});
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -14,20 +14,36 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Component, Prop, Emit, Vue,
|
||||
} from 'vue-property-decorator';
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
@Component
|
||||
export default class IconButton extends Vue {
|
||||
@Prop() public text!: number;
|
||||
export default defineComponent({
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
iconPrefix: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'click',
|
||||
],
|
||||
setup(_, { emit }) {
|
||||
function onClicked() {
|
||||
emit('click');
|
||||
}
|
||||
|
||||
@Prop() public iconPrefix!: string;
|
||||
|
||||
@Prop() public iconName!: string;
|
||||
|
||||
@Emit('click') public onClicked() { /* do nothing except firing event */ }
|
||||
}
|
||||
return {
|
||||
onClicked,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -12,16 +12,23 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent, useSlots } from 'vue';
|
||||
import { Clipboard } from '@/infrastructure/Clipboard';
|
||||
|
||||
@Component
|
||||
export default class Code extends Vue {
|
||||
public copyCode(): void {
|
||||
const code = this.$slots.default[0].text;
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const slots = useSlots();
|
||||
|
||||
function copyCode() {
|
||||
const code = slots.default()[0].text;
|
||||
Clipboard.copyText(code);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
copyCode,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -6,8 +6,8 @@
|
||||
<hr />
|
||||
<p>
|
||||
<strong>1. The easy alternative</strong>. Run your script without any manual steps by
|
||||
<a :href="this.macOsDownloadUrl">downloading desktop version</a> of {{ this.appName }} on the
|
||||
{{ this.osName }} system you wish to configure, and then click on the Run button. This is
|
||||
<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 />
|
||||
@@ -20,7 +20,7 @@
|
||||
<p>
|
||||
<ol>
|
||||
<li
|
||||
v-for='(step, index) in this.data.steps'
|
||||
v-for='(step, index) in data.steps'
|
||||
v-bind:key="index"
|
||||
class="step"
|
||||
>
|
||||
@@ -34,7 +34,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-if="step.code" class="step__code">
|
||||
<Code>{{ step.code.instruction }}</Code>
|
||||
<CodeInstruction>{{ step.code.instruction }}</CodeInstruction>
|
||||
<font-awesome-icon
|
||||
v-if="step.code.details"
|
||||
class="explanation"
|
||||
@@ -49,36 +49,47 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import {
|
||||
defineComponent, PropType, computed,
|
||||
} from 'vue';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
import Code from './Code.vue';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
import CodeInstruction from './CodeInstruction.vue';
|
||||
import { IInstructionListData } from './InstructionListData';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
Code,
|
||||
CodeInstruction,
|
||||
},
|
||||
})
|
||||
export default class InstructionList extends Vue {
|
||||
public appName = '';
|
||||
props: {
|
||||
data: {
|
||||
type: Object as PropType<IInstructionListData>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { info } = useApplication();
|
||||
|
||||
public macOsDownloadUrl = '';
|
||||
const appName = computed<string>(() => info.name);
|
||||
|
||||
public osName = '';
|
||||
const macOsDownloadUrl = computed<string>(
|
||||
() => info.getDownloadUrl(OperatingSystem.macOS),
|
||||
);
|
||||
|
||||
@Prop() public data: IInstructionListData;
|
||||
|
||||
public async created() {
|
||||
if (!this.data) {
|
||||
const osName = computed<string>(() => {
|
||||
if (!props.data) {
|
||||
throw new Error('missing data');
|
||||
}
|
||||
const app = await ApplicationFactory.Current.getApp();
|
||||
this.appName = app.info.name;
|
||||
this.macOsDownloadUrl = app.info.getDownloadUrl(OperatingSystem.macOS);
|
||||
this.osName = renderOsName(this.data.operatingSystem);
|
||||
}
|
||||
}
|
||||
return renderOsName(props.data.operatingSystem);
|
||||
});
|
||||
|
||||
return {
|
||||
appName,
|
||||
macOsDownloadUrl,
|
||||
osName,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function renderOsName(os: OperatingSystem): string {
|
||||
switch (os) {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div class="container" v-if="hasCode">
|
||||
<IconButton
|
||||
v-if="this.canRun"
|
||||
v-if="canRun"
|
||||
text="Run"
|
||||
v-on:click="executeCode"
|
||||
icon-prefix="fas"
|
||||
icon-name="play"
|
||||
/>
|
||||
<IconButton
|
||||
:text="this.isDesktopVersion ? 'Save' : 'Download'"
|
||||
:text="isDesktopVersion ? 'Save' : 'Download'"
|
||||
v-on:click="saveCode"
|
||||
icon-prefix="fas"
|
||||
:icon-name="this.isDesktopVersion ? 'save' : 'file-download'"
|
||||
:icon-name="isDesktopVersion ? 'save' : 'file-download'"
|
||||
/>
|
||||
<IconButton
|
||||
text="Copy"
|
||||
@@ -19,25 +19,24 @@
|
||||
icon-prefix="fas"
|
||||
icon-name="copy"
|
||||
/>
|
||||
<Dialog v-if="this.hasInstructions" ref="instructionsDialog">
|
||||
<InstructionList :data="this.instructions" />
|
||||
</Dialog>
|
||||
<ModalDialog v-if="instructions" ref="instructionsDialog">
|
||||
<InstructionList :data="instructions" />
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component } from 'vue-property-decorator';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import { defineComponent, ref, computed } from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
|
||||
import { Clipboard } from '@/infrastructure/Clipboard';
|
||||
import Dialog from '@/presentation/components/Shared/Dialog.vue';
|
||||
import ModalDialog from '@/presentation/components/Shared/ModalDialog.vue';
|
||||
import { Environment } from '@/application/Environment/Environment';
|
||||
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 { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { CodeRunner } from '@/infrastructure/CodeRunner';
|
||||
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import InstructionList from './Instructions/InstructionList.vue';
|
||||
@@ -45,79 +44,89 @@ import IconButton from './IconButton.vue';
|
||||
import { IInstructionListData } from './Instructions/InstructionListData';
|
||||
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
|
||||
|
||||
@Component({
|
||||
const isDesktopVersion = Environment.CurrentEnvironment.isDesktop;
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
IconButton,
|
||||
InstructionList,
|
||||
Dialog,
|
||||
ModalDialog,
|
||||
},
|
||||
})
|
||||
export default class TheCodeButtons extends StatefulVue {
|
||||
public readonly isDesktopVersion = Environment.CurrentEnvironment.isDesktop;
|
||||
setup() {
|
||||
const {
|
||||
currentState, currentContext, onStateChange, events,
|
||||
} = useCollectionState();
|
||||
|
||||
public canRun = false;
|
||||
const instructionsDialog = ref<typeof ModalDialog>();
|
||||
const canRun = computed<boolean>(() => getCanRunState(currentState.value.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,
|
||||
));
|
||||
|
||||
public hasCode = false;
|
||||
|
||||
public instructions: IInstructionListData | undefined;
|
||||
|
||||
public hasInstructions = false;
|
||||
|
||||
public fileName = '';
|
||||
|
||||
public async copyCode() {
|
||||
const code = await this.getCurrentCode();
|
||||
async function copyCode() {
|
||||
const code = await getCurrentCode();
|
||||
Clipboard.copyText(code.current);
|
||||
}
|
||||
|
||||
public async saveCode() {
|
||||
const context = await this.getCurrentContext();
|
||||
saveCode(this.fileName, context.state);
|
||||
if (this.hasInstructions) {
|
||||
(this.$refs.instructionsDialog as Dialog).show();
|
||||
}
|
||||
function saveCode() {
|
||||
saveCodeToDisk(fileName.value, currentState.value);
|
||||
instructionsDialog.value?.show();
|
||||
}
|
||||
|
||||
public async executeCode() {
|
||||
const context = await this.getCurrentContext();
|
||||
await executeCode(context);
|
||||
async function executeCode() {
|
||||
await runCode(currentContext);
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
||||
this.updateRunState(newState.os);
|
||||
this.updateDownloadState(newState.collection);
|
||||
this.updateCodeState(newState.code);
|
||||
onStateChange((newState) => {
|
||||
subscribeToCodeChanges(newState.code);
|
||||
}, { immediate: true });
|
||||
|
||||
function subscribeToCodeChanges(code: IApplicationCode) {
|
||||
hasCode.value = code.current && code.current.length > 0;
|
||||
events.unsubscribeAll();
|
||||
events.register(code.changed.on((newCode) => {
|
||||
hasCode.value = newCode && newCode.code.length > 0;
|
||||
}));
|
||||
}
|
||||
|
||||
private async getCurrentCode(): Promise<IApplicationCode> {
|
||||
const context = await this.getCurrentContext();
|
||||
const { code } = context.state;
|
||||
async function getCurrentCode(): Promise<IApplicationCode> {
|
||||
const { code } = currentContext.state;
|
||||
return code;
|
||||
}
|
||||
|
||||
private updateRunState(selectedOs: OperatingSystem) {
|
||||
const isRunningOnSelectedOs = selectedOs === Environment.CurrentEnvironment.os;
|
||||
this.canRun = this.isDesktopVersion && isRunningOnSelectedOs;
|
||||
}
|
||||
return {
|
||||
isDesktopVersion,
|
||||
canRun,
|
||||
hasCode,
|
||||
instructions,
|
||||
fileName,
|
||||
instructionsDialog,
|
||||
copyCode,
|
||||
saveCode,
|
||||
executeCode,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
private updateDownloadState(collection: ICategoryCollection) {
|
||||
this.fileName = buildFileName(collection.scripting);
|
||||
this.hasInstructions = hasInstructions(collection.os);
|
||||
if (this.hasInstructions) {
|
||||
this.instructions = getInstructions(collection.os, this.fileName);
|
||||
}
|
||||
}
|
||||
|
||||
private updateCodeState(code: IApplicationCode) {
|
||||
this.hasCode = code.current && code.current.length > 0;
|
||||
this.events.unsubscribeAll();
|
||||
this.events.register(code.changed.on((newCode) => {
|
||||
this.hasCode = newCode && newCode.code.length > 0;
|
||||
}));
|
||||
function getDownloadInstructions(
|
||||
os: OperatingSystem,
|
||||
fileName: string,
|
||||
): IInstructionListData | undefined {
|
||||
if (!hasInstructions(os)) {
|
||||
return undefined;
|
||||
}
|
||||
return getInstructions(os, fileName);
|
||||
}
|
||||
|
||||
function saveCode(fileName: string, state: IReadOnlyCategoryCollectionState) {
|
||||
function getCanRunState(selectedOs: OperatingSystem): boolean {
|
||||
const isRunningOnSelectedOs = selectedOs === Environment.CurrentEnvironment.os;
|
||||
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);
|
||||
@@ -141,7 +150,7 @@ function buildFileName(scripting: IScriptingDefinition) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
async function executeCode(context: IReadOnlyApplicationContext) {
|
||||
async function runCode(context: IReadOnlyApplicationContext) {
|
||||
const runner = new CodeRunner();
|
||||
await runner.runCode(
|
||||
/* code: */ context.state.code.current,
|
||||
|
||||
@@ -1,82 +1,103 @@
|
||||
<template>
|
||||
<Responsive
|
||||
<SizeObserver
|
||||
v-on:sizeChanged="sizeChanged()"
|
||||
v-non-collapsing>
|
||||
<div
|
||||
:id="editorId"
|
||||
class="code-area"
|
||||
/>
|
||||
</Responsive>
|
||||
</SizeObserver>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop } from 'vue-property-decorator';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import { defineComponent, onUnmounted, onMounted } from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/CodeBuilderFactory';
|
||||
import Responsive from '@/presentation/components/Shared/Responsive.vue';
|
||||
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
|
||||
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
|
||||
import ace from './ace-importer';
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
Responsive,
|
||||
export default defineComponent({
|
||||
props: {
|
||||
theme: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
directives: { NonCollapsing },
|
||||
})
|
||||
export default class TheCodeArea extends StatefulVue {
|
||||
public readonly editorId = 'codeEditor';
|
||||
},
|
||||
components: {
|
||||
SizeObserver,
|
||||
},
|
||||
directives: {
|
||||
NonCollapsing,
|
||||
},
|
||||
setup(props) {
|
||||
const { onStateChange, currentState, events } = useCollectionState();
|
||||
|
||||
private editor!: ace.Ace.Editor;
|
||||
const editorId = 'codeEditor';
|
||||
let editor: ace.Ace.Editor | undefined;
|
||||
let currentMarkerId: number | undefined;
|
||||
|
||||
private currentMarkerId?: number;
|
||||
onUnmounted(() => {
|
||||
destroyEditor();
|
||||
});
|
||||
|
||||
@Prop() private theme!: string;
|
||||
onMounted(() => { // allow editor HTML to render
|
||||
onStateChange((newState) => {
|
||||
handleNewState(newState);
|
||||
}, { immediate: true });
|
||||
});
|
||||
|
||||
public destroyed() {
|
||||
this.destroyEditor();
|
||||
}
|
||||
|
||||
public sizeChanged() {
|
||||
if (this.editor) {
|
||||
this.editor.resize();
|
||||
}
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
||||
this.destroyEditor();
|
||||
this.editor = initializeEditor(
|
||||
this.theme,
|
||||
this.editorId,
|
||||
function handleNewState(newState: IReadOnlyCategoryCollectionState) {
|
||||
destroyEditor();
|
||||
editor = initializeEditor(
|
||||
props.theme,
|
||||
editorId,
|
||||
newState.collection.scripting.language,
|
||||
);
|
||||
const appCode = newState.code;
|
||||
const innerCode = appCode.current || getDefaultCode(newState.collection.scripting.language);
|
||||
this.editor.setValue(innerCode, 1);
|
||||
this.events.unsubscribeAll();
|
||||
this.events.register(appCode.changed.on((code) => this.updateCode(code)));
|
||||
editor.setValue(innerCode, 1);
|
||||
events.unsubscribeAll();
|
||||
events.register(appCode.changed.on((code) => updateCode(code)));
|
||||
}
|
||||
|
||||
private async updateCode(event: ICodeChangedEvent) {
|
||||
this.removeCurrentHighlighting();
|
||||
function updateCode(event: ICodeChangedEvent) {
|
||||
removeCurrentHighlighting();
|
||||
if (event.isEmpty()) {
|
||||
const context = await this.getCurrentContext();
|
||||
const defaultCode = getDefaultCode(context.state.collection.scripting.language);
|
||||
this.editor.setValue(defaultCode, 1);
|
||||
const defaultCode = getDefaultCode(currentState.value.collection.scripting.language);
|
||||
editor.setValue(defaultCode, 1);
|
||||
return;
|
||||
}
|
||||
this.editor.setValue(event.code, 1);
|
||||
if (event.addedScripts && event.addedScripts.length) {
|
||||
this.reactToChanges(event, event.addedScripts);
|
||||
} else if (event.changedScripts && event.changedScripts.length) {
|
||||
this.reactToChanges(event, event.changedScripts);
|
||||
editor.setValue(event.code, 1);
|
||||
if (event.addedScripts?.length > 0) {
|
||||
reactToChanges(event, event.addedScripts);
|
||||
} else if (event.changedScripts?.length > 0) {
|
||||
reactToChanges(event, event.changedScripts);
|
||||
}
|
||||
}
|
||||
|
||||
private reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
|
||||
function sizeChanged() {
|
||||
editor?.resize();
|
||||
}
|
||||
|
||||
function destroyEditor() {
|
||||
editor?.destroy();
|
||||
editor = undefined;
|
||||
}
|
||||
|
||||
function removeCurrentHighlighting() {
|
||||
if (!currentMarkerId) {
|
||||
return;
|
||||
}
|
||||
editor.session.removeMarker(currentMarkerId);
|
||||
currentMarkerId = undefined;
|
||||
}
|
||||
|
||||
function reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
|
||||
const positions = scripts
|
||||
.map((script) => event.getScriptPositionInCode(script));
|
||||
const start = Math.min(
|
||||
@@ -85,42 +106,33 @@ export default class TheCodeArea extends StatefulVue {
|
||||
const end = Math.max(
|
||||
...positions.map((position) => position.endLine),
|
||||
);
|
||||
this.scrollToLine(end + 2);
|
||||
this.highlight(start, end);
|
||||
scrollToLine(end + 2);
|
||||
highlight(start, end);
|
||||
}
|
||||
|
||||
private highlight(startRow: number, endRow: number) {
|
||||
function highlight(startRow: number, endRow: number) {
|
||||
const AceRange = ace.require('ace/range').Range;
|
||||
this.currentMarkerId = this.editor.session.addMarker(
|
||||
currentMarkerId = editor.session.addMarker(
|
||||
new AceRange(startRow, 0, endRow, 0),
|
||||
'code-area__highlight',
|
||||
'fullLine',
|
||||
);
|
||||
}
|
||||
|
||||
private scrollToLine(row: number) {
|
||||
const column = this.editor.session.getLine(row).length;
|
||||
this.editor.gotoLine(row, column, true);
|
||||
function scrollToLine(row: number) {
|
||||
const column = editor.session.getLine(row).length;
|
||||
editor.gotoLine(row, column, true);
|
||||
}
|
||||
|
||||
private removeCurrentHighlighting() {
|
||||
if (!this.currentMarkerId) {
|
||||
return;
|
||||
}
|
||||
this.editor.session.removeMarker(this.currentMarkerId);
|
||||
this.currentMarkerId = undefined;
|
||||
}
|
||||
|
||||
private destroyEditor() {
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
this.editor = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
editorId,
|
||||
sizeChanged,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function initializeEditor(
|
||||
theme: string,
|
||||
theme: string | undefined,
|
||||
editorId: string,
|
||||
language: ScriptingLanguage,
|
||||
): ace.Ace.Editor {
|
||||
|
||||
@@ -8,12 +8,16 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from 'vue-property-decorator';
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
@Component
|
||||
export default class MenuOptionList extends Vue {
|
||||
@Prop() public label: string;
|
||||
}
|
||||
export default defineComponent({
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -6,26 +6,42 @@
|
||||
enabled: enabled,
|
||||
}"
|
||||
v-non-collapsing
|
||||
@click="enabled && onClicked()">{{label}}</span>
|
||||
@click="onClicked()">{{label}}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Component, Prop, Emit, Vue,
|
||||
} from 'vue-property-decorator';
|
||||
import { defineComponent } from 'vue';
|
||||
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
directives: { NonCollapsing },
|
||||
})
|
||||
export default class MenuOptionListItem extends Vue {
|
||||
@Prop() public enabled: boolean;
|
||||
props: {
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'click',
|
||||
],
|
||||
setup(props, { emit }) {
|
||||
const onClicked = () => {
|
||||
if (!props.enabled) {
|
||||
return;
|
||||
}
|
||||
emit('click');
|
||||
};
|
||||
|
||||
@Prop() public label: string;
|
||||
|
||||
@Emit('click') public onClicked() { /* do nothing except firing event */ }
|
||||
}
|
||||
return {
|
||||
onClicked,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<MenuOptionList label="Select">
|
||||
<MenuOptionListItem
|
||||
label="None"
|
||||
:enabled="this.currentSelection !== SelectionType.None"
|
||||
:enabled="currentSelection !== SelectionType.None"
|
||||
@click="selectType(SelectionType.None)"
|
||||
v-tooltip="
|
||||
'Deselect all selected scripts.<br/>'
|
||||
@@ -11,7 +11,7 @@
|
||||
/>
|
||||
<MenuOptionListItem
|
||||
label="Standard"
|
||||
:enabled="this.currentSelection !== SelectionType.Standard"
|
||||
:enabled="currentSelection !== SelectionType.Standard"
|
||||
@click="selectType(SelectionType.Standard)"
|
||||
v-tooltip="
|
||||
'🛡️ Balanced for privacy and functionality.<br/>'
|
||||
@@ -20,7 +20,7 @@
|
||||
/>
|
||||
<MenuOptionListItem
|
||||
label="Strict"
|
||||
:enabled="this.currentSelection !== SelectionType.Strict"
|
||||
:enabled="currentSelection !== SelectionType.Strict"
|
||||
@click="selectType(SelectionType.Strict)"
|
||||
v-tooltip="
|
||||
'🚫 Stronger privacy, disables risky functions that may leak your data.<br/>'
|
||||
@@ -30,7 +30,7 @@
|
||||
/>
|
||||
<MenuOptionListItem
|
||||
label="All"
|
||||
:enabled="this.currentSelection !== SelectionType.All"
|
||||
:enabled="currentSelection !== SelectionType.All"
|
||||
@click="selectType(SelectionType.All)"
|
||||
v-tooltip="
|
||||
'🔒 Strongest privacy, disabling any functionality that may leak your data.<br/>'
|
||||
@@ -42,47 +42,59 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component } from 'vue-property-decorator';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import MenuOptionList from '../MenuOptionList.vue';
|
||||
import MenuOptionListItem from '../MenuOptionListItem.vue';
|
||||
import { SelectionType, SelectionTypeHandler } from './SelectionTypeHandler';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MenuOptionList,
|
||||
MenuOptionListItem,
|
||||
},
|
||||
})
|
||||
export default class TheSelector extends StatefulVue {
|
||||
public SelectionType = SelectionType;
|
||||
setup() {
|
||||
const { modifyCurrentState, onStateChange, events } = useCollectionState();
|
||||
|
||||
public currentSelection = SelectionType.None;
|
||||
const currentSelection = ref(SelectionType.None);
|
||||
|
||||
private selectionTypeHandler: SelectionTypeHandler;
|
||||
let selectionTypeHandler: SelectionTypeHandler;
|
||||
|
||||
public async selectType(type: SelectionType) {
|
||||
if (this.currentSelection === type) {
|
||||
onStateChange(() => {
|
||||
unregisterMutators();
|
||||
|
||||
modifyCurrentState((state) => {
|
||||
registerStateMutator(state);
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
function unregisterMutators() {
|
||||
events.unsubscribeAll();
|
||||
}
|
||||
|
||||
function registerStateMutator(state: ICategoryCollectionState) {
|
||||
selectionTypeHandler = new SelectionTypeHandler(state);
|
||||
updateSelections();
|
||||
events.register(state.selection.changed.on(() => updateSelections()));
|
||||
}
|
||||
|
||||
function selectType(type: SelectionType) {
|
||||
if (currentSelection.value === type) {
|
||||
return;
|
||||
}
|
||||
this.selectionTypeHandler.selectType(type);
|
||||
selectionTypeHandler.selectType(type);
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: ICategoryCollectionState): void {
|
||||
this.events.unsubscribeAll();
|
||||
this.selectionTypeHandler = new SelectionTypeHandler(newState);
|
||||
this.updateSelections();
|
||||
this.events.register(newState.selection.changed.on(() => this.updateSelections()));
|
||||
function updateSelections() {
|
||||
currentSelection.value = selectionTypeHandler.getCurrentSelectionType();
|
||||
}
|
||||
|
||||
private updateSelections() {
|
||||
this.currentSelection = this.selectionTypeHandler.getCurrentSelectionType();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SelectionType,
|
||||
currentSelection,
|
||||
selectType,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<MenuOptionList>
|
||||
<MenuOptionListItem
|
||||
v-for="os in this.allOses"
|
||||
v-for="os in allOses"
|
||||
:key="os.name"
|
||||
:enabled="currentOs !== os.os"
|
||||
@click="changeOs(os.os)"
|
||||
@@ -11,41 +11,55 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component } from 'vue-property-decorator';
|
||||
import {
|
||||
defineComponent, computed,
|
||||
} from 'vue';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
import MenuOptionList from './MenuOptionList.vue';
|
||||
import MenuOptionListItem from './MenuOptionListItem.vue';
|
||||
|
||||
@Component({
|
||||
interface IOsViewModel {
|
||||
readonly name: string;
|
||||
readonly os: OperatingSystem;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MenuOptionList,
|
||||
MenuOptionListItem,
|
||||
},
|
||||
})
|
||||
export default class TheOsChanger extends StatefulVue {
|
||||
public allOses: Array<{ name: string, os: OperatingSystem }> = [];
|
||||
setup() {
|
||||
const { modifyCurrentContext, currentState } = useCollectionState();
|
||||
const { application } = useApplication();
|
||||
|
||||
public currentOs?: OperatingSystem = null;
|
||||
|
||||
public async created() {
|
||||
const app = await ApplicationFactory.Current.getApp();
|
||||
this.allOses = app.getSupportedOsList()
|
||||
.map((os) => ({ os, name: renderOsName(os) }));
|
||||
const allOses = computed<ReadonlyArray<IOsViewModel>>(() => (
|
||||
application.getSupportedOsList() ?? [])
|
||||
.map((os) : IOsViewModel => (
|
||||
{
|
||||
os,
|
||||
name: renderOsName(os),
|
||||
}
|
||||
)));
|
||||
|
||||
public async changeOs(newOs: OperatingSystem) {
|
||||
const context = await this.getCurrentContext();
|
||||
const currentOs = computed<OperatingSystem>(() => {
|
||||
return currentState.value.os;
|
||||
});
|
||||
|
||||
function changeOs(newOs: OperatingSystem) {
|
||||
modifyCurrentContext((context) => {
|
||||
context.changeContext(newOs);
|
||||
});
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
||||
this.currentOs = newState.os;
|
||||
this.$forceUpdate(); // v-bind:class is not updated otherwise
|
||||
}
|
||||
}
|
||||
return {
|
||||
allOses,
|
||||
currentOs,
|
||||
changeOs,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function renderOsName(os: OperatingSystem): string {
|
||||
switch (os) {
|
||||
@@ -56,7 +70,3 @@ function renderOsName(os: OperatingSystem): string {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
@@ -5,53 +5,55 @@
|
||||
<TheViewChanger
|
||||
class="item"
|
||||
v-on:viewChanged="$emit('viewChanged', $event)"
|
||||
v-if="!this.isSearching" />
|
||||
v-if="!isSearching" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component } from 'vue-property-decorator';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import { defineComponent, ref, onUnmounted } from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||
import TheOsChanger from './TheOsChanger.vue';
|
||||
import TheSelector from './Selector/TheSelector.vue';
|
||||
import TheViewChanger from './View/TheViewChanger.vue';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
TheSelector,
|
||||
TheOsChanger,
|
||||
TheViewChanger,
|
||||
},
|
||||
})
|
||||
export default class TheScriptsMenu extends StatefulVue {
|
||||
public isSearching = false;
|
||||
setup() {
|
||||
const { onStateChange, events } = useCollectionState();
|
||||
|
||||
private listeners = new Array<IEventSubscription>();
|
||||
const isSearching = ref(false);
|
||||
|
||||
public destroyed() {
|
||||
this.unsubscribeAll();
|
||||
}
|
||||
onStateChange((state) => {
|
||||
subscribe(state);
|
||||
}, { immediate: true });
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
||||
this.subscribe(newState);
|
||||
}
|
||||
|
||||
private subscribe(state: IReadOnlyCategoryCollectionState) {
|
||||
this.listeners.push(state.filter.filterRemoved.on(() => {
|
||||
this.isSearching = false;
|
||||
}));
|
||||
state.filter.filtered.on(() => {
|
||||
this.isSearching = true;
|
||||
onUnmounted(() => {
|
||||
unsubscribeAll();
|
||||
});
|
||||
|
||||
function subscribe(state: IReadOnlyCategoryCollectionState) {
|
||||
events.register(state.filter.filterRemoved.on(() => {
|
||||
isSearching.value = false;
|
||||
}));
|
||||
events.register(state.filter.filtered.on(() => {
|
||||
isSearching.value = true;
|
||||
}));
|
||||
}
|
||||
|
||||
private unsubscribeAll() {
|
||||
this.listeners.forEach((listener) => listener.unsubscribe());
|
||||
this.listeners.splice(0, this.listeners.length);
|
||||
function unsubscribeAll() {
|
||||
events.unsubscribeAll();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isSearching,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
label="View"
|
||||
class="part">
|
||||
<MenuOptionListItem
|
||||
v-for="view in this.viewOptions"
|
||||
v-for="view in viewOptions"
|
||||
:key="view.type"
|
||||
:label="view.displayName"
|
||||
:enabled="currentView !== view.type"
|
||||
@@ -13,53 +13,54 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import MenuOptionList from '../MenuOptionList.vue';
|
||||
import MenuOptionListItem from '../MenuOptionListItem.vue';
|
||||
import { ViewType } from './ViewType';
|
||||
|
||||
const DefaultView = ViewType.Cards;
|
||||
interface IViewOption {
|
||||
readonly type: ViewType;
|
||||
readonly displayName: string;
|
||||
}
|
||||
const viewOptions: readonly IViewOption[] = [
|
||||
{ type: ViewType.Cards, displayName: 'Cards' },
|
||||
{ type: ViewType.Tree, displayName: 'Tree' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MenuOptionList,
|
||||
MenuOptionListItem,
|
||||
},
|
||||
})
|
||||
export default class TheViewChanger extends Vue {
|
||||
public readonly viewOptions: IViewOption[] = [
|
||||
{ type: ViewType.Cards, displayName: 'Cards' },
|
||||
{ type: ViewType.Tree, displayName: 'Tree' },
|
||||
];
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
viewChanged: (viewType: ViewType) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
const currentView = ref<ViewType>();
|
||||
|
||||
public ViewType = ViewType;
|
||||
setView(DefaultView);
|
||||
|
||||
public currentView?: ViewType = null;
|
||||
|
||||
public mounted() {
|
||||
this.setView(DefaultView);
|
||||
}
|
||||
|
||||
public groupBy(type: ViewType) {
|
||||
this.setView(type);
|
||||
}
|
||||
|
||||
private setView(view: ViewType) {
|
||||
if (this.currentView === view) {
|
||||
function setView(view: ViewType) {
|
||||
if (currentView.value === view) {
|
||||
throw new Error(`View is already "${ViewType[view]}"`);
|
||||
}
|
||||
this.currentView = view;
|
||||
this.$emit('viewChanged', this.currentView);
|
||||
currentView.value = view;
|
||||
emit('viewChanged', currentView.value);
|
||||
}
|
||||
}
|
||||
return {
|
||||
ViewType,
|
||||
viewOptions,
|
||||
currentView,
|
||||
setView,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
interface IViewOption {
|
||||
readonly type: ViewType;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="handle"
|
||||
:style="{ cursor: cursorCssValue }"
|
||||
@mousedown="startResize">
|
||||
<div class="line" />
|
||||
<font-awesome-icon
|
||||
class="icon"
|
||||
:icon="['fas', 'arrows-alt-h']"
|
||||
/>
|
||||
<div class="line" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
|
||||
@Component
|
||||
export default class Handle extends Vue {
|
||||
public readonly cursorCssValue = 'ew-resize';
|
||||
|
||||
private initialX: number = undefined;
|
||||
|
||||
public startResize(event: MouseEvent): void {
|
||||
this.initialX = event.clientX;
|
||||
document.body.style.setProperty('cursor', this.cursorCssValue);
|
||||
document.addEventListener('mousemove', this.resize);
|
||||
window.addEventListener('mouseup', this.stopResize);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
public resize(event: MouseEvent): void {
|
||||
const displacementX = event.clientX - this.initialX;
|
||||
this.$emit('resized', displacementX);
|
||||
this.initialX = event.clientX;
|
||||
}
|
||||
|
||||
public stopResize(): void {
|
||||
document.body.style.removeProperty('cursor');
|
||||
document.removeEventListener('mousemove', this.resize);
|
||||
window.removeEventListener('mouseup', this.stopResize);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
$color : $color-primary-dark;
|
||||
$color-hover : $color-primary;
|
||||
|
||||
.handle {
|
||||
@include clickable($cursor: 'ew-resize');
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@include hover-or-touch {
|
||||
.line {
|
||||
background: $color-hover;
|
||||
}
|
||||
.image {
|
||||
color: $color-hover;
|
||||
}
|
||||
}
|
||||
.line {
|
||||
flex: 1;
|
||||
background: $color;
|
||||
width: 3px;
|
||||
}
|
||||
.icon {
|
||||
color: $color;
|
||||
}
|
||||
margin-right: 5px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,16 +2,16 @@
|
||||
<div
|
||||
class="slider"
|
||||
v-bind:style="{
|
||||
'--vertical-margin': this.verticalMargin,
|
||||
'--first-min-width': this.firstMinWidth,
|
||||
'--first-initial-width': this.firstInitialWidth,
|
||||
'--second-min-width': this.secondMinWidth,
|
||||
'--vertical-margin': verticalMargin,
|
||||
'--first-min-width': firstMinWidth,
|
||||
'--first-initial-width': firstInitialWidth,
|
||||
'--second-min-width': secondMinWidth,
|
||||
}"
|
||||
>
|
||||
<div class="first" ref="firstElement">
|
||||
<slot name="first" />
|
||||
</div>
|
||||
<Handle class="handle" @resized="onResize($event)" />
|
||||
<SliderHandle class="handle" @resized="onResize($event)" />
|
||||
<div class="second">
|
||||
<slot name="second" />
|
||||
</div>
|
||||
@@ -19,30 +19,45 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from 'vue-property-decorator';
|
||||
import Handle from './Handle.vue';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import SliderHandle from './SliderHandle.vue';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
Handle,
|
||||
SliderHandle,
|
||||
},
|
||||
})
|
||||
export default class HorizontalResizeSlider extends Vue {
|
||||
@Prop() public verticalMargin: string;
|
||||
props: {
|
||||
verticalMargin: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
firstMinWidth: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
firstInitialWidth: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
secondMinWidth: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const firstElement = ref<HTMLElement>();
|
||||
|
||||
@Prop() public firstMinWidth: string;
|
||||
|
||||
@Prop() public firstInitialWidth: string;
|
||||
|
||||
@Prop() public secondMinWidth: string;
|
||||
|
||||
private get left(): HTMLElement { return this.$refs.firstElement as HTMLElement; }
|
||||
|
||||
public onResize(displacementX: number): void {
|
||||
const leftWidth = this.left.offsetWidth + displacementX;
|
||||
this.left.style.width = `${leftWidth}px`;
|
||||
function onResize(displacementX: number): void {
|
||||
const leftWidth = firstElement.value.offsetWidth + displacementX;
|
||||
firstElement.value.style.width = `${leftWidth}px`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
firstElement,
|
||||
onResize,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
87
src/presentation/components/Scripts/Slider/SliderHandle.vue
Normal file
87
src/presentation/components/Scripts/Slider/SliderHandle.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div
|
||||
class="handle"
|
||||
:style="{ cursor: cursorCssValue }"
|
||||
@mousedown="startResize">
|
||||
<div class="line" />
|
||||
<font-awesome-icon
|
||||
class="icon"
|
||||
:icon="['fas', 'arrows-alt-h']"
|
||||
/>
|
||||
<div class="line" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
resized: (displacementX: number) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
const cursorCssValue = 'ew-resize';
|
||||
let initialX: number | undefined;
|
||||
|
||||
const resize = (event) => {
|
||||
const displacementX = event.clientX - initialX;
|
||||
emit('resized', displacementX);
|
||||
initialX = event.clientX;
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
document.body.style.removeProperty('cursor');
|
||||
document.removeEventListener('mousemove', resize);
|
||||
window.removeEventListener('mouseup', stopResize);
|
||||
};
|
||||
|
||||
function startResize(event: MouseEvent): void {
|
||||
initialX = event.clientX;
|
||||
document.body.style.setProperty('cursor', cursorCssValue);
|
||||
document.addEventListener('mousemove', resize);
|
||||
window.addEventListener('mouseup', stopResize);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
return {
|
||||
cursorCssValue,
|
||||
startResize,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
$color : $color-primary-dark;
|
||||
$color-hover : $color-primary;
|
||||
|
||||
.handle {
|
||||
@include clickable($cursor: 'ew-resize');
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@include hover-or-touch {
|
||||
.line {
|
||||
background: $color-hover;
|
||||
}
|
||||
.image {
|
||||
color: $color-hover;
|
||||
}
|
||||
}
|
||||
.line {
|
||||
flex: 1;
|
||||
background: $color;
|
||||
width: 3px;
|
||||
}
|
||||
.icon {
|
||||
color: $color;
|
||||
}
|
||||
margin-right: 5px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -19,24 +19,26 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import TheCodeArea from '@/presentation/components/Code/TheCodeArea.vue';
|
||||
import TheScriptsView from '@/presentation/components/Scripts/View/TheScriptsView.vue';
|
||||
import TheScriptsMenu from '@/presentation/components/Scripts/Menu/TheScriptsMenu.vue';
|
||||
import HorizontalResizeSlider from '@/presentation/components/Scripts/Slider/HorizontalResizeSlider.vue';
|
||||
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
TheCodeArea,
|
||||
TheScriptsView,
|
||||
TheScriptsMenu,
|
||||
HorizontalResizeSlider,
|
||||
},
|
||||
})
|
||||
export default class TheScriptArea extends Vue {
|
||||
public currentView = ViewType.Cards;
|
||||
}
|
||||
setup() {
|
||||
const currentView = ref(ViewType.Cards);
|
||||
|
||||
return { currentView };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Responsive v-on:widthChanged="width = $event">
|
||||
<SizeObserver v-on:widthChanged="width = $event">
|
||||
<!--
|
||||
<div id="responsivity-debug">
|
||||
Width: {{ width || 'undefined' }}
|
||||
@@ -25,86 +25,85 @@
|
||||
v-bind:key="categoryId"
|
||||
:categoryId="categoryId"
|
||||
:activeCategoryId="activeCategoryId"
|
||||
v-on:selected="onSelected(categoryId, $event)"
|
||||
v-on:cardExpansionChanged="onSelected(categoryId, $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="error">Something went bad 😢</div>
|
||||
</Responsive>
|
||||
</SizeObserver>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component } from 'vue-property-decorator';
|
||||
import Responsive from '@/presentation/components/Shared/Responsive.vue';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import { ICategory } from '@/domain/ICategory';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import {
|
||||
defineComponent, ref, onMounted, onUnmounted, computed,
|
||||
} from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
|
||||
import { hasDirective } from './NonCollapsingDirective';
|
||||
import CardListItem from './CardListItem.vue';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
CardListItem,
|
||||
Responsive,
|
||||
SizeObserver,
|
||||
},
|
||||
})
|
||||
export default class CardList extends StatefulVue {
|
||||
public width = 0;
|
||||
setup() {
|
||||
const { currentState, onStateChange } = useCollectionState();
|
||||
|
||||
public categoryIds: number[] = [];
|
||||
const width = ref<number>(0);
|
||||
const categoryIds = computed<ReadonlyArray<number>>(() => currentState
|
||||
.value.collection.actions.map((category) => category.id));
|
||||
const activeCategoryId = ref<number | undefined>(undefined);
|
||||
|
||||
public activeCategoryId?: number = null;
|
||||
|
||||
public created() {
|
||||
document.addEventListener('click', this.outsideClickListener);
|
||||
function onSelected(categoryId: number, isExpanded: boolean) {
|
||||
activeCategoryId.value = isExpanded ? categoryId : undefined;
|
||||
}
|
||||
|
||||
public destroyed() {
|
||||
document.removeEventListener('click', this.outsideClickListener);
|
||||
}
|
||||
onStateChange(() => {
|
||||
collapseAllCards();
|
||||
}, { immediate: true });
|
||||
|
||||
public onSelected(categoryId: number, isExpanded: boolean) {
|
||||
this.activeCategoryId = isExpanded ? categoryId : undefined;
|
||||
const outsideClickListener = (event: PointerEvent): void => {
|
||||
if (areAllCardsCollapsed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
||||
this.setCategories(newState.collection.actions);
|
||||
this.activeCategoryId = undefined;
|
||||
const element = document.querySelector(`[data-category="${activeCategoryId.value}"]`);
|
||||
const target = event.target as Element;
|
||||
if (element && !element.contains(target)) {
|
||||
onOutsideOfActiveCardClicked(target);
|
||||
}
|
||||
};
|
||||
|
||||
private setCategories(categories: ReadonlyArray<ICategory>): void {
|
||||
this.categoryIds = categories.map((category) => category.id);
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', outsideClickListener);
|
||||
});
|
||||
|
||||
private onOutsideOfActiveCardClicked(clickedElement: Element): void {
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', outsideClickListener);
|
||||
});
|
||||
|
||||
function onOutsideOfActiveCardClicked(clickedElement: Element): void {
|
||||
if (isClickable(clickedElement) || hasDirective(clickedElement)) {
|
||||
return;
|
||||
}
|
||||
this.collapseAllCards();
|
||||
if (hasDirective(clickedElement)) {
|
||||
return;
|
||||
}
|
||||
this.activeCategoryId = null;
|
||||
collapseAllCards();
|
||||
}
|
||||
|
||||
private outsideClickListener(event: PointerEvent) {
|
||||
if (this.areAllCardsCollapsed()) {
|
||||
return;
|
||||
}
|
||||
const element = document.querySelector(`[data-category="${this.activeCategoryId}"]`);
|
||||
const target = event.target as Element;
|
||||
if (element && !element.contains(target)) {
|
||||
this.onOutsideOfActiveCardClicked(target);
|
||||
}
|
||||
function areAllCardsCollapsed(): boolean {
|
||||
return !activeCategoryId.value;
|
||||
}
|
||||
|
||||
private collapseAllCards(): void {
|
||||
this.activeCategoryId = undefined;
|
||||
function collapseAllCards(): void {
|
||||
activeCategoryId.value = undefined;
|
||||
}
|
||||
|
||||
private areAllCardsCollapsed(): boolean {
|
||||
return !this.activeCategoryId;
|
||||
}
|
||||
}
|
||||
return {
|
||||
width,
|
||||
categoryIds,
|
||||
activeCategoryId,
|
||||
onSelected,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function isClickable(element: Element) {
|
||||
const cursorName = window.getComputedStyle(element).cursor;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="card"
|
||||
v-on:click="onSelected(!isExpanded)"
|
||||
v-on:click="isExpanded = !isExpanded"
|
||||
v-bind:class="{
|
||||
'is-collapsed': !isExpanded,
|
||||
'is-inactive': activeCategoryId && activeCategoryId != categoryId,
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="card__expander__close-button">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'times']"
|
||||
v-on:click="onSelected(false)"
|
||||
v-on:click="collapse()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,74 +49,97 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Component, Prop, Watch, Emit,
|
||||
} from 'vue-property-decorator';
|
||||
defineComponent, ref, watch, computed,
|
||||
} from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
ScriptsTree,
|
||||
},
|
||||
})
|
||||
export default class CardListItem extends StatefulVue {
|
||||
@Prop() public categoryId!: number;
|
||||
props: {
|
||||
categoryId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
activeCategoryId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
cardExpansionChanged: (isExpanded: boolean) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const { events, onStateChange, currentState } = useCollectionState();
|
||||
|
||||
@Prop() public activeCategoryId!: number;
|
||||
const isExpanded = computed({
|
||||
get: () => {
|
||||
return props.activeCategoryId === props.categoryId;
|
||||
},
|
||||
set: (newValue) => {
|
||||
if (newValue) {
|
||||
scrollToCard();
|
||||
}
|
||||
emit('cardExpansionChanged', newValue);
|
||||
},
|
||||
});
|
||||
|
||||
public cardTitle = '';
|
||||
const isAnyChildSelected = ref(false);
|
||||
const areAllChildrenSelected = ref(false);
|
||||
const cardElement = ref<HTMLElement>();
|
||||
|
||||
public isExpanded = false;
|
||||
const cardTitle = computed<string | undefined>(() => {
|
||||
if (!props.categoryId || !currentState.value) {
|
||||
return undefined;
|
||||
}
|
||||
const category = currentState.value.collection.findCategory(props.categoryId);
|
||||
return category?.name;
|
||||
});
|
||||
|
||||
public isAnyChildSelected = false;
|
||||
function collapse() {
|
||||
isExpanded.value = false;
|
||||
}
|
||||
|
||||
public areAllChildrenSelected = false;
|
||||
|
||||
public async mounted() {
|
||||
const context = await this.getCurrentContext();
|
||||
this.events.register(context.state.selection.changed.on(
|
||||
() => this.updateSelectionIndicators(this.categoryId),
|
||||
onStateChange(async (state) => {
|
||||
events.unsubscribeAll();
|
||||
events.register(state.selection.changed.on(
|
||||
() => updateSelectionIndicators(props.categoryId),
|
||||
));
|
||||
await this.updateState(this.categoryId);
|
||||
await updateSelectionIndicators(props.categoryId);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(
|
||||
() => props.categoryId,
|
||||
(categoryId) => updateSelectionIndicators(categoryId),
|
||||
);
|
||||
|
||||
async function scrollToCard() {
|
||||
await sleep(400); // wait a bit to allow GUI to render the expanded card
|
||||
cardElement.value.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
@Emit('selected')
|
||||
public onSelected(isExpanded: boolean) {
|
||||
this.isExpanded = isExpanded;
|
||||
async function updateSelectionIndicators(categoryId: number) {
|
||||
const category = currentState.value.collection.findCategory(categoryId);
|
||||
const { selection } = currentState.value;
|
||||
isAnyChildSelected.value = category ? selection.isAnySelected(category) : false;
|
||||
areAllChildrenSelected.value = category ? selection.areAllSelected(category) : false;
|
||||
}
|
||||
|
||||
@Watch('activeCategoryId')
|
||||
public async onActiveCategoryChanged(value?: number) {
|
||||
this.isExpanded = value === this.categoryId;
|
||||
}
|
||||
|
||||
@Watch('isExpanded')
|
||||
public async onExpansionChanged(newValue: number, oldValue: number) {
|
||||
if (!oldValue && newValue) {
|
||||
await new Promise((resolve) => { setTimeout(resolve, 400); });
|
||||
const focusElement = this.$refs.cardElement as HTMLElement;
|
||||
focusElement.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('categoryId')
|
||||
public async updateState(value?: number) {
|
||||
const context = await this.getCurrentContext();
|
||||
const category = !value ? undefined : context.state.collection.findCategory(value);
|
||||
this.cardTitle = category ? category.name : undefined;
|
||||
await this.updateSelectionIndicators(value);
|
||||
}
|
||||
|
||||
protected handleCollectionState(): void { /* do nothing */ }
|
||||
|
||||
private async updateSelectionIndicators(categoryId: number) {
|
||||
const context = await this.getCurrentContext();
|
||||
const { selection } = context.state;
|
||||
const category = context.state.collection.findCategory(categoryId);
|
||||
this.isAnyChildSelected = category ? selection.isAnySelected(category) : false;
|
||||
this.areAllChildrenSelected = category ? selection.areAllSelected(category) : false;
|
||||
}
|
||||
}
|
||||
return {
|
||||
cardTitle,
|
||||
isExpanded,
|
||||
isAnyChildSelected,
|
||||
areAllChildrenSelected,
|
||||
cardElement,
|
||||
collapse,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DirectiveOptions } from 'vue';
|
||||
import { ObjectDirective } from 'vue';
|
||||
|
||||
const attributeName = 'data-interaction-does-not-collapse';
|
||||
|
||||
@@ -10,8 +10,8 @@ export function hasDirective(el: Element): boolean {
|
||||
return !!parent;
|
||||
}
|
||||
|
||||
export const NonCollapsing: DirectiveOptions = {
|
||||
inserted(el: HTMLElement) {
|
||||
export const NonCollapsing: ObjectDirective<HTMLElement> = {
|
||||
inserted(el: HTMLElement) { // In Vue 3, use "mounted"
|
||||
el.setAttribute(attributeName, '');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ICategory, IScript } from '@/domain/ICategory';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { INode, NodeType } from './SelectableTree/Node/INode';
|
||||
import { INodeContent, NodeType } from './SelectableTree/Node/INodeContent';
|
||||
|
||||
export function parseAllCategories(collection: ICategoryCollection): INode[] | undefined {
|
||||
export function parseAllCategories(collection: ICategoryCollection): INodeContent[] | undefined {
|
||||
return createCategoryNodes(collection.actions);
|
||||
}
|
||||
|
||||
export function parseSingleCategory(
|
||||
categoryId: number,
|
||||
collection: ICategoryCollection,
|
||||
): INode[] | undefined {
|
||||
): INodeContent[] | undefined {
|
||||
const category = collection.findCategory(categoryId);
|
||||
if (!category) {
|
||||
throw new Error(`Category with id ${categoryId} does not exist`);
|
||||
@@ -34,7 +34,7 @@ export function getCategoryNodeId(category: ICategory): string {
|
||||
|
||||
function parseCategoryRecursively(
|
||||
parentCategory: ICategory,
|
||||
): INode[] {
|
||||
): INodeContent[] {
|
||||
if (!parentCategory) {
|
||||
throw new Error('parentCategory is undefined');
|
||||
}
|
||||
@@ -44,12 +44,12 @@ function parseCategoryRecursively(
|
||||
];
|
||||
}
|
||||
|
||||
function createScriptNodes(scripts: ReadonlyArray<IScript>): INode[] {
|
||||
function createScriptNodes(scripts: ReadonlyArray<IScript>): INodeContent[] {
|
||||
return (scripts || [])
|
||||
.map((script) => convertScriptToNode(script));
|
||||
}
|
||||
|
||||
function createCategoryNodes(categories: ReadonlyArray<ICategory>): INode[] {
|
||||
function createCategoryNodes(categories: ReadonlyArray<ICategory>): INodeContent[] {
|
||||
return (categories || [])
|
||||
.map((category) => ({ category, children: parseCategoryRecursively(category) }))
|
||||
.map((data) => convertCategoryToNode(data.category, data.children));
|
||||
@@ -57,8 +57,8 @@ function createCategoryNodes(categories: ReadonlyArray<ICategory>): INode[] {
|
||||
|
||||
function convertCategoryToNode(
|
||||
category: ICategory,
|
||||
children: readonly INode[],
|
||||
): INode {
|
||||
children: readonly INodeContent[],
|
||||
): INodeContent {
|
||||
return {
|
||||
id: getCategoryNodeId(category),
|
||||
type: NodeType.Category,
|
||||
@@ -69,7 +69,7 @@ function convertCategoryToNode(
|
||||
};
|
||||
}
|
||||
|
||||
function convertScriptToNode(script: IScript): INode {
|
||||
function convertScriptToNode(script: IScript): INodeContent {
|
||||
return {
|
||||
id: getScriptNodeId(script),
|
||||
type: NodeType.Script,
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Watch } from 'vue-property-decorator';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import {
|
||||
defineComponent, watch, ref,
|
||||
} from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { ICategory } from '@/domain/ICategory';
|
||||
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
@@ -26,96 +28,123 @@ import {
|
||||
getScriptId,
|
||||
} from './ScriptNodeParser';
|
||||
import SelectableTree from './SelectableTree/SelectableTree.vue';
|
||||
import { INode, NodeType } from './SelectableTree/Node/INode';
|
||||
import { INodeContent, NodeType } from './SelectableTree/Node/INodeContent';
|
||||
import { INodeSelectedEvent } from './SelectableTree/INodeSelectedEvent';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
props: {
|
||||
categoryId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
SelectableTree,
|
||||
},
|
||||
})
|
||||
export default class ScriptsTree extends StatefulVue {
|
||||
@Prop() public categoryId?: number;
|
||||
setup(props) {
|
||||
const {
|
||||
modifyCurrentState, currentState, onStateChange, events,
|
||||
} = useCollectionState();
|
||||
|
||||
public nodes?: ReadonlyArray<INode> = null;
|
||||
const nodes = ref<ReadonlyArray<INodeContent>>([]);
|
||||
const selectedNodeIds = ref<ReadonlyArray<string>>([]);
|
||||
const filterText = ref<string | undefined>(undefined);
|
||||
|
||||
public selectedNodeIds?: ReadonlyArray<string> = [];
|
||||
let filtered: IFilterResult | undefined;
|
||||
|
||||
public filterText?: string = null;
|
||||
watch(
|
||||
() => props.categoryId,
|
||||
async (newCategoryId) => { await setNodes(newCategoryId); },
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
private filtered?: IFilterResult;
|
||||
onStateChange((state) => {
|
||||
setCurrentFilter(state.filter.currentFilter);
|
||||
if (!props.categoryId) {
|
||||
nodes.value = parseAllCategories(state.collection);
|
||||
}
|
||||
events.unsubscribeAll();
|
||||
modifyCurrentState((mutableState) => {
|
||||
registerStateMutators(mutableState);
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
public async toggleNodeSelection(event: INodeSelectedEvent) {
|
||||
const context = await this.getCurrentContext();
|
||||
function toggleNodeSelection(event: INodeSelectedEvent) {
|
||||
modifyCurrentState((state) => {
|
||||
switch (event.node.type) {
|
||||
case NodeType.Category:
|
||||
toggleCategoryNodeSelection(event, context.state);
|
||||
toggleCategoryNodeSelection(event, state);
|
||||
break;
|
||||
case NodeType.Script:
|
||||
toggleScriptNodeSelection(event, context.state);
|
||||
toggleScriptNodeSelection(event, state);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown node type: ${event.node.id}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Watch('categoryId', { immediate: true })
|
||||
public async setNodes(categoryId?: number) {
|
||||
const context = await this.getCurrentContext();
|
||||
if (categoryId) {
|
||||
this.nodes = parseSingleCategory(categoryId, context.state.collection);
|
||||
} else {
|
||||
this.nodes = parseAllCategories(context.state.collection);
|
||||
function filterPredicate(node: INodeContent): boolean {
|
||||
return containsScript(node, filtered.scriptMatches)
|
||||
|| containsCategory(node, filtered.categoryMatches);
|
||||
}
|
||||
this.selectedNodeIds = context.state.selection.selectedScripts
|
||||
|
||||
async function setNodes(categoryId?: number) {
|
||||
if (categoryId) {
|
||||
nodes.value = parseSingleCategory(categoryId, currentState.value.collection);
|
||||
} else {
|
||||
nodes.value = parseAllCategories(currentState.value.collection);
|
||||
}
|
||||
selectedNodeIds.value = currentState.value.selection.selectedScripts
|
||||
.map((selected) => getScriptNodeId(selected.script));
|
||||
}
|
||||
|
||||
public filterPredicate(node: INode): boolean {
|
||||
return this.filtered.scriptMatches
|
||||
.some((script: IScript) => node.id === getScriptNodeId(script))
|
||||
|| this.filtered.categoryMatches
|
||||
.some((category: ICategory) => node.id === getCategoryNodeId(category));
|
||||
}
|
||||
|
||||
protected async handleCollectionState(newState: ICategoryCollectionState) {
|
||||
this.setCurrentFilter(newState.filter.currentFilter);
|
||||
if (!this.categoryId) {
|
||||
this.nodes = parseAllCategories(newState.collection);
|
||||
}
|
||||
this.events.unsubscribeAll();
|
||||
this.subscribeState(newState);
|
||||
}
|
||||
|
||||
private subscribeState(state: ICategoryCollectionState) {
|
||||
this.events.register(
|
||||
state.selection.changed.on(this.handleSelectionChanged),
|
||||
state.filter.filterRemoved.on(this.handleFilterRemoved),
|
||||
state.filter.filtered.on(this.handleFiltered),
|
||||
function registerStateMutators(state: ICategoryCollectionState) {
|
||||
events.register(
|
||||
state.selection.changed.on((scripts) => handleSelectionChanged(scripts)),
|
||||
state.filter.filterRemoved.on(() => handleFilterRemoved()),
|
||||
state.filter.filtered.on((filterResult) => handleFiltered(filterResult)),
|
||||
);
|
||||
}
|
||||
|
||||
private setCurrentFilter(currentFilter: IFilterResult | undefined) {
|
||||
function setCurrentFilter(currentFilter: IFilterResult | undefined) {
|
||||
if (!currentFilter) {
|
||||
this.handleFilterRemoved();
|
||||
handleFilterRemoved();
|
||||
} else {
|
||||
this.handleFiltered(currentFilter);
|
||||
handleFiltered(currentFilter);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
|
||||
this.selectedNodeIds = selectedScripts
|
||||
function handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
|
||||
selectedNodeIds.value = selectedScripts
|
||||
.map((node) => node.id);
|
||||
}
|
||||
|
||||
private handleFilterRemoved() {
|
||||
this.filterText = '';
|
||||
function handleFilterRemoved() {
|
||||
filterText.value = '';
|
||||
}
|
||||
|
||||
private handleFiltered(result: IFilterResult) {
|
||||
this.filterText = result.query;
|
||||
this.filtered = result;
|
||||
function handleFiltered(result: IFilterResult) {
|
||||
filterText.value = result.query;
|
||||
filtered = result;
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
selectedNodeIds,
|
||||
filterText,
|
||||
toggleNodeSelection,
|
||||
filterPredicate,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function containsScript(expected: INodeContent, scripts: readonly IScript[]) {
|
||||
return scripts.some((existing: IScript) => expected.id === getScriptNodeId(existing));
|
||||
}
|
||||
|
||||
function containsCategory(expected: INodeContent, categories: readonly ICategory[]) {
|
||||
return categories.some((existing: ICategory) => expected.id === getCategoryNodeId(existing));
|
||||
}
|
||||
|
||||
function toggleCategoryNodeSelection(
|
||||
@@ -144,7 +173,3 @@ function toggleScriptNodeSelection(
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { INode } from './Node/INode';
|
||||
import { INodeContent } from './Node/INodeContent';
|
||||
|
||||
export interface INodeSelectedEvent {
|
||||
isSelected: boolean;
|
||||
node: INode;
|
||||
node: INodeContent;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
declare module 'liquor-tree' {
|
||||
import { PluginObject } from 'vue';
|
||||
import { VueClass } from 'vue-class-component/lib/declarations';
|
||||
|
||||
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Tree.js
|
||||
export interface ILiquorTree {
|
||||
@@ -70,6 +69,6 @@ declare module 'liquor-tree' {
|
||||
matcher(query: string, node: ILiquorTreeExistingNode): boolean;
|
||||
}
|
||||
|
||||
const LiquorTree: PluginObject<Vue> & VueClass<Vue>;
|
||||
const LiquorTree: PluginObject<Vue>;
|
||||
export default LiquorTree;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree';
|
||||
import { INode } from '../../Node/INode';
|
||||
import { INodeContent } from '../../Node/INodeContent';
|
||||
import { convertExistingToNode } from './NodeTranslator';
|
||||
|
||||
export type FilterPredicate = (node: INode) => boolean;
|
||||
export type FilterPredicate = (node: INodeContent) => boolean;
|
||||
|
||||
export class NodePredicateFilter implements ILiquorTreeFilter {
|
||||
public emptyText = ''; // Does not matter as a custom mesage is shown
|
||||
public emptyText = ''; // Does not matter as a custom message is shown
|
||||
|
||||
constructor(private readonly filterPredicate: FilterPredicate) {
|
||||
if (!filterPredicate) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree';
|
||||
import { NodeType } from '../../Node/INode';
|
||||
import { NodeType } from '../../Node/INodeContent';
|
||||
|
||||
export function getNewState(
|
||||
node: ILiquorTreeNode,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ILiquorTreeNewNode, ILiquorTreeExistingNode } from 'liquor-tree';
|
||||
import { INode } from '../../Node/INode';
|
||||
import { INodeContent } from '../../Node/INodeContent';
|
||||
|
||||
// Functions to translate INode to LiqourTree models and vice versa for anti-corruption
|
||||
|
||||
export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode): INode {
|
||||
export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode): INodeContent {
|
||||
if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); }
|
||||
return {
|
||||
id: liquorTreeNode.id,
|
||||
@@ -16,7 +16,7 @@ export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode):
|
||||
};
|
||||
}
|
||||
|
||||
export function toNewLiquorTreeNode(node: INode): ILiquorTreeNewNode {
|
||||
export function toNewLiquorTreeNode(node: INodeContent): ILiquorTreeNewNode {
|
||||
if (!node) { throw new Error('node is undefined'); }
|
||||
return {
|
||||
id: node.id,
|
||||
|
||||
@@ -27,21 +27,29 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from 'vue-property-decorator';
|
||||
import { defineComponent, ref, PropType } from 'vue';
|
||||
import DocumentationText from './DocumentationText.vue';
|
||||
import ToggleDocumentationButton from './ToggleDocumentationButton.vue';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
DocumentationText,
|
||||
ToggleDocumentationButton,
|
||||
},
|
||||
})
|
||||
export default class Documentation extends Vue {
|
||||
@Prop() public docs!: readonly string[];
|
||||
props: {
|
||||
docs: {
|
||||
type: Array as PropType<readonly string[]>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const isExpanded = ref(false);
|
||||
|
||||
public isExpanded = false;
|
||||
}
|
||||
return {
|
||||
isExpanded,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -7,27 +7,38 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from 'vue-property-decorator';
|
||||
import { defineComponent, PropType, computed } from 'vue';
|
||||
import { createRenderer } from './MarkdownRenderer';
|
||||
|
||||
@Component
|
||||
export default class DocumentationText extends Vue {
|
||||
@Prop() public docs: readonly string[];
|
||||
export default defineComponent({
|
||||
props: {
|
||||
docs: {
|
||||
type: Array as PropType<ReadonlyArray<string>>,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const renderedText = computed<string>(() => renderText(props.docs));
|
||||
|
||||
private readonly renderer = createRenderer();
|
||||
return {
|
||||
renderedText,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
get renderedText(): string {
|
||||
if (!this.docs || this.docs.length === 0) {
|
||||
const renderer = createRenderer();
|
||||
|
||||
function renderText(docs: readonly string[] | undefined): string {
|
||||
if (!docs || docs.length === 0) {
|
||||
return '';
|
||||
}
|
||||
if (this.docs.length === 1) {
|
||||
return this.renderer.render(this.docs[0]);
|
||||
if (docs.length === 1) {
|
||||
return renderer.render(docs[0]);
|
||||
}
|
||||
const bulletpoints = this.docs
|
||||
const bulletpoints = docs
|
||||
.map((doc) => renderAsMarkdownListItem(doc))
|
||||
.join('\n');
|
||||
return this.renderer.render(bulletpoints);
|
||||
}
|
||||
return renderer.render(bulletpoints);
|
||||
}
|
||||
|
||||
function renderAsMarkdownListItem(content: string): string {
|
||||
@@ -39,7 +50,6 @@ function renderAsMarkdownListItem(content: string): string {
|
||||
.map((line) => `\n ${line}`)
|
||||
.join()}`;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss"> /* Not scoped due to element styling such as "a". */
|
||||
@@ -115,5 +125,4 @@ $text-size: 0.75em; // Lower looks bad on Firefox
|
||||
list-style: square;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<a
|
||||
class="button"
|
||||
target="_blank"
|
||||
v-bind:class="{ 'button-on': this.isOn }"
|
||||
v-bind:class="{ 'button-on': isOn }"
|
||||
v-on:click.stop
|
||||
v-on:click="toggle()"
|
||||
>
|
||||
@@ -11,22 +11,31 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
|
||||
@Component
|
||||
export default class ToggleDocumentationButton extends Vue {
|
||||
public isOn = false;
|
||||
export default defineComponent({
|
||||
emits: [
|
||||
'show',
|
||||
'hide',
|
||||
],
|
||||
setup(_, { emit }) {
|
||||
const isOn = ref(false);
|
||||
|
||||
public toggle() {
|
||||
this.isOn = !this.isOn;
|
||||
if (this.isOn) {
|
||||
this.$emit('show');
|
||||
function toggle() {
|
||||
isOn.value = !isOn.value;
|
||||
if (isOn.value) {
|
||||
emit('show');
|
||||
} else {
|
||||
this.$emit('hide');
|
||||
emit('hide');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isOn,
|
||||
toggle,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -3,11 +3,11 @@ export enum NodeType {
|
||||
Category,
|
||||
}
|
||||
|
||||
export interface INode {
|
||||
export interface INodeContent {
|
||||
readonly id: string;
|
||||
readonly text: string;
|
||||
readonly isReversible: boolean;
|
||||
readonly docs: ReadonlyArray<string>;
|
||||
readonly children?: ReadonlyArray<INode>;
|
||||
readonly children?: ReadonlyArray<INodeContent>;
|
||||
readonly type: NodeType;
|
||||
}
|
||||
@@ -1,30 +1,33 @@
|
||||
<template>
|
||||
<Documentable :docs="this.data.docs">
|
||||
<DocumentableNode :docs="data.docs">
|
||||
<div id="node">
|
||||
<div class="item text">{{ this.data.text }}</div>
|
||||
<div class="item text">{{ data.text }}</div>
|
||||
<RevertToggle
|
||||
class="item"
|
||||
v-if="data.isReversible"
|
||||
:node="data" />
|
||||
</div>
|
||||
</Documentable>
|
||||
</DocumentableNode>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import { INode } from './INode';
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
import { INodeContent } from './INodeContent';
|
||||
import RevertToggle from './RevertToggle.vue';
|
||||
import Documentable from './Documentation/Documentable.vue';
|
||||
import DocumentableNode from './Documentation/DocumentableNode.vue';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RevertToggle,
|
||||
Documentable,
|
||||
DocumentableNode,
|
||||
},
|
||||
})
|
||||
export default class Node extends Vue {
|
||||
@Prop() public data: INode;
|
||||
}
|
||||
props: {
|
||||
data: {
|
||||
type: Object as PropType<INodeContent>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -4,7 +4,7 @@
|
||||
type="checkbox"
|
||||
class="input-checkbox"
|
||||
v-model="isReverted"
|
||||
@change="onRevertToggled()"
|
||||
@change="toggleRevert()"
|
||||
v-on:click.stop>
|
||||
<div class="checkbox-animate">
|
||||
<span class="checkbox-off">revert</span>
|
||||
@@ -14,42 +14,64 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Watch } from 'vue-property-decorator';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import {
|
||||
PropType, defineComponent, ref, watch,
|
||||
} from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { IReverter } from './Reverter/IReverter';
|
||||
import { INode } from './INode';
|
||||
import { INodeContent } from './INodeContent';
|
||||
import { getReverter } from './Reverter/ReverterFactory';
|
||||
|
||||
@Component
|
||||
export default class RevertToggle extends StatefulVue {
|
||||
@Prop() public node: INode;
|
||||
export default defineComponent({
|
||||
props: {
|
||||
node: {
|
||||
type: Object as PropType<INodeContent>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const {
|
||||
currentState, modifyCurrentState, onStateChange, events,
|
||||
} = useCollectionState();
|
||||
|
||||
public isReverted = false;
|
||||
const isReverted = ref(false);
|
||||
|
||||
private handler: IReverter;
|
||||
let handler: IReverter | undefined;
|
||||
|
||||
@Watch('node', { immediate: true }) public async onNodeChanged(node: INode) {
|
||||
const context = await this.getCurrentContext();
|
||||
this.handler = getReverter(node, context.state.collection);
|
||||
watch(
|
||||
() => props.node,
|
||||
async (node) => { await onNodeChanged(node); },
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onStateChange((newState) => {
|
||||
updateStatus(newState.selection.selectedScripts);
|
||||
events.unsubscribeAll();
|
||||
events.register(newState.selection.changed.on((scripts) => updateStatus(scripts)));
|
||||
}, { immediate: true });
|
||||
|
||||
async function onNodeChanged(node: INodeContent) {
|
||||
handler = getReverter(node, currentState.value.collection);
|
||||
updateStatus(currentState.value.selection.selectedScripts);
|
||||
}
|
||||
|
||||
public async onRevertToggled() {
|
||||
const context = await this.getCurrentContext();
|
||||
this.handler.selectWithRevertState(this.isReverted, context.state.selection);
|
||||
function toggleRevert() {
|
||||
modifyCurrentState((state) => {
|
||||
handler.selectWithRevertState(isReverted.value, state.selection);
|
||||
});
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
||||
this.updateStatus(newState.selection.selectedScripts);
|
||||
this.events.unsubscribeAll();
|
||||
this.events.register(newState.selection.changed.on((scripts) => this.updateStatus(scripts)));
|
||||
async function updateStatus(scripts: ReadonlyArray<SelectedScript>) {
|
||||
isReverted.value = handler?.getState(scripts) ?? false;
|
||||
}
|
||||
|
||||
private updateStatus(scripts: ReadonlyArray<SelectedScript>) {
|
||||
this.isReverted = this.handler.getState(scripts);
|
||||
}
|
||||
}
|
||||
return {
|
||||
isReverted,
|
||||
toggleRevert,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -76,7 +98,6 @@ $size-height : 30px;
|
||||
border-radius: $size-height;
|
||||
line-height: $size-height;
|
||||
font-size: math.div($size-height, 2);
|
||||
display: inline-block;
|
||||
|
||||
input.input-checkbox {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { INode, NodeType } from '../INode';
|
||||
import { INodeContent, NodeType } from '../INodeContent';
|
||||
import { IReverter } from './IReverter';
|
||||
import { ScriptReverter } from './ScriptReverter';
|
||||
import { CategoryReverter } from './CategoryReverter';
|
||||
|
||||
export function getReverter(node: INode, collection: ICategoryCollection): IReverter {
|
||||
export function getReverter(node: INodeContent, collection: ICategoryCollection): IReverter {
|
||||
switch (node.type) {
|
||||
case NodeType.Category:
|
||||
return new CategoryReverter(node.id, collection);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<span>
|
||||
<span v-if="initialLiquorTreeNodes != null && initialLiquorTreeNodes.length > 0">
|
||||
<tree
|
||||
<span v-if="initialLiquorTreeNodes?.length > 0">
|
||||
<LiquorTree
|
||||
:options="liquorTreeOptions"
|
||||
:data="initialLiquorTreeNodes"
|
||||
v-on:node:checked="nodeSelected($event)"
|
||||
v-on:node:unchecked="nodeSelected($event)"
|
||||
ref="treeElement"
|
||||
@node:checked="nodeSelected($event)"
|
||||
@node:unchecked="nodeSelected($event)"
|
||||
ref="liquorTree"
|
||||
>
|
||||
<span class="tree-text" slot-scope="{ node }">
|
||||
<Node :data="convertExistingToNode(node)" />
|
||||
<NodeContent :data="convertExistingToNode(node)" />
|
||||
</span>
|
||||
</tree>
|
||||
</LiquorTree>
|
||||
</span>
|
||||
<span v-else>Nooo 😢</span>
|
||||
</span>
|
||||
@@ -19,76 +19,99 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Component, Prop, Vue, Watch,
|
||||
} from 'vue-property-decorator';
|
||||
PropType, defineComponent, ref, watch,
|
||||
} from 'vue';
|
||||
import LiquorTree, {
|
||||
ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree, ILiquorTreeNode, ILiquorTreeNodeState,
|
||||
} from 'liquor-tree';
|
||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
||||
import Node from './Node/Node.vue';
|
||||
import { INode } from './Node/INode';
|
||||
import NodeContent from './Node/NodeContent.vue';
|
||||
import { INodeContent } from './Node/INodeContent';
|
||||
import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeWrapper/NodeTranslator';
|
||||
import { INodeSelectedEvent } from './INodeSelectedEvent';
|
||||
import { getNewState } from './LiquorTree/NodeWrapper/NodeStateUpdater';
|
||||
import { LiquorTreeOptions } from './LiquorTree/LiquorTreeOptions';
|
||||
import { FilterPredicate, NodePredicateFilter } from './LiquorTree/NodeWrapper/NodePredicateFilter';
|
||||
|
||||
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
|
||||
@Component({
|
||||
/**
|
||||
* Wrapper for Liquor Tree, reveals only abstracted INode for communication.
|
||||
* Stateless to make it easier to switch out Liquor Tree to another component.
|
||||
*/
|
||||
export default defineComponent({
|
||||
components: {
|
||||
LiquorTree,
|
||||
Node,
|
||||
NodeContent,
|
||||
},
|
||||
})
|
||||
export default class SelectableTree extends Vue { // Stateless to make it easier to switch out
|
||||
@Prop() public filterPredicate?: FilterPredicate;
|
||||
|
||||
@Prop() public filterText?: string;
|
||||
|
||||
@Prop() public selectedNodeIds?: ReadonlyArray<string>;
|
||||
|
||||
@Prop() public initialNodes?: ReadonlyArray<INode>;
|
||||
|
||||
public initialLiquorTreeNodes?: ILiquorTreeNewNode[] = null;
|
||||
|
||||
public liquorTreeOptions = new LiquorTreeOptions(
|
||||
new NodePredicateFilter((node) => this.filterPredicate(node)),
|
||||
props: {
|
||||
filterPredicate: {
|
||||
type: Function as PropType<FilterPredicate>,
|
||||
default: undefined,
|
||||
},
|
||||
filterText: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
selectedNodeIds: {
|
||||
type: Array as PropType<ReadonlyArray<string>>,
|
||||
default: undefined,
|
||||
},
|
||||
initialNodes: {
|
||||
type: Array as PropType<ReadonlyArray<INodeContent>>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const liquorTree = ref< { tree: ILiquorTree }>();
|
||||
const initialLiquorTreeNodes = ref<ReadonlyArray<ILiquorTreeNewNode>>();
|
||||
const liquorTreeOptions = new LiquorTreeOptions(
|
||||
new NodePredicateFilter((node) => props.filterPredicate(node)),
|
||||
);
|
||||
|
||||
public convertExistingToNode = convertExistingToNode;
|
||||
|
||||
public nodeSelected(node: ILiquorTreeExistingNode) {
|
||||
function nodeSelected(node: ILiquorTreeExistingNode) {
|
||||
const event: INodeSelectedEvent = {
|
||||
node: convertExistingToNode(node),
|
||||
isSelected: node.states.checked,
|
||||
};
|
||||
this.$emit('nodeSelected', event);
|
||||
emit('nodeSelected', event);
|
||||
}
|
||||
|
||||
@Watch('initialNodes', { immediate: true })
|
||||
public async updateNodes(nodes: readonly INode[]) {
|
||||
watch(
|
||||
() => props.initialNodes,
|
||||
(nodes) => setInitialNodes(nodes),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.filterText,
|
||||
(filterText) => setFilterText(filterText),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.selectedNodeIds,
|
||||
(selectedNodeIds) => setSelectedStatus(selectedNodeIds),
|
||||
);
|
||||
|
||||
async function setInitialNodes(nodes: readonly INodeContent[]) {
|
||||
if (!nodes) {
|
||||
throw new Error('missing initial nodes');
|
||||
}
|
||||
const initialNodes = nodes.map((node) => toNewLiquorTreeNode(node));
|
||||
if (this.selectedNodeIds) {
|
||||
if (props.selectedNodeIds) {
|
||||
recurseDown(
|
||||
initialNodes,
|
||||
(node) => {
|
||||
node.state = updateState(node.state, node, this.selectedNodeIds);
|
||||
node.state = updateState(node.state, node, props.selectedNodeIds);
|
||||
},
|
||||
);
|
||||
}
|
||||
this.initialLiquorTreeNodes = initialNodes;
|
||||
const api = await this.getLiquorTreeApi();
|
||||
// We need to set the model manually on each update because liquor tree is not reactive to data
|
||||
// changes after its initialization.
|
||||
api.setModel(this.initialLiquorTreeNodes);
|
||||
initialLiquorTreeNodes.value = initialNodes;
|
||||
const api = await getLiquorTreeApi();
|
||||
api.setModel(initialLiquorTreeNodes.value);
|
||||
}
|
||||
|
||||
@Watch('filterText', { immediate: true })
|
||||
public async updateFilterText(filterText?: string) {
|
||||
const api = await this.getLiquorTreeApi();
|
||||
async function setFilterText(filterText?: string) {
|
||||
const api = await getLiquorTreeApi();
|
||||
if (!filterText) {
|
||||
api.clearFilter();
|
||||
} else {
|
||||
@@ -96,12 +119,11 @@ export default class SelectableTree extends Vue { // Stateless to make it easier
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('selectedNodeIds')
|
||||
public async setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) {
|
||||
async function setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) {
|
||||
if (!selectedNodeIds) {
|
||||
throw new Error('Selected recurseDown nodes are undefined');
|
||||
}
|
||||
const tree = await this.getLiquorTreeApi();
|
||||
const tree = await getLiquorTreeApi();
|
||||
tree.recurseDown(
|
||||
(node) => {
|
||||
node.states = updateState(node.states, node, selectedNodeIds);
|
||||
@@ -109,19 +131,27 @@ export default class SelectableTree extends Vue { // Stateless to make it easier
|
||||
);
|
||||
}
|
||||
|
||||
private async getLiquorTreeApi(): Promise<ILiquorTree> {
|
||||
const accessor = (): ILiquorTree => {
|
||||
const uiElement = this.$refs.treeElement;
|
||||
type TreeElement = typeof uiElement & { tree: ILiquorTree };
|
||||
return uiElement ? (uiElement as TreeElement).tree : undefined;
|
||||
};
|
||||
const treeElement = await tryUntilDefined(accessor, 5, 20); // Wait for it to render
|
||||
if (!treeElement) {
|
||||
async function getLiquorTreeApi(): Promise<ILiquorTree> {
|
||||
const tree = await tryUntilDefined(
|
||||
() => liquorTree.value?.tree,
|
||||
5,
|
||||
20,
|
||||
);
|
||||
if (!tree) {
|
||||
throw Error('Referenced tree element cannot be found. Perhaps it\'s not yet rendered?');
|
||||
}
|
||||
return treeElement;
|
||||
return tree;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
liquorTreeOptions,
|
||||
initialLiquorTreeNodes,
|
||||
convertExistingToNode,
|
||||
nodeSelected,
|
||||
liquorTree,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function updateState(
|
||||
old: ILiquorTreeNodeState,
|
||||
@@ -162,3 +192,4 @@ async function tryUntilDefined<T>(
|
||||
return value;
|
||||
}
|
||||
</script>
|
||||
./Node/INodeContent
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div v-else> <!-- Searching -->
|
||||
<div class="search">
|
||||
<div class="search__query">
|
||||
<div>Searching for "{{this.searchQuery | threeDotsTrim }}"</div>
|
||||
<div>Searching for "{{ trimmedSearchQuery }}"</div>
|
||||
<div class="search__query__close-button">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'times']"
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!searchHasMatches" class="search-no-matches">
|
||||
<div>Sorry, no matches for "{{this.searchQuery | threeDotsTrim }}" 😞</div>
|
||||
<div>Sorry, no matches for "{{ trimmedSearchQuery }}" 😞</div>
|
||||
<div>
|
||||
Feel free to extend the scripts
|
||||
<a :href="repositoryUrl" class="child github" target="_blank" rel="noopener noreferrer">here</a> ✨
|
||||
@@ -32,75 +32,81 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop } from 'vue-property-decorator';
|
||||
import TheGrouper from '@/presentation/components/Scripts/Menu/View/TheViewChanger.vue';
|
||||
import {
|
||||
defineComponent, PropType, ref, computed,
|
||||
} from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
|
||||
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
|
||||
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
|
||||
/** Shows content of single category or many categories */
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
TheGrouper,
|
||||
ScriptsTree,
|
||||
CardList,
|
||||
},
|
||||
filters: {
|
||||
threeDotsTrim(query: string) {
|
||||
props: {
|
||||
currentView: {
|
||||
type: Number as PropType<ViewType>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { modifyCurrentState, onStateChange, events } = useCollectionState();
|
||||
const { info } = useApplication();
|
||||
|
||||
const repositoryUrl = computed<string>(() => info.repositoryWebUrl);
|
||||
const searchQuery = ref<string>();
|
||||
const isSearching = ref(false);
|
||||
const searchHasMatches = ref(false);
|
||||
const trimmedSearchQuery = computed(() => {
|
||||
const query = searchQuery.value;
|
||||
const threshold = 30;
|
||||
if (query.length <= threshold - 3) {
|
||||
return query;
|
||||
}
|
||||
return `${query.substr(0, threshold)}...`;
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class TheScriptsView extends StatefulVue {
|
||||
public repositoryUrl = '';
|
||||
return `${query.substring(0, threshold)}...`;
|
||||
});
|
||||
|
||||
public searchQuery = '';
|
||||
onStateChange((newState) => {
|
||||
events.unsubscribeAll();
|
||||
subscribeState(newState);
|
||||
});
|
||||
|
||||
public isSearching = false;
|
||||
|
||||
public searchHasMatches = false;
|
||||
|
||||
@Prop() public currentView: ViewType;
|
||||
|
||||
public ViewType = ViewType; // Make it accessible from the view
|
||||
|
||||
public async created() {
|
||||
const app = await ApplicationFactory.Current.getApp();
|
||||
this.repositoryUrl = app.info.repositoryWebUrl;
|
||||
}
|
||||
|
||||
public async clearSearchQuery() {
|
||||
const context = await this.getCurrentContext();
|
||||
const { filter } = context.state;
|
||||
function clearSearchQuery() {
|
||||
modifyCurrentState((state) => {
|
||||
const { filter } = state;
|
||||
filter.removeFilter();
|
||||
});
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
||||
this.events.unsubscribeAll();
|
||||
this.subscribeState(newState);
|
||||
}
|
||||
|
||||
private subscribeState(state: IReadOnlyCategoryCollectionState) {
|
||||
this.events.register(
|
||||
function subscribeState(state: IReadOnlyCategoryCollectionState) {
|
||||
events.register(
|
||||
state.filter.filterRemoved.on(() => {
|
||||
this.isSearching = false;
|
||||
isSearching.value = false;
|
||||
}),
|
||||
state.filter.filtered.on((result: IFilterResult) => {
|
||||
this.searchQuery = result.query;
|
||||
this.isSearching = true;
|
||||
this.searchHasMatches = result.hasAnyMatches();
|
||||
searchQuery.value = result.query;
|
||||
isSearching.value = true;
|
||||
searchHasMatches.value = result.hasAnyMatches();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repositoryUrl,
|
||||
trimmedSearchQuery,
|
||||
isSearching,
|
||||
searchHasMatches,
|
||||
clearSearchQuery,
|
||||
ViewType,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -161,5 +167,4 @@ $margin-inner: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
18
src/presentation/components/Shared/Hooks/UseApplication.ts
Normal file
18
src/presentation/components/Shared/Hooks/UseApplication.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
import { IApplication } from '@/domain/IApplication';
|
||||
|
||||
/* Application is always static */
|
||||
let cachedApplication: IApplication;
|
||||
|
||||
// Running tests through Vue CLI throws 'Top-level-await is only supported in EcmaScript Modules'
|
||||
// This is a temporary workaround until migrating to Vite
|
||||
ApplicationFactory.Current.getApp().then((app) => {
|
||||
cachedApplication = app;
|
||||
});
|
||||
|
||||
export function useApplication(application: IApplication = cachedApplication) {
|
||||
return {
|
||||
application,
|
||||
info: application.info,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ref, computed, readonly } from 'vue';
|
||||
import { IApplicationContext, IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import { buildContext } from '@/application/Context/ApplicationContextFactory';
|
||||
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
|
||||
|
||||
let singletonContext: IApplicationContext;
|
||||
|
||||
// Running tests through Vue CLI throws 'Top-level-await is only supported in EcmaScript Modules'
|
||||
// This is a temporary workaround until migrating to Vite
|
||||
buildContext().then((context) => {
|
||||
singletonContext = context;
|
||||
});
|
||||
|
||||
export function useCollectionState(context: IApplicationContext = singletonContext) {
|
||||
const events = new EventSubscriptionCollection();
|
||||
const ownEvents = new EventSubscriptionCollection();
|
||||
|
||||
const currentState = ref<ICategoryCollectionState>(context.state);
|
||||
ownEvents.register(
|
||||
context.contextChanged.on((event) => {
|
||||
currentState.value = event.newState;
|
||||
}),
|
||||
);
|
||||
|
||||
type NewStateEventHandler = (
|
||||
newState: IReadOnlyCategoryCollectionState,
|
||||
oldState: IReadOnlyCategoryCollectionState | undefined,
|
||||
) => void;
|
||||
interface IStateCallbackSettings {
|
||||
readonly immediate: boolean;
|
||||
}
|
||||
const defaultSettings: IStateCallbackSettings = {
|
||||
immediate: false,
|
||||
};
|
||||
function onStateChange(
|
||||
handler: NewStateEventHandler,
|
||||
settings: Partial<IStateCallbackSettings> = defaultSettings,
|
||||
) {
|
||||
if (!handler) {
|
||||
throw new Error('missing state handler');
|
||||
}
|
||||
ownEvents.register(
|
||||
context.contextChanged.on((event) => {
|
||||
handler(event.newState, event.oldState);
|
||||
}),
|
||||
);
|
||||
const defaultedSettings: IStateCallbackSettings = {
|
||||
...defaultSettings,
|
||||
...settings,
|
||||
};
|
||||
if (defaultedSettings.immediate) {
|
||||
handler(context.state, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
type StateModifier = (
|
||||
state: ICategoryCollectionState,
|
||||
) => void;
|
||||
function modifyCurrentState(mutator: StateModifier) {
|
||||
if (!mutator) {
|
||||
throw new Error('missing state mutator');
|
||||
}
|
||||
mutator(context.state);
|
||||
}
|
||||
|
||||
type ContextModifier = (
|
||||
state: IApplicationContext,
|
||||
) => void;
|
||||
function modifyCurrentContext(mutator: ContextModifier) {
|
||||
if (!mutator) {
|
||||
throw new Error('missing context mutator');
|
||||
}
|
||||
mutator(context);
|
||||
}
|
||||
|
||||
return {
|
||||
modifyCurrentState,
|
||||
modifyCurrentContext,
|
||||
onStateChange,
|
||||
currentContext: context as IReadOnlyApplicationContext,
|
||||
currentState: readonly(computed<IReadOnlyCategoryCollectionState>(() => currentState.value)),
|
||||
events,
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="dialog__close-button">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'times']"
|
||||
@click="$modal.hide(name)"
|
||||
@click="hide"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,18 +18,41 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent, onMounted } from 'vue';
|
||||
|
||||
@Component
|
||||
export default class Dialog extends Vue {
|
||||
private static idCounter = 0;
|
||||
let idCounter = 0;
|
||||
|
||||
public name = (++Dialog.idCounter).toString();
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const name = (++idCounter).toString();
|
||||
|
||||
public show(): void {
|
||||
this.$modal.show(this.name);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let modal: any;
|
||||
|
||||
onMounted(async () => {
|
||||
// Hack until Vue 3, so we can use vue-js-modal
|
||||
const main = await import('@/presentation/main');
|
||||
const { getVue } = main;
|
||||
modal = getVue().$modal;
|
||||
});
|
||||
|
||||
function show(): void {
|
||||
modal.show(name);
|
||||
}
|
||||
}
|
||||
|
||||
function hide(): void {
|
||||
modal.hide();
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
modal,
|
||||
hide,
|
||||
show,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<div ref="containerElement" class="container">
|
||||
<slot ref="containerElement" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Emit } from 'vue-property-decorator';
|
||||
import { throttle } from './Throttle';
|
||||
|
||||
@Component
|
||||
export default class Responsive extends Vue {
|
||||
private width: number;
|
||||
|
||||
private height: number;
|
||||
|
||||
private observer: ResizeObserver;
|
||||
|
||||
private get container(): HTMLElement { return this.$refs.containerElement as HTMLElement; }
|
||||
|
||||
public async mounted() {
|
||||
this.width = this.container.offsetWidth;
|
||||
this.height = this.container.offsetHeight;
|
||||
const resizeCallback = throttle(() => this.updateSize(), 200);
|
||||
|
||||
if ('ResizeObserver' in window === false) {
|
||||
const module = await import('@juggle/resize-observer');
|
||||
window.ResizeObserver = module.ResizeObserver;
|
||||
}
|
||||
|
||||
this.observer = new window.ResizeObserver(resizeCallback);
|
||||
this.observer.observe(this.container);
|
||||
this.fireChangeEvents();
|
||||
}
|
||||
|
||||
public updateSize() {
|
||||
let sizeChanged = false;
|
||||
if (this.isWidthChanged()) {
|
||||
this.updateWidth(this.container.offsetWidth);
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (this.isHeightChanged()) {
|
||||
this.updateHeight(this.container.offsetHeight);
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (sizeChanged) {
|
||||
this.$emit('sizeChanged');
|
||||
}
|
||||
}
|
||||
|
||||
@Emit('widthChanged') public updateWidth(width: number) {
|
||||
this.width = width;
|
||||
}
|
||||
|
||||
@Emit('heightChanged') public updateHeight(height: number) {
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
public destroyed() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private fireChangeEvents() {
|
||||
this.updateWidth(this.container.offsetWidth);
|
||||
this.updateHeight(this.container.offsetHeight);
|
||||
this.$emit('sizeChanged');
|
||||
}
|
||||
|
||||
private isWidthChanged(): boolean {
|
||||
return this.width !== this.container.offsetWidth;
|
||||
}
|
||||
|
||||
private isHeightChanged(): boolean {
|
||||
return this.height !== this.container.offsetHeight;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: inline-block; // if inline then it has no height or weight
|
||||
}
|
||||
</style>
|
||||
103
src/presentation/components/Shared/SizeObserver.vue
Normal file
103
src/presentation/components/Shared/SizeObserver.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div ref="containerElement" class="container">
|
||||
<slot ref="containerElement" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, ref, onMounted, onBeforeUnmount,
|
||||
} from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
sizeChanged: () => true,
|
||||
widthChanged: (width: number) => true,
|
||||
heightChanged: (height: number) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
const containerElement = ref<HTMLElement>();
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let observer: ResizeObserver;
|
||||
|
||||
onMounted(async () => {
|
||||
width = containerElement.value.offsetWidth;
|
||||
height = containerElement.value.offsetHeight;
|
||||
|
||||
observer = await initializeResizeObserver(updateSize);
|
||||
observer.observe(containerElement.value);
|
||||
|
||||
fireChangeEvents();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect();
|
||||
});
|
||||
|
||||
async function initializeResizeObserver(
|
||||
callback: ResizeObserverCallback,
|
||||
): Promise<ResizeObserver> {
|
||||
if ('ResizeObserver' in window) {
|
||||
return new window.ResizeObserver(callback);
|
||||
}
|
||||
const module = await import('@juggle/resize-observer');
|
||||
return new module.ResizeObserver(callback);
|
||||
}
|
||||
|
||||
function updateSize() {
|
||||
let sizeChanged = false;
|
||||
if (isWidthChanged()) {
|
||||
updateWidth(containerElement.value.offsetWidth);
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (isHeightChanged()) {
|
||||
updateHeight(containerElement.value.offsetHeight);
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (sizeChanged) {
|
||||
emit('sizeChanged');
|
||||
}
|
||||
}
|
||||
|
||||
function updateWidth(newWidth: number) {
|
||||
width = newWidth;
|
||||
emit('widthChanged', newWidth);
|
||||
}
|
||||
|
||||
function updateHeight(newHeight: number) {
|
||||
height = newHeight;
|
||||
emit('heightChanged', newHeight);
|
||||
}
|
||||
|
||||
function fireChangeEvents() {
|
||||
updateWidth(containerElement.value.offsetWidth);
|
||||
updateHeight(containerElement.value.offsetHeight);
|
||||
emit('sizeChanged');
|
||||
}
|
||||
|
||||
function isWidthChanged(): boolean {
|
||||
return width !== containerElement.value.offsetWidth;
|
||||
}
|
||||
|
||||
function isHeightChanged(): boolean {
|
||||
return height !== containerElement.value.offsetHeight;
|
||||
}
|
||||
|
||||
return {
|
||||
containerElement,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: inline-block; // if inline then it has no height or weight
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
|
||||
import { IApplicationContext, IApplicationContextChangedEvent } from '@/application/Context/IApplicationContext';
|
||||
import { buildContext } from '@/application/Context/ApplicationContextFactory';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore because https://github.com/vuejs/vue-class-component/issues/91
|
||||
@Component
|
||||
export abstract class StatefulVue extends Vue {
|
||||
private static readonly instance = new AsyncLazy<IApplicationContext>(() => buildContext());
|
||||
|
||||
protected readonly events = new EventSubscriptionCollection();
|
||||
|
||||
private readonly ownEvents = new EventSubscriptionCollection();
|
||||
|
||||
public async mounted() {
|
||||
const context = await this.getCurrentContext();
|
||||
this.ownEvents.register(
|
||||
context.contextChanged.on((event) => this.handleStateChangedEvent(event)),
|
||||
);
|
||||
this.handleCollectionState(context.state, undefined);
|
||||
}
|
||||
|
||||
protected abstract handleCollectionState(
|
||||
newState: IReadOnlyCategoryCollectionState,
|
||||
oldState: IReadOnlyCategoryCollectionState | undefined): void;
|
||||
|
||||
protected getCurrentContext(): Promise<IApplicationContext> {
|
||||
return StatefulVue.instance.getValue();
|
||||
}
|
||||
|
||||
private handleStateChangedEvent(event: IApplicationContextChangedEvent) {
|
||||
this.handleCollectionState(event.newState, event.oldState);
|
||||
}
|
||||
}
|
||||
@@ -18,28 +18,35 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent } from 'vue';
|
||||
import { Environment } from '@/application/Environment/Environment';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import DownloadUrlListItem from './DownloadUrlListItem.vue';
|
||||
|
||||
@Component({
|
||||
components: { DownloadUrlListItem },
|
||||
})
|
||||
export default class DownloadUrlList extends Vue {
|
||||
public readonly supportedDesktops: ReadonlyArray<OperatingSystem>;
|
||||
const supportedOperativeSystems: readonly OperatingSystem[] = [
|
||||
OperatingSystem.Windows,
|
||||
OperatingSystem.Linux,
|
||||
OperatingSystem.macOS,
|
||||
];
|
||||
|
||||
public readonly hasCurrentOsDesktopVersion: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const supportedOperativeSystems = [
|
||||
OperatingSystem.Windows, OperatingSystem.Linux, OperatingSystem.macOS];
|
||||
export default defineComponent({
|
||||
components: {
|
||||
DownloadUrlListItem,
|
||||
},
|
||||
setup() {
|
||||
const currentOs = Environment.CurrentEnvironment.os;
|
||||
this.supportedDesktops = supportedOperativeSystems.sort((os) => (os === currentOs ? 0 : 1));
|
||||
this.hasCurrentOsDesktopVersion = supportedOperativeSystems.includes(currentOs);
|
||||
}
|
||||
}
|
||||
const supportedDesktops = [
|
||||
...supportedOperativeSystems,
|
||||
].sort((os) => (os === currentOs ? 0 : 1));
|
||||
|
||||
const hasCurrentOsDesktopVersion = supportedOperativeSystems.includes(currentOs);
|
||||
|
||||
return {
|
||||
supportedDesktops,
|
||||
hasCurrentOsDesktopVersion,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -11,42 +11,48 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Component, Prop, Watch, Vue,
|
||||
} from 'vue-property-decorator';
|
||||
defineComponent, PropType, computed,
|
||||
} from 'vue';
|
||||
import { Environment } from '@/application/Environment/Environment';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
|
||||
@Component
|
||||
export default class DownloadUrlListItem extends Vue {
|
||||
@Prop() public operatingSystem!: OperatingSystem;
|
||||
const currentOs = Environment.CurrentEnvironment.os;
|
||||
|
||||
public downloadUrl = '';
|
||||
export default defineComponent({
|
||||
props: {
|
||||
operatingSystem: {
|
||||
type: Number as PropType<OperatingSystem>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { info } = useApplication();
|
||||
|
||||
public operatingSystemName = '';
|
||||
const isCurrentOs = computed<boolean>(() => {
|
||||
return currentOs === props.operatingSystem;
|
||||
});
|
||||
|
||||
public isCurrentOs = false;
|
||||
const operatingSystemName = computed<string>(() => {
|
||||
return getOperatingSystemName(props.operatingSystem);
|
||||
});
|
||||
|
||||
public hasCurrentOsDesktopVersion = false;
|
||||
const hasCurrentOsDesktopVersion = computed<boolean>(() => {
|
||||
return hasDesktopVersion(props.operatingSystem);
|
||||
});
|
||||
|
||||
public async mounted() {
|
||||
await this.onOperatingSystemChanged(this.operatingSystem);
|
||||
}
|
||||
const downloadUrl = computed<string | undefined>(() => {
|
||||
return info.getDownloadUrl(props.operatingSystem);
|
||||
});
|
||||
|
||||
@Watch('operatingSystem')
|
||||
public async onOperatingSystemChanged(os: OperatingSystem) {
|
||||
const currentOs = Environment.CurrentEnvironment.os;
|
||||
this.isCurrentOs = os === currentOs;
|
||||
this.downloadUrl = await getDownloadUrl(os);
|
||||
this.operatingSystemName = getOperatingSystemName(os);
|
||||
this.hasCurrentOsDesktopVersion = hasDesktopVersion(currentOs);
|
||||
}
|
||||
}
|
||||
|
||||
async function getDownloadUrl(os: OperatingSystem): Promise<string> {
|
||||
const context = await ApplicationFactory.Current.getApp();
|
||||
return context.info.getDownloadUrl(os);
|
||||
}
|
||||
return {
|
||||
downloadUrl,
|
||||
operatingSystemName,
|
||||
isCurrentOs,
|
||||
hasCurrentOsDesktopVersion,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function hasDesktopVersion(os: OperatingSystem): boolean {
|
||||
return os === OperatingSystem.Windows
|
||||
|
||||
@@ -41,30 +41,26 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { Environment } from '@/application/Environment/Environment';
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
import { IApplication } from '@/domain/IApplication';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
|
||||
@Component
|
||||
export default class PrivacyPolicy extends Vue {
|
||||
public repositoryUrl = '';
|
||||
const { isDesktop } = Environment.CurrentEnvironment;
|
||||
|
||||
public feedbackUrl = '';
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { info } = useApplication();
|
||||
|
||||
public isDesktop = Environment.CurrentEnvironment.isDesktop;
|
||||
const repositoryUrl = computed<string>(() => info.repositoryUrl);
|
||||
const feedbackUrl = computed<string>(() => info.feedbackUrl);
|
||||
|
||||
public async created() {
|
||||
const app = await ApplicationFactory.Current.getApp();
|
||||
this.initialize(app);
|
||||
}
|
||||
|
||||
private initialize(app: IApplication) {
|
||||
const { info } = app;
|
||||
this.repositoryUrl = info.repositoryWebUrl;
|
||||
this.feedbackUrl = info.feedbackUrl;
|
||||
}
|
||||
}
|
||||
return {
|
||||
repositoryUrl,
|
||||
feedbackUrl,
|
||||
isDesktop,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -33,57 +33,58 @@
|
||||
</div>
|
||||
<div class="footer__section__item">
|
||||
<font-awesome-icon class="icon" :icon="['fas', 'user-secret']" />
|
||||
<a @click="$refs.privacyDialog.show()">Privacy</a>
|
||||
<a @click="privacyDialog.show()">Privacy</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog ref="privacyDialog">
|
||||
<ModalDialog ref="privacyDialog">
|
||||
<PrivacyPolicy />
|
||||
</Dialog>
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent, ref, computed } from 'vue';
|
||||
import { Environment } from '@/application/Environment/Environment';
|
||||
import Dialog from '@/presentation/components/Shared/Dialog.vue';
|
||||
import { IApplication } from '@/domain/IApplication';
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
import ModalDialog from '@/presentation/components/Shared/ModalDialog.vue';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
import DownloadUrlList from './DownloadUrlList.vue';
|
||||
import PrivacyPolicy from './PrivacyPolicy.vue';
|
||||
|
||||
@Component({
|
||||
const { isDesktop } = Environment.CurrentEnvironment;
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
Dialog, PrivacyPolicy, DownloadUrlList,
|
||||
ModalDialog,
|
||||
PrivacyPolicy,
|
||||
DownloadUrlList,
|
||||
},
|
||||
})
|
||||
export default class TheFooter extends Vue {
|
||||
public readonly isDesktop = Environment.CurrentEnvironment.isDesktop;
|
||||
setup() {
|
||||
const { info } = useApplication();
|
||||
|
||||
public version = '';
|
||||
const privacyDialog = ref<typeof ModalDialog>();
|
||||
|
||||
public repositoryUrl = '';
|
||||
const version = computed<string>(() => info.version.toString());
|
||||
|
||||
public releaseUrl = '';
|
||||
const homepageUrl = computed<string>(() => info.homepage);
|
||||
|
||||
public feedbackUrl = '';
|
||||
const repositoryUrl = computed<string>(() => info.repositoryWebUrl);
|
||||
|
||||
public homepageUrl = '';
|
||||
const releaseUrl = computed<string>(() => info.releaseUrl);
|
||||
|
||||
public async created() {
|
||||
const app = await ApplicationFactory.Current.getApp();
|
||||
this.initialize(app);
|
||||
}
|
||||
const feedbackUrl = computed<string>(() => info.feedbackUrl);
|
||||
|
||||
private initialize(app: IApplication) {
|
||||
const { info } = app;
|
||||
this.version = info.version.toString();
|
||||
this.homepageUrl = info.homepage;
|
||||
this.repositoryUrl = info.repositoryWebUrl;
|
||||
this.releaseUrl = info.releaseUrl;
|
||||
this.feedbackUrl = info.feedbackUrl;
|
||||
}
|
||||
}
|
||||
return {
|
||||
isDesktop,
|
||||
privacyDialog,
|
||||
version,
|
||||
homepageUrl,
|
||||
repositoryUrl,
|
||||
releaseUrl,
|
||||
feedbackUrl,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
<template>
|
||||
<div id="container">
|
||||
<h1 class="child title">{{ title }}</h1>
|
||||
<h2 class="child subtitle">Now you have the choice</h2>
|
||||
<h2 class="child subtitle">{{ subtitle }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
|
||||
@Component
|
||||
export default class TheHeader extends Vue {
|
||||
public title = '';
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { info } = useApplication();
|
||||
|
||||
public subtitle = '';
|
||||
const title = computed(() => info.name);
|
||||
const subtitle = computed(() => info.slogan);
|
||||
|
||||
public async created() {
|
||||
const app = await ApplicationFactory.Current.getApp();
|
||||
this.title = app.info.name;
|
||||
this.subtitle = app.info.slogan;
|
||||
}
|
||||
}
|
||||
return {
|
||||
title,
|
||||
subtitle,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<input
|
||||
type="search"
|
||||
class="search-term"
|
||||
:placeholder="searchPlaceHolder"
|
||||
:placeholder="searchPlaceholder"
|
||||
v-model="searchQuery"
|
||||
>
|
||||
<div class="icon-wrapper">
|
||||
@@ -13,53 +13,75 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Watch } from 'vue-property-decorator';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import {
|
||||
defineComponent, ref, watch, computed,
|
||||
} from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
|
||||
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
|
||||
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
|
||||
@Component({
|
||||
directives: { NonCollapsing },
|
||||
})
|
||||
export default class TheSearchBar extends StatefulVue {
|
||||
public searchPlaceHolder = 'Search';
|
||||
export default defineComponent({
|
||||
directives: {
|
||||
NonCollapsing,
|
||||
},
|
||||
setup() {
|
||||
const {
|
||||
modifyCurrentState, onStateChange, events, currentState,
|
||||
} = useCollectionState();
|
||||
|
||||
public searchQuery = '';
|
||||
const searchPlaceholder = computed<string>(() => {
|
||||
const { totalScripts } = currentState.value.collection;
|
||||
return `Search in ${totalScripts} scripts`;
|
||||
});
|
||||
const searchQuery = ref<string>();
|
||||
|
||||
@Watch('searchQuery')
|
||||
public async updateFilter(newFilter?: string) {
|
||||
const context = await this.getCurrentContext();
|
||||
const { filter } = context.state;
|
||||
watch(searchQuery, (newFilter) => updateFilter(newFilter));
|
||||
|
||||
function updateFilter(newFilter: string) {
|
||||
modifyCurrentState((state) => {
|
||||
const { filter } = state;
|
||||
if (!newFilter) {
|
||||
filter.removeFilter();
|
||||
} else {
|
||||
filter.setFilter(newFilter);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState) {
|
||||
const { totalScripts } = newState.collection;
|
||||
this.searchPlaceHolder = `Search in ${totalScripts} scripts`;
|
||||
this.searchQuery = newState.filter.currentFilter ? newState.filter.currentFilter.query : '';
|
||||
this.events.unsubscribeAll();
|
||||
this.subscribeFilter(newState.filter);
|
||||
onStateChange((newState) => {
|
||||
events.unsubscribeAll();
|
||||
subscribeSearchQuery(newState);
|
||||
}, { immediate: true });
|
||||
|
||||
function subscribeSearchQuery(newState: IReadOnlyCategoryCollectionState) {
|
||||
searchQuery.value = newState.filter.currentFilter ? newState.filter.currentFilter.query : '';
|
||||
subscribeFilter(newState.filter);
|
||||
}
|
||||
|
||||
private subscribeFilter(filter: IReadOnlyUserFilter) {
|
||||
this.events.register(filter.filtered.on((result) => this.handleFiltered(result)));
|
||||
this.events.register(filter.filterRemoved.on(() => this.handleFilterRemoved()));
|
||||
function subscribeFilter(filter: IReadOnlyUserFilter) {
|
||||
events.register(
|
||||
filter.filtered.on((result) => handleFiltered(result)),
|
||||
filter.filterRemoved.on(() => handleFilterRemoved()),
|
||||
);
|
||||
}
|
||||
|
||||
private handleFiltered(result: IFilterResult) {
|
||||
this.searchQuery = result.query;
|
||||
function handleFilterRemoved() {
|
||||
searchQuery.value = '';
|
||||
}
|
||||
|
||||
private handleFilterRemoved() {
|
||||
this.searchQuery = '';
|
||||
function handleFiltered(result: IFilterResult) {
|
||||
searchQuery.value = result.query;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
searchPlaceholder,
|
||||
searchQuery,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import Vue from 'vue';
|
||||
import { buildContext } from '@/application/Context/ApplicationContextFactory';
|
||||
import App from './components/App.vue';
|
||||
import { ApplicationBootstrapper } from './bootstrapping/ApplicationBootstrapper';
|
||||
|
||||
new ApplicationBootstrapper()
|
||||
let vue: Vue;
|
||||
|
||||
buildContext().then(() => {
|
||||
// hack workaround to solve running tests through
|
||||
// Vue CLI throws 'Top-level-await is only supported in EcmaScript Modules'
|
||||
// once migrated to vite, remove buildContext() call from here and use top-level-await
|
||||
new ApplicationBootstrapper()
|
||||
.bootstrap(Vue);
|
||||
|
||||
new Vue({
|
||||
vue = new Vue({
|
||||
render: (h) => h(App),
|
||||
}).$mount('#app');
|
||||
}).$mount('#app');
|
||||
});
|
||||
|
||||
export const getVue = () => vue; // exporting is hack until Vue 3 so vue-js-modal can be used
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
|
||||
describe('UseApplication', () => {
|
||||
it('should return the actual application from factory', async () => {
|
||||
// arrange
|
||||
const expected = await ApplicationFactory.Current.getApp();
|
||||
|
||||
// act
|
||||
const { application } = useApplication(expected);
|
||||
|
||||
// assert
|
||||
expect(application).to.equal(expected);
|
||||
});
|
||||
|
||||
it('should return the actual info from the application', async () => {
|
||||
// arrange
|
||||
const app = await ApplicationFactory.Current.getApp();
|
||||
const expected = app.info;
|
||||
|
||||
// act
|
||||
const { info } = useApplication();
|
||||
|
||||
// assert
|
||||
expect(info).to.equal(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
|
||||
describe('UseCollectionState', () => {
|
||||
describe('currentContext', () => {
|
||||
it('multiple calls get the same instance', () => {
|
||||
// act
|
||||
const firstContext = useCollectionState().currentContext;
|
||||
const secondContext = useCollectionState().currentContext;
|
||||
// assert
|
||||
expect(firstContext).to.equal(secondContext);
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentState', () => {
|
||||
it('returns current collection state', () => {
|
||||
// arrange
|
||||
const { currentContext } = useCollectionState();
|
||||
const expectedState = currentContext.state;
|
||||
|
||||
// act
|
||||
const { currentState } = useCollectionState();
|
||||
const actualState = currentState.value;
|
||||
|
||||
// assert
|
||||
expect(expectedState).to.equal(actualState);
|
||||
});
|
||||
it('returns changed collection state', () => {
|
||||
// arrange
|
||||
const { currentContext, currentState, modifyCurrentContext } = useCollectionState();
|
||||
const newOs = pickNonCurrentOs(currentContext);
|
||||
|
||||
// act
|
||||
modifyCurrentContext((context) => {
|
||||
context.changeContext(newOs);
|
||||
});
|
||||
const expectedState = currentContext.state;
|
||||
|
||||
// assert
|
||||
expect(currentState.value).to.equal(expectedState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modifyCurrentContext', () => {
|
||||
it('modifies the current context', () => {
|
||||
// arrange
|
||||
const { currentContext, currentState, modifyCurrentContext } = useCollectionState();
|
||||
const expectedOs = pickNonCurrentOs(currentContext);
|
||||
|
||||
// act
|
||||
modifyCurrentContext((context) => {
|
||||
context.changeContext(expectedOs);
|
||||
});
|
||||
|
||||
// assert
|
||||
expect(currentContext.state.os).to.equal(expectedOs);
|
||||
expect(currentState.value.os).to.equal(expectedOs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modifyCurrentState', () => {
|
||||
it('modifies the current state', () => {
|
||||
// arrange
|
||||
const { currentState, modifyCurrentState } = useCollectionState();
|
||||
const expectedFilter = 'expected-filter';
|
||||
|
||||
// act
|
||||
modifyCurrentState((state) => {
|
||||
state.filter.setFilter(expectedFilter);
|
||||
});
|
||||
|
||||
// assert
|
||||
const actualFilter = currentState.value.filter.currentFilter.query;
|
||||
expect(actualFilter).to.equal(expectedFilter);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function pickNonCurrentOs(context: IReadOnlyApplicationContext) {
|
||||
return context.app.getSupportedOsList().find((os) => os !== context.state.os);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getScriptNodeId, getScriptId, getCategoryNodeId, getCategoryId, parseSingleCategory,
|
||||
parseAllCategories,
|
||||
} from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser';
|
||||
import { INode, NodeType } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INode';
|
||||
import { INodeContent, NodeType } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { ICategory } from '@/domain/ICategory';
|
||||
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
||||
@@ -87,7 +87,7 @@ function isReversible(category: ICategory): boolean {
|
||||
return category.subCategories.every((c) => isReversible(c));
|
||||
}
|
||||
|
||||
function expectSameCategory(node: INode, category: ICategory): void {
|
||||
function expectSameCategory(node: INodeContent, category: ICategory): void {
|
||||
expect(node.type).to.equal(NodeType.Category, getErrorMessage('type'));
|
||||
expect(node.id).to.equal(getCategoryNodeId(category), getErrorMessage('id'));
|
||||
expect(node.docs).to.equal(category.docs, getErrorMessage('docs'));
|
||||
@@ -107,7 +107,7 @@ function expectSameCategory(node: INode, category: ICategory): void {
|
||||
}
|
||||
}
|
||||
|
||||
function expectSameScript(node: INode, script: IScript): void {
|
||||
function expectSameScript(node: INodeContent, script: IScript): void {
|
||||
expect(node.type).to.equal(NodeType.Script, getErrorMessage('type'));
|
||||
expect(node.id).to.equal(getScriptNodeId(script), getErrorMessage('id'));
|
||||
expect(node.docs).to.equal(script.docs, getErrorMessage('docs'));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { ILiquorTreeExistingNode } from 'liquor-tree';
|
||||
import { NodeType, INode } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INode';
|
||||
import { NodeType, INodeContent } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent';
|
||||
import { NodePredicateFilter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter';
|
||||
|
||||
describe('NodePredicateFilter', () => {
|
||||
@@ -18,7 +18,7 @@ describe('NodePredicateFilter', () => {
|
||||
states: undefined,
|
||||
children: [],
|
||||
};
|
||||
const expected: INode = {
|
||||
const expected: INodeContent = {
|
||||
id: 'script',
|
||||
text: 'script-text',
|
||||
isReversible: false,
|
||||
@@ -26,8 +26,8 @@ describe('NodePredicateFilter', () => {
|
||||
children: [],
|
||||
type: NodeType.Script,
|
||||
};
|
||||
let actual: INode;
|
||||
const predicate = (node: INode) => { actual = node; return true; };
|
||||
let actual: INodeContent;
|
||||
const predicate = (node: INodeContent) => { actual = node; return true; };
|
||||
const sut = new NodePredicateFilter(predicate);
|
||||
// act
|
||||
sut.matcher('nop query', object);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { ILiquorTreeNode } from 'liquor-tree';
|
||||
import { NodeType } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INode';
|
||||
import { NodeType } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent';
|
||||
import { getNewState } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater';
|
||||
|
||||
describe('NodeStateUpdater', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from 'chai';
|
||||
import {
|
||||
ILiquorTreeExistingNode, ILiquorTreeNewNode, ILiquorTreeNodeData, ICustomLiquorTreeData,
|
||||
} from 'liquor-tree';
|
||||
import { NodeType, INode } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INode';
|
||||
import { NodeType, INodeContent } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent';
|
||||
import { convertExistingToNode, toNewLiquorTreeNode } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator';
|
||||
|
||||
describe('NodeTranslator', () => {
|
||||
@@ -27,7 +27,7 @@ describe('NodeTranslator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
function getNode(): INode {
|
||||
function getNode(): INodeContent {
|
||||
return {
|
||||
id: '1',
|
||||
text: 'parentcategory',
|
||||
@@ -62,7 +62,7 @@ function getNode(): INode {
|
||||
};
|
||||
}
|
||||
|
||||
function getExpectedExistingNodeData(node: INode): ILiquorTreeNodeData {
|
||||
function getExpectedExistingNodeData(node: INodeContent): ILiquorTreeNodeData {
|
||||
return {
|
||||
text: node.text,
|
||||
type: node.type,
|
||||
@@ -71,7 +71,7 @@ function getExpectedExistingNodeData(node: INode): ILiquorTreeNodeData {
|
||||
};
|
||||
}
|
||||
|
||||
function getExpectedNewNodeData(node: INode): ICustomLiquorTreeData {
|
||||
function getExpectedNewNodeData(node: INodeContent): ICustomLiquorTreeData {
|
||||
return {
|
||||
type: node.type,
|
||||
docs: node.docs,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { INode, NodeType } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INode';
|
||||
import { INodeContent, NodeType } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent';
|
||||
import { getReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/ReverterFactory';
|
||||
import { ScriptReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/ScriptReverter';
|
||||
import { CategoryReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/CategoryReverter';
|
||||
@@ -14,7 +14,7 @@ describe('ReverterFactory', () => {
|
||||
it('gets CategoryReverter for category node', () => {
|
||||
// arrange
|
||||
const category = new CategoryStub(0).withScriptIds('55');
|
||||
const node = getNodeStub(getCategoryNodeId(category), NodeType.Category);
|
||||
const node = getNodeContentStub(getCategoryNodeId(category), NodeType.Category);
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(category);
|
||||
// act
|
||||
@@ -25,7 +25,7 @@ describe('ReverterFactory', () => {
|
||||
it('gets ScriptReverter for script node', () => {
|
||||
// arrange
|
||||
const script = new ScriptStub('test');
|
||||
const node = getNodeStub(getScriptNodeId(script), NodeType.Script);
|
||||
const node = getNodeContentStub(getScriptNodeId(script), NodeType.Script);
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(0).withScript(script));
|
||||
// act
|
||||
@@ -34,7 +34,7 @@ describe('ReverterFactory', () => {
|
||||
expect(result instanceof ScriptReverter).to.equal(true);
|
||||
});
|
||||
});
|
||||
function getNodeStub(nodeId: string, type: NodeType): INode {
|
||||
function getNodeContentStub(nodeId: string, type: NodeType): INodeContent {
|
||||
return {
|
||||
id: nodeId,
|
||||
text: 'text',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
import { ApplicationStub } from '@tests/unit/shared/Stubs/ApplicationStub';
|
||||
import { ProjectInformationStub } from '@tests/unit/shared/Stubs/ProjectInformationStub';
|
||||
|
||||
describe('UseApplication', () => {
|
||||
it('should return expected info', () => {
|
||||
// arrange
|
||||
const expectedInfo = new ProjectInformationStub()
|
||||
.withName('expected-project-information');
|
||||
const application = new ApplicationStub()
|
||||
.withProjectInformation(expectedInfo);
|
||||
// act
|
||||
const { info } = useApplication(application);
|
||||
// assert
|
||||
expect(info).to.equal(expectedInfo);
|
||||
});
|
||||
|
||||
it('should return expected application', () => {
|
||||
// arrange
|
||||
const expectedApp = new ApplicationStub()
|
||||
.withProjectInformation(
|
||||
new ProjectInformationStub().withName('expected-application'),
|
||||
);
|
||||
// act
|
||||
const { application } = useApplication(expectedApp);
|
||||
// assert
|
||||
expect(application).to.equal(expectedApp);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
|
||||
import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { ApplicationContextChangedEventStub } from '@tests/unit/shared/Stubs/ApplicationContextChangedEventStub';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('UseCollectionState', () => {
|
||||
describe('currentContext', () => {
|
||||
it('returns current context', () => {
|
||||
// arrange
|
||||
const expected = new ApplicationContextStub();
|
||||
|
||||
// act
|
||||
const { currentContext } = useCollectionState(expected);
|
||||
|
||||
// assert
|
||||
expect(currentContext).to.equal(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentState', () => {
|
||||
it('returns current collection state', () => {
|
||||
// arrange
|
||||
const expected = new CategoryCollectionStateStub();
|
||||
const context = new ApplicationContextStub()
|
||||
.withState(expected);
|
||||
|
||||
// act
|
||||
const { currentState } = useCollectionState(context);
|
||||
|
||||
// assert
|
||||
expect(currentState.value).to.equal(expected);
|
||||
});
|
||||
it('returns changed collection state', () => {
|
||||
// arrange
|
||||
const newState = new CategoryCollectionStateStub();
|
||||
const context = new ApplicationContextStub();
|
||||
const { currentState } = useCollectionState(context);
|
||||
|
||||
// act
|
||||
context.dispatchContextChange(
|
||||
new ApplicationContextChangedEventStub().withNewState(newState),
|
||||
);
|
||||
|
||||
// assert
|
||||
expect(currentState.value).to.equal(newState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onStateChange', () => {
|
||||
describe('throws when callback is absent', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing state handler';
|
||||
const context = new ApplicationContextStub();
|
||||
const { onStateChange } = useCollectionState(context);
|
||||
// act
|
||||
const act = () => onStateChange(absentValue);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
it('call handler when context state changes', () => {
|
||||
// arrange
|
||||
const expected = true;
|
||||
const context = new ApplicationContextStub();
|
||||
const { onStateChange } = useCollectionState(context);
|
||||
let wasCalled = false;
|
||||
|
||||
// act
|
||||
onStateChange(() => {
|
||||
wasCalled = true;
|
||||
});
|
||||
context.dispatchContextChange();
|
||||
|
||||
// assert
|
||||
expect(wasCalled).to.equal(expected);
|
||||
});
|
||||
it('call handler immediately when immediate is true', () => {
|
||||
// arrange
|
||||
const expected = true;
|
||||
const context = new ApplicationContextStub();
|
||||
const { onStateChange } = useCollectionState(context);
|
||||
let wasCalled = false;
|
||||
|
||||
// act
|
||||
onStateChange(() => {
|
||||
wasCalled = true;
|
||||
}, { immediate: true });
|
||||
|
||||
// assert
|
||||
expect(wasCalled).to.equal(expected);
|
||||
});
|
||||
it('does not call handler immediately when immediate is false', () => {
|
||||
// arrange
|
||||
const expected = false;
|
||||
const context = new ApplicationContextStub();
|
||||
const { onStateChange } = useCollectionState(context);
|
||||
let wasCalled = false;
|
||||
|
||||
// act
|
||||
onStateChange(() => {
|
||||
wasCalled = true;
|
||||
}, { immediate: false });
|
||||
|
||||
// assert
|
||||
expect(wasCalled).to.equal(expected);
|
||||
});
|
||||
it('call multiple handlers when context state changes', () => {
|
||||
// arrange
|
||||
const expected = 5;
|
||||
const context = new ApplicationContextStub();
|
||||
const { onStateChange } = useCollectionState(context);
|
||||
let totalCalled = 0;
|
||||
|
||||
// act
|
||||
onStateChange(() => {
|
||||
totalCalled++;
|
||||
}, { immediate: false });
|
||||
for (let i = 0; i < expected; i++) {
|
||||
context.dispatchContextChange();
|
||||
}
|
||||
|
||||
// assert
|
||||
expect(totalCalled).to.equal(expected);
|
||||
});
|
||||
it('call handler with new state after state changes', () => {
|
||||
// arrange
|
||||
const expected = new CategoryCollectionStateStub();
|
||||
let actual: IReadOnlyCategoryCollectionState;
|
||||
const context = new ApplicationContextStub();
|
||||
const { onStateChange } = useCollectionState(context);
|
||||
|
||||
// act
|
||||
onStateChange((newState) => {
|
||||
actual = newState;
|
||||
});
|
||||
context.dispatchContextChange(
|
||||
new ApplicationContextChangedEventStub().withNewState(expected),
|
||||
);
|
||||
|
||||
// assert
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
it('call handler with old state after state changes', () => {
|
||||
// arrange
|
||||
const expected = new CategoryCollectionStateStub();
|
||||
let actual: IReadOnlyCategoryCollectionState;
|
||||
const context = new ApplicationContextStub();
|
||||
const { onStateChange } = useCollectionState(context);
|
||||
|
||||
// act
|
||||
onStateChange((_, oldState) => {
|
||||
actual = oldState;
|
||||
});
|
||||
context.dispatchContextChange(
|
||||
new ApplicationContextChangedEventStub().withOldState(expected),
|
||||
);
|
||||
|
||||
// assert
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modifyCurrentState', () => {
|
||||
describe('throws when callback is absent', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing state mutator';
|
||||
const context = new ApplicationContextStub();
|
||||
const { modifyCurrentState } = useCollectionState(context);
|
||||
// act
|
||||
const act = () => modifyCurrentState(absentValue);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
it('modifies current collection state', () => {
|
||||
// arrange
|
||||
const oldOs = OperatingSystem.Windows;
|
||||
const newOs = OperatingSystem.Linux;
|
||||
const state = new CategoryCollectionStateStub()
|
||||
.withOs(oldOs);
|
||||
const context = new ApplicationContextStub()
|
||||
.withState(state);
|
||||
const { modifyCurrentState } = useCollectionState(context);
|
||||
|
||||
// act
|
||||
modifyCurrentState((mutableState) => {
|
||||
const stubState = (mutableState as CategoryCollectionStateStub);
|
||||
stubState.withOs(newOs);
|
||||
});
|
||||
const actualOs = context.state.collection.os;
|
||||
|
||||
// assert
|
||||
expect(actualOs).to.equal(newOs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modifyCurrentContext', () => {
|
||||
describe('throws when callback is absent', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing context mutator';
|
||||
const context = new ApplicationContextStub();
|
||||
const { modifyCurrentContext } = useCollectionState(context);
|
||||
// act
|
||||
const act = () => modifyCurrentContext(absentValue);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
it('modifies the current context', () => {
|
||||
// arrange
|
||||
const oldState = new CategoryCollectionStateStub()
|
||||
.withOs(OperatingSystem.Linux);
|
||||
const newState = new CategoryCollectionStateStub()
|
||||
.withOs(OperatingSystem.macOS);
|
||||
const context = new ApplicationContextStub()
|
||||
.withState(oldState);
|
||||
const { modifyCurrentContext } = useCollectionState(context);
|
||||
|
||||
// act
|
||||
modifyCurrentContext((mutableContext) => {
|
||||
const contextStub = mutableContext as ApplicationContextStub;
|
||||
contextStub.withState(newState);
|
||||
});
|
||||
const actualState = context.state;
|
||||
|
||||
// assert
|
||||
expect(actualState).to.equal(newState);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IApplicationContextChangedEvent } from '@/application/Context/IApplicationContext';
|
||||
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { CategoryCollectionStateStub } from './CategoryCollectionStateStub';
|
||||
|
||||
export class ApplicationContextChangedEventStub implements IApplicationContextChangedEvent {
|
||||
newState: ICategoryCollectionState = new CategoryCollectionStateStub();
|
||||
|
||||
oldState: ICategoryCollectionState = new CategoryCollectionStateStub();
|
||||
|
||||
withNewState(newState: ICategoryCollectionState) {
|
||||
this.newState = newState;
|
||||
return this;
|
||||
}
|
||||
|
||||
withOldState(oldState: ICategoryCollectionState) {
|
||||
this.oldState = oldState;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
38
tests/unit/shared/Stubs/ApplicationContextStub.ts
Normal file
38
tests/unit/shared/Stubs/ApplicationContextStub.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { IApplicationContext, IApplicationContextChangedEvent } from '@/application/Context/IApplicationContext';
|
||||
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { IApplication } from '@/domain/IApplication';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { CategoryCollectionStateStub } from './CategoryCollectionStateStub';
|
||||
import { ApplicationStub } from './ApplicationStub';
|
||||
import { EventSourceStub } from './EventSourceStub';
|
||||
import { ApplicationContextChangedEventStub } from './ApplicationContextChangedEventStub';
|
||||
|
||||
export class ApplicationContextStub implements IApplicationContext {
|
||||
public state: ICategoryCollectionState = new CategoryCollectionStateStub();
|
||||
|
||||
public changeContext(os: OperatingSystem): void {
|
||||
const oldState = this.state;
|
||||
const newState = new CategoryCollectionStateStub()
|
||||
.withOs(os);
|
||||
this.state = newState;
|
||||
const event = new ApplicationContextChangedEventStub()
|
||||
.withOldState(oldState)
|
||||
.withNewState(newState);
|
||||
this.dispatchContextChange(event);
|
||||
}
|
||||
|
||||
public app: IApplication = new ApplicationStub();
|
||||
|
||||
public contextChanged = new EventSourceStub<IApplicationContextChangedEvent>();
|
||||
|
||||
public withState(state: ICategoryCollectionState) {
|
||||
this.state = state;
|
||||
return this;
|
||||
}
|
||||
|
||||
public dispatchContextChange(
|
||||
event: IApplicationContextChangedEvent = new ApplicationContextChangedEventStub(),
|
||||
) {
|
||||
this.contextChanged.notify(event);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { ICategoryCollectionState } from '@/application/Context/State/ICategoryC
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { CategoryCollectionStub } from './CategoryCollectionStub';
|
||||
import { UserSelectionStub } from './UserSelectionStub';
|
||||
import { UserFilterStub } from './UserFilterStub';
|
||||
@@ -11,24 +13,35 @@ import { ApplicationCodeStub } from './ApplicationCodeStub';
|
||||
import { CategoryStub } from './CategoryStub';
|
||||
|
||||
export class CategoryCollectionStateStub implements ICategoryCollectionState {
|
||||
private collectionStub = new CategoryCollectionStub();
|
||||
|
||||
public readonly code: IApplicationCode = new ApplicationCodeStub();
|
||||
|
||||
public readonly filter: IUserFilter = new UserFilterStub();
|
||||
|
||||
public readonly os = OperatingSystem.Windows;
|
||||
public get os(): OperatingSystem {
|
||||
return this.collectionStub.os;
|
||||
}
|
||||
|
||||
public readonly collection: CategoryCollectionStub;
|
||||
public get collection(): ICategoryCollection {
|
||||
return this.collectionStub;
|
||||
}
|
||||
|
||||
public readonly selection: UserSelectionStub;
|
||||
|
||||
constructor(readonly allScripts: IScript[]) {
|
||||
constructor(readonly allScripts: IScript[] = [new ScriptStub('script-id')]) {
|
||||
this.selection = new UserSelectionStub(allScripts);
|
||||
this.collection = new CategoryCollectionStub()
|
||||
this.collectionStub = new CategoryCollectionStub()
|
||||
.withOs(this.os)
|
||||
.withTotalScripts(this.allScripts.length)
|
||||
.withAction(new CategoryStub(0).withScripts(...allScripts));
|
||||
}
|
||||
|
||||
public withOs(os: OperatingSystem) {
|
||||
this.collectionStub = this.collectionStub.withOs(os);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSelectedScripts(initialScripts: readonly SelectedScript[]) {
|
||||
this.selection.withSelectedScripts(initialScripts);
|
||||
return this;
|
||||
|
||||
22
tests/unit/shared/Stubs/EventSourceStub.ts
Normal file
22
tests/unit/shared/Stubs/EventSourceStub.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { EventHandler, IEventSource, IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||
import { EventSubscriptionStub } from './EventSubscriptionStub';
|
||||
|
||||
export class EventSourceStub<T> implements IEventSource<T> {
|
||||
private readonly handlers = new Array<EventHandler<T>>();
|
||||
|
||||
public on(handler: EventHandler<T>): IEventSubscription {
|
||||
this.handlers.push(handler);
|
||||
return new EventSubscriptionStub(() => {
|
||||
const index = this.handlers.indexOf(handler);
|
||||
if (index !== -1) {
|
||||
this.handlers.splice(index, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public notify(data: T) {
|
||||
for (const handler of this.handlers) {
|
||||
handler(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
tests/unit/shared/Stubs/EventSubscriptionStub.ts
Normal file
19
tests/unit/shared/Stubs/EventSubscriptionStub.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||
|
||||
type UnsubscribeCallback = () => void;
|
||||
|
||||
export class EventSubscriptionStub implements IEventSubscription {
|
||||
private readonly onUnsubscribe = new Array<UnsubscribeCallback>();
|
||||
|
||||
constructor(unsubscribeCallback?: UnsubscribeCallback) {
|
||||
if (unsubscribeCallback) {
|
||||
this.onUnsubscribe.push(unsubscribeCallback);
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe(): void {
|
||||
for (const callback of this.onUnsubscribe) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "es2017",
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
|
||||
Reference in New Issue
Block a user