Compare commits
11 Commits
capability
...
0.12.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a14929a13c | ||
|
|
6a20d804dc | ||
|
|
ae75059cc1 | ||
|
|
39e650cf11 | ||
|
|
bc91237d7c | ||
|
|
9e5491fdbf | ||
|
|
986ba078a6 | ||
|
|
061afad967 | ||
|
|
3bc8da4cbf | ||
|
|
1b9be8fe2d | ||
|
|
3a594ac7fd |
2
.github/workflows/checks.security.yaml
vendored
2
.github/workflows/checks.security.yaml
vendored
@@ -19,4 +19,4 @@ jobs:
|
||||
uses: ./.github/actions/setup-node
|
||||
-
|
||||
name: NPM audit
|
||||
run: exit "$(npm audit)" # Since node 15.x, it does not fail with error if we don't explicitly exit
|
||||
run: npm audit --omit=dev
|
||||
|
||||
28
README.md
28
README.md
@@ -4,13 +4,13 @@
|
||||
|
||||
<!-- markdownlint-disable MD033 -->
|
||||
<p align="center">
|
||||
<a href="https://undergroundwires.dev/donate?project=privacy.sexy">
|
||||
<a href="https://undergroundwires.dev/donate?project=privacy.sexy" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="donation badge"
|
||||
src="https://undergroundwires.dev/img/badges/donate/flat.svg"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/blob/master/CONTRIBUTING.md">
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/blob/master/CONTRIBUTING.md" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="contributions are welcome"
|
||||
src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat"
|
||||
@@ -18,13 +18,13 @@
|
||||
</a>
|
||||
<!-- Code quality -->
|
||||
<br />
|
||||
<a href="https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript">
|
||||
<a href="https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="Language grade: JavaScript/TypeScript"
|
||||
src="https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://codeclimate.com/github/undergroundwires/privacy.sexy/maintainability">
|
||||
<a href="https://codeclimate.com/github/undergroundwires/privacy.sexy/maintainability" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="Maintainability"
|
||||
src="https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability"
|
||||
@@ -32,19 +32,19 @@
|
||||
</a>
|
||||
<!-- Tests -->
|
||||
<br />
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.unit.yaml">
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.unit.yaml" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="Unit tests status"
|
||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/unit-tests/badge.svg"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.integration.yaml">
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.integration.yaml" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="Integration tests status"
|
||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/integration-tests/badge.svg"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.e2e.yaml">
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.e2e.yaml" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="E2E tests status"
|
||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/e2e-tests/badge.svg"
|
||||
@@ -52,19 +52,19 @@
|
||||
</a>
|
||||
<!-- Checks -->
|
||||
<br />
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.quality.yaml">
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.quality.yaml" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="Quality checks status"
|
||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/quality-checks/badge.svg"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.security.yaml">
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.security.yaml" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="Security checks status"
|
||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/security-checks/badge.svg"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.build.yaml">
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.build.yaml" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="Build checks status"
|
||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
|
||||
@@ -72,19 +72,19 @@
|
||||
</a>
|
||||
<!-- Release -->
|
||||
<br />
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml">
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="Git release status"
|
||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-git/badge.svg"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.site.yaml">
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.site.yaml" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="Site release status"
|
||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-site/badge.svg"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.desktop.yaml">
|
||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.desktop.yaml" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="Desktop application release status"
|
||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-desktop/badge.svg"
|
||||
@@ -92,7 +92,7 @@
|
||||
</a>
|
||||
<!-- Others -->
|
||||
<br />
|
||||
<a href="https://github.com/undergroundwires/bump-everywhere">
|
||||
<a href="https://github.com/undergroundwires/bump-everywhere" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
alt="Auto-versioned by bump-everywhere"
|
||||
src="https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from 'cypress'
|
||||
import { defineConfig } from 'cypress';
|
||||
import setupPlugins from './tests/e2e/plugins/index.js';
|
||||
|
||||
export default defineConfig({
|
||||
fixturesFolder: 'tests/e2e/fixtures',
|
||||
@@ -6,7 +7,7 @@ export default defineConfig({
|
||||
videosFolder: 'tests/e2e/videos',
|
||||
e2e: {
|
||||
setupNodeEvents(on, config) {
|
||||
return require('./tests/e2e/plugins/index.js')(on, config)
|
||||
return setupPlugins(on, config);
|
||||
},
|
||||
specPattern: 'tests/e2e/specs/**/*.cy.{js,jsx,ts,tsx}',
|
||||
supportFile: 'tests/e2e/support/index.js',
|
||||
|
||||
@@ -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.
|
||||
@@ -22,9 +25,9 @@ It's designed event-driven from bottom to top. It listens user events (from top)
|
||||
- [**`electron/`**](./../src/presentation/electron/): Electron configuration for the desktop application.
|
||||
- [**`main.ts`**](./../src/presentation/main.ts): Main process of Electron, started as first thing when app starts.
|
||||
- [**`/public/`**](./../public/): Contains static assets that are directly copied and do not go through webpack.
|
||||
- [**`/vue.config.js`**](./../vue.config.js): Global Vue CLI configurations loaded by `@vue/cli-service`.
|
||||
- [**`/postcss.config.js`**](./../postcss.config.js): PostCSS configurations used by Vue CLI internally.
|
||||
- [**`/babel.config.js`**](./../babel.config.js): Babel configurations for polyfills used by `@vue/cli-plugin-babel`.
|
||||
- [**`/vue.config.cjs`**](./../vue.config.cjs): Global Vue CLI configurations loaded by `@vue/cli-service`.
|
||||
- [**`/postcss.config.cjs`**](./../postcss.config.cjs): PostCSS configurations used by Vue CLI internally.
|
||||
- [**`/babel.config.cjs`**](./../babel.config.cjs): Babel configurations for polyfills used by `@vue/cli-plugin-babel`.
|
||||
|
||||
## Visual design best-practices
|
||||
|
||||
@@ -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,45 @@ 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/Hooks/UseCollectionState.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.
|
||||
[`UseCollectionState.ts`](./../src/presentation/components/Shared/Hooks/UseCollectionState.ts) provides several functionalities including:
|
||||
|
||||
## Modals
|
||||
- **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).
|
||||
|
||||
[Dialog.vue](./../src/presentation/components/Shared/Dialog.vue) is a shared component that other components used to show modal windows.
|
||||
📖 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.
|
||||
|
||||
You can use it by wrapping the content inside of its `slot` and call `.show()` function on its reference. For example:
|
||||
## Dependency injections
|
||||
|
||||
```html
|
||||
<Dialog ref="testDialog">
|
||||
<div>Hello world</div>
|
||||
</Dialog>
|
||||
<div @click="$refs.testDialog.show()">Show dialog</div>
|
||||
```
|
||||
The presentation layer uses Vue's native dependency injection system to increase testability and decouple components.
|
||||
|
||||
To add a new dependency:
|
||||
|
||||
1. **Define its symbol**: Define an associated symbol for every dependency in [`injectionSymbols.ts`](./../src/presentation/injectionSymbols.ts). Symbols are grouped into:
|
||||
- **Singletons**: Shared across components, instantiated once.
|
||||
- **Transients**: Factories yielding a new instance on every access.
|
||||
2. **Provide the dependency**: Modify the [`provideDependencies`](./../src/presentation/bootstrapping/DependencyProvider.ts) function to include the new dependency. [`App.vue`](./../src/presentation/components/App.vue) calls this function within its `setup()` hook to register the dependencies.
|
||||
3. **Inject the dependency**: Use Vue's `inject` method alongside the defined symbol to incorporate the dependency into components.
|
||||
- For singletons, invoke the factory method: `inject(symbolKey)()`.
|
||||
- For transients, directly inject: `inject(symbolKey)`.
|
||||
|
||||
## Shared UI components
|
||||
|
||||
Shared UI components promote consistency and simplifies the creation of the front-end.
|
||||
|
||||
In order to maintain portability and easy maintainability, the preference is towards using homegrown components over third-party ones or comprehensive UI frameworks like Quasar.
|
||||
|
||||
Shared components include:
|
||||
|
||||
- [ModalDialog.vue](./../src/presentation/components/Shared/Modal/ModalDialog.vue) is utilized for rendering modal windows.
|
||||
- [TooltipWrapper.vue](./../src/presentation/components/Shared/TooltipWrapper.vue) acts as a wrapper for rendering tooltips.
|
||||
|
||||
## Sass naming convention
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Common aspects for all tests:
|
||||
- Unit tests test each component in isolation.
|
||||
- All unit tests goes under [`./tests/unit`](./../tests/unit).
|
||||
- They rely on [stubs](./../tests/unit/shared/Stubs) for isolation.
|
||||
- Unit tests include also Vue component tests using `@vue/test-utils`.
|
||||
|
||||
### Unit tests structure
|
||||
|
||||
|
||||
562
package-lock.json
generated
562
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",
|
||||
@@ -25,10 +25,7 @@
|
||||
"markdown-it": "^13.0.1",
|
||||
"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": "^2.7.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.3.2",
|
||||
@@ -46,6 +43,7 @@
|
||||
"@vue/cli-service": "~5.0.8",
|
||||
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"@vue/test-utils": "^1.3.6",
|
||||
"chai": "^4.3.7",
|
||||
"cypress": "^12.17.2",
|
||||
"electron": "^25.3.2",
|
||||
@@ -3313,6 +3311,12 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@one-ini/wasm": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -4828,6 +4832,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/test-utils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-1.3.6.tgz",
|
||||
"integrity": "sha512-udMmmF1ts3zwxUJEIAj5ziioR900reDrt6C9H3XpWPsLBx2lpHKoA4BTdd9HNIYbkGltWw+JjWJ+5O6QBwiyEw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"dom-event-types": "^1.0.0",
|
||||
"lodash": "^4.17.15",
|
||||
"pretty": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "2.x",
|
||||
"vue-template-compiler": "^2.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/vue-loader-v15": {
|
||||
"name": "vue-loader",
|
||||
"version": "15.10.0",
|
||||
@@ -5045,6 +5064,12 @@
|
||||
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -7055,6 +7080,36 @@
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/condense-newlines": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/condense-newlines/-/condense-newlines-0.2.1.tgz",
|
||||
"integrity": "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"is-whitespace": "^0.3.0",
|
||||
"kind-of": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ini": "^1.3.4",
|
||||
"proto-list": "~1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/config-chain/node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/config-file-ts": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.4.tgz",
|
||||
@@ -7836,6 +7891,13 @@
|
||||
"integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/de-indent": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
@@ -8421,6 +8483,12 @@
|
||||
"utila": "~0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-event-types": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-event-types/-/dom-event-types-1.1.0.tgz",
|
||||
"integrity": "sha512-jNCX+uNJ3v38BKvPbpki6j5ItVlnSqVV6vDWGS6rExzCMjsc39frLjm1n91o6YaKK6AZl0wLloItW6C6mr61BQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
|
||||
@@ -8556,6 +8624,57 @@
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
|
||||
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@one-ini/wasm": "0.1.1",
|
||||
"commander": "^10.0.0",
|
||||
"minimatch": "9.0.1",
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"bin": {
|
||||
"editorconfig": "bin/editorconfig"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig/node_modules/commander": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig/node_modules/minimatch": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
|
||||
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -10376,6 +10495,18 @@
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
@@ -12158,6 +12289,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-buffer": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/is-callable": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
@@ -12230,6 +12367,15 @@
|
||||
"integrity": "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -12577,6 +12723,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-whitespace": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz",
|
||||
"integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
@@ -12810,6 +12965,66 @@
|
||||
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/js-beautify": {
|
||||
"version": "1.14.9",
|
||||
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.9.tgz",
|
||||
"integrity": "sha512-coM7xq1syLcMyuVGyToxcj2AlzhkDjmfklL8r0JgJ7A76wyGMpJ1oA35mr4APdYNO/o/4YY8H54NQIJzhMbhBg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"config-chain": "^1.1.13",
|
||||
"editorconfig": "^1.0.3",
|
||||
"glob": "^8.1.0",
|
||||
"nopt": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"css-beautify": "js/bin/css-beautify.js",
|
||||
"html-beautify": "js/bin/html-beautify.js",
|
||||
"js-beautify": "js/bin/js-beautify.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify/node_modules/glob": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
|
||||
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^5.0.1",
|
||||
"once": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify/node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/js-message": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz",
|
||||
@@ -13160,6 +13375,18 @@
|
||||
"json-buffer": "3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
|
||||
"integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"is-buffer": "^1.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/klaw": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz",
|
||||
@@ -15639,6 +15866,21 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz",
|
||||
"integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"abbrev": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-package-data": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
|
||||
@@ -20362,6 +20604,20 @@
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz",
|
||||
"integrity": "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"condense-newlines": "^0.2.1",
|
||||
"extend-shallow": "^2.0.1",
|
||||
"js-beautify": "^1.6.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-bytes": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||
@@ -20582,6 +20838,12 @@
|
||||
"levenshtein-edit-distance": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -22261,11 +22523,6 @@
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
|
||||
@@ -25610,14 +25867,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",
|
||||
@@ -25777,17 +26026,6 @@
|
||||
"integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vue-js-modal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-js-modal/-/vue-js-modal-2.0.1.tgz",
|
||||
"integrity": "sha512-5FUwsH2zoxRKX4a7wkFAqX0eITCcIMunJDEfIxzHs2bHw9o20+Iqm+uQvBcg1jkzyo1+tVgThR/7NGU8djbD8Q==",
|
||||
"dependencies": {
|
||||
"resize-observer-polyfill": "^1.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^2.6.11"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-loader": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.0.0.tgz",
|
||||
@@ -25886,15 +26124,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",
|
||||
@@ -25922,6 +26151,17 @@
|
||||
"integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vue-template-compiler": {
|
||||
"version": "2.7.14",
|
||||
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.14.tgz",
|
||||
"integrity": "sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"de-indent": "^1.0.2",
|
||||
"he": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-template-es2015-compiler": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz",
|
||||
@@ -29418,6 +29658,12 @@
|
||||
"integrity": "sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg==",
|
||||
"dev": true
|
||||
},
|
||||
"@one-ini/wasm": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
|
||||
"dev": true
|
||||
},
|
||||
"@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -30624,6 +30870,17 @@
|
||||
"vue-eslint-parser": "^9.1.1"
|
||||
}
|
||||
},
|
||||
"@vue/test-utils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-1.3.6.tgz",
|
||||
"integrity": "sha512-udMmmF1ts3zwxUJEIAj5ziioR900reDrt6C9H3XpWPsLBx2lpHKoA4BTdd9HNIYbkGltWw+JjWJ+5O6QBwiyEw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"dom-event-types": "^1.0.0",
|
||||
"lodash": "^4.17.15",
|
||||
"pretty": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"@vue/vue-loader-v15": {
|
||||
"version": "npm:vue-loader@15.10.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.10.0.tgz",
|
||||
@@ -30827,6 +31084,12 @@
|
||||
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
|
||||
"dev": true
|
||||
},
|
||||
"abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"dev": true
|
||||
},
|
||||
"accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -32341,6 +32604,35 @@
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"condense-newlines": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/condense-newlines/-/condense-newlines-0.2.1.tgz",
|
||||
"integrity": "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"is-whitespace": "^0.3.0",
|
||||
"kind-of": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ini": "^1.3.4",
|
||||
"proto-list": "~1.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"config-file-ts": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.4.tgz",
|
||||
@@ -32913,6 +33205,13 @@
|
||||
"integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==",
|
||||
"dev": true
|
||||
},
|
||||
"de-indent": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
@@ -33324,6 +33623,12 @@
|
||||
"utila": "~0.4"
|
||||
}
|
||||
},
|
||||
"dom-event-types": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-event-types/-/dom-event-types-1.1.0.tgz",
|
||||
"integrity": "sha512-jNCX+uNJ3v38BKvPbpki6j5ItVlnSqVV6vDWGS6rExzCMjsc39frLjm1n91o6YaKK6AZl0wLloItW6C6mr61BQ==",
|
||||
"dev": true
|
||||
},
|
||||
"dom-serializer": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
|
||||
@@ -33432,6 +33737,44 @@
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"editorconfig": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
|
||||
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@one-ini/wasm": "0.1.1",
|
||||
"commander": "^10.0.0",
|
||||
"minimatch": "9.0.1",
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"commander": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
|
||||
"dev": true
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
|
||||
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -34814,6 +35157,15 @@
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
||||
},
|
||||
"extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-extendable": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
@@ -36123,6 +36475,12 @@
|
||||
"has-tostringtag": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"is-buffer": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
|
||||
"dev": true
|
||||
},
|
||||
"is-callable": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
@@ -36168,6 +36526,12 @@
|
||||
"integrity": "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w==",
|
||||
"dev": true
|
||||
},
|
||||
"is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"dev": true
|
||||
},
|
||||
"is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -36400,6 +36764,12 @@
|
||||
"call-bind": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"is-whitespace": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz",
|
||||
"integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==",
|
||||
"dev": true
|
||||
},
|
||||
"is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
@@ -36578,6 +36948,51 @@
|
||||
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
|
||||
"dev": true
|
||||
},
|
||||
"js-beautify": {
|
||||
"version": "1.14.9",
|
||||
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.9.tgz",
|
||||
"integrity": "sha512-coM7xq1syLcMyuVGyToxcj2AlzhkDjmfklL8r0JgJ7A76wyGMpJ1oA35mr4APdYNO/o/4YY8H54NQIJzhMbhBg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"config-chain": "^1.1.13",
|
||||
"editorconfig": "^1.0.3",
|
||||
"glob": "^8.1.0",
|
||||
"nopt": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"glob": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
|
||||
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^5.0.1",
|
||||
"once": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"js-message": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz",
|
||||
@@ -36882,6 +37297,15 @@
|
||||
"json-buffer": "3.0.0"
|
||||
}
|
||||
},
|
||||
"kind-of": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
|
||||
"integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"is-buffer": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"klaw": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz",
|
||||
@@ -38704,6 +39128,15 @@
|
||||
"integrity": "sha512-7Ws63oC+215smeKJQCxzrK21VFVlCFBkwl0MOObt0HOpVQXs3u483sAmtkF33nNqZ5rSOQjB76fgyPBmAUrtCA==",
|
||||
"dev": true
|
||||
},
|
||||
"nopt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz",
|
||||
"integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"abbrev": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"normalize-package-data": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
|
||||
@@ -41875,6 +42308,17 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"pretty": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz",
|
||||
"integrity": "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"condense-newlines": "^0.2.1",
|
||||
"extend-shallow": "^2.0.1",
|
||||
"js-beautify": "^1.6.12"
|
||||
}
|
||||
},
|
||||
"pretty-bytes": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||
@@ -42043,6 +42487,12 @@
|
||||
"levenshtein-edit-distance": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
|
||||
"dev": true
|
||||
},
|
||||
"proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -43343,11 +43793,6 @@
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"dev": true
|
||||
},
|
||||
"resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.22.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
|
||||
@@ -45848,12 +46293,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",
|
||||
@@ -45974,14 +46413,6 @@
|
||||
"integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==",
|
||||
"dev": true
|
||||
},
|
||||
"vue-js-modal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-js-modal/-/vue-js-modal-2.0.1.tgz",
|
||||
"integrity": "sha512-5FUwsH2zoxRKX4a7wkFAqX0eITCcIMunJDEfIxzHs2bHw9o20+Iqm+uQvBcg1jkzyo1+tVgThR/7NGU8djbD8Q==",
|
||||
"requires": {
|
||||
"resize-observer-polyfill": "^1.5.1"
|
||||
}
|
||||
},
|
||||
"vue-loader": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.0.0.tgz",
|
||||
@@ -46055,12 +46486,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",
|
||||
@@ -46087,6 +46512,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"vue-template-compiler": {
|
||||
"version": "2.7.14",
|
||||
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.14.tgz",
|
||||
"integrity": "sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"de-indent": "^1.0.2",
|
||||
"he": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"vue-template-es2015-compiler": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz",
|
||||
|
||||
15
package.json
15
package.json
@@ -5,10 +5,11 @@
|
||||
"slogan": "Now you have the choice",
|
||||
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
|
||||
"author": "undergroundwires",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"test:unit": "vue-cli-service test:unit",
|
||||
"test:unit": "vue-cli-service test:unit --include ./tests/bootstrap/setup.ts",
|
||||
"test:e2e": "vue-cli-service test:e2e",
|
||||
"lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:eslint && npm run lint:yaml",
|
||||
"create-icons": "node img/logo-update.mjs",
|
||||
@@ -21,7 +22,7 @@
|
||||
"lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"postuninstall": "electron-builder install-app-deps",
|
||||
"test:integration": "vue-cli-service test:unit \"tests/integration/**/*.spec.ts\""
|
||||
"test:integration": "vue-cli-service test:unit \"tests/integration/**/*.spec.ts\" --include ./tests/bootstrap/setup.ts"
|
||||
},
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
@@ -41,10 +42,7 @@
|
||||
"markdown-it": "^13.0.1",
|
||||
"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": "^2.7.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.3.2",
|
||||
@@ -62,6 +60,7 @@
|
||||
"@vue/cli-service": "~5.0.8",
|
||||
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"@vue/test-utils": "^1.3.6",
|
||||
"chai": "^4.3.7",
|
||||
"cypress": "^12.17.2",
|
||||
"electron": "^25.3.2",
|
||||
@@ -85,10 +84,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": {
|
||||
|
||||
50
src/application/Common/CustomError.ts
Normal file
50
src/application/Common/CustomError.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Provides a unified and resilient way to extend errors across platforms.
|
||||
|
||||
Rationale:
|
||||
- Babel:
|
||||
> "Built-in classes cannot be properly subclassed due to limitations in ES5"
|
||||
> https://web.archive.org/web/20230810014108/https://babeljs.io/docs/caveats#classes
|
||||
- TypeScript:
|
||||
> "Extending built-ins like Error, Array, and Map may no longer work"
|
||||
> https://web.archive.org/web/20230810014143/https://github.com/Microsoft/TypeScript-wiki/blob/main/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
|
||||
*/
|
||||
export abstract class CustomError extends Error {
|
||||
constructor(message?: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
|
||||
fixPrototype(this, new.target.prototype);
|
||||
ensureStackTrace(this);
|
||||
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
export const Environment = {
|
||||
getSetPrototypeOf: () => Object.setPrototypeOf,
|
||||
getCaptureStackTrace: () => Error.captureStackTrace,
|
||||
};
|
||||
|
||||
function fixPrototype(target: Error, prototype: CustomError) {
|
||||
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget
|
||||
const setPrototypeOf = Environment.getSetPrototypeOf();
|
||||
if (!functionExists(setPrototypeOf)) {
|
||||
return;
|
||||
}
|
||||
setPrototypeOf(target, prototype);
|
||||
}
|
||||
|
||||
function ensureStackTrace(target: Error) {
|
||||
const captureStackTrace = Environment.getCaptureStackTrace();
|
||||
if (!functionExists(captureStackTrace)) {
|
||||
// captureStackTrace is only available on V8, if it's not available
|
||||
// modern JS engines will usually generate a stack trace on error objects when they're thrown.
|
||||
return;
|
||||
}
|
||||
captureStackTrace(target, target.constructor);
|
||||
}
|
||||
|
||||
function functionExists(func: unknown): boolean {
|
||||
// Not doing truthy/falsy check i.e. if(func) as most values are truthy in JS for robustness
|
||||
return typeof func === 'function';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum FilterActionType {
|
||||
Apply,
|
||||
Clear,
|
||||
}
|
||||
37
src/application/Context/State/Filter/Event/FilterChange.ts
Normal file
37
src/application/Context/State/Filter/Event/FilterChange.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
||||
import { FilterActionType } from './FilterActionType';
|
||||
import { IFilterChangeDetails, IFilterChangeDetailsVisitor } from './IFilterChangeDetails';
|
||||
|
||||
export class FilterChange implements IFilterChangeDetails {
|
||||
public static forApply(filter: IFilterResult) {
|
||||
if (!filter) {
|
||||
throw new Error('missing filter');
|
||||
}
|
||||
return new FilterChange(FilterActionType.Apply, filter);
|
||||
}
|
||||
|
||||
public static forClear() {
|
||||
return new FilterChange(FilterActionType.Clear);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
public readonly actionType: FilterActionType,
|
||||
public readonly filter?: IFilterResult,
|
||||
) { }
|
||||
|
||||
public visit(visitor: IFilterChangeDetailsVisitor): void {
|
||||
if (!visitor) {
|
||||
throw new Error('missing visitor');
|
||||
}
|
||||
switch (this.actionType) {
|
||||
case FilterActionType.Apply:
|
||||
visitor.onApply(this.filter);
|
||||
break;
|
||||
case FilterActionType.Clear:
|
||||
visitor.onClear();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${this.actionType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
||||
import { FilterActionType } from './FilterActionType';
|
||||
|
||||
export interface IFilterChangeDetails {
|
||||
readonly actionType: FilterActionType;
|
||||
readonly filter?: IFilterResult;
|
||||
|
||||
visit(visitor: IFilterChangeDetailsVisitor): void;
|
||||
}
|
||||
|
||||
export interface IFilterChangeDetailsVisitor {
|
||||
onClear(): void;
|
||||
onApply(filter: IFilterResult): void;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { IEventSource } from '@/infrastructure/Events/IEventSource';
|
||||
import { IFilterResult } from './IFilterResult';
|
||||
import { IFilterChangeDetails } from './Event/IFilterChangeDetails';
|
||||
|
||||
export interface IReadOnlyUserFilter {
|
||||
readonly currentFilter: IFilterResult | undefined;
|
||||
readonly filtered: IEventSource<IFilterResult>;
|
||||
readonly filterRemoved: IEventSource<void>;
|
||||
readonly filterChanged: IEventSource<IFilterChangeDetails>;
|
||||
}
|
||||
|
||||
export interface IUserFilter extends IReadOnlyUserFilter {
|
||||
setFilter(filter: string): void;
|
||||
removeFilter(): void;
|
||||
applyFilter(filter: string): void;
|
||||
clearFilter(): void;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { FilterResult } from './FilterResult';
|
||||
import { IFilterResult } from './IFilterResult';
|
||||
import { IUserFilter } from './IUserFilter';
|
||||
import { IFilterChangeDetails } from './Event/IFilterChangeDetails';
|
||||
import { FilterChange } from './Event/FilterChange';
|
||||
|
||||
export class UserFilter implements IUserFilter {
|
||||
public readonly filtered = new EventSource<IFilterResult>();
|
||||
|
||||
public readonly filterRemoved = new EventSource<void>();
|
||||
public readonly filterChanged = new EventSource<IFilterChangeDetails>();
|
||||
|
||||
public currentFilter: IFilterResult | undefined;
|
||||
|
||||
@@ -16,9 +16,9 @@ export class UserFilter implements IUserFilter {
|
||||
|
||||
}
|
||||
|
||||
public setFilter(filter: string): void {
|
||||
public applyFilter(filter: string): void {
|
||||
if (!filter) {
|
||||
throw new Error('Filter must be defined and not empty. Use removeFilter() to remove the filter');
|
||||
throw new Error('Filter must be defined and not empty. Use clearFilter() to remove the filter');
|
||||
}
|
||||
const filterLowercase = filter.toLocaleLowerCase();
|
||||
const filteredScripts = this.collection.getAllScripts().filter(
|
||||
@@ -33,12 +33,12 @@ export class UserFilter implements IUserFilter {
|
||||
filter,
|
||||
);
|
||||
this.currentFilter = matches;
|
||||
this.filtered.notify(matches);
|
||||
this.filterChanged.notify(FilterChange.forApply(this.currentFilter));
|
||||
}
|
||||
|
||||
public removeFilter(): void {
|
||||
public clearFilter(): void {
|
||||
this.currentFilter = undefined;
|
||||
this.filterRemoved.notify();
|
||||
this.filterChanged.notify(FilterChange.forClear());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { CustomError } from '@/application/Common/CustomError';
|
||||
import { NodeType } from './NodeType';
|
||||
import { NodeData } from './NodeData';
|
||||
|
||||
export class NodeDataError extends Error {
|
||||
export class NodeDataError extends CustomError {
|
||||
constructor(message: string, public readonly context: INodeDataErrorContext) {
|
||||
super(createMessage(message, context));
|
||||
Object.setPrototypeOf(this, new.target.prototype); // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3139,6 +3139,68 @@ actions:
|
||||
-
|
||||
category: Disable Windows Defender Firewall # Also known as Windows Firewall, Microsoft Defender Firewall
|
||||
children:
|
||||
-
|
||||
category: Disable Windows Defender Firewall Services and Drivers (breaks Microsoft Store and `netsh advfirewall` CLI)
|
||||
children:
|
||||
-
|
||||
name: Disable Windows Defender Firewall Authorization Driver service
|
||||
docs:
|
||||
- http://batcmd.com/windows/10/services/mpsdrv/
|
||||
# ❗️ Breaks: `netsh advfirewall set`
|
||||
# Disabling and stopping it breaks "netsh advfirewall set" commands such as
|
||||
# `netsh advfirewall set allprofiles state on`, `netsh advfirewall set allprofiles state off`.
|
||||
# More about `netsh firewall` context: https://docs.microsoft.com/en-us/troubleshoot/windows-server/networking/netsh-advfirewall-firewall-control-firewall-behavior
|
||||
# ! Breaks: Windows Store
|
||||
# The Windows Defender Firewall service depends on this service.
|
||||
# Disabling this will also disable the Windows Defender Firewall service, breaking Microsoft Store.
|
||||
# https://i.imgur.com/zTmtSwT.png
|
||||
call:
|
||||
-
|
||||
function: DisableServiceInRegistry # We must disable it on registry level, "Access is denied" for sc config
|
||||
parameters:
|
||||
serviceName: mpsdrv # Check: (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\mpsdrv").Start
|
||||
defaultStartupMode: Manual # Alowed values: Boot | System | Automatic | Manual
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: '%SystemRoot%\System32\drivers\mpsdrv.sys'
|
||||
-
|
||||
name: Disable Windows Defender Firewall service
|
||||
docs:
|
||||
- http://batcmd.com/windows/10/services/mpssvc/
|
||||
- https://en.wikipedia.org/wiki/Windows_Firewall
|
||||
# More information about MpsSvc:
|
||||
- https://web.archive.org/web/20110203202612/http://technet.microsoft.com/en-us/library/dd364391(v=WS.10).aspx
|
||||
# More information about boot time protection and stopping the firewall service:
|
||||
- https://web.archive.org/web/20110131034058/http://blogs.technet.com:80/b/networking/archive/2009/03/24/stopping-the-windows-authenticating-firewall-service-and-the-boot-time-policy.aspx
|
||||
# Stopping the service associated with Windows Firewall is not supported by Microsoft:
|
||||
- https://web.archive.org/web/20121106033255/http://technet.microsoft.com/en-us/library/cc753180.aspx
|
||||
# ❗️ Breaks Microsoft Store
|
||||
# Can no longer update nor install apps, they both fail with 0x80073D0A
|
||||
# Also breaks some of Store apps such as Photos:
|
||||
# - https://answers.microsoft.com/en-us/windows/forum/all/microsoft-store-windows-defender-windows-firewall/f2f68cd7-64ec-4fe1-ade4-9d12cde057f9
|
||||
# - https://github.com/undergroundwires/privacy.sexy/issues/104#issuecomment-962651791
|
||||
# > The MpsSvc service host much more functionality than just windows firewall. For instance, Windows
|
||||
# Service hardening which is a windows protection of system services. It also host network isolatio
|
||||
# which is a crucial part of the confidence model for Windows Store based applications. 3rd party firewalls
|
||||
# know this fact and instead of disabling the firewall service they coordinate through public APIs with Windows
|
||||
# Firewall so that they can have ownership of the firewall policies of the computer. Hence you do not have to do
|
||||
# anything special once you install a 3rd party security product.
|
||||
# Source: https://www.walkernews.net/2012/09/23/how-to-fix-windows-store-app-update-error-code-0x80073d0a/
|
||||
# ❗️ Breaks: `netsh advfirewall set`
|
||||
# Disabling and stopping it breaks "netsh advfirewall set" commands such as
|
||||
# `netsh advfirewall set allprofiles state on`, `netsh advfirewall set allprofiles state off`.
|
||||
# More about `netsh firewall` context: https://docs.microsoft.com/en-us/troubleshoot/windows-server/networking/netsh-advfirewall-firewall-control-firewall-behavior
|
||||
call:
|
||||
-
|
||||
function: DisableServiceInRegistry # We must disable it on registry level, "Access is denied" for sc config
|
||||
parameters:
|
||||
serviceName: MpsSvc # Check: (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\MpsSvc").Start
|
||||
defaultStartupMode: Automatic # Alowed values: Boot | System | Automatic | Manual
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: '%WinDir%\system32\mpssvc.dll'
|
||||
-
|
||||
name: Disable Firewall through command-line utility
|
||||
# ❗️ Following must be enabled and in running state:
|
||||
@@ -3214,6 +3276,11 @@ actions:
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy\DomainProfile" /v "EnableFirewall" /t REG_DWORD /d 1 /f
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy\PublicProfile" /v "EnableFirewall" /t REG_DWORD /d 1 /f
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy\PrivateProfile" /v "EnableFirewall" /t REG_DWORD /d 1 /f
|
||||
-
|
||||
name: Hide the "Firewall and network protection" area from Windows Defender Security Center
|
||||
docs: https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefenderSecurityCenter::FirewallNetworkProtection_UILockdown
|
||||
code: reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Firewall and network protection" /v "UILockdown" /t REG_DWORD /d "1" /f
|
||||
revertCode: reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Firewall and network protection" /v "UILockdown" /f 2>nul
|
||||
-
|
||||
name: Disable Microsoft Defender Antivirus # Depreciated since Windows 10 version 1903
|
||||
docs:
|
||||
@@ -4504,11 +4571,6 @@ actions:
|
||||
docs: https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefenderSecurityCenter::DeviceSecurity_DisableTpmFirmwareUpdateWarning
|
||||
code: reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Device security" /v "DisableTpmFirmwareUpdateWarning" /t REG_DWORD /d "1" /f
|
||||
revertCode: reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Device security" /v "DisableTpmFirmwareUpdateWarning" /f 2>nul
|
||||
-
|
||||
name: Hide the "Firewall and network protection" area
|
||||
docs: https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefenderSecurityCenter::FirewallNetworkProtection_UILockdown
|
||||
code: reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Firewall and network protection" /v "UILockdown" /t REG_DWORD /d "1" /f
|
||||
revertCode: reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Firewall and network protection" /v "UILockdown" /f 2>nul
|
||||
-
|
||||
category: Hide Windows Defender notifications
|
||||
children:
|
||||
@@ -4609,43 +4671,6 @@ actions:
|
||||
# 1. Some cannot be disabled (access error) normally but only with DisableServiceInRegistry
|
||||
# 2. Some cannot be disabled even using DisableServiceInRegistry, must be disabled as TrustedInstaller using RunInlineCodeAsTrustedInstaller
|
||||
children:
|
||||
-
|
||||
name: Disable Windows Defender Firewall service (breaks Microsoft Store and `netsh advfirewall` CLI)
|
||||
docs:
|
||||
- http://batcmd.com/windows/10/services/mpssvc/
|
||||
- https://en.wikipedia.org/wiki/Windows_Firewall
|
||||
# More information about MpsSvc:
|
||||
- https://web.archive.org/web/20110203202612/http://technet.microsoft.com/en-us/library/dd364391(v=WS.10).aspx
|
||||
# More information about boot time protection and stopping the firewall service:
|
||||
- https://web.archive.org/web/20110131034058/http://blogs.technet.com:80/b/networking/archive/2009/03/24/stopping-the-windows-authenticating-firewall-service-and-the-boot-time-policy.aspx
|
||||
# Stopping the service associated with Windows Firewall is not supported by Microsoft:
|
||||
- https://web.archive.org/web/20121106033255/http://technet.microsoft.com/en-us/library/cc753180.aspx
|
||||
# ❗️ Breaks Microsoft Store
|
||||
# Can no longer update nor install apps, they both fail with 0x80073D0A
|
||||
# Also breaks some of Store apps such as Photos:
|
||||
# - https://answers.microsoft.com/en-us/windows/forum/all/microsoft-store-windows-defender-windows-firewall/f2f68cd7-64ec-4fe1-ade4-9d12cde057f9
|
||||
# - https://github.com/undergroundwires/privacy.sexy/issues/104#issuecomment-962651791
|
||||
# > The MpsSvc service host much more functionality than just windows firewall. For instance, Windows
|
||||
# Service hardening which is a windows protection of system services. It also host network isolatio
|
||||
# which is a crucial part of the confidence model for Windows Store based applications. 3rd party firewalls
|
||||
# know this fact and instead of disabling the firewall service they coordinate through public APIs with Windows
|
||||
# Firewall so that they can have ownership of the firewall policies of the computer. Hence you do not have to do
|
||||
# anything special once you install a 3rd party security product.
|
||||
# Source: https://www.walkernews.net/2012/09/23/how-to-fix-windows-store-app-update-error-code-0x80073d0a/
|
||||
# ❗️ Breaks: `netsh advfirewall set`
|
||||
# Disabling and stopping it breaks "netsh advfirewall set" commands such as
|
||||
# `netsh advfirewall set allprofiles state on`, `netsh advfirewall set allprofiles state off`.
|
||||
# More about `netsh firewall` context: https://docs.microsoft.com/en-us/troubleshoot/windows-server/networking/netsh-advfirewall-firewall-control-firewall-behavior
|
||||
call:
|
||||
-
|
||||
function: DisableServiceInRegistry # We must disable it on registry level, "Access is denied" for sc config
|
||||
parameters:
|
||||
serviceName: MpsSvc # Check: (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\MpsSvc").Start
|
||||
defaultStartupMode: Automatic # Alowed values: Boot | System | Automatic | Manual
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: '%WinDir%\system32\mpssvc.dll'
|
||||
-
|
||||
name: Disable Windows Defender Antivirus service
|
||||
# ❗️ Breaks `Set-MpPreference` PowerShell cmdlet that helps to manage Defender
|
||||
@@ -4657,8 +4682,8 @@ actions:
|
||||
-
|
||||
function: RunInlineCodeAsTrustedInstaller
|
||||
parameters:
|
||||
code: sc stop "WinDefend" >nul & sc config "WinDefend" start=disabled
|
||||
revertCode: sc config "WinDefend" start=auto & sc start "WinDefend" >nul
|
||||
code: sc stop "WinDefend" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WinDefend" /v "Start" /t REG_DWORD /d "4" /f
|
||||
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WinDefend" /v "Start" /t REG_DWORD /d "2" /f & sc start "WinDefend" >nul 2>&1
|
||||
# - # "Access is denied" when renaming file
|
||||
# function: RenameSystemFile
|
||||
# parameters:
|
||||
@@ -4666,24 +4691,6 @@ actions:
|
||||
-
|
||||
category: Disable kernel-level Windows Defender drivers
|
||||
children:
|
||||
-
|
||||
name: Disable Windows Defender Firewall Authorization Driver service (breaks `netsh advfirewall` CLI)
|
||||
docs:
|
||||
- http://batcmd.com/windows/10/services/mpsdrv/
|
||||
# ❗️ Breaks: `netsh advfirewall set`
|
||||
# Disabling and stopping it breaks "netsh advfirewall set" commands such as
|
||||
# `netsh advfirewall set allprofiles state on`, `netsh advfirewall set allprofiles state off`.
|
||||
# More about `netsh firewall` context: https://docs.microsoft.com/en-us/troubleshoot/windows-server/networking/netsh-advfirewall-firewall-control-firewall-behavior
|
||||
call:
|
||||
-
|
||||
function: DisableServiceInRegistry # We must disable it on registry level, "Access is denied" for sc config
|
||||
parameters:
|
||||
serviceName: mpsdrv # Check: (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\mpsdrv").Start
|
||||
defaultStartupMode: Manual # Alowed values: Boot | System | Automatic | Manual
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
filePath: '%SystemRoot%\System32\drivers\mpsdrv.sys'
|
||||
# - Skipping wdnsfltr "Windows Defender Network Stream Filter Driver" as it's Windows 1709 only
|
||||
-
|
||||
name: Disable Microsoft Defender Antivirus Network Inspection System Driver service
|
||||
@@ -4693,8 +4700,8 @@ actions:
|
||||
function: RunInlineCodeAsTrustedInstaller
|
||||
parameters:
|
||||
# "net stop" is used to stop dependend services as well, "sc stop" fails
|
||||
code: net stop "WdNisDrv" /yes >nul & sc config "WdNisDrv" start=disabled
|
||||
revertCode: sc config "WdNisDrv" start=demand & sc start "WdNisDrv" >nul
|
||||
code: net stop "WdNisDrv" /yes >nul & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisDrv" /v "Start" /t REG_DWORD /d "4" /f
|
||||
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisDrv" /v "Start" /t REG_DWORD /d "3" /f & sc start "WdNisDrv" >nul
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
@@ -4712,8 +4719,8 @@ actions:
|
||||
-
|
||||
function: RunInlineCodeAsTrustedInstaller
|
||||
parameters:
|
||||
code: sc stop "WdFilter" >nul & sc config "WdFilter" start=disabled
|
||||
revertCode: sc config "WdFilter" start=boot & sc start "WdFilter" >nul
|
||||
code: sc stop "WdFilter" >nul & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdFilter" /v "Start" /t REG_DWORD /d "4" /f
|
||||
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdFilter" /v "Start" /t REG_DWORD /d "0" /f & sc start "WdFilter" >nul
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
@@ -4729,8 +4736,8 @@ actions:
|
||||
-
|
||||
function: RunInlineCodeAsTrustedInstaller
|
||||
parameters:
|
||||
code: sc stop "WdBoot" >nul & sc config "WdBoot" start=disabled
|
||||
revertCode: sc config "WdBoot" start=boot & sc start "WdBoot" >nul
|
||||
code: sc stop "WdBoot" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdBoot" /v "Start" /t REG_DWORD /d "4" /f
|
||||
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdBoot" /v "Start" /t REG_DWORD /d "0" /f & sc start "WdBoot" >nul 2>&1
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
@@ -4748,8 +4755,8 @@ actions:
|
||||
-
|
||||
function: RunInlineCodeAsTrustedInstaller
|
||||
parameters:
|
||||
code: sc stop "WdNisSvc" >nul & sc config "WdNisSvc" start=disabled
|
||||
revertCode: sc config "WdNisSvc" start=auto & sc start "WdNisSvc" >nul
|
||||
code: sc stop "WdNisSvc" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisSvc" /v "Start" /t REG_DWORD /d "4" /f
|
||||
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisSvc" /v "Start" /t REG_DWORD /d "2" /f & sc start "WdNisSvc" >nul 2>&1
|
||||
# - # "Access is denied" when renaming file
|
||||
# function: RenameSystemFile
|
||||
# parameters:
|
||||
@@ -4759,10 +4766,10 @@ actions:
|
||||
docs: http://batcmd.com/windows/10/services/sense/
|
||||
call:
|
||||
-
|
||||
function: DisableServiceInRegistry # We must disable it on registry level, "Access is denied" for sc config
|
||||
function: RunInlineCodeAsTrustedInstaller # We must disable it on registry level, "Access is denied" for sc config
|
||||
parameters:
|
||||
serviceName: Sense # Check: (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Sense").Start
|
||||
defaultStartupMode: Manual # Alowed values: Boot | System | Automatic | Manual
|
||||
code: sc stop "Sense" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\Sense" /v "Start" /t REG_DWORD /d "4" /f
|
||||
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\Sense" /v "Start" /t REG_DWORD /d "3" /f & sc start "Sense" >nul 2>&1 # Alowed values: Boot | System | Automatic | Manual
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
@@ -4782,8 +4789,8 @@ actions:
|
||||
# ✅ Can disable using registry as TrustedInstaller
|
||||
function: RunInlineCodeAsTrustedInstaller
|
||||
parameters:
|
||||
code: reg add "HKLM\SYSTEM\CurrentControlSet\Services\SecurityHealthService" /v Start /t REG_DWORD /d 4 /f
|
||||
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\SecurityHealthService" /v Start /t REG_DWORD /d 3 /f
|
||||
code: sc stop "SecurityHealthService" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\SecurityHealthService" /v Start /t REG_DWORD /d 4 /f
|
||||
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\SecurityHealthService" /v Start /t REG_DWORD /d 3 /f & sc start "SecurityHealthService" >nul 2>&1
|
||||
-
|
||||
function: RenameSystemFile
|
||||
parameters:
|
||||
@@ -7359,44 +7366,10 @@ functions:
|
||||
call:
|
||||
function: RunPowerShell
|
||||
parameters:
|
||||
code: |-
|
||||
$capabilityName = '{{ $capabilityName }}'
|
||||
try {
|
||||
# Using wildcard for version number handling
|
||||
$capability = Get-WindowsCapability -Online -Name "$capabilityName*"
|
||||
if (!$capability) {
|
||||
Write-Host "Skipping. Capability `"$capabilityName`" is missing."
|
||||
exit 0
|
||||
}
|
||||
if ($capability.State -eq 'NotPresent') {
|
||||
Write-Host "Skipping. Capability `"$capabilityName`" is missing."
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Removing capability `"$capabilityName`""
|
||||
Remove-WindowsCapability -Online -Name "$($capability.Name)" -ErrorAction Stop
|
||||
Write-Host "Successfully removed `"$CapabilityName`""
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to remove `"$capabilityName`": $_"
|
||||
}
|
||||
code: Get-WindowsCapability -Online -Name '{{ $capabilityName }}*' | Remove-WindowsCapability -Online
|
||||
revertCode: |-
|
||||
$capabilityName = '{{ $capabilityName }}'
|
||||
try {
|
||||
# Using wildcard for version number handling
|
||||
$capability = Get-WindowsCapability -Online -Name "$capabilityName*"
|
||||
if (!$capability) {
|
||||
Write-Error "Failed to find `"$capabilityName`"."
|
||||
exit 1
|
||||
}
|
||||
$result = Add-WindowsCapability -Name $capability.Name -Online -ErrorAction Stop
|
||||
Write-Host "Successfully added `"$capabilityName`"."
|
||||
if ($result.RestartNeeded -eq 'Yes') {
|
||||
Write-Warning "A restart is needed to finish installing `"$capabilityName`"."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to add `"$capabilityName`": $_"
|
||||
}
|
||||
$capability = Get-WindowsCapability -Online -Name '{{ $capabilityName }}*'
|
||||
Add-WindowsCapability -Name "$capability.Name" -Online
|
||||
-
|
||||
name: RenameSystemFile
|
||||
parameters:
|
||||
|
||||
@@ -27,3 +27,21 @@
|
||||
*/
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@mixin fade-slide-transition($name, $duration, $offset-upward: null) {
|
||||
.#{$name}-enter-active,
|
||||
.#{$name}-leave-active {
|
||||
transition: all $duration;
|
||||
}
|
||||
|
||||
.#{$name}-leave-active,
|
||||
.#{$name}-enter, // Vue 2.X compatibility
|
||||
.#{$name}-enter-from // Vue 3.X compatibility
|
||||
{
|
||||
opacity: 0;
|
||||
|
||||
@if $offset-upward {
|
||||
transform: translateY($offset-upward);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { VModalBootstrapper } from './Modules/VModalBootstrapper';
|
||||
import { TreeBootstrapper } from './Modules/TreeBootstrapper';
|
||||
import { IconBootstrapper } from './Modules/IconBootstrapper';
|
||||
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
|
||||
@@ -19,7 +18,6 @@ export class ApplicationBootstrapper implements IVueBootstrapper {
|
||||
new TreeBootstrapper(),
|
||||
new VueBootstrapper(),
|
||||
new TooltipBootstrapper(),
|
||||
new VModalBootstrapper(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
28
src/presentation/bootstrapping/DependencyProvider.ts
Normal file
28
src/presentation/bootstrapping/DependencyProvider.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { InjectionKey, provide } from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
import {
|
||||
useCollectionStateKey, useApplicationKey, useEnvironmentKey,
|
||||
} from '@/presentation/injectionSymbols';
|
||||
import { IApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import { Environment } from '@/application/Environment/Environment';
|
||||
|
||||
export function provideDependencies(context: IApplicationContext) {
|
||||
registerSingleton(useApplicationKey, useApplication(context.app));
|
||||
registerTransient(useCollectionStateKey, () => useCollectionState(context));
|
||||
registerSingleton(useEnvironmentKey, Environment.CurrentEnvironment);
|
||||
}
|
||||
|
||||
function registerSingleton<T>(
|
||||
key: InjectionKey<T>,
|
||||
value: T,
|
||||
) {
|
||||
provide(key, value);
|
||||
}
|
||||
|
||||
function registerTransient<T>(
|
||||
key: InjectionKey<() => T>,
|
||||
factory: () => T,
|
||||
) {
|
||||
provide(key, factory);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import VModal from 'vue-js-modal';
|
||||
import { VueConstructor, IVueBootstrapper } from '../IVueBootstrapper';
|
||||
|
||||
export class VModalBootstrapper implements IVueBootstrapper {
|
||||
public bootstrap(vue: VueConstructor): void {
|
||||
vue.use(VModal, { dynamic: true, injectModalsContainer: true });
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,18 @@
|
||||
</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';
|
||||
import { buildContext } from '@/application/Context/ApplicationContextFactory';
|
||||
import { provideDependencies } from '../bootstrapping/DependencyProvider';
|
||||
|
||||
@Component({
|
||||
const singletonAppContext = await buildContext();
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
TheHeader,
|
||||
TheCodeButtons,
|
||||
@@ -26,10 +30,10 @@ import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
|
||||
TheSearchBar,
|
||||
TheFooter,
|
||||
},
|
||||
})
|
||||
export default class App extends Vue {
|
||||
|
||||
}
|
||||
setup() {
|
||||
provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts
|
||||
},
|
||||
});
|
||||
</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">
|
||||
|
||||
@@ -2,26 +2,41 @@
|
||||
<span class="code-wrapper">
|
||||
<span class="dollar">$</span>
|
||||
<code><slot /></code>
|
||||
<font-awesome-icon
|
||||
class="copy-button"
|
||||
:icon="['fas', 'copy']"
|
||||
@click="copyCode"
|
||||
v-tooltip.top-center="'Copy'"
|
||||
/>
|
||||
<TooltipWrapper>
|
||||
<font-awesome-icon
|
||||
class="copy-button"
|
||||
:icon="['fas', 'copy']"
|
||||
@click="copyCode"
|
||||
/>
|
||||
<template v-slot:tooltip>
|
||||
Copy
|
||||
</template>
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent, useSlots } from 'vue';
|
||||
import { Clipboard } from '@/infrastructure/Clipboard';
|
||||
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
|
||||
|
||||
@Component
|
||||
export default class Code extends Vue {
|
||||
public copyCode(): void {
|
||||
const code = this.$slots.default[0].text;
|
||||
Clipboard.copyText(code);
|
||||
}
|
||||
}
|
||||
export default defineComponent({
|
||||
components: {
|
||||
TooltipWrapper,
|
||||
},
|
||||
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,27 +20,33 @@
|
||||
<p>
|
||||
<ol>
|
||||
<li
|
||||
v-for='(step, index) in this.data.steps'
|
||||
v-for='(step, index) in data.steps'
|
||||
v-bind:key="index"
|
||||
class="step"
|
||||
>
|
||||
<div class="step__action">
|
||||
<span>{{ step.action.instruction }}</span>
|
||||
<font-awesome-icon
|
||||
v-if="step.action.details"
|
||||
class="explanation"
|
||||
:icon="['fas', 'info-circle']"
|
||||
v-tooltip.top-center="step.action.details"
|
||||
/>
|
||||
<TooltipWrapper v-if="step.action.details">
|
||||
<font-awesome-icon
|
||||
class="explanation"
|
||||
:icon="['fas', 'info-circle']"
|
||||
/>
|
||||
<template v-slot:tooltip>
|
||||
<div v-html="step.action.details" />
|
||||
</template>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
<div v-if="step.code" class="step__code">
|
||||
<Code>{{ step.code.instruction }}</Code>
|
||||
<font-awesome-icon
|
||||
v-if="step.code.details"
|
||||
class="explanation"
|
||||
:icon="['fas', 'info-circle']"
|
||||
v-tooltip.top-center="step.code.details"
|
||||
/>
|
||||
<CodeInstruction>{{ step.code.instruction }}</CodeInstruction>
|
||||
<TooltipWrapper v-if="step.code.details">
|
||||
<font-awesome-icon
|
||||
class="explanation"
|
||||
:icon="['fas', 'info-circle']"
|
||||
/>
|
||||
<template v-slot:tooltip>
|
||||
<div v-html="step.code.details" />
|
||||
</template>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
@@ -49,36 +55,50 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import {
|
||||
defineComponent, PropType, computed,
|
||||
inject,
|
||||
} from 'vue';
|
||||
import { useApplicationKey } from '@/presentation/injectionSymbols';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
import Code from './Code.vue';
|
||||
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
|
||||
import CodeInstruction from './CodeInstruction.vue';
|
||||
import { IInstructionListData } from './InstructionListData';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
Code,
|
||||
CodeInstruction,
|
||||
TooltipWrapper,
|
||||
},
|
||||
})
|
||||
export default class InstructionList extends Vue {
|
||||
public appName = '';
|
||||
props: {
|
||||
data: {
|
||||
type: Object as PropType<IInstructionListData>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { info } = inject(useApplicationKey);
|
||||
|
||||
public macOsDownloadUrl = '';
|
||||
const appName = computed<string>(() => info.name);
|
||||
|
||||
public osName = '';
|
||||
const macOsDownloadUrl = computed<string>(
|
||||
() => info.getDownloadUrl(OperatingSystem.macOS),
|
||||
);
|
||||
|
||||
@Prop() public data: IInstructionListData;
|
||||
const osName = computed<string>(() => {
|
||||
if (!props.data) {
|
||||
throw new Error('missing data');
|
||||
}
|
||||
return renderOsName(props.data.operatingSystem);
|
||||
});
|
||||
|
||||
public async created() {
|
||||
if (!this.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 {
|
||||
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,26 @@
|
||||
icon-prefix="fas"
|
||||
icon-name="copy"
|
||||
/>
|
||||
<Dialog v-if="this.hasInstructions" ref="instructionsDialog">
|
||||
<InstructionList :data="this.instructions" />
|
||||
</Dialog>
|
||||
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
|
||||
<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, inject,
|
||||
} from 'vue';
|
||||
import { useCollectionStateKey, useEnvironmentKey } from '@/presentation/injectionSymbols';
|
||||
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/Modal/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 +46,88 @@ import IconButton from './IconButton.vue';
|
||||
import { IInstructionListData } from './Instructions/InstructionListData';
|
||||
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
|
||||
|
||||
@Component({
|
||||
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,
|
||||
} = inject(useCollectionStateKey)();
|
||||
const { isDesktop } = inject(useEnvironmentKey);
|
||||
|
||||
public canRun = false;
|
||||
const areInstructionsVisible = ref(false);
|
||||
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop));
|
||||
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();
|
||||
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();
|
||||
async function copyCode() {
|
||||
const code = await getCurrentCode();
|
||||
Clipboard.copyText(code.current);
|
||||
}
|
||||
}
|
||||
|
||||
public async executeCode() {
|
||||
const context = await this.getCurrentContext();
|
||||
await executeCode(context);
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
||||
this.updateRunState(newState.os);
|
||||
this.updateDownloadState(newState.collection);
|
||||
this.updateCodeState(newState.code);
|
||||
}
|
||||
|
||||
private async getCurrentCode(): Promise<IApplicationCode> {
|
||||
const context = await this.getCurrentContext();
|
||||
const { code } = context.state;
|
||||
return code;
|
||||
}
|
||||
|
||||
private updateRunState(selectedOs: OperatingSystem) {
|
||||
const isRunningOnSelectedOs = selectedOs === Environment.CurrentEnvironment.os;
|
||||
this.canRun = this.isDesktopVersion && isRunningOnSelectedOs;
|
||||
}
|
||||
|
||||
private updateDownloadState(collection: ICategoryCollection) {
|
||||
this.fileName = buildFileName(collection.scripting);
|
||||
this.hasInstructions = hasInstructions(collection.os);
|
||||
if (this.hasInstructions) {
|
||||
this.instructions = getInstructions(collection.os, this.fileName);
|
||||
function saveCode() {
|
||||
saveCodeToDisk(fileName.value, currentState.value);
|
||||
areInstructionsVisible.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}));
|
||||
async function executeCode() {
|
||||
await runCode(currentContext);
|
||||
}
|
||||
|
||||
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;
|
||||
}));
|
||||
}
|
||||
|
||||
async function getCurrentCode(): Promise<IApplicationCode> {
|
||||
const { code } = currentContext.state;
|
||||
return code;
|
||||
}
|
||||
|
||||
return {
|
||||
isDesktopVersion: isDesktop,
|
||||
canRun,
|
||||
hasCode,
|
||||
instructions,
|
||||
fileName,
|
||||
areInstructionsVisible,
|
||||
copyCode,
|
||||
saveCode,
|
||||
executeCode,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function getDownloadInstructions(
|
||||
os: OperatingSystem,
|
||||
fileName: string,
|
||||
): IInstructionListData | undefined {
|
||||
if (!hasInstructions(os)) {
|
||||
return undefined;
|
||||
}
|
||||
return getInstructions(os, fileName);
|
||||
}
|
||||
|
||||
function saveCode(fileName: string, state: IReadOnlyCategoryCollectionState) {
|
||||
function getCanRunState(selectedOs: OperatingSystem, isDesktopVersion: boolean): 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 +151,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,126 +1,140 @@
|
||||
<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, inject,
|
||||
} from 'vue';
|
||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
||||
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 } = inject(useCollectionStateKey)();
|
||||
|
||||
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();
|
||||
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);
|
||||
editor.setValue(innerCode, 1);
|
||||
events.unsubscribeAll();
|
||||
events.register(appCode.changed.on((code) => updateCode(code)));
|
||||
}
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
||||
this.destroyEditor();
|
||||
this.editor = initializeEditor(
|
||||
this.theme,
|
||||
this.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)));
|
||||
}
|
||||
|
||||
private async updateCode(event: ICodeChangedEvent) {
|
||||
this.removeCurrentHighlighting();
|
||||
if (event.isEmpty()) {
|
||||
const context = await this.getCurrentContext();
|
||||
const defaultCode = getDefaultCode(context.state.collection.scripting.language);
|
||||
this.editor.setValue(defaultCode, 1);
|
||||
return;
|
||||
function updateCode(event: ICodeChangedEvent) {
|
||||
removeCurrentHighlighting();
|
||||
if (event.isEmpty()) {
|
||||
const defaultCode = getDefaultCode(currentState.value.collection.scripting.language);
|
||||
editor.setValue(defaultCode, 1);
|
||||
return;
|
||||
}
|
||||
editor.setValue(event.code, 1);
|
||||
if (event.addedScripts?.length > 0) {
|
||||
reactToChanges(event, event.addedScripts);
|
||||
} else if (event.changedScripts?.length > 0) {
|
||||
reactToChanges(event, event.changedScripts);
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
function sizeChanged() {
|
||||
editor?.resize();
|
||||
}
|
||||
}
|
||||
|
||||
private reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
|
||||
const positions = scripts
|
||||
.map((script) => event.getScriptPositionInCode(script));
|
||||
const start = Math.min(
|
||||
...positions.map((position) => position.startLine),
|
||||
);
|
||||
const end = Math.max(
|
||||
...positions.map((position) => position.endLine),
|
||||
);
|
||||
this.scrollToLine(end + 2);
|
||||
this.highlight(start, end);
|
||||
}
|
||||
|
||||
private highlight(startRow: number, endRow: number) {
|
||||
const AceRange = ace.require('ace/range').Range;
|
||||
this.currentMarkerId = this.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);
|
||||
}
|
||||
|
||||
private removeCurrentHighlighting() {
|
||||
if (!this.currentMarkerId) {
|
||||
return;
|
||||
function destroyEditor() {
|
||||
editor?.destroy();
|
||||
editor = undefined;
|
||||
}
|
||||
this.editor.session.removeMarker(this.currentMarkerId);
|
||||
this.currentMarkerId = undefined;
|
||||
}
|
||||
|
||||
private destroyEditor() {
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
this.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(
|
||||
...positions.map((position) => position.startLine),
|
||||
);
|
||||
const end = Math.max(
|
||||
...positions.map((position) => position.endLine),
|
||||
);
|
||||
scrollToLine(end + 2);
|
||||
highlight(start, end);
|
||||
}
|
||||
|
||||
function highlight(startRow: number, endRow: number) {
|
||||
const AceRange = ace.require('ace/range').Range;
|
||||
currentMarkerId = editor.session.addMarker(
|
||||
new AceRange(startRow, 0, endRow, 0),
|
||||
'code-area__highlight',
|
||||
'fullLine',
|
||||
);
|
||||
}
|
||||
|
||||
function scrollToLine(row: number) {
|
||||
const column = editor.session.getLine(row).length;
|
||||
editor.gotoLine(row, column, true);
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
@@ -1,88 +1,125 @@
|
||||
<template>
|
||||
<MenuOptionList label="Select">
|
||||
<MenuOptionListItem
|
||||
label="None"
|
||||
:enabled="this.currentSelection !== SelectionType.None"
|
||||
@click="selectType(SelectionType.None)"
|
||||
v-tooltip="
|
||||
'Deselect all selected scripts.<br/>'
|
||||
+ '💡 Good start to dive deeper into tweaks and select only what you want.'
|
||||
"
|
||||
/>
|
||||
<MenuOptionListItem
|
||||
label="Standard"
|
||||
:enabled="this.currentSelection !== SelectionType.Standard"
|
||||
@click="selectType(SelectionType.Standard)"
|
||||
v-tooltip="
|
||||
'🛡️ Balanced for privacy and functionality.<br/>'
|
||||
+ 'OS and applications will function normally.<br/>'
|
||||
+ '💡 Recommended for everyone'"
|
||||
/>
|
||||
<MenuOptionListItem
|
||||
label="Strict"
|
||||
:enabled="this.currentSelection !== SelectionType.Strict"
|
||||
@click="selectType(SelectionType.Strict)"
|
||||
v-tooltip="
|
||||
'🚫 Stronger privacy, disables risky functions that may leak your data.<br/>'
|
||||
+ '⚠️ Double check to remove scripts where you would trade functionality for privacy<br/>'
|
||||
+ '💡 Recommended for daily users that prefers more privacy over non-essential functions'
|
||||
"
|
||||
/>
|
||||
<MenuOptionListItem
|
||||
label="All"
|
||||
:enabled="this.currentSelection !== SelectionType.All"
|
||||
@click="selectType(SelectionType.All)"
|
||||
v-tooltip="
|
||||
'🔒 Strongest privacy, disabling any functionality that may leak your data.<br/>'
|
||||
+ '🛑 Not designed for daily users, it will break important functionalities.<br/>'
|
||||
+ '💡 Only recommended for extreme use-cases like crime labs where no leak is acceptable'
|
||||
"
|
||||
/>
|
||||
<TooltipWrapper>
|
||||
<!-- None -->
|
||||
<MenuOptionListItem
|
||||
label="None"
|
||||
:enabled="currentSelection !== SelectionType.None"
|
||||
@click="selectType(SelectionType.None)"
|
||||
/>
|
||||
<template v-slot:tooltip>
|
||||
Deselect all selected scripts.
|
||||
<br />
|
||||
💡 Good start to dive deeper into tweaks and select only what you want.
|
||||
</template>
|
||||
</TooltipWrapper>
|
||||
|
||||
<!-- Standard -->
|
||||
<TooltipWrapper>
|
||||
<MenuOptionListItem
|
||||
label="Standard"
|
||||
:enabled="currentSelection !== SelectionType.Standard"
|
||||
@click="selectType(SelectionType.Standard)"
|
||||
/>
|
||||
<template v-slot:tooltip>
|
||||
🛡️ Balanced for privacy and functionality.
|
||||
<br />
|
||||
OS and applications will function normally.
|
||||
<br />
|
||||
💡 Recommended for everyone
|
||||
</template>
|
||||
</TooltipWrapper>
|
||||
|
||||
<!-- Strict -->
|
||||
<TooltipWrapper>
|
||||
<MenuOptionListItem
|
||||
label="Strict"
|
||||
:enabled="currentSelection !== SelectionType.Strict"
|
||||
@click="selectType(SelectionType.Strict)"
|
||||
/>
|
||||
<template v-slot:tooltip>
|
||||
🚫 Stronger privacy, disables risky functions that may leak your data.
|
||||
<br />
|
||||
⚠️ Double check to remove scripts where you would trade functionality for privacy
|
||||
<br />
|
||||
💡 Recommended for daily users that prefers more privacy over non-essential functions
|
||||
</template>
|
||||
</TooltipWrapper>
|
||||
|
||||
<!-- All -->
|
||||
<TooltipWrapper>
|
||||
<MenuOptionListItem
|
||||
label="All"
|
||||
:enabled="currentSelection !== SelectionType.All"
|
||||
@click="selectType(SelectionType.All)"
|
||||
/>
|
||||
<template v-slot:tooltip>
|
||||
🔒 Strongest privacy, disabling any functionality that may leak your data.
|
||||
<br />
|
||||
🛑 Not designed for daily users, it will break important functionalities.
|
||||
<br />
|
||||
💡 Only recommended for extreme use-cases like crime labs where no leak is acceptable
|
||||
</template>
|
||||
</TooltipWrapper>
|
||||
</MenuOptionList>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component } from 'vue-property-decorator';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import { defineComponent, ref, inject } from 'vue';
|
||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
||||
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
|
||||
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,
|
||||
TooltipWrapper,
|
||||
},
|
||||
})
|
||||
export default class TheSelector extends StatefulVue {
|
||||
public SelectionType = SelectionType;
|
||||
setup() {
|
||||
const { modifyCurrentState, onStateChange, events } = inject(useCollectionStateKey)();
|
||||
|
||||
public currentSelection = SelectionType.None;
|
||||
const currentSelection = ref(SelectionType.None);
|
||||
|
||||
private selectionTypeHandler: SelectionTypeHandler;
|
||||
let selectionTypeHandler: SelectionTypeHandler;
|
||||
|
||||
public async selectType(type: SelectionType) {
|
||||
if (this.currentSelection === type) {
|
||||
return;
|
||||
onStateChange(() => {
|
||||
unregisterMutators();
|
||||
|
||||
modifyCurrentState((state) => {
|
||||
registerStateMutator(state);
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
function unregisterMutators() {
|
||||
events.unsubscribeAll();
|
||||
}
|
||||
this.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 registerStateMutator(state: ICategoryCollectionState) {
|
||||
selectionTypeHandler = new SelectionTypeHandler(state);
|
||||
updateSelections();
|
||||
events.register(state.selection.changed.on(() => updateSelections()));
|
||||
}
|
||||
|
||||
private updateSelections() {
|
||||
this.currentSelection = this.selectionTypeHandler.getCurrentSelectionType();
|
||||
}
|
||||
}
|
||||
function selectType(type: SelectionType) {
|
||||
if (currentSelection.value === type) {
|
||||
return;
|
||||
}
|
||||
selectionTypeHandler.selectType(type);
|
||||
}
|
||||
|
||||
function updateSelections() {
|
||||
currentSelection.value = 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,54 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component } from 'vue-property-decorator';
|
||||
import {
|
||||
defineComponent, computed, inject,
|
||||
} from 'vue';
|
||||
import { useApplicationKey, useCollectionStateKey } from '@/presentation/injectionSymbols';
|
||||
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 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 } = inject(useCollectionStateKey)();
|
||||
const { application } = inject(useApplicationKey);
|
||||
|
||||
public currentOs?: OperatingSystem = null;
|
||||
const allOses = computed<ReadonlyArray<IOsViewModel>>(() => (
|
||||
application.getSupportedOsList() ?? [])
|
||||
.map((os) : IOsViewModel => (
|
||||
{
|
||||
os,
|
||||
name: renderOsName(os),
|
||||
}
|
||||
)));
|
||||
|
||||
public async created() {
|
||||
const app = await ApplicationFactory.Current.getApp();
|
||||
this.allOses = app.getSupportedOsList()
|
||||
.map((os) => ({ os, name: renderOsName(os) }));
|
||||
}
|
||||
const currentOs = computed<OperatingSystem>(() => {
|
||||
return currentState.value.os;
|
||||
});
|
||||
|
||||
public async changeOs(newOs: OperatingSystem) {
|
||||
const context = await this.getCurrentContext();
|
||||
context.changeContext(newOs);
|
||||
}
|
||||
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 +69,3 @@ function renderOsName(os: OperatingSystem): string {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
@@ -5,53 +5,59 @@
|
||||
<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 { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||
import {
|
||||
defineComponent, ref, onUnmounted, inject,
|
||||
} from 'vue';
|
||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
||||
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
|
||||
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 } = inject(useCollectionStateKey)();
|
||||
|
||||
private listeners = new Array<IEventSubscription>();
|
||||
const isSearching = ref(false);
|
||||
|
||||
public destroyed() {
|
||||
this.unsubscribeAll();
|
||||
}
|
||||
onStateChange((state) => {
|
||||
subscribeToFilterChanges(state.filter);
|
||||
}, { 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();
|
||||
});
|
||||
}
|
||||
|
||||
private unsubscribeAll() {
|
||||
this.listeners.forEach((listener) => listener.unsubscribe());
|
||||
this.listeners.splice(0, this.listeners.length);
|
||||
}
|
||||
}
|
||||
function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
|
||||
events.register(
|
||||
filter.filterChanged.on((event) => {
|
||||
event.visit({
|
||||
onApply: () => { isSearching.value = true; },
|
||||
onClear: () => { isSearching.value = false; },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new Error(`View is already "${ViewType[view]}"`);
|
||||
function setView(view: ViewType) {
|
||||
if (currentView.value === view) {
|
||||
throw new Error(`View is already "${ViewType[view]}"`);
|
||||
}
|
||||
currentView.value = view;
|
||||
emit('viewChanged', currentView.value);
|
||||
}
|
||||
this.currentView = view;
|
||||
this.$emit('viewChanged', this.currentView);
|
||||
}
|
||||
}
|
||||
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;
|
||||
function onResize(displacementX: number): void {
|
||||
const leftWidth = firstElement.value.offsetWidth + displacementX;
|
||||
firstElement.value.style.width = `${leftWidth}px`;
|
||||
}
|
||||
|
||||
@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`;
|
||||
}
|
||||
}
|
||||
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,86 @@
|
||||
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,
|
||||
inject,
|
||||
} from 'vue';
|
||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
||||
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 } = inject(useCollectionStateKey)();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public destroyed() {
|
||||
document.removeEventListener('click', this.outsideClickListener);
|
||||
}
|
||||
|
||||
public onSelected(categoryId: number, isExpanded: boolean) {
|
||||
this.activeCategoryId = isExpanded ? categoryId : undefined;
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
||||
this.setCategories(newState.collection.actions);
|
||||
this.activeCategoryId = undefined;
|
||||
}
|
||||
|
||||
private setCategories(categories: ReadonlyArray<ICategory>): void {
|
||||
this.categoryIds = categories.map((category) => category.id);
|
||||
}
|
||||
|
||||
private onOutsideOfActiveCardClicked(clickedElement: Element): void {
|
||||
if (isClickable(clickedElement) || hasDirective(clickedElement)) {
|
||||
return;
|
||||
function onSelected(categoryId: number, isExpanded: boolean) {
|
||||
activeCategoryId.value = isExpanded ? categoryId : undefined;
|
||||
}
|
||||
this.collapseAllCards();
|
||||
if (hasDirective(clickedElement)) {
|
||||
return;
|
||||
}
|
||||
this.activeCategoryId = null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
onStateChange(() => {
|
||||
collapseAllCards();
|
||||
}, { immediate: true });
|
||||
|
||||
private collapseAllCards(): void {
|
||||
this.activeCategoryId = undefined;
|
||||
}
|
||||
const outsideClickListener = (event: PointerEvent): void => {
|
||||
if (areAllCardsCollapsed()) {
|
||||
return;
|
||||
}
|
||||
const element = document.querySelector(`[data-category="${activeCategoryId.value}"]`);
|
||||
const target = event.target as Element;
|
||||
if (element && !element.contains(target)) {
|
||||
onOutsideOfActiveCardClicked(target);
|
||||
}
|
||||
};
|
||||
|
||||
private areAllCardsCollapsed(): boolean {
|
||||
return !this.activeCategoryId;
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', outsideClickListener);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', outsideClickListener);
|
||||
});
|
||||
|
||||
function onOutsideOfActiveCardClicked(clickedElement: Element): void {
|
||||
if (isClickable(clickedElement) || hasDirective(clickedElement)) {
|
||||
return;
|
||||
}
|
||||
collapseAllCards();
|
||||
}
|
||||
|
||||
function areAllCardsCollapsed(): boolean {
|
||||
return !activeCategoryId.value;
|
||||
}
|
||||
|
||||
function collapseAllCards(): void {
|
||||
activeCategoryId.value = undefined;
|
||||
}
|
||||
|
||||
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,98 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Component, Prop, Watch, Emit,
|
||||
} from 'vue-property-decorator';
|
||||
defineComponent, ref, watch, computed,
|
||||
inject,
|
||||
} from 'vue';
|
||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
||||
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 } = inject(useCollectionStateKey)();
|
||||
|
||||
@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;
|
||||
|
||||
public areAllChildrenSelected = false;
|
||||
|
||||
public async mounted() {
|
||||
const context = await this.getCurrentContext();
|
||||
this.events.register(context.state.selection.changed.on(
|
||||
() => this.updateSelectionIndicators(this.categoryId),
|
||||
));
|
||||
await this.updateState(this.categoryId);
|
||||
}
|
||||
|
||||
@Emit('selected')
|
||||
public onSelected(isExpanded: boolean) {
|
||||
this.isExpanded = isExpanded;
|
||||
}
|
||||
|
||||
@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' });
|
||||
function collapse() {
|
||||
isExpanded.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
onStateChange(async (state) => {
|
||||
events.unsubscribeAll();
|
||||
events.register(state.selection.changed.on(
|
||||
() => updateSelectionIndicators(props.categoryId),
|
||||
));
|
||||
await updateSelectionIndicators(props.categoryId);
|
||||
}, { immediate: true });
|
||||
|
||||
protected handleCollectionState(): void { /* do nothing */ }
|
||||
watch(
|
||||
() => props.categoryId,
|
||||
(categoryId) => updateSelectionIndicators(categoryId),
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
async function scrollToCard() {
|
||||
await sleep(400); // wait a bit to allow GUI to render the expanded card
|
||||
cardElement.value.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,11 +14,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Watch } from 'vue-property-decorator';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import {
|
||||
defineComponent, watch, ref, inject,
|
||||
} from 'vue';
|
||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { ICategory } from '@/domain/ICategory';
|
||||
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import {
|
||||
@@ -26,96 +28,118 @@ 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,
|
||||
} = inject(useCollectionStateKey)();
|
||||
|
||||
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;
|
||||
|
||||
private filtered?: IFilterResult;
|
||||
|
||||
public async toggleNodeSelection(event: INodeSelectedEvent) {
|
||||
const context = await this.getCurrentContext();
|
||||
switch (event.node.type) {
|
||||
case NodeType.Category:
|
||||
toggleCategoryNodeSelection(event, context.state);
|
||||
break;
|
||||
case NodeType.Script:
|
||||
toggleScriptNodeSelection(event, context.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);
|
||||
}
|
||||
this.selectedNodeIds = context.state.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),
|
||||
watch(
|
||||
() => props.categoryId,
|
||||
async (newCategoryId) => { await setNodes(newCategoryId); },
|
||||
{ immediate: true },
|
||||
);
|
||||
}
|
||||
|
||||
private setCurrentFilter(currentFilter: IFilterResult | undefined) {
|
||||
if (!currentFilter) {
|
||||
this.handleFilterRemoved();
|
||||
} else {
|
||||
this.handleFiltered(currentFilter);
|
||||
onStateChange((state) => {
|
||||
setCurrentFilter(state.filter.currentFilter);
|
||||
if (!props.categoryId) {
|
||||
nodes.value = parseAllCategories(state.collection);
|
||||
}
|
||||
events.unsubscribeAll();
|
||||
subscribeToState(state);
|
||||
}, { immediate: true });
|
||||
|
||||
function toggleNodeSelection(event: INodeSelectedEvent) {
|
||||
modifyCurrentState((state) => {
|
||||
switch (event.node.type) {
|
||||
case NodeType.Category:
|
||||
toggleCategoryNodeSelection(event, state);
|
||||
break;
|
||||
case NodeType.Script:
|
||||
toggleScriptNodeSelection(event, state);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown node type: ${event.node.id}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
|
||||
this.selectedNodeIds = selectedScripts
|
||||
.map((node) => node.id);
|
||||
}
|
||||
function filterPredicate(node: INodeContent): boolean {
|
||||
return containsScript(node, filtered.scriptMatches)
|
||||
|| containsCategory(node, filtered.categoryMatches);
|
||||
}
|
||||
|
||||
private handleFilterRemoved() {
|
||||
this.filterText = '';
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
private handleFiltered(result: IFilterResult) {
|
||||
this.filterText = result.query;
|
||||
this.filtered = result;
|
||||
}
|
||||
function subscribeToState(state: IReadOnlyCategoryCollectionState) {
|
||||
events.register(
|
||||
state.selection.changed.on((scripts) => handleSelectionChanged(scripts)),
|
||||
state.filter.filterChanged.on((event) => {
|
||||
event.visit({
|
||||
onApply: (filter) => {
|
||||
filterText.value = filter.query;
|
||||
filtered = filter;
|
||||
},
|
||||
onClear: () => {
|
||||
filterText.value = '';
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function setCurrentFilter(currentFilter: IFilterResult | undefined) {
|
||||
filtered = currentFilter;
|
||||
filterText.value = currentFilter?.query || '';
|
||||
}
|
||||
|
||||
function handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
|
||||
selectedNodeIds.value = selectedScripts
|
||||
.map((node) => node.id);
|
||||
}
|
||||
|
||||
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 +168,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) {
|
||||
return '';
|
||||
}
|
||||
if (this.docs.length === 1) {
|
||||
return this.renderer.render(this.docs[0]);
|
||||
}
|
||||
const bulletpoints = this.docs
|
||||
.map((doc) => renderAsMarkdownListItem(doc))
|
||||
.join('\n');
|
||||
return this.renderer.render(bulletpoints);
|
||||
const renderer = createRenderer();
|
||||
|
||||
function renderText(docs: readonly string[] | undefined): string {
|
||||
if (!docs || docs.length === 0) {
|
||||
return '';
|
||||
}
|
||||
if (docs.length === 1) {
|
||||
return renderer.render(docs[0]);
|
||||
}
|
||||
const bulletpoints = docs
|
||||
.map((doc) => renderAsMarkdownListItem(doc))
|
||||
.join('\n');
|
||||
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>
|
||||
|
||||
@@ -124,33 +124,50 @@ function isGoodPathPart(part: string): boolean {
|
||||
&& !/^[0-9a-f]{40}$/.test(part); // Git SHA (e.g. GitHub links)
|
||||
}
|
||||
|
||||
const ExternalAnchorElementAttributes: Record<string, string> = {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
};
|
||||
|
||||
function openUrlsInNewTab(md: MarkdownIt) {
|
||||
// https://github.com/markdown-it/markdown-it/blob/12.2.0/docs/architecture.md#renderer
|
||||
const defaultRender = getDefaultRenderer(md, 'link_open');
|
||||
const defaultRender = getOrDefaultRenderer(md, 'link_open');
|
||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx];
|
||||
if (!getTokenAttributeValue(token, 'target')) {
|
||||
token.attrPush(['target', '_blank']);
|
||||
}
|
||||
|
||||
Object.entries(ExternalAnchorElementAttributes).forEach(([name, value]) => {
|
||||
const currentValue = getAttribute(token, name);
|
||||
if (!currentValue) {
|
||||
token.attrPush([name, value]);
|
||||
} else if (currentValue !== value) {
|
||||
setAttribute(token, name, value);
|
||||
}
|
||||
});
|
||||
return defaultRender(tokens, idx, options, env, self);
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultRenderer(md: MarkdownIt, ruleName: string): Renderer.RenderRule {
|
||||
function getOrDefaultRenderer(md: MarkdownIt, ruleName: string): Renderer.RenderRule {
|
||||
const renderer = md.renderer.rules[ruleName];
|
||||
if (renderer) {
|
||||
return renderer;
|
||||
}
|
||||
return (tokens, idx, options, _env, self) => {
|
||||
return renderer || defaultRenderer;
|
||||
function defaultRenderer(tokens, idx, options, _env, self) {
|
||||
return self.renderToken(tokens, idx, options);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenAttributeValue(token: Token, attributeName: string): string | undefined {
|
||||
const attributeIndex = token.attrIndex(attributeName);
|
||||
function getAttribute(token: Token, name: string): string | undefined {
|
||||
const attributeIndex = token.attrIndex(name);
|
||||
if (attributeIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const value = token.attrs[attributeIndex][1];
|
||||
return value;
|
||||
}
|
||||
|
||||
function setAttribute(token: Token, name: string, value: string): void {
|
||||
const attributeIndex = token.attrIndex(name);
|
||||
if (attributeIndex < 0) {
|
||||
throw new Error('Attribute does not exist');
|
||||
}
|
||||
token.attrs[attributeIndex][1] = value;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
} else {
|
||||
this.$emit('hide');
|
||||
function toggle() {
|
||||
isOn.value = !isOn.value;
|
||||
if (isOn.value) {
|
||||
emit('show');
|
||||
} else {
|
||||
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">
|
||||
@@ -1,162 +1,86 @@
|
||||
<template>
|
||||
<div class="checkbox-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="input-checkbox"
|
||||
v-model="isReverted"
|
||||
@change="onRevertToggled()"
|
||||
v-on:click.stop>
|
||||
<div class="checkbox-animate">
|
||||
<span class="checkbox-off">revert</span>
|
||||
<span class="checkbox-on">revert</span>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
v-model="isChecked"
|
||||
:stopClickPropagation="true"
|
||||
:label="'revert'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Watch } from 'vue-property-decorator';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import {
|
||||
PropType, defineComponent, ref, watch,
|
||||
computed, inject,
|
||||
} from 'vue';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
||||
import { IReverter } from './Reverter/IReverter';
|
||||
import { INode } from './INode';
|
||||
import { INodeContent } from './INodeContent';
|
||||
import { getReverter } from './Reverter/ReverterFactory';
|
||||
import ToggleSwitch from './ToggleSwitch.vue';
|
||||
|
||||
@Component
|
||||
export default class RevertToggle extends StatefulVue {
|
||||
@Prop() public node: INode;
|
||||
export default defineComponent({
|
||||
components: {
|
||||
ToggleSwitch,
|
||||
},
|
||||
props: {
|
||||
node: {
|
||||
type: Object as PropType<INodeContent>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const {
|
||||
currentState, modifyCurrentState, onStateChange, events,
|
||||
} = inject(useCollectionStateKey)();
|
||||
|
||||
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 },
|
||||
);
|
||||
|
||||
public async onRevertToggled() {
|
||||
const context = await this.getCurrentContext();
|
||||
this.handler.selectWithRevertState(this.isReverted, context.state.selection);
|
||||
}
|
||||
onStateChange((newState) => {
|
||||
updateRevertStatusFromState(newState.selection.selectedScripts);
|
||||
events.unsubscribeAll();
|
||||
events.register(
|
||||
newState.selection.changed.on((scripts) => updateRevertStatusFromState(scripts)),
|
||||
);
|
||||
}, { immediate: true });
|
||||
|
||||
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 onNodeChanged(node: INodeContent) {
|
||||
handler = getReverter(node, currentState.value.collection);
|
||||
updateRevertStatusFromState(currentState.value.selection.selectedScripts);
|
||||
}
|
||||
|
||||
private updateStatus(scripts: ReadonlyArray<SelectedScript>) {
|
||||
this.isReverted = this.handler.getState(scripts);
|
||||
}
|
||||
}
|
||||
async function updateRevertStatusFromState(scripts: ReadonlyArray<SelectedScript>) {
|
||||
isReverted.value = handler?.getState(scripts) ?? false;
|
||||
}
|
||||
|
||||
function syncReversionStatusWithState(value: boolean) {
|
||||
if (value === isReverted.value) {
|
||||
return;
|
||||
}
|
||||
modifyCurrentState((state) => {
|
||||
handler.selectWithRevertState(value, state.selection);
|
||||
});
|
||||
}
|
||||
|
||||
const isChecked = computed({
|
||||
get() {
|
||||
return isReverted.value;
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
syncReversionStatusWithState(value);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
isChecked,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use 'sass:math';
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
$color-bullet-unchecked : $color-primary-darker;
|
||||
$color-bullet-checked : $color-on-secondary;
|
||||
$color-text-unchecked : $color-on-primary;
|
||||
$color-text-checked : $color-on-secondary;
|
||||
$color-bg-unchecked : $color-primary;
|
||||
$color-bg-checked : $color-secondary;
|
||||
$size-width : 85px;
|
||||
$size-height : 30px;
|
||||
|
||||
// https://www.designlabthemes.com/css-toggle-switch/
|
||||
.checkbox-switch {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: $size-width;
|
||||
height: $size-height;
|
||||
-webkit-border-radius: $size-height;
|
||||
border-radius: $size-height;
|
||||
line-height: $size-height;
|
||||
font-size: math.div($size-height, 2);
|
||||
display: inline-block;
|
||||
|
||||
input.input-checkbox {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: $size-width;
|
||||
height: $size-height;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
z-index: 2;
|
||||
@include clickable;
|
||||
}
|
||||
|
||||
.checkbox-animate {
|
||||
position: relative;
|
||||
width: $size-width;
|
||||
height: $size-height;
|
||||
background-color: $color-bg-unchecked;
|
||||
-webkit-transition: background-color 0.25s ease-out 0s;
|
||||
transition: background-color 0.25s ease-out 0s;
|
||||
|
||||
// Circle
|
||||
&:before {
|
||||
$circle-size: $size-height * 0.66;
|
||||
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: $circle-size;
|
||||
height: $circle-size;
|
||||
border-radius: $circle-size * 2;
|
||||
-webkit-border-radius: $circle-size * 2;
|
||||
background-color: $color-bullet-unchecked;
|
||||
top: $size-height * 0.16;
|
||||
left: $size-width * 0.05;
|
||||
-webkit-transition: left 0.3s ease-out 0s;
|
||||
transition: left 0.3s ease-out 0s;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
input.input-checkbox:checked {
|
||||
+ .checkbox-animate {
|
||||
background-color: $color-bg-checked;
|
||||
}
|
||||
+ .checkbox-animate:before {
|
||||
left: ($size-width - math.div($size-width, 3.5));
|
||||
background-color: $color-bullet-checked;
|
||||
}
|
||||
+ .checkbox-animate .checkbox-off {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
+ .checkbox-animate .checkbox-on {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-off, .checkbox-on {
|
||||
text-transform: uppercase;
|
||||
float: left;
|
||||
font-weight: 700;
|
||||
-webkit-transition: all 0.3s ease-out 0s;
|
||||
transition: all 0.3s ease-out 0s;
|
||||
}
|
||||
|
||||
.checkbox-off {
|
||||
margin-left: math.div($size-width, 3);
|
||||
opacity: 1;
|
||||
color: $color-text-unchecked;
|
||||
}
|
||||
|
||||
.checkbox-on {
|
||||
display: none;
|
||||
float: right;
|
||||
margin-right: math.div($size-width, 3);
|
||||
opacity: 0;
|
||||
color: $color-text-checked;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div
|
||||
class="toggle-switch"
|
||||
@click="handleClickPropagation"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle-input"
|
||||
v-model="isChecked"
|
||||
>
|
||||
<div class="toggle-animation">
|
||||
<span class="label-off">{{ label }}</span>
|
||||
<span class="label-on">{{ label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: Boolean,
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
stopClickPropagation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
input: (isChecked: boolean) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const isChecked = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(value: boolean) {
|
||||
if (value === props.value) {
|
||||
return;
|
||||
}
|
||||
emit('input', value);
|
||||
},
|
||||
});
|
||||
|
||||
function handleClickPropagation(event: Event): void {
|
||||
if (props.stopClickPropagation) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isChecked,
|
||||
handleClickPropagation,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use 'sass:math';
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
$color-toggle-unchecked : $color-primary-darker;
|
||||
$color-toggle-checked : $color-on-secondary;
|
||||
$color-text-unchecked : $color-on-primary;
|
||||
$color-text-checked : $color-on-secondary;
|
||||
$color-bg-unchecked : $color-primary;
|
||||
$color-bg-checked : $color-secondary;
|
||||
$size-height : 30px;
|
||||
$size-circle : math.div($size-height * 2, 3);
|
||||
$padding-horizontal : 0.40em;
|
||||
$gap : 0.25em;
|
||||
|
||||
@mixin locateNearCircle($direction: 'left') {
|
||||
$circle-width: calc(#{$size-circle} + #{$padding-horizontal});
|
||||
$circle-space: calc(#{$circle-width} + #{$gap});
|
||||
@if $direction == 'left' {
|
||||
margin-left: $circle-space;
|
||||
} @else {
|
||||
margin-right: $circle-space;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin setVisibility($isVisible: true) {
|
||||
@if $isVisible {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
} @else {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: auto;
|
||||
height: $size-height;
|
||||
border-radius: $size-height;
|
||||
line-height: $size-height;
|
||||
font-size: math.div($size-height, 2);
|
||||
|
||||
input.toggle-input {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
z-index: 2;
|
||||
@include clickable;
|
||||
}
|
||||
|
||||
.toggle-animation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $gap;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: $color-bg-unchecked;
|
||||
transition: background-color 0.25s ease-out;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: $padding-horizontal;
|
||||
$initial-top: 50%;
|
||||
$centered-top-offset: math.div($size-circle, 2);
|
||||
$centered-top: calc(#{$initial-top} - #{$centered-top-offset});
|
||||
top: $centered-top;
|
||||
width: $size-circle;
|
||||
height: $size-circle;
|
||||
border-radius: 50%;
|
||||
background-color: $color-toggle-unchecked;
|
||||
transition: left 0.3s ease-out;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
input.toggle-input:checked + .toggle-animation {
|
||||
background-color: $color-bg-checked;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
&:before {
|
||||
$left-offset: calc(100% - #{$size-circle});
|
||||
$padded-left-offset: calc(#{$left-offset} - #{$padding-horizontal});
|
||||
left: $padded-left-offset;
|
||||
background-color: $color-toggle-checked;
|
||||
}
|
||||
|
||||
.label-off {
|
||||
@include setVisibility(false);
|
||||
}
|
||||
|
||||
.label-on {
|
||||
@include setVisibility(true);
|
||||
}
|
||||
}
|
||||
|
||||
.label-off, .label-on {
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.label-off {
|
||||
@include setVisibility(true);
|
||||
@include locateNearCircle('left');
|
||||
padding-right: $padding-horizontal;
|
||||
}
|
||||
|
||||
.label-on {
|
||||
@include setVisibility(false);
|
||||
color: $color-text-checked;
|
||||
|
||||
@include locateNearCircle('right');
|
||||
padding-left: $padding-horizontal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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,109 +19,139 @@
|
||||
|
||||
<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;
|
||||
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)),
|
||||
);
|
||||
|
||||
@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)),
|
||||
);
|
||||
|
||||
public convertExistingToNode = convertExistingToNode;
|
||||
|
||||
public nodeSelected(node: ILiquorTreeExistingNode) {
|
||||
const event: INodeSelectedEvent = {
|
||||
node: convertExistingToNode(node),
|
||||
isSelected: node.states.checked,
|
||||
};
|
||||
this.$emit('nodeSelected', event);
|
||||
}
|
||||
|
||||
@Watch('initialNodes', { immediate: true })
|
||||
public async updateNodes(nodes: readonly INode[]) {
|
||||
if (!nodes) {
|
||||
throw new Error('missing initial nodes');
|
||||
function nodeSelected(node: ILiquorTreeExistingNode) {
|
||||
const event: INodeSelectedEvent = {
|
||||
node: convertExistingToNode(node),
|
||||
isSelected: node.states.checked,
|
||||
};
|
||||
emit('nodeSelected', event);
|
||||
}
|
||||
const initialNodes = nodes.map((node) => toNewLiquorTreeNode(node));
|
||||
if (this.selectedNodeIds) {
|
||||
recurseDown(
|
||||
initialNodes,
|
||||
|
||||
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 (props.selectedNodeIds) {
|
||||
recurseDown(
|
||||
initialNodes,
|
||||
(node) => {
|
||||
node.state = updateState(node.state, node, props.selectedNodeIds);
|
||||
},
|
||||
);
|
||||
}
|
||||
initialLiquorTreeNodes.value = initialNodes;
|
||||
const api = await getLiquorTreeApi();
|
||||
api.setModel(initialLiquorTreeNodes.value);
|
||||
}
|
||||
|
||||
async function setFilterText(filterText?: string) {
|
||||
const api = await getLiquorTreeApi();
|
||||
if (!filterText) {
|
||||
api.clearFilter();
|
||||
} else {
|
||||
api.filter('filtered'); // text does not matter, it'll trigger the filterPredicate
|
||||
}
|
||||
}
|
||||
|
||||
async function setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) {
|
||||
if (!selectedNodeIds) {
|
||||
throw new Error('Selected recurseDown nodes are undefined');
|
||||
}
|
||||
const tree = await getLiquorTreeApi();
|
||||
tree.recurseDown(
|
||||
(node) => {
|
||||
node.state = updateState(node.state, node, this.selectedNodeIds);
|
||||
node.states = updateState(node.states, node, 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);
|
||||
}
|
||||
|
||||
@Watch('filterText', { immediate: true })
|
||||
public async updateFilterText(filterText?: string) {
|
||||
const api = await this.getLiquorTreeApi();
|
||||
if (!filterText) {
|
||||
api.clearFilter();
|
||||
} else {
|
||||
api.filter('filtered'); // text does not matter, it'll trigger the filterPredicate
|
||||
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 tree;
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('selectedNodeIds')
|
||||
public async setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) {
|
||||
if (!selectedNodeIds) {
|
||||
throw new Error('Selected recurseDown nodes are undefined');
|
||||
}
|
||||
const tree = await this.getLiquorTreeApi();
|
||||
tree.recurseDown(
|
||||
(node) => {
|
||||
node.states = updateState(node.states, node, selectedNodeIds);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
return {
|
||||
liquorTreeOptions,
|
||||
initialLiquorTreeNodes,
|
||||
convertExistingToNode,
|
||||
nodeSelected,
|
||||
liquorTree,
|
||||
};
|
||||
const treeElement = await tryUntilDefined(accessor, 5, 20); // Wait for it to render
|
||||
if (!treeElement) {
|
||||
throw Error('Referenced tree element cannot be found. Perhaps it\'s not yet rendered?');
|
||||
}
|
||||
return treeElement;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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,10 +17,10 @@
|
||||
</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" target="_blank" class="child github">here</a> ✨
|
||||
<a :href="repositoryUrl" class="child github" target="_blank" rel="noopener noreferrer">here</a> ✨
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,75 +32,83 @@
|
||||
</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,
|
||||
inject,
|
||||
} from 'vue';
|
||||
import { useApplicationKey, useCollectionStateKey } from '@/presentation/injectionSymbols';
|
||||
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 { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
|
||||
|
||||
/** 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 } = inject(useCollectionStateKey)();
|
||||
const { info } = inject(useApplicationKey);
|
||||
|
||||
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)}...`;
|
||||
},
|
||||
return `${query.substring(0, threshold)}...`;
|
||||
});
|
||||
|
||||
onStateChange((newState) => {
|
||||
events.unsubscribeAll();
|
||||
subscribeToFilterChanges(newState.filter);
|
||||
});
|
||||
|
||||
function clearSearchQuery() {
|
||||
modifyCurrentState((state) => {
|
||||
const { filter } = state;
|
||||
filter.clearFilter();
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
|
||||
events.register(
|
||||
filter.filterChanged.on((event) => {
|
||||
event.visit({
|
||||
onApply: (newFilter) => {
|
||||
searchQuery.value = newFilter.query;
|
||||
isSearching.value = true;
|
||||
searchHasMatches.value = newFilter.hasAnyMatches();
|
||||
},
|
||||
onClear: () => {
|
||||
isSearching.value = false;
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
repositoryUrl,
|
||||
trimmedSearchQuery,
|
||||
isSearching,
|
||||
searchHasMatches,
|
||||
clearSearchQuery,
|
||||
ViewType,
|
||||
};
|
||||
},
|
||||
})
|
||||
export default class TheScriptsView extends StatefulVue {
|
||||
public repositoryUrl = '';
|
||||
|
||||
public searchQuery = '';
|
||||
|
||||
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;
|
||||
filter.removeFilter();
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
||||
this.events.unsubscribeAll();
|
||||
this.subscribeState(newState);
|
||||
}
|
||||
|
||||
private subscribeState(state: IReadOnlyCategoryCollectionState) {
|
||||
this.events.register(
|
||||
state.filter.filterRemoved.on(() => {
|
||||
this.isSearching = false;
|
||||
}),
|
||||
state.filter.filtered.on((result: IFilterResult) => {
|
||||
this.searchQuery = result.query;
|
||||
this.isSearching = true;
|
||||
this.searchHasMatches = result.hasAnyMatches();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -161,5 +169,4 @@ $margin-inner: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<modal
|
||||
:name="name"
|
||||
:adaptive="true"
|
||||
height="auto">
|
||||
<div class="dialog">
|
||||
<div class="dialog__content">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="dialog__close-button">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'times']"
|
||||
@click="$modal.hide(name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
|
||||
@Component
|
||||
export default class Dialog extends Vue {
|
||||
private static idCounter = 0;
|
||||
|
||||
public name = (++Dialog.idCounter).toString();
|
||||
|
||||
public show(): void {
|
||||
this.$modal.show(this.name);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
@mixin scrollable() {
|
||||
overflow-y: auto;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
color: $color-surface;
|
||||
font-family: $font-normal;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@include scrollable;
|
||||
|
||||
&__content {
|
||||
color: $color-on-surface;
|
||||
width: 100%;
|
||||
margin: 5%;
|
||||
}
|
||||
|
||||
&__close-button {
|
||||
color: $color-primary-dark;
|
||||
width: auto;
|
||||
font-size: 1.5em;
|
||||
margin-right: 0.25em;
|
||||
align-self: flex-start;
|
||||
@include clickable;
|
||||
@include hover-or-touch {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
src/presentation/components/Shared/Hooks/README.md
Normal file
5
src/presentation/components/Shared/Hooks/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Hooks
|
||||
|
||||
This folder contains shared hooks used throughout the application.
|
||||
|
||||
To use the hooks, prefer using Vue-native `provide` / `inject` pattern to keep the components independently testable without side-effect.
|
||||
11
src/presentation/components/Shared/Hooks/UseApplication.ts
Normal file
11
src/presentation/components/Shared/Hooks/UseApplication.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IApplication } from '@/domain/IApplication';
|
||||
|
||||
export function useApplication(application: IApplication) {
|
||||
if (!application) {
|
||||
throw new Error('missing application');
|
||||
}
|
||||
return {
|
||||
application,
|
||||
info: application.info,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ref, computed, readonly } from 'vue';
|
||||
import { IApplicationContext, IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
|
||||
|
||||
export function useCollectionState(context: IApplicationContext) {
|
||||
if (!context) {
|
||||
throw new Error('missing context');
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IEnvironment } from '@/application/Environment/IEnvironment';
|
||||
|
||||
export function useEnvironment(environment: IEnvironment) {
|
||||
if (!environment) {
|
||||
throw new Error('missing environment');
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Ref, computed, watch } from 'vue';
|
||||
|
||||
/**
|
||||
* This function monitors a set of conditions (represented as refs) and
|
||||
* maintains a composite status based on all conditions.
|
||||
*/
|
||||
export function useAllTrueWatcher(
|
||||
...conditions: Ref<boolean>[]
|
||||
) {
|
||||
const allMetCallbacks = new Array<() => void>();
|
||||
|
||||
const areAllConditionsMet = computed(() => conditions.every((condition) => condition.value));
|
||||
|
||||
watch(areAllConditionsMet, (areMet) => {
|
||||
if (areMet) {
|
||||
allMetCallbacks.forEach((action) => action());
|
||||
}
|
||||
});
|
||||
|
||||
function resetAllConditions() {
|
||||
conditions.forEach((condition) => {
|
||||
condition.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function onAllConditionsMet(callback: () => void) {
|
||||
allMetCallbacks.push(callback);
|
||||
if (areAllConditionsMet.value) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resetAllConditions,
|
||||
onAllConditionsMet,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Ref, watchEffect } from 'vue';
|
||||
|
||||
/**
|
||||
* Manages focus transitions, ensuring good usability and accessibility.
|
||||
*/
|
||||
export function useCurrentFocusToggle(shouldDisableFocus: Ref<boolean>) {
|
||||
let previouslyFocusedElement: HTMLElement | undefined;
|
||||
|
||||
watchEffect(() => {
|
||||
if (shouldDisableFocus.value) {
|
||||
previouslyFocusedElement = document.activeElement as HTMLElement | null;
|
||||
previouslyFocusedElement?.blur();
|
||||
} else {
|
||||
if (!previouslyFocusedElement || previouslyFocusedElement.tagName === 'BODY') {
|
||||
// It doesn't make sense to return focus to the body after the modal is
|
||||
// closed because the body itself doesn't offer meaningful interactivity
|
||||
return;
|
||||
}
|
||||
previouslyFocusedElement.focus();
|
||||
previouslyFocusedElement = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { onBeforeMount, onBeforeUnmount } from 'vue';
|
||||
|
||||
export function useEscapeKeyListener(callback: () => void) {
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeMount(() => {
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Ref, watch, onBeforeUnmount } from 'vue';
|
||||
|
||||
/*
|
||||
It blocks background scrolling.
|
||||
Designed to be used by modals, overlays etc.
|
||||
*/
|
||||
export function useLockBodyBackgroundScroll(isActive: Ref<boolean>) {
|
||||
const originalStyles = {
|
||||
overflow: document.body.style.overflow,
|
||||
width: document.body.style.width,
|
||||
};
|
||||
|
||||
const block = () => {
|
||||
originalStyles.overflow = document.body.style.overflow;
|
||||
originalStyles.width = document.body.style.width;
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.width = '100vw';
|
||||
};
|
||||
|
||||
const unblock = () => {
|
||||
document.body.style.overflow = originalStyles.overflow;
|
||||
document.body.style.width = originalStyles.width;
|
||||
};
|
||||
|
||||
watch(isActive, (shouldBlock) => {
|
||||
if (shouldBlock) {
|
||||
block();
|
||||
} else {
|
||||
unblock();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unblock();
|
||||
});
|
||||
}
|
||||
150
src/presentation/components/Shared/Modal/ModalContainer.vue
Normal file
150
src/presentation/components/Shared/Modal/ModalContainer.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isRendered"
|
||||
class="modal-container"
|
||||
>
|
||||
<ModalOverlay
|
||||
@transitionedOut="onOverlayTransitionedOut"
|
||||
@click="onBackgroundOverlayClick"
|
||||
:show="isOpen"
|
||||
/>
|
||||
<ModalContent
|
||||
class="modal-content"
|
||||
:show="isOpen"
|
||||
@transitionedOut="onContentTransitionedOut"
|
||||
>
|
||||
<slot />
|
||||
</ModalContent>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, ref, watchEffect, nextTick,
|
||||
} from 'vue';
|
||||
import ModalOverlay from './ModalOverlay.vue';
|
||||
import ModalContent from './ModalContent.vue';
|
||||
import { useLockBodyBackgroundScroll } from './Hooks/UseLockBodyBackgroundScroll';
|
||||
import { useCurrentFocusToggle } from './Hooks/UseCurrentFocusToggle';
|
||||
import { useEscapeKeyListener } from './Hooks/UseEscapeKeyListener';
|
||||
import { useAllTrueWatcher } from './Hooks/UseAllTrueWatcher';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
},
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
input: (isOpen: boolean) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
closeOnOutsideClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const isRendered = ref(false);
|
||||
const isOpen = ref(false);
|
||||
const overlayTransitionedOut = ref(false);
|
||||
const contentTransitionedOut = ref(false);
|
||||
|
||||
useLockBodyBackgroundScroll(isOpen);
|
||||
useCurrentFocusToggle(isOpen);
|
||||
useEscapeKeyListener(() => handleEscapeKeyUp());
|
||||
|
||||
const {
|
||||
onAllConditionsMet: onModalFullyTransitionedOut,
|
||||
resetAllConditions: resetTransitionStatus,
|
||||
} = useAllTrueWatcher(overlayTransitionedOut, contentTransitionedOut);
|
||||
|
||||
onModalFullyTransitionedOut(() => {
|
||||
isRendered.value = false;
|
||||
resetTransitionStatus();
|
||||
if (props.value) {
|
||||
emit('input', false);
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.value) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
function onOverlayTransitionedOut() {
|
||||
overlayTransitionedOut.value = true;
|
||||
}
|
||||
|
||||
function onContentTransitionedOut() {
|
||||
contentTransitionedOut.value = true;
|
||||
}
|
||||
|
||||
function handleEscapeKeyUp() {
|
||||
close();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!isRendered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = false;
|
||||
|
||||
if (props.value) {
|
||||
emit('input', false);
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (isRendered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRendered.value = true;
|
||||
|
||||
nextTick(() => { // Let the modal render first
|
||||
isOpen.value = true;
|
||||
});
|
||||
|
||||
if (!props.value) {
|
||||
emit('input', true);
|
||||
}
|
||||
}
|
||||
|
||||
function onBackgroundOverlayClick() {
|
||||
if (props.closeOnOutsideClick) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRendered,
|
||||
onBackgroundOverlayClick,
|
||||
onOverlayTransitionedOut,
|
||||
onContentTransitionedOut,
|
||||
isOpen,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
box-sizing: border-box;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
z-index: 999;
|
||||
}
|
||||
</style>
|
||||
95
src/presentation/components/Shared/Modal/ModalContent.vue
Normal file
95
src/presentation/components/Shared/Modal/ModalContent.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<transition
|
||||
name="modal-content-transition"
|
||||
@after-leave="onAfterTransitionLeave"
|
||||
>
|
||||
<div v-if="show" class="modal-content-wrapper">
|
||||
<div
|
||||
ref="modalElement"
|
||||
class="modal-content-content"
|
||||
role="dialog"
|
||||
aria-expanded="true"
|
||||
aria-modal="true"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'transitionedOut',
|
||||
],
|
||||
setup(_, { emit }) {
|
||||
const modalElement = ref<HTMLElement>();
|
||||
|
||||
function onAfterTransitionLeave() {
|
||||
emit('transitionedOut');
|
||||
}
|
||||
|
||||
return {
|
||||
onAfterTransitionLeave,
|
||||
modalElement,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
$modal-content-transition-duration: 400ms;
|
||||
$modal-content-color-shadow: $color-on-surface;
|
||||
$modal-content-color-background: $color-surface;
|
||||
$modal-content-offset-upward: 20px;
|
||||
|
||||
@mixin scrollable() {
|
||||
overflow-y: auto;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.modal-content-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.modal-content-content {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
pointer-events: auto;
|
||||
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
max-width: 600px;
|
||||
|
||||
background-color: $color-surface;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 20px 60px -2px $color-on-surface;
|
||||
|
||||
@include scrollable;
|
||||
}
|
||||
|
||||
@include fade-slide-transition('modal-content-transition', $modal-content-transition-duration, $modal-content-offset-upward);
|
||||
</style>
|
||||
87
src/presentation/components/Shared/Modal/ModalDialog.vue
Normal file
87
src/presentation/components/Shared/Modal/ModalDialog.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<ModalContainer
|
||||
v-model="showDialog"
|
||||
>
|
||||
<div class="dialog">
|
||||
<div class="dialog__content">
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
class="dialog__close-button"
|
||||
@click="hide"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'times']"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import ModalContainer from './ModalContainer.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
ModalContainer,
|
||||
},
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
input: (isOpen: boolean) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const showDialog = computed({
|
||||
get: () => props.value,
|
||||
set: (value) => {
|
||||
if (value !== props.value) {
|
||||
emit('input', value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function hide() {
|
||||
showDialog.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
showDialog,
|
||||
hide,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
.dialog {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
&__content {
|
||||
margin: 5%;
|
||||
}
|
||||
|
||||
&__close-button {
|
||||
color: $color-primary-dark;
|
||||
width: auto;
|
||||
font-size: 1.5em;
|
||||
margin-right: 0.25em;
|
||||
align-self: flex-start;
|
||||
@include clickable;
|
||||
@include hover-or-touch {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
65
src/presentation/components/Shared/Modal/ModalOverlay.vue
Normal file
65
src/presentation/components/Shared/Modal/ModalOverlay.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<transition
|
||||
name="modal-overlay-transition"
|
||||
@after-leave="onAfterTransitionLeave"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="modal-overlay-background"
|
||||
aria-expanded="true"
|
||||
@click.self.stop="onClick"
|
||||
/>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'click',
|
||||
'transitionedOut',
|
||||
],
|
||||
setup(_, { emit }) {
|
||||
function onAfterTransitionLeave() {
|
||||
emit('transitionedOut');
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
emit('click');
|
||||
}
|
||||
|
||||
return {
|
||||
onAfterTransitionLeave,
|
||||
onClick,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
$modal-overlay-transition-duration: 50ms;
|
||||
$modal-overlay-color-background: $color-on-surface;
|
||||
|
||||
.modal-overlay-background {
|
||||
position: fixed;
|
||||
box-sizing: border-box;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: rgba($modal-overlay-color-background, 0.3);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@include fade-slide-transition('modal-overlay-transition', $modal-overlay-transition-duration);
|
||||
</style>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
65
src/presentation/components/Shared/TooltipWrapper.vue
Normal file
65
src/presentation/components/Shared/TooltipWrapper.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<!--
|
||||
This component acts as a wrapper for the v-tooltip to solve the following:
|
||||
- Direct inclusion of inline HTML in tooltip components has challenges such as
|
||||
- absence of linting or editor support,
|
||||
- involves cumbersome string concatenation.
|
||||
This component caters to these issues by permitting HTML usage in a slot.
|
||||
- It provides an abstraction for a third-party component which simplifies
|
||||
switching and acts as an anti-corruption layer.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="tooltip-container" v-tooltip.top-center="tooltipHtml">
|
||||
<slot />
|
||||
<div class="tooltip-content" ref="tooltipWrapper">
|
||||
<slot name="tooltip" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, ref, onMounted, onUpdated, nextTick,
|
||||
} from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const tooltipWrapper = ref<HTMLElement | undefined>();
|
||||
const tooltipHtml = ref<string | undefined>();
|
||||
|
||||
onMounted(() => updateTooltipHTML());
|
||||
|
||||
onUpdated(() => {
|
||||
nextTick(() => {
|
||||
updateTooltipHTML();
|
||||
});
|
||||
});
|
||||
|
||||
function updateTooltipHTML() {
|
||||
const newValue = tooltipWrapper.value?.innerHTML;
|
||||
const oldValue = tooltipHtml.value;
|
||||
if (newValue === oldValue) {
|
||||
return;
|
||||
}
|
||||
tooltipHtml.value = newValue;
|
||||
}
|
||||
|
||||
return {
|
||||
tooltipWrapper,
|
||||
tooltipHtml,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
.tooltip-container {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -18,28 +18,35 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { Environment } from '@/application/Environment/Environment';
|
||||
import { defineComponent, inject } from 'vue';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { useEnvironmentKey } from '@/presentation/injectionSymbols';
|
||||
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;
|
||||
export default defineComponent({
|
||||
components: {
|
||||
DownloadUrlListItem,
|
||||
},
|
||||
setup() {
|
||||
const { os: currentOs } = inject(useEnvironmentKey);
|
||||
const supportedDesktops = [
|
||||
...supportedOperativeSystems,
|
||||
].sort((os) => (os === currentOs ? 0 : 1));
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const supportedOperativeSystems = [
|
||||
OperatingSystem.Windows, OperatingSystem.Linux, OperatingSystem.macOS];
|
||||
const currentOs = Environment.CurrentEnvironment.os;
|
||||
this.supportedDesktops = supportedOperativeSystems.sort((os) => (os === currentOs ? 0 : 1));
|
||||
this.hasCurrentOsDesktopVersion = supportedOperativeSystems.includes(currentOs);
|
||||
}
|
||||
}
|
||||
const hasCurrentOsDesktopVersion = supportedOperativeSystems.includes(currentOs);
|
||||
|
||||
return {
|
||||
supportedDesktops,
|
||||
hasCurrentOsDesktopVersion,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -11,42 +11,47 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Component, Prop, Watch, Vue,
|
||||
} from 'vue-property-decorator';
|
||||
import { Environment } from '@/application/Environment/Environment';
|
||||
defineComponent, PropType, computed,
|
||||
inject,
|
||||
} from 'vue';
|
||||
import { useApplicationKey, useEnvironmentKey } from '@/presentation/injectionSymbols';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
|
||||
@Component
|
||||
export default class DownloadUrlListItem extends Vue {
|
||||
@Prop() public operatingSystem!: OperatingSystem;
|
||||
export default defineComponent({
|
||||
props: {
|
||||
operatingSystem: {
|
||||
type: Number as PropType<OperatingSystem>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { info } = inject(useApplicationKey);
|
||||
const { os: currentOs } = inject(useEnvironmentKey);
|
||||
|
||||
public downloadUrl = '';
|
||||
const isCurrentOs = computed<boolean>(() => {
|
||||
return currentOs === props.operatingSystem;
|
||||
});
|
||||
|
||||
public operatingSystemName = '';
|
||||
const operatingSystemName = computed<string>(() => {
|
||||
return getOperatingSystemName(props.operatingSystem);
|
||||
});
|
||||
|
||||
public isCurrentOs = false;
|
||||
const hasCurrentOsDesktopVersion = computed<boolean>(() => {
|
||||
return hasDesktopVersion(props.operatingSystem);
|
||||
});
|
||||
|
||||
public hasCurrentOsDesktopVersion = false;
|
||||
const downloadUrl = computed<string | undefined>(() => {
|
||||
return info.getDownloadUrl(props.operatingSystem);
|
||||
});
|
||||
|
||||
public async mounted() {
|
||||
await this.onOperatingSystemChanged(this.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
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
<div class="line__emoji">🤖</div>
|
||||
<div>
|
||||
All transparent: Deployed automatically from the master branch
|
||||
of the <a :href="repositoryUrl" target="_blank">source code</a> with no changes.
|
||||
of the <a :href="repositoryUrl" target="_blank" rel="noopener noreferrer">source code</a> with no changes.
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isDesktop" class="line">
|
||||
<div class="line__emoji">📈</div>
|
||||
<div>
|
||||
Basic <a href="https://aws.amazon.com/cloudfront/reporting/" target="_blank">CDN statistics</a>
|
||||
Basic <a href="https://aws.amazon.com/cloudfront/reporting/" target="_blank" rel="noopener noreferrer">CDN statistics</a>
|
||||
are collected by AWS but they cannot be traced to you or your behavior.
|
||||
You can download the offline version if you don't want any CDN data collection.
|
||||
</div>
|
||||
@@ -35,36 +35,30 @@
|
||||
<div>
|
||||
As almost no data is collected, the application gets better
|
||||
only with your active feedback.
|
||||
Feel free to <a :href="feedbackUrl" target="_blank">create an issue</a> 😊</div>
|
||||
Feel free to <a :href="feedbackUrl" target="_blank" rel="noopener noreferrer">create an issue</a> 😊</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { Environment } from '@/application/Environment/Environment';
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
import { IApplication } from '@/domain/IApplication';
|
||||
import { defineComponent, computed, inject } from 'vue';
|
||||
import { useApplicationKey, useEnvironmentKey } from '@/presentation/injectionSymbols';
|
||||
|
||||
@Component
|
||||
export default class PrivacyPolicy extends Vue {
|
||||
public repositoryUrl = '';
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { info } = inject(useApplicationKey);
|
||||
const { isDesktop } = inject(useEnvironmentKey);
|
||||
|
||||
public feedbackUrl = '';
|
||||
const repositoryUrl = computed<string>(() => info.repositoryUrl);
|
||||
const feedbackUrl = computed<string>(() => info.feedbackUrl);
|
||||
|
||||
public isDesktop = Environment.CurrentEnvironment.isDesktop;
|
||||
|
||||
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">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<span v-if="isDesktop" class="footer__section__item">
|
||||
<font-awesome-icon class="icon" :icon="['fas', 'globe']" />
|
||||
<span>
|
||||
Online version at <a :href="homepageUrl" target="_blank">{{ homepageUrl }}</a>
|
||||
Online version at <a :href="homepageUrl" target="_blank" rel="noopener noreferrer">{{ homepageUrl }}</a>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else class="footer__section__item">
|
||||
@@ -14,76 +14,82 @@
|
||||
</div>
|
||||
<div class="footer__section">
|
||||
<div class="footer__section__item">
|
||||
<a :href="feedbackUrl" target="_blank">
|
||||
<a :href="feedbackUrl" target="_blank" rel="noopener noreferrer">
|
||||
<font-awesome-icon class="icon" :icon="['far', 'smile']" />
|
||||
<span>Feedback</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer__section__item">
|
||||
<a :href="repositoryUrl" target="_blank">
|
||||
<a :href="repositoryUrl" target="_blank" rel="noopener noreferrer">
|
||||
<font-awesome-icon class="icon" :icon="['fab', 'github']" />
|
||||
<span>Source Code</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer__section__item">
|
||||
<a :href="releaseUrl" target="_blank">
|
||||
<a :href="releaseUrl" target="_blank" rel="noopener noreferrer">
|
||||
<font-awesome-icon class="icon" :icon="['fas', 'tag']" />
|
||||
<span>v{{ version }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer__section__item">
|
||||
<font-awesome-icon class="icon" :icon="['fas', 'user-secret']" />
|
||||
<a @click="$refs.privacyDialog.show()">Privacy</a>
|
||||
<a @click="showPrivacyDialog()">Privacy</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog ref="privacyDialog">
|
||||
<ModalDialog v-model="isPrivacyDialogVisible">
|
||||
<PrivacyPolicy />
|
||||
</Dialog>
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
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 {
|
||||
defineComponent, ref, computed, inject,
|
||||
} from 'vue';
|
||||
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
||||
import { useApplicationKey, useEnvironmentKey } from '@/presentation/injectionSymbols';
|
||||
import DownloadUrlList from './DownloadUrlList.vue';
|
||||
import PrivacyPolicy from './PrivacyPolicy.vue';
|
||||
|
||||
@Component({
|
||||
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 } = inject(useApplicationKey);
|
||||
const { isDesktop } = inject(useEnvironmentKey);
|
||||
|
||||
public version = '';
|
||||
const isPrivacyDialogVisible = ref(false);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
function showPrivacyDialog() {
|
||||
isPrivacyDialogVisible.value = true;
|
||||
}
|
||||
|
||||
return {
|
||||
isDesktop,
|
||||
isPrivacyDialogVisible,
|
||||
showPrivacyDialog,
|
||||
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, inject } from 'vue';
|
||||
import { useApplicationKey } from '@/presentation/injectionSymbols';
|
||||
|
||||
@Component
|
||||
export default class TheHeader extends Vue {
|
||||
public title = '';
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { info } = inject(useApplicationKey);
|
||||
|
||||
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,
|
||||
inject,
|
||||
} from 'vue';
|
||||
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
||||
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,
|
||||
} = inject(useCollectionStateKey)();
|
||||
|
||||
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;
|
||||
if (!newFilter) {
|
||||
filter.removeFilter();
|
||||
} else {
|
||||
filter.setFilter(newFilter);
|
||||
watch(searchQuery, (newFilter) => updateFilter(newFilter));
|
||||
|
||||
function updateFilter(newFilter: string) {
|
||||
modifyCurrentState((state) => {
|
||||
const { filter } = state;
|
||||
if (!newFilter) {
|
||||
filter.clearFilter();
|
||||
} else {
|
||||
filter.applyFilter(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();
|
||||
updateFromInitialFilter(newState.filter.currentFilter);
|
||||
subscribeToFilterChanges(newState.filter);
|
||||
}, { immediate: true });
|
||||
|
||||
private subscribeFilter(filter: IReadOnlyUserFilter) {
|
||||
this.events.register(filter.filtered.on((result) => this.handleFiltered(result)));
|
||||
this.events.register(filter.filterRemoved.on(() => this.handleFilterRemoved()));
|
||||
}
|
||||
function updateFromInitialFilter(filter?: IFilterResult) {
|
||||
searchQuery.value = filter?.query || '';
|
||||
}
|
||||
|
||||
private handleFiltered(result: IFilterResult) {
|
||||
this.searchQuery = result.query;
|
||||
}
|
||||
function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
|
||||
events.register(
|
||||
filter.filterChanged.on((event) => {
|
||||
event.visit({
|
||||
onApply: (result) => {
|
||||
searchQuery.value = result.query;
|
||||
},
|
||||
onClear: () => {
|
||||
searchQuery.value = '';
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
searchPlaceholder,
|
||||
searchQuery,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
private handleFilterRemoved() {
|
||||
this.searchQuery = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
16
src/presentation/injectionSymbols.ts
Normal file
16
src/presentation/injectionSymbols.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
import { useEnvironment } from '@/presentation/components/Shared/Hooks/UseEnvironment';
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
export const useCollectionStateKey = defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState');
|
||||
export const useApplicationKey = defineSingletonKey<ReturnType<typeof useApplication>>('useApplication');
|
||||
export const useEnvironmentKey = defineSingletonKey<ReturnType<typeof useEnvironment>>('useEnvironment');
|
||||
|
||||
function defineSingletonKey<T>(key: string) {
|
||||
return Symbol(key) as InjectionKey<T>;
|
||||
}
|
||||
|
||||
function defineTransientKey<T>(key: string) {
|
||||
return Symbol(key) as InjectionKey<() => T>;
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import Vue from 'vue';
|
||||
import { buildContext } from '@/application/Context/ApplicationContextFactory';
|
||||
import App from './components/App.vue';
|
||||
import { ApplicationBootstrapper } from './bootstrapping/ApplicationBootstrapper';
|
||||
|
||||
new ApplicationBootstrapper()
|
||||
.bootstrap(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({
|
||||
render: (h) => h(App),
|
||||
}).$mount('#app');
|
||||
new Vue({
|
||||
render: (h) => h(App),
|
||||
}).$mount('#app');
|
||||
});
|
||||
|
||||
6
src/presentation/shims-vue.d.ts
vendored
6
src/presentation/shims-vue.d.ts
vendored
@@ -1,5 +1,7 @@
|
||||
import Vue from 'vue';
|
||||
/* eslint-disable */
|
||||
|
||||
declare module '*.vue' {
|
||||
export default Vue;
|
||||
import { DefineComponent } from 'vue';
|
||||
const component: DefineComponent;
|
||||
export default component;
|
||||
}
|
||||
|
||||
4
tests/bootstrap/setup.ts
Normal file
4
tests/bootstrap/setup.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import 'mocha';
|
||||
import { enableAutoDestroy } from '@vue/test-utils';
|
||||
|
||||
enableAutoDestroy(afterEach);
|
||||
@@ -9,7 +9,7 @@
|
||||
// /* eslint-disable import/no-extraneous-dependencies, global-require */
|
||||
// const webpack = require('@cypress/webpack-preprocessor')
|
||||
|
||||
module.exports = (on, config) => {
|
||||
export default (on, config) => {
|
||||
// on('file:preprocessor', webpack({
|
||||
// webpackOptions: require('@vue/cli-service/webpack.config'),
|
||||
// watchOptions: {}
|
||||
|
||||
50
tests/e2e/specs/revert-toggle.cy.js
Normal file
50
tests/e2e/specs/revert-toggle.cy.js
Normal file
@@ -0,0 +1,50 @@
|
||||
describe('revert toggle', () => {
|
||||
context('toggle switch', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/');
|
||||
cy.get('.card')
|
||||
.eq(1) // to get 2nd element, first is often cleanup that may lack revert button
|
||||
.click(); // open the card card
|
||||
cy.get('.toggle-switch')
|
||||
.first()
|
||||
.as('toggleSwitch');
|
||||
});
|
||||
|
||||
it('should be visible', () => {
|
||||
cy.get('@toggleSwitch')
|
||||
.should('be.visible');
|
||||
});
|
||||
|
||||
it('should have revert label', () => {
|
||||
cy.get('@toggleSwitch')
|
||||
.find('span')
|
||||
.contains('revert');
|
||||
});
|
||||
|
||||
it('should render label completely without clipping', () => {
|
||||
cy
|
||||
.get('@toggleSwitch')
|
||||
.find('span')
|
||||
.should(($label) => {
|
||||
const text = $label.text();
|
||||
const font = getFont($label[0]);
|
||||
const expectedMinimumTextWidth = getTextWidth(text, font);
|
||||
const containerWidth = $label.parent().width();
|
||||
expect(expectedMinimumTextWidth).to.be.lessThan(containerWidth);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getFont(element) {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
return `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`;
|
||||
}
|
||||
|
||||
function getTextWidth(text, font) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.font = font;
|
||||
const measuredWidth = ctx.measureText(text).width;
|
||||
return measuredWidth;
|
||||
}
|
||||
174
tests/unit/application/Common/CustomError.spec.ts
Normal file
174
tests/unit/application/Common/CustomError.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// eslint-disable-next-line max-classes-per-file
|
||||
import 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { CustomError, Environment } from '@/application/Common/CustomError';
|
||||
|
||||
describe('CustomError', () => {
|
||||
afterEach(() => {
|
||||
Environment.getSetPrototypeOf = () => Object.setPrototypeOf;
|
||||
Environment.getCaptureStackTrace = () => Error.captureStackTrace;
|
||||
});
|
||||
describe('sets members as expected', () => {
|
||||
it('`name`', () => {
|
||||
// arrange
|
||||
const expectedName = CustomErrorConcrete.name;
|
||||
// act
|
||||
const sut = new CustomErrorConcrete();
|
||||
// assert
|
||||
expect(sut.name).to.equal(expectedName);
|
||||
});
|
||||
it('`message`', () => {
|
||||
// arrange
|
||||
const expectedMessage = 'expected message';
|
||||
// act
|
||||
const sut = new CustomErrorConcrete(expectedMessage);
|
||||
// assert
|
||||
expect(sut.message).to.equal(expectedMessage);
|
||||
});
|
||||
it('`cause`', () => {
|
||||
// arrange
|
||||
const expectedCause = new Error('expected cause');
|
||||
// act
|
||||
const sut = new CustomErrorConcrete(undefined, {
|
||||
cause: expectedCause,
|
||||
});
|
||||
// assert
|
||||
expect(sut.cause).to.equal(expectedCause);
|
||||
});
|
||||
describe('`stack`', () => {
|
||||
it('sets using `getCaptureStackTrace` if available', () => {
|
||||
// arrange
|
||||
const mockStackTrace = 'mocked stack trace';
|
||||
Environment.getCaptureStackTrace = () => (error) => {
|
||||
(error as Error).stack = mockStackTrace;
|
||||
};
|
||||
// act
|
||||
const sut = new CustomErrorConcrete();
|
||||
// assert
|
||||
expect(sut.stack).to.equal(mockStackTrace);
|
||||
});
|
||||
it('defined', () => {
|
||||
// arrange
|
||||
const customError = new CustomErrorConcrete();
|
||||
|
||||
// act
|
||||
const { stack } = customError;
|
||||
|
||||
// assert
|
||||
expect(stack).to.not.equal(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('retains correct prototypes', () => {
|
||||
it('instance of `Error`', () => {
|
||||
// arrange
|
||||
const expected = Error;
|
||||
// act
|
||||
const sut = new CustomErrorConcrete();
|
||||
// assert
|
||||
expect(sut).to.be.an.instanceof(expected);
|
||||
});
|
||||
it('instance of `CustomErrorConcrete`', () => {
|
||||
// arrange
|
||||
const expected = CustomErrorConcrete;
|
||||
// act
|
||||
const sut = new CustomErrorConcrete();
|
||||
// assert
|
||||
expect(sut).to.be.an.instanceof(expected);
|
||||
});
|
||||
it('instance of `CustomError`', () => {
|
||||
// arrange
|
||||
const expected = CustomError;
|
||||
// act
|
||||
const sut = new CustomErrorConcrete();
|
||||
// assert
|
||||
expect(sut).to.be.an.instanceof(expected);
|
||||
});
|
||||
it('thrown error retains `CustomError` type', () => {
|
||||
// arrange
|
||||
const expected = CustomError;
|
||||
let thrownError: unknown;
|
||||
// act
|
||||
try {
|
||||
throw new CustomErrorConcrete('message');
|
||||
} catch (e) {
|
||||
thrownError = e;
|
||||
}
|
||||
// assert
|
||||
expect(thrownError).to.be.an.instanceof(expected);
|
||||
});
|
||||
});
|
||||
describe('environment compatibility', () => {
|
||||
describe('Object.setPrototypeOf', () => {
|
||||
it('does not throw if unavailable', () => {
|
||||
// arrange
|
||||
Environment.getSetPrototypeOf = () => undefined;
|
||||
|
||||
// act
|
||||
const act = () => new CustomErrorConcrete();
|
||||
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
it('calls if available', () => {
|
||||
// arrange
|
||||
let wasCalled = false;
|
||||
const setPrototypeOf = () => { wasCalled = true; };
|
||||
Environment.getSetPrototypeOf = () => setPrototypeOf;
|
||||
|
||||
// act
|
||||
// eslint-disable-next-line no-new
|
||||
new CustomErrorConcrete();
|
||||
|
||||
// assert
|
||||
expect(wasCalled).to.equal(true);
|
||||
});
|
||||
});
|
||||
describe('Error.captureStackTrace', () => {
|
||||
it('does not throw if unavailable', () => {
|
||||
// arrange
|
||||
Environment.getCaptureStackTrace = () => undefined;
|
||||
|
||||
// act
|
||||
const act = () => new CustomErrorConcrete();
|
||||
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
it('calls if available', () => {
|
||||
// arrange
|
||||
let wasCalled = false;
|
||||
const captureStackTrace = () => { wasCalled = true; };
|
||||
Environment.getCaptureStackTrace = () => captureStackTrace;
|
||||
|
||||
// act
|
||||
// eslint-disable-next-line no-new
|
||||
new CustomErrorConcrete();
|
||||
|
||||
// assert
|
||||
expect(wasCalled).to.equal(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('runtime behavior sanity checks', () => {
|
||||
/*
|
||||
* These tests are intended to verify the behavior of the JavaScript runtime or environment,
|
||||
* rather than specific application logic. Typically, we avoid such tests because we
|
||||
* trust the behavior of the underlying platform. However, they've been included here
|
||||
* due to previous unexpected issues, specifically failures when trying to log
|
||||
* `new Error().stack`. These issues arose because of factors like transpilation,
|
||||
* source-mapping, and variances in JavaScript engine behaviors.
|
||||
*/
|
||||
it('`Error.stack` is defined', () => {
|
||||
const error = new Error();
|
||||
|
||||
// act
|
||||
const { stack } = error;
|
||||
|
||||
// assert
|
||||
expect(stack).to.not.equal(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class CustomErrorConcrete extends CustomError { }
|
||||
@@ -47,7 +47,7 @@ describe('ApplicationContext', () => {
|
||||
const sut = testContext
|
||||
.withInitialOs(OperatingSystem.Windows)
|
||||
.construct();
|
||||
sut.state.filter.setFilter('filtered');
|
||||
sut.state.filter.applyFilter('filtered');
|
||||
sut.changeContext(OperatingSystem.macOS);
|
||||
// assert
|
||||
expectEmptyState(sut.state);
|
||||
@@ -65,10 +65,10 @@ describe('ApplicationContext', () => {
|
||||
.withInitialOs(os)
|
||||
.construct();
|
||||
const firstState = sut.state;
|
||||
firstState.filter.setFilter(expectedFilter);
|
||||
firstState.filter.applyFilter(expectedFilter);
|
||||
sut.changeContext(os);
|
||||
sut.changeContext(changedOs);
|
||||
sut.state.filter.setFilter('second-state');
|
||||
sut.state.filter.applyFilter('second-state');
|
||||
sut.changeContext(os);
|
||||
// assert
|
||||
const actualFilter = sut.state.filter.currentFilter.query;
|
||||
@@ -103,7 +103,7 @@ describe('ApplicationContext', () => {
|
||||
.withInitialOs(os)
|
||||
.construct();
|
||||
const initialState = sut.state;
|
||||
initialState.filter.setFilter('dirty-state');
|
||||
initialState.filter.applyFilter('dirty-state');
|
||||
sut.changeContext(os);
|
||||
// assert
|
||||
expect(testContext.firedEvents.length).to.equal(0);
|
||||
|
||||
@@ -91,11 +91,11 @@ describe('CategoryCollectionState', () => {
|
||||
.withAction(new CategoryStub(0).withScript(expectedScript));
|
||||
const sut = new CategoryCollectionState(collection);
|
||||
// act
|
||||
let actualScript: IScript;
|
||||
sut.filter.filtered.on((result) => {
|
||||
[actualScript] = result.scriptMatches;
|
||||
let actualScript: IScript | undefined;
|
||||
sut.filter.filterChanged.on((result) => {
|
||||
[actualScript] = result.filter?.scriptMatches ?? [undefined];
|
||||
});
|
||||
sut.filter.setFilter(scriptNameFilter);
|
||||
sut.filter.applyFilter(scriptNameFilter);
|
||||
// assert
|
||||
expect(expectedScript).to.equal(actualScript);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { FilterChange } from '@/application/Context/State/Filter/Event/FilterChange';
|
||||
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { FilterResultStub } from '@tests/unit/shared/Stubs/FilterResultStub';
|
||||
import { FilterActionType } from '@/application/Context/State/Filter/Event/FilterActionType';
|
||||
import { FilterChangeDetailsVisitorStub } from '@tests/unit/shared/Stubs/FilterChangeDetailsVisitorStub';
|
||||
|
||||
describe('FilterChange', () => {
|
||||
describe('forApply', () => {
|
||||
describe('throws when filter is absent', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing filter';
|
||||
const filterValue = absentValue;
|
||||
// act
|
||||
const act = () => FilterChange.forApply(filterValue);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
it('sets filter result', () => {
|
||||
// arrange
|
||||
const expectedFilter = new FilterResultStub();
|
||||
// act
|
||||
const sut = FilterChange.forApply(expectedFilter);
|
||||
// assert
|
||||
const actualFilter = sut.filter;
|
||||
expect(actualFilter).to.equal(expectedFilter);
|
||||
});
|
||||
it('sets action as expected', () => {
|
||||
// arrange
|
||||
const expectedAction = FilterActionType.Apply;
|
||||
// act
|
||||
const sut = FilterChange.forApply(new FilterResultStub());
|
||||
// assert
|
||||
const actualAction = sut.actionType;
|
||||
expect(actualAction).to.equal(expectedAction);
|
||||
});
|
||||
});
|
||||
describe('forClear', () => {
|
||||
it('does not set filter result', () => {
|
||||
// arrange
|
||||
const expectedFilter = undefined;
|
||||
// act
|
||||
const sut = FilterChange.forClear();
|
||||
// assert
|
||||
const actualFilter = sut.filter;
|
||||
expect(actualFilter).to.equal(expectedFilter);
|
||||
});
|
||||
it('sets action as expected', () => {
|
||||
// arrange
|
||||
const expectedAction = FilterActionType.Clear;
|
||||
// act
|
||||
const sut = FilterChange.forClear();
|
||||
// assert
|
||||
const actualAction = sut.actionType;
|
||||
expect(actualAction).to.equal(expectedAction);
|
||||
});
|
||||
});
|
||||
describe('visit', () => {
|
||||
describe('throws when visitor is absent', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing visitor';
|
||||
const visitorValue = absentValue;
|
||||
const sut = FilterChange.forClear();
|
||||
// act
|
||||
const act = () => sut.visit(visitorValue);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
describe('onClear', () => {
|
||||
itVisitsOnce(
|
||||
() => FilterChange.forClear(),
|
||||
);
|
||||
});
|
||||
describe('onApply', () => {
|
||||
itVisitsOnce(
|
||||
() => FilterChange.forApply(new FilterResultStub()),
|
||||
);
|
||||
|
||||
it('visits with expected filter', () => {
|
||||
// arrange
|
||||
const expectedFilter = new FilterResultStub();
|
||||
const sut = FilterChange.forApply(expectedFilter);
|
||||
const visitor = new FilterChangeDetailsVisitorStub();
|
||||
// act
|
||||
sut.visit(visitor);
|
||||
// assert
|
||||
expect(visitor.visitedResults).to.have.lengthOf(1);
|
||||
expect(visitor.visitedResults).to.include(expectedFilter);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function itVisitsOnce(sutFactory: () => FilterChange) {
|
||||
it('visits', () => {
|
||||
// arrange
|
||||
const sut = sutFactory();
|
||||
const expectedType = sut.actionType;
|
||||
const visitor = new FilterChangeDetailsVisitorStub();
|
||||
// act
|
||||
sut.visit(visitor);
|
||||
// assert
|
||||
expect(visitor.visitedEvents).to.include(expectedType);
|
||||
});
|
||||
it('visits once', () => {
|
||||
// arrange
|
||||
const sut = sutFactory();
|
||||
const expectedType = sut.actionType;
|
||||
const visitor = new FilterChangeDetailsVisitorStub();
|
||||
// act
|
||||
sut.visit(visitor);
|
||||
// assert
|
||||
expect(
|
||||
visitor.visitedEvents.filter((action) => action === expectedType),
|
||||
).to.have.lengthOf(1);
|
||||
});
|
||||
}
|
||||
@@ -5,173 +5,182 @@ import { UserFilter } from '@/application/Context/State/Filter/UserFilter';
|
||||
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
||||
import { FilterChange } from '@/application/Context/State/Filter/Event/FilterChange';
|
||||
import { IFilterChangeDetails } from '@/application/Context/State/Filter/Event/IFilterChangeDetails';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
|
||||
describe('UserFilter', () => {
|
||||
describe('removeFilter', () => {
|
||||
describe('clearFilter', () => {
|
||||
it('signals when removing filter', () => {
|
||||
// arrange
|
||||
let isCalled = false;
|
||||
const expectedChange = FilterChange.forClear();
|
||||
let actualChange: IFilterChangeDetails;
|
||||
const sut = new UserFilter(new CategoryCollectionStub());
|
||||
sut.filterRemoved.on(() => {
|
||||
isCalled = true;
|
||||
sut.filterChanged.on((change) => {
|
||||
actualChange = change;
|
||||
});
|
||||
// act
|
||||
sut.removeFilter();
|
||||
sut.clearFilter();
|
||||
// assert
|
||||
expect(isCalled).to.be.equal(true);
|
||||
expect(actualChange).to.deep.equal(expectedChange);
|
||||
});
|
||||
it('sets currentFilter to undefined', () => {
|
||||
// arrange
|
||||
const sut = new UserFilter(new CategoryCollectionStub());
|
||||
// act
|
||||
sut.setFilter('non-important');
|
||||
sut.removeFilter();
|
||||
sut.applyFilter('non-important');
|
||||
sut.clearFilter();
|
||||
// assert
|
||||
expect(sut.currentFilter).to.be.equal(undefined);
|
||||
});
|
||||
});
|
||||
describe('setFilter', () => {
|
||||
it('signals when no matches', () => {
|
||||
// arrange
|
||||
let actual: IFilterResult;
|
||||
const nonMatchingFilter = 'non matching filter';
|
||||
const sut = new UserFilter(new CategoryCollectionStub());
|
||||
sut.filtered.on((filterResult) => {
|
||||
actual = filterResult;
|
||||
});
|
||||
// act
|
||||
sut.setFilter(nonMatchingFilter);
|
||||
// assert
|
||||
expect(actual.hasAnyMatches()).be.equal(false);
|
||||
expect(actual.query).to.equal(nonMatchingFilter);
|
||||
});
|
||||
it('sets currentFilter as expected when no matches', () => {
|
||||
// arrange
|
||||
const nonMatchingFilter = 'non matching filter';
|
||||
const sut = new UserFilter(new CategoryCollectionStub());
|
||||
// act
|
||||
sut.setFilter(nonMatchingFilter);
|
||||
// assert
|
||||
const actual = sut.currentFilter;
|
||||
expect(actual.hasAnyMatches()).be.equal(false);
|
||||
expect(actual.query).to.equal(nonMatchingFilter);
|
||||
});
|
||||
describe('signals when matches', () => {
|
||||
describe('signals when script matches', () => {
|
||||
it('code matches', () => {
|
||||
// arrange
|
||||
const code = 'HELLO world';
|
||||
const filter = 'Hello WoRLD';
|
||||
let actual: IFilterResult;
|
||||
const script = new ScriptStub('id').withCode(code);
|
||||
const category = new CategoryStub(33).withScript(script);
|
||||
const sut = new UserFilter(new CategoryCollectionStub()
|
||||
.withAction(category));
|
||||
sut.filtered.on((filterResult) => {
|
||||
actual = filterResult;
|
||||
});
|
||||
// act
|
||||
sut.setFilter(filter);
|
||||
// assert
|
||||
expect(actual.hasAnyMatches()).be.equal(true);
|
||||
expect(actual.categoryMatches).to.have.lengthOf(0);
|
||||
expect(actual.scriptMatches).to.have.lengthOf(1);
|
||||
expect(actual.scriptMatches[0]).to.deep.equal(script);
|
||||
expect(actual.query).to.equal(filter);
|
||||
expect(sut.currentFilter).to.deep.equal(actual);
|
||||
});
|
||||
it('revertCode matches', () => {
|
||||
// arrange
|
||||
const revertCode = 'HELLO world';
|
||||
const filter = 'Hello WoRLD';
|
||||
let actual: IFilterResult;
|
||||
const script = new ScriptStub('id').withRevertCode(revertCode);
|
||||
const category = new CategoryStub(33).withScript(script);
|
||||
const sut = new UserFilter(new CategoryCollectionStub()
|
||||
.withAction(category));
|
||||
sut.filtered.on((filterResult) => {
|
||||
actual = filterResult;
|
||||
});
|
||||
// act
|
||||
sut.setFilter(filter);
|
||||
// assert
|
||||
expect(actual.hasAnyMatches()).be.equal(true);
|
||||
expect(actual.categoryMatches).to.have.lengthOf(0);
|
||||
expect(actual.scriptMatches).to.have.lengthOf(1);
|
||||
expect(actual.scriptMatches[0]).to.deep.equal(script);
|
||||
expect(actual.query).to.equal(filter);
|
||||
expect(sut.currentFilter).to.deep.equal(actual);
|
||||
});
|
||||
it('name matches', () => {
|
||||
// arrange
|
||||
const name = 'HELLO world';
|
||||
const filter = 'Hello WoRLD';
|
||||
let actual: IFilterResult;
|
||||
const script = new ScriptStub('id').withName(name);
|
||||
const category = new CategoryStub(33).withScript(script);
|
||||
const sut = new UserFilter(new CategoryCollectionStub()
|
||||
.withAction(category));
|
||||
sut.filtered.on((filterResult) => {
|
||||
actual = filterResult;
|
||||
});
|
||||
// act
|
||||
sut.setFilter(filter);
|
||||
// assert
|
||||
expect(actual.hasAnyMatches()).be.equal(true);
|
||||
expect(actual.categoryMatches).to.have.lengthOf(0);
|
||||
expect(actual.scriptMatches).to.have.lengthOf(1);
|
||||
expect(actual.scriptMatches[0]).to.deep.equal(script);
|
||||
expect(actual.query).to.equal(filter);
|
||||
expect(sut.currentFilter).to.deep.equal(actual);
|
||||
});
|
||||
});
|
||||
it('signals when category matches', () => {
|
||||
// arrange
|
||||
describe('applyFilter', () => {
|
||||
interface IApplyFilterTestCase {
|
||||
readonly name: string;
|
||||
readonly filter: string;
|
||||
readonly collection: ICategoryCollection;
|
||||
readonly assert: (result: IFilterResult) => void;
|
||||
}
|
||||
const testCases: readonly IApplyFilterTestCase[] = [
|
||||
(() => {
|
||||
const nonMatchingFilter = 'non matching filter';
|
||||
return {
|
||||
name: 'given no matches',
|
||||
filter: nonMatchingFilter,
|
||||
collection: new CategoryCollectionStub(),
|
||||
assert: (filter) => {
|
||||
expect(filter.hasAnyMatches()).be.equal(false);
|
||||
expect(filter.query).to.equal(nonMatchingFilter);
|
||||
},
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const code = 'HELLO world';
|
||||
const matchingFilter = 'Hello WoRLD';
|
||||
const script = new ScriptStub('id').withCode(code);
|
||||
return {
|
||||
name: 'given script match with case-insensitive code',
|
||||
filter: matchingFilter,
|
||||
collection: new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(33).withScript(script)),
|
||||
assert: (filter) => {
|
||||
expect(filter.hasAnyMatches()).be.equal(true);
|
||||
expect(filter.categoryMatches).to.have.lengthOf(0);
|
||||
expect(filter.scriptMatches).to.have.lengthOf(1);
|
||||
expect(filter.scriptMatches[0]).to.deep.equal(script);
|
||||
expect(filter.query).to.equal(matchingFilter);
|
||||
},
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const revertCode = 'HELLO world';
|
||||
const matchingFilter = 'Hello WoRLD';
|
||||
const script = new ScriptStub('id').withRevertCode(revertCode);
|
||||
return {
|
||||
name: 'given script match with case-insensitive revertCode',
|
||||
filter: matchingFilter,
|
||||
collection: new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(33).withScript(script)),
|
||||
assert: (filter) => {
|
||||
expect(filter.hasAnyMatches()).be.equal(true);
|
||||
expect(filter.categoryMatches).to.have.lengthOf(0);
|
||||
expect(filter.scriptMatches).to.have.lengthOf(1);
|
||||
expect(filter.scriptMatches[0]).to.deep.equal(script);
|
||||
expect(filter.query).to.equal(matchingFilter);
|
||||
},
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const name = 'HELLO world';
|
||||
const matchingFilter = 'Hello WoRLD';
|
||||
const script = new ScriptStub('id').withName(name);
|
||||
return {
|
||||
name: 'given script match with case-insensitive name',
|
||||
filter: matchingFilter,
|
||||
collection: new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(33).withScript(script)),
|
||||
assert: (filter) => {
|
||||
expect(filter.hasAnyMatches()).be.equal(true);
|
||||
expect(filter.categoryMatches).to.have.lengthOf(0);
|
||||
expect(filter.scriptMatches).to.have.lengthOf(1);
|
||||
expect(filter.scriptMatches[0]).to.deep.equal(script);
|
||||
expect(filter.query).to.equal(matchingFilter);
|
||||
},
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const categoryName = 'HELLO world';
|
||||
const filter = 'Hello WoRLD';
|
||||
let actual: IFilterResult;
|
||||
const matchingFilter = 'Hello WoRLD';
|
||||
const category = new CategoryStub(55).withName(categoryName);
|
||||
const sut = new UserFilter(new CategoryCollectionStub()
|
||||
.withAction(category));
|
||||
sut.filtered.on((filterResult) => {
|
||||
actual = filterResult;
|
||||
});
|
||||
// act
|
||||
sut.setFilter(filter);
|
||||
// assert
|
||||
expect(actual.hasAnyMatches()).be.equal(true);
|
||||
expect(actual.categoryMatches).to.have.lengthOf(1);
|
||||
expect(actual.categoryMatches[0]).to.deep.equal(category);
|
||||
expect(actual.scriptMatches).to.have.lengthOf(0);
|
||||
expect(actual.query).to.equal(filter);
|
||||
expect(sut.currentFilter).to.deep.equal(actual);
|
||||
});
|
||||
it('signals when category and script matches', () => {
|
||||
// arrange
|
||||
return {
|
||||
name: 'given category match with case-insensitive name',
|
||||
filter: matchingFilter,
|
||||
collection: new CategoryCollectionStub()
|
||||
.withAction(category),
|
||||
assert: (filter) => {
|
||||
expect(filter.hasAnyMatches()).be.equal(true);
|
||||
expect(filter.categoryMatches).to.have.lengthOf(1);
|
||||
expect(filter.categoryMatches[0]).to.deep.equal(category);
|
||||
expect(filter.scriptMatches).to.have.lengthOf(0);
|
||||
expect(filter.query).to.equal(matchingFilter);
|
||||
},
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const matchingText = 'HELLO world';
|
||||
const filter = 'Hello WoRLD';
|
||||
let actual: IFilterResult;
|
||||
const matchingFilter = 'Hello WoRLD';
|
||||
const script = new ScriptStub('script')
|
||||
.withName(matchingText);
|
||||
const category = new CategoryStub(55)
|
||||
.withName(matchingText)
|
||||
.withScript(script);
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(category);
|
||||
const sut = new UserFilter(collection);
|
||||
sut.filtered.on((filterResult) => {
|
||||
actual = filterResult;
|
||||
return {
|
||||
name: 'given category and script matches with case-insensitive names',
|
||||
filter: matchingFilter,
|
||||
collection: new CategoryCollectionStub()
|
||||
.withAction(category),
|
||||
assert: (filter) => {
|
||||
expect(filter.hasAnyMatches()).be.equal(true);
|
||||
expect(filter.categoryMatches).to.have.lengthOf(1);
|
||||
expect(filter.categoryMatches[0]).to.deep.equal(category);
|
||||
expect(filter.scriptMatches).to.have.lengthOf(1);
|
||||
expect(filter.scriptMatches[0]).to.deep.equal(script);
|
||||
expect(filter.query).to.equal(matchingFilter);
|
||||
},
|
||||
};
|
||||
})(),
|
||||
];
|
||||
describe('sets currentFilter as expected', () => {
|
||||
testCases.forEach(({
|
||||
name, filter, collection, assert,
|
||||
}) => {
|
||||
it(name, () => {
|
||||
// arrange
|
||||
const sut = new UserFilter(collection);
|
||||
// act
|
||||
sut.applyFilter(filter);
|
||||
// assert
|
||||
const actual = sut.currentFilter;
|
||||
assert(actual);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('signals as expected', () => {
|
||||
testCases.forEach(({
|
||||
name, filter, collection, assert,
|
||||
}) => {
|
||||
it(name, () => {
|
||||
// arrange
|
||||
const sut = new UserFilter(collection);
|
||||
let actualFilterResult: IFilterResult;
|
||||
sut.filterChanged.on((filterResult) => {
|
||||
actualFilterResult = filterResult.filter;
|
||||
});
|
||||
// act
|
||||
sut.applyFilter(filter);
|
||||
// assert
|
||||
assert(actualFilterResult);
|
||||
});
|
||||
// act
|
||||
sut.setFilter(filter);
|
||||
// assert
|
||||
expect(actual.hasAnyMatches()).be.equal(true);
|
||||
expect(actual.categoryMatches).to.have.lengthOf(1);
|
||||
expect(actual.categoryMatches[0]).to.deep.equal(category);
|
||||
expect(actual.scriptMatches).to.have.lengthOf(1);
|
||||
expect(actual.scriptMatches[0]).to.deep.equal(script);
|
||||
expect(actual.query).to.equal(filter);
|
||||
expect(sut.currentFilter).to.deep.equal(actual);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect } from 'chai';
|
||||
import { INodeDataErrorContext, NodeDataError } from '@/application/Parser/NodeValidation/NodeDataError';
|
||||
import { NodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub';
|
||||
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
|
||||
import { CustomError } from '@/application/Common/CustomError';
|
||||
|
||||
describe('NodeDataError', () => {
|
||||
it('sets message as expected', () => {
|
||||
@@ -28,20 +29,13 @@ describe('NodeDataError', () => {
|
||||
// assert
|
||||
expect(sut.context).to.equal(expected);
|
||||
});
|
||||
it('sets stack as expected', () => {
|
||||
it('extends CustomError', () => {
|
||||
// arrange
|
||||
const expected = CustomError;
|
||||
// act
|
||||
const sut = new NodeDataErrorBuilder()
|
||||
.build();
|
||||
// assert
|
||||
expect(sut.stack !== undefined);
|
||||
});
|
||||
it('extends Error', () => {
|
||||
// arrange
|
||||
const expected = Error;
|
||||
// act
|
||||
const sut = new NodeDataErrorBuilder().build();
|
||||
// assert
|
||||
expect(sut).to.be.an.instanceof(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,17 +8,18 @@ describe('NonCollapsingDirective', () => {
|
||||
describe('NonCollapsing', () => {
|
||||
it('adds expected attribute to the element when inserted', () => {
|
||||
// arrange
|
||||
const element = getElementMock();
|
||||
const element = createElementMock();
|
||||
// act
|
||||
NonCollapsing.inserted(element, undefined, undefined, undefined);
|
||||
// assert
|
||||
expect(element.hasAttribute(expectedAttributeName));
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasDirective', () => {
|
||||
it('returns true if the element has expected attribute', () => {
|
||||
// arrange
|
||||
const element = getElementMock();
|
||||
const element = createElementMock();
|
||||
element.setAttribute(expectedAttributeName, undefined);
|
||||
// act
|
||||
const actual = hasDirective(element);
|
||||
@@ -27,8 +28,8 @@ describe('NonCollapsingDirective', () => {
|
||||
});
|
||||
it('returns true if the element has a parent with expected attribute', () => {
|
||||
// arrange
|
||||
const parent = getElementMock();
|
||||
const element = getElementMock();
|
||||
const parent = createElementMock();
|
||||
const element = createElementMock();
|
||||
parent.appendChild(element);
|
||||
element.setAttribute(expectedAttributeName, undefined);
|
||||
// act
|
||||
@@ -38,16 +39,15 @@ describe('NonCollapsingDirective', () => {
|
||||
});
|
||||
it('returns false if nor the element or its parent has expected attribute', () => {
|
||||
// arrange
|
||||
const element = getElementMock();
|
||||
const element = createElementMock();
|
||||
// act
|
||||
const actual = hasDirective(element);
|
||||
// assert
|
||||
expect(actual).to.equal(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getElementMock(): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
return element;
|
||||
}
|
||||
function createElementMock(): HTMLElement {
|
||||
return document.createElement('div');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user