Compare commits

...

11 Commits

Author SHA1 Message Date
undergroundwires
a14929a13c Migrate to ES6 modules
Configure project to use ES6 modules to enable top-level await
capabilities. This change helps project to align well with modern JS
standards.

- Set `type` to `module` in `package.json`.
- Use import/export syntax in Cypress configuration files.
- Rename configurations files that do not support modules to use
  the `.cjs` extension:
  - `vue.config.js` to `vue.config.cjs` (vuejs/vue-cli#4477).
  - `babel.config.js` to `babel.config.cjs (babel/babel-loader#894)
  - `.eslintrc.js` to `.eslintrc.cjs` (eslint/eslint#13440,
    eslint/eslint#14137)
  - `postcss.config.js` to `postcss.config.cjs` (postcss/postcss#1771)
- Provide a workaround for Vue CLI & Mocha ES6 modules conflict in
  Vue configuration file (vuejs/vue-cli#7417).
2023-08-17 18:50:14 +02:00
undergroundwires
6a20d804dc Refactor filter (search query) event handling
Refactor filter event handling to a unified event with visitor pattern
to simplify the code, avoid future bugs and provide better test
coverage.

This commit shifts from using separate `filtered` and `filterRemoved`
events to a singular, more expressive `filterChanged` event. The new
approach emits a detailed payload that explicitly indicates the filter
action and the associated filter data. The event object unifies the way
the presentation layer reacts to the events.

Benefits with this approach include:

- Simplifying event listeners by reducing the number of events to
  handle.
- Increasing code clarity and reduces potential for oversight by
  providing explicit action details in the event payload.
- Offering extensibility for future actions without introducing new
  events.
- Providing visitor pattern to handle different kind of events in easy
  and robust manner without code repetition.

Other changes:

- Refactor components handling of events to follow DRY and KISS
  principles better.
- Refactor `UserFilter.spec.ts` to:
  - Make it easier to add new tests.
  - Increase code coverage by running all event-based tests on the
    current property.
2023-08-16 15:09:26 +02:00
undergroundwires
ae75059cc1 Increase testability through dependency injection
- Remove existing integration tests for hooks as they're redundant after
  this change.
- Document the pattern in relevant documentation.
- Introduce `useEnvironment` to increase testability.
- Update components to inject dependencies rather than importing hooks
  directly.
2023-08-15 18:11:30 +02:00
undergroundwires
39e650cf11 Fix revert toggle partial rendering
This commits fixes an issue where the `REVERT` label on revert toggle
might render as `REVER` or in a similarly clipped manner due to its
fixed width. The problem is visible when certain fonts fail to load or
browser engines render content non-standardly.

Changes:
- Refactor UI component to have its own separate Vue component with unit
  tests.
- Rework component design to utilize flexbox, enhancing its adaptability
  and simplifying the structure.
- Remove obselete `webkit` directives.
- Refactor SCSS for clearer structure and better SCSS best-practices.
- Use `em` when possible instead of `px` for improved responsiveness.
2023-08-14 15:28:15 +02:00
undergroundwires
bc91237d7c Refactor usage of tooltips for flexibility
This commit introduces a new Vue component to handle tooltips. It acts
as a wrapper for the `v-tooltip`. It enhances the maintainability,
readability and portability of tooltips by enabling the direct inclusion
of inline HTML in the tooltip components. It solves issues such as
absence of linting or editor support and cumbersome string
concatenation.

It also provides an abstraction layer that simplifies the switching
between different tooltip implementations, which would allow a smooth
migration to Vue 3 (see #230).
2023-08-12 16:53:58 +02:00
undergroundwires
9e5491fdbf Implement custom lightweight modal #230
Introduce a brand new lightweight and efficient modal component. It is
designed to be visually similar to the previous one to not introduce a
change in feel of the application in a patch release, but behind the
scenes it features:

- Enhanced application speed and reduced bundle size.
- New flexbox-driven layout, eliminating JS calculations.
- Composition API ready for Vue 3.0 #230.

Other changes:

- Adopt idiomatic Vue via `v-modal` binding.
- Add unit tests for both the modal and dialog.
- Remove `vue-js-modal` dependency in favor of the new implementation.
- Adjust modal shadow color to better match theme.
- Add `@vue/test-utils` for unit testing.
2023-08-11 19:35:26 +02:00
undergroundwires
986ba078a6 Fix failing tests due to failed error logging
Unit and integration tests have been failing due to failed logging of
`Error` objects. These were creating an issue where `mocha` was not
properly returning right exit codes, leading to test pipelines
incorrectly passing despite test failures.

- Fix runtime behavior of failing to retrieve error stacks.
- Add tests for error handling.
- Add more robust custom error handling.

Related issues: babel/babel#14273, vuejs/vue-cli#6994.
2023-08-10 19:49:08 +02:00
he3als
061afad967 Improve Defender scripts #201
Reliably disable Defender services (by always using `reg` with
TrustedInstaller`), and put Firewall services in Windows Firewall
section, so that people do not accidently disable Windows Firewall
services and break Microsoft Store.

Co-authored-by: undergroundwires <git@undergroundwires.dev>
2023-08-09 15:16:31 +02:00
undergroundwires
3bc8da4cbf Fix failing security tests
This commit changes the behavior of auditing to audit only production
dependencies.

Security checks have been failing for months due to Vue CLI dependencies
and lack of resolution from the developers. This commit makes auditing
ignore development dependencies.

The reasons include:

- Vulnerabilities in developer dependencies cause pipelines to fail
  on every run.
- This is caused by dependencies such that lack resolution from the
  developers. Vue developers consider `npm audit` broken design and do
  not prioritize solutions. Discussions: vuejs/vue-cli#6637,
  vuejs/vue-cli#6621, vuejs/vue-cli#6555, vuejs/vue-cli#6553,
  vuejs/vue-cli#6523, vuejs/vue-cli#6486, vuejs/vue-cli#6632.
- Development packages are not relevant for the production payload.
- False positives create behavior of ignoring them completely instead of
  taking action, which creates a security vulnerability itself.
- Failed tests are shown in a badge on README file, giving wrong picture
  of security posture of users.

`npm audit --omit=dev` is used instead of `npm audit --production` which
is deprecated as of npm v8.7.0 npm/cli#4744.

This commit also removes exiting with output of `npm audit` command to
fix exiting with textual output, leading to failures.
2023-08-08 20:02:09 +02:00
undergroundwires
1b9be8fe2d Refactor Vue components using Composition API #230
- Migrate `StatefulVue`:
  - Introduce `UseCollectionState` that replaces its behavior and acts
    as a shared state store.
  - Add more encapsulated, granular functions based on read or write
    access to state in CollectionState.
- Some linting rules get activates due to new code-base compability to
  modern parses, fix linting errors.
  - Rename Dialog to ModalDialog as after refactoring,
    eslintvue/no-reserved-component-names does not allow name Dialog.
  - To comply with `vue/multi-word-component-names`, rename:
    - `Code`          -> `CodeInstruction`
    - `Handle`        -> `SliderHandle`
    - `Documentable`  -> `DocumentableNode`
    - `Node`          -> `NodeContent`
    - `INode`         -> `INodeContent`
    - `Responsive`    -> `SizeObserver`
- Remove `vue-property-decorator` and `vue-class-component`
  dependencies.
- Refactor `watch` with computed properties when possible for cleaner
  code.
  - Introduce `UseApplication` to reduce repeated code in new components
    that use `computed` more heavily than before.
- Change TypeScript target to `es2017` to allow top level async calls
  for getting application context/state/instance to simplify the code by
  removing async calls. However, mocha (unit and integration) tests do
  not run with top level awaits, so a workaround is used.
2023-08-07 13:16:39 +02:00
undergroundwires
3a594ac7fd Improve user privacy with secure outbound links
All outbound links now include `rel="noopener noreferrer"` attribute.
This security improvement prevents the new page from being able to
access the `window.opener` property and ensures it runs in a separate
process.

`rel="noopener"`:

   When a new page is opened using `target="_blank"`, the new page runs
   on the same process as the originating page, and has a reference to
   the originating page `window.opener`. By implementing
   `rel="noopener"`, the new page is prevented to use `window.opener`
   property.
   It's security issue because the newly opened website could
   potentially redirect the page to a malicious URL. Even though
   privacy.sexy doesn't have any sensitive information to protect, this
   can still be a vector for phishing attacks.

`rel="noreferrer"`:

  It implies features of `noopener`, and also prevents `Referer` header
  from being sent to the new page. Referer headers may include
  sensitive data, because they tell the new page the URL of the page
  the request is coming from.
2023-08-06 02:09:11 +02:00
126 changed files with 5750 additions and 1873 deletions

View File

@@ -19,4 +19,4 @@ jobs:
uses: ./.github/actions/setup-node uses: ./.github/actions/setup-node
- -
name: NPM audit 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

View File

@@ -4,13 +4,13 @@
<!-- markdownlint-disable MD033 --> <!-- markdownlint-disable MD033 -->
<p align="center"> <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 <img
alt="donation badge" alt="donation badge"
src="https://undergroundwires.dev/img/badges/donate/flat.svg" src="https://undergroundwires.dev/img/badges/donate/flat.svg"
/> />
</a> </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 <img
alt="contributions are welcome" alt="contributions are welcome"
src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat" src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat"
@@ -18,13 +18,13 @@
</a> </a>
<!-- Code quality --> <!-- Code quality -->
<br /> <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 <img
alt="Language grade: JavaScript/TypeScript" alt="Language grade: JavaScript/TypeScript"
src="https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18" src="https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18"
/> />
</a> </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 <img
alt="Maintainability" alt="Maintainability"
src="https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability" src="https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability"
@@ -32,19 +32,19 @@
</a> </a>
<!-- Tests --> <!-- Tests -->
<br /> <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 <img
alt="Unit tests status" alt="Unit tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/unit-tests/badge.svg" src="https://github.com/undergroundwires/privacy.sexy/workflows/unit-tests/badge.svg"
/> />
</a> </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 <img
alt="Integration tests status" alt="Integration tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/integration-tests/badge.svg" src="https://github.com/undergroundwires/privacy.sexy/workflows/integration-tests/badge.svg"
/> />
</a> </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 <img
alt="E2E tests status" alt="E2E tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/e2e-tests/badge.svg" src="https://github.com/undergroundwires/privacy.sexy/workflows/e2e-tests/badge.svg"
@@ -52,19 +52,19 @@
</a> </a>
<!-- Checks --> <!-- Checks -->
<br /> <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 <img
alt="Quality checks status" alt="Quality checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/quality-checks/badge.svg" src="https://github.com/undergroundwires/privacy.sexy/workflows/quality-checks/badge.svg"
/> />
</a> </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 <img
alt="Security checks status" alt="Security checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/security-checks/badge.svg" src="https://github.com/undergroundwires/privacy.sexy/workflows/security-checks/badge.svg"
/> />
</a> </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 <img
alt="Build checks status" alt="Build checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg" src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
@@ -72,19 +72,19 @@
</a> </a>
<!-- Release --> <!-- Release -->
<br /> <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 <img
alt="Git release status" alt="Git release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-git/badge.svg" src="https://github.com/undergroundwires/privacy.sexy/workflows/release-git/badge.svg"
/> />
</a> </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 <img
alt="Site release status" alt="Site release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-site/badge.svg" src="https://github.com/undergroundwires/privacy.sexy/workflows/release-site/badge.svg"
/> />
</a> </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 <img
alt="Desktop application release status" alt="Desktop application release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-desktop/badge.svg" src="https://github.com/undergroundwires/privacy.sexy/workflows/release-desktop/badge.svg"
@@ -92,7 +92,7 @@
</a> </a>
<!-- Others --> <!-- Others -->
<br /> <br />
<a href="https://github.com/undergroundwires/bump-everywhere"> <a href="https://github.com/undergroundwires/bump-everywhere" target="_blank" rel="noopener noreferrer">
<img <img
alt="Auto-versioned by bump-everywhere" alt="Auto-versioned by bump-everywhere"
src="https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true" src="https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true"

View File

@@ -1,4 +1,5 @@
import { defineConfig } from 'cypress' import { defineConfig } from 'cypress';
import setupPlugins from './tests/e2e/plugins/index.js';
export default defineConfig({ export default defineConfig({
fixturesFolder: 'tests/e2e/fixtures', fixturesFolder: 'tests/e2e/fixtures',
@@ -6,7 +7,7 @@ export default defineConfig({
videosFolder: 'tests/e2e/videos', videosFolder: 'tests/e2e/videos',
e2e: { e2e: {
setupNodeEvents(on, config) { 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}', specPattern: 'tests/e2e/specs/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'tests/e2e/support/index.js', supportFile: 'tests/e2e/support/index.js',

View File

@@ -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. 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. Each layer treat application layer differently.
![State](./../img/architecture/app-state.png) ![State](./../img/architecture/app-state.png)
@@ -45,7 +47,7 @@ Each layer treat application layer differently.
- So state is mutable, and fires related events when mutated. - So state is mutable, and fires related events when mutated.
- 📖 Read more: [application.md | Application state](./application.md#application-state). - 📖 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 ## AWS infrastructure

View File

@@ -1,8 +1,10 @@
# Presentation layer # 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. 📖 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. - [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue global objects including components and plugins.
- [**`components/`**](./../src/presentation/components/): Contains all Vue components and their helper classes. - [**`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. - [**`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. - [**`assets/`**](./../src/presentation/assets/styles/): Contains assets that webpack will process.
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts - [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles used throughout different components. - [**`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. - [**`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. - [**`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. - [**`/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`. - [**`/vue.config.cjs`**](./../vue.config.cjs): Global Vue CLI configurations loaded by `@vue/cli-service`.
- [**`/postcss.config.js`**](./../postcss.config.js): PostCSS configurations used by Vue CLI internally. - [**`/postcss.config.cjs`**](./../postcss.config.cjs): PostCSS configurations used by Vue CLI internally.
- [**`/babel.config.js`**](./../babel.config.js): Babel configurations for polyfills used by `@vue/cli-plugin-babel`. - [**`/babel.config.cjs`**](./../babel.config.cjs): Babel configurations for polyfills used by `@vue/cli-plugin-babel`.
## Visual design best-practices ## 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 ## 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: [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 ## 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. 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.
- 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.
📖 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 The presentation layer uses Vue's native dependency injection system to increase testability and decouple components.
<Dialog ref="testDialog">
<div>Hello world</div> To add a new dependency:
</Dialog>
<div @click="$refs.testDialog.show()">Show dialog</div> 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 ## Sass naming convention

View File

@@ -18,6 +18,7 @@ Common aspects for all tests:
- Unit tests test each component in isolation. - Unit tests test each component in isolation.
- All unit tests goes under [`./tests/unit`](./../tests/unit). - All unit tests goes under [`./tests/unit`](./../tests/unit).
- They rely on [stubs](./../tests/unit/shared/Stubs) for isolation. - They rely on [stubs](./../tests/unit/shared/Stubs) for isolation.
- Unit tests include also Vue component tests using `@vue/test-utils`.
### Unit tests structure ### Unit tests structure

562
package-lock.json generated
View File

@@ -6,7 +6,7 @@
"packages": { "packages": {
"": { "": {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.11.4", "version": "0.12.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/fontawesome-svg-core": "^6.4.0",
@@ -25,10 +25,7 @@
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"npm": "^9.8.1", "npm": "^9.8.1",
"v-tooltip": "2.1.3", "v-tooltip": "2.1.3",
"vue": "^2.7.14", "vue": "^2.7.14"
"vue-class-component": "^7.2.6",
"vue-js-modal": "^2.0.1",
"vue-property-decorator": "^9.1.2"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.3.2", "@rushstack/eslint-patch": "^1.3.2",
@@ -46,6 +43,7 @@
"@vue/cli-service": "~5.0.8", "@vue/cli-service": "~5.0.8",
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0", "@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^1.3.6",
"chai": "^4.3.7", "chai": "^4.3.7",
"cypress": "^12.17.2", "cypress": "^12.17.2",
"electron": "^25.3.2", "electron": "^25.3.2",
@@ -3313,6 +3311,12 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0" "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": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "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": { "node_modules/@vue/vue-loader-v15": {
"name": "vue-loader", "name": "vue-loader",
"version": "15.10.0", "version": "15.10.0",
@@ -5045,6 +5064,12 @@
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
"dev": true "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": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -7055,6 +7080,36 @@
"typedarray": "^0.0.6" "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": { "node_modules/config-file-ts": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.4.tgz", "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.4.tgz",
@@ -7836,6 +7891,13 @@
"integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==", "integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==",
"dev": true "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": { "node_modules/debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -8421,6 +8483,12 @@
"utila": "~0.4" "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": { "node_modules/dom-serializer": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
@@ -8556,6 +8624,57 @@
"safer-buffer": "^2.1.0" "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "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", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" "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": { "node_modules/extract-zip": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -12158,6 +12289,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/is-callable": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -12230,6 +12367,15 @@
"integrity": "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w==", "integrity": "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w==",
"dev": true "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": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -12577,6 +12723,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/is-wsl": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -12810,6 +12965,66 @@
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"dev": true "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": { "node_modules/js-message": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz", "resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz",
@@ -13160,6 +13375,18 @@
"json-buffer": "3.0.0" "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": { "node_modules/klaw": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz", "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz",
@@ -15639,6 +15866,21 @@
"dev": true, "dev": true,
"hasInstallScript": 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": { "node_modules/normalize-package-data": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", "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" "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": { "node_modules/pretty-bytes": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -20582,6 +20838,12 @@
"levenshtein-edit-distance": "^1.0.0" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -22261,11 +22523,6 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true "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": { "node_modules/resolve": {
"version": "1.22.1", "version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
@@ -25610,14 +25867,6 @@
"csstype": "^3.1.0" "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": { "node_modules/vue-cli-plugin-electron-builder": {
"version": "3.0.0-alpha.4", "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", "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==", "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==",
"dev": true "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": { "node_modules/vue-loader": {
"version": "17.0.0", "version": "17.0.0",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.0.0.tgz", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.0.0.tgz",
@@ -25886,15 +26124,6 @@
"node": ">=8" "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": { "node_modules/vue-resize": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-1.0.1.tgz", "resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-1.0.1.tgz",
@@ -25922,6 +26151,17 @@
"integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==", "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==",
"dev": true "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": { "node_modules/vue-template-es2015-compiler": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz", "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==", "integrity": "sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg==",
"dev": true "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": { "@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -30624,6 +30870,17 @@
"vue-eslint-parser": "^9.1.1" "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": { "@vue/vue-loader-v15": {
"version": "npm:vue-loader@15.10.0", "version": "npm:vue-loader@15.10.0",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.10.0.tgz", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.10.0.tgz",
@@ -30827,6 +31084,12 @@
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
"dev": true "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": { "accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -32341,6 +32604,35 @@
"typedarray": "^0.0.6" "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": { "config-file-ts": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.4.tgz", "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.4.tgz",
@@ -32913,6 +33205,13 @@
"integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==", "integrity": "sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==",
"dev": true "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": { "debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -33324,6 +33623,12 @@
"utila": "~0.4" "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": { "dom-serializer": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
@@ -33432,6 +33737,44 @@
"safer-buffer": "^2.1.0" "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": { "ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "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", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" "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": { "extract-zip": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -36123,6 +36475,12 @@
"has-tostringtag": "^1.0.0" "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": { "is-callable": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -36168,6 +36526,12 @@
"integrity": "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w==", "integrity": "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w==",
"dev": true "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": { "is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -36400,6 +36764,12 @@
"call-bind": "^1.0.2" "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": { "is-wsl": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -36578,6 +36948,51 @@
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"dev": true "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": { "js-message": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz", "resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz",
@@ -36882,6 +37297,15 @@
"json-buffer": "3.0.0" "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": { "klaw": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz", "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz",
@@ -38704,6 +39128,15 @@
"integrity": "sha512-7Ws63oC+215smeKJQCxzrK21VFVlCFBkwl0MOObt0HOpVQXs3u483sAmtkF33nNqZ5rSOQjB76fgyPBmAUrtCA==", "integrity": "sha512-7Ws63oC+215smeKJQCxzrK21VFVlCFBkwl0MOObt0HOpVQXs3u483sAmtkF33nNqZ5rSOQjB76fgyPBmAUrtCA==",
"dev": true "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": { "normalize-package-data": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@@ -41875,6 +42308,17 @@
"dev": true, "dev": true,
"optional": 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": { "pretty-bytes": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -42043,6 +42487,12 @@
"levenshtein-edit-distance": "^1.0.0" "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": { "proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -43343,11 +43793,6 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true "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": { "resolve": {
"version": "1.22.1", "version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
@@ -45848,12 +46293,6 @@
"csstype": "^3.1.0" "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": { "vue-cli-plugin-electron-builder": {
"version": "3.0.0-alpha.4", "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", "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==", "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==",
"dev": true "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": { "vue-loader": {
"version": "17.0.0", "version": "17.0.0",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.0.0.tgz", "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": { "vue-resize": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-1.0.1.tgz", "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": { "vue-template-es2015-compiler": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz", "resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz",

View File

@@ -5,10 +5,11 @@
"slogan": "Now you have the choice", "slogan": "Now you have the choice",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆", "description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
"author": "undergroundwires", "author": "undergroundwires",
"type": "module",
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "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", "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", "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", "create-icons": "node img/logo-update.mjs",
@@ -21,7 +22,7 @@
"lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml", "lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"postuninstall": "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", "main": "index.js",
"dependencies": { "dependencies": {
@@ -41,10 +42,7 @@
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"npm": "^9.8.1", "npm": "^9.8.1",
"v-tooltip": "2.1.3", "v-tooltip": "2.1.3",
"vue": "^2.7.14", "vue": "^2.7.14"
"vue-class-component": "^7.2.6",
"vue-js-modal": "^2.0.1",
"vue-property-decorator": "^9.1.2"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.3.2", "@rushstack/eslint-patch": "^1.3.2",
@@ -62,6 +60,7 @@
"@vue/cli-service": "~5.0.8", "@vue/cli-service": "~5.0.8",
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0", "@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^1.3.6",
"chai": "^4.3.7", "chai": "^4.3.7",
"cypress": "^12.17.2", "cypress": "^12.17.2",
"electron": "^25.3.2", "electron": "^25.3.2",
@@ -85,10 +84,10 @@
"sass-loader": "^13.3.2", "sass-loader": "^13.3.2",
"svgexport": "^0.4.2", "svgexport": "^0.4.2",
"ts-loader": "^9.4.4", "ts-loader": "^9.4.4",
"tslib": "~2.4.0",
"typescript": "~4.6.2", "typescript": "~4.6.2",
"vue-cli-plugin-electron-builder": "^3.0.0-alpha.4", "vue-cli-plugin-electron-builder": "^3.0.0-alpha.4",
"yaml-lint": "^1.7.0", "yaml-lint": "^1.7.0"
"tslib": "~2.4.0"
}, },
"overrides": { "overrides": {
"vue-cli-plugin-electron-builder": { "vue-cli-plugin-electron-builder": {

View 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';
}

View File

@@ -0,0 +1,4 @@
export enum FilterActionType {
Apply,
Clear,
}

View 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}`);
}
}
}

View File

@@ -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;
}

View File

@@ -1,13 +1,13 @@
import { IEventSource } from '@/infrastructure/Events/IEventSource'; import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { IFilterResult } from './IFilterResult'; import { IFilterResult } from './IFilterResult';
import { IFilterChangeDetails } from './Event/IFilterChangeDetails';
export interface IReadOnlyUserFilter { export interface IReadOnlyUserFilter {
readonly currentFilter: IFilterResult | undefined; readonly currentFilter: IFilterResult | undefined;
readonly filtered: IEventSource<IFilterResult>; readonly filterChanged: IEventSource<IFilterChangeDetails>;
readonly filterRemoved: IEventSource<void>;
} }
export interface IUserFilter extends IReadOnlyUserFilter { export interface IUserFilter extends IReadOnlyUserFilter {
setFilter(filter: string): void; applyFilter(filter: string): void;
removeFilter(): void; clearFilter(): void;
} }

View File

@@ -4,11 +4,11 @@ import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { FilterResult } from './FilterResult'; import { FilterResult } from './FilterResult';
import { IFilterResult } from './IFilterResult'; import { IFilterResult } from './IFilterResult';
import { IUserFilter } from './IUserFilter'; import { IUserFilter } from './IUserFilter';
import { IFilterChangeDetails } from './Event/IFilterChangeDetails';
import { FilterChange } from './Event/FilterChange';
export class UserFilter implements IUserFilter { export class UserFilter implements IUserFilter {
public readonly filtered = new EventSource<IFilterResult>(); public readonly filterChanged = new EventSource<IFilterChangeDetails>();
public readonly filterRemoved = new EventSource<void>();
public currentFilter: IFilterResult | undefined; 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) { 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 filterLowercase = filter.toLocaleLowerCase();
const filteredScripts = this.collection.getAllScripts().filter( const filteredScripts = this.collection.getAllScripts().filter(
@@ -33,12 +33,12 @@ export class UserFilter implements IUserFilter {
filter, filter,
); );
this.currentFilter = matches; this.currentFilter = matches;
this.filtered.notify(matches); this.filterChanged.notify(FilterChange.forApply(this.currentFilter));
} }
public removeFilter(): void { public clearFilter(): void {
this.currentFilter = undefined; this.currentFilter = undefined;
this.filterRemoved.notify(); this.filterChanged.notify(FilterChange.forClear());
} }
} }

View File

@@ -1,11 +1,10 @@
import { CustomError } from '@/application/Common/CustomError';
import { NodeType } from './NodeType'; import { NodeType } from './NodeType';
import { NodeData } from './NodeData'; import { NodeData } from './NodeData';
export class NodeDataError extends Error { export class NodeDataError extends CustomError {
constructor(message: string, public readonly context: INodeDataErrorContext) { constructor(message: string, public readonly context: INodeDataErrorContext) {
super(createMessage(message, context)); 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;
} }
} }

View File

@@ -3139,6 +3139,68 @@ actions:
- -
category: Disable Windows Defender Firewall # Also known as Windows Firewall, Microsoft Defender Firewall category: Disable Windows Defender Firewall # Also known as Windows Firewall, Microsoft Defender Firewall
children: 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 name: Disable Firewall through command-line utility
# ❗️ Following must be enabled and in running state: # ❗️ 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\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\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 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 name: Disable Microsoft Defender Antivirus # Depreciated since Windows 10 version 1903
docs: docs:
@@ -4504,11 +4571,6 @@ actions:
docs: https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefenderSecurityCenter::DeviceSecurity_DisableTpmFirmwareUpdateWarning 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 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 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 category: Hide Windows Defender notifications
children: children:
@@ -4609,43 +4671,6 @@ actions:
# 1. Some cannot be disabled (access error) normally but only with DisableServiceInRegistry # 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 # 2. Some cannot be disabled even using DisableServiceInRegistry, must be disabled as TrustedInstaller using RunInlineCodeAsTrustedInstaller
children: 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 name: Disable Windows Defender Antivirus service
# ❗️ Breaks `Set-MpPreference` PowerShell cmdlet that helps to manage Defender # ❗️ Breaks `Set-MpPreference` PowerShell cmdlet that helps to manage Defender
@@ -4657,8 +4682,8 @@ actions:
- -
function: RunInlineCodeAsTrustedInstaller function: RunInlineCodeAsTrustedInstaller
parameters: parameters:
code: sc stop "WinDefend" >nul & sc config "WinDefend" start=disabled code: sc stop "WinDefend" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WinDefend" /v "Start" /t REG_DWORD /d "4" /f
revertCode: sc config "WinDefend" start=auto & sc start "WinDefend" >nul 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 # - # "Access is denied" when renaming file
# function: RenameSystemFile # function: RenameSystemFile
# parameters: # parameters:
@@ -4666,24 +4691,6 @@ actions:
- -
category: Disable kernel-level Windows Defender drivers category: Disable kernel-level Windows Defender drivers
children: 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 # - Skipping wdnsfltr "Windows Defender Network Stream Filter Driver" as it's Windows 1709 only
- -
name: Disable Microsoft Defender Antivirus Network Inspection System Driver service name: Disable Microsoft Defender Antivirus Network Inspection System Driver service
@@ -4693,8 +4700,8 @@ actions:
function: RunInlineCodeAsTrustedInstaller function: RunInlineCodeAsTrustedInstaller
parameters: parameters:
# "net stop" is used to stop dependend services as well, "sc stop" fails # "net stop" is used to stop dependend services as well, "sc stop" fails
code: net stop "WdNisDrv" /yes >nul & sc config "WdNisDrv" start=disabled code: net stop "WdNisDrv" /yes >nul & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisDrv" /v "Start" /t REG_DWORD /d "4" /f
revertCode: sc config "WdNisDrv" start=demand & sc start "WdNisDrv" >nul revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisDrv" /v "Start" /t REG_DWORD /d "3" /f & sc start "WdNisDrv" >nul
- -
function: RenameSystemFile function: RenameSystemFile
parameters: parameters:
@@ -4712,8 +4719,8 @@ actions:
- -
function: RunInlineCodeAsTrustedInstaller function: RunInlineCodeAsTrustedInstaller
parameters: parameters:
code: sc stop "WdFilter" >nul & sc config "WdFilter" start=disabled code: sc stop "WdFilter" >nul & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdFilter" /v "Start" /t REG_DWORD /d "4" /f
revertCode: sc config "WdFilter" start=boot & sc start "WdFilter" >nul revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdFilter" /v "Start" /t REG_DWORD /d "0" /f & sc start "WdFilter" >nul
- -
function: RenameSystemFile function: RenameSystemFile
parameters: parameters:
@@ -4729,8 +4736,8 @@ actions:
- -
function: RunInlineCodeAsTrustedInstaller function: RunInlineCodeAsTrustedInstaller
parameters: parameters:
code: sc stop "WdBoot" >nul & sc config "WdBoot" start=disabled code: sc stop "WdBoot" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdBoot" /v "Start" /t REG_DWORD /d "4" /f
revertCode: sc config "WdBoot" start=boot & sc start "WdBoot" >nul revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdBoot" /v "Start" /t REG_DWORD /d "0" /f & sc start "WdBoot" >nul 2>&1
- -
function: RenameSystemFile function: RenameSystemFile
parameters: parameters:
@@ -4748,8 +4755,8 @@ actions:
- -
function: RunInlineCodeAsTrustedInstaller function: RunInlineCodeAsTrustedInstaller
parameters: parameters:
code: sc stop "WdNisSvc" >nul & sc config "WdNisSvc" start=disabled code: sc stop "WdNisSvc" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisSvc" /v "Start" /t REG_DWORD /d "4" /f
revertCode: sc config "WdNisSvc" start=auto & sc start "WdNisSvc" >nul 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 # - # "Access is denied" when renaming file
# function: RenameSystemFile # function: RenameSystemFile
# parameters: # parameters:
@@ -4759,10 +4766,10 @@ actions:
docs: http://batcmd.com/windows/10/services/sense/ docs: http://batcmd.com/windows/10/services/sense/
call: 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: parameters:
serviceName: Sense # Check: (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Sense").Start code: sc stop "Sense" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\Sense" /v "Start" /t REG_DWORD /d "4" /f
defaultStartupMode: Manual # Alowed values: Boot | System | Automatic | Manual 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 function: RenameSystemFile
parameters: parameters:
@@ -4782,8 +4789,8 @@ actions:
# ✅ Can disable using registry as TrustedInstaller # ✅ Can disable using registry as TrustedInstaller
function: RunInlineCodeAsTrustedInstaller function: RunInlineCodeAsTrustedInstaller
parameters: parameters:
code: reg add "HKLM\SYSTEM\CurrentControlSet\Services\SecurityHealthService" /v Start /t REG_DWORD /d 4 /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 revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\SecurityHealthService" /v Start /t REG_DWORD /d 3 /f & sc start "SecurityHealthService" >nul 2>&1
- -
function: RenameSystemFile function: RenameSystemFile
parameters: parameters:

View File

@@ -27,3 +27,21 @@
*/ */
-webkit-tap-highlight-color: transparent; -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);
}
}
}

View File

@@ -1,4 +1,3 @@
import { VModalBootstrapper } from './Modules/VModalBootstrapper';
import { TreeBootstrapper } from './Modules/TreeBootstrapper'; import { TreeBootstrapper } from './Modules/TreeBootstrapper';
import { IconBootstrapper } from './Modules/IconBootstrapper'; import { IconBootstrapper } from './Modules/IconBootstrapper';
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper'; import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
@@ -19,7 +18,6 @@ export class ApplicationBootstrapper implements IVueBootstrapper {
new TreeBootstrapper(), new TreeBootstrapper(),
new VueBootstrapper(), new VueBootstrapper(),
new TooltipBootstrapper(), new TooltipBootstrapper(),
new VModalBootstrapper(),
]; ];
} }
} }

View 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);
}

View File

@@ -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 });
}
}

View File

@@ -11,14 +11,18 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-property-decorator'; import { defineComponent } from 'vue';
import TheHeader from '@/presentation/components/TheHeader.vue'; import TheHeader from '@/presentation/components/TheHeader.vue';
import TheFooter from '@/presentation/components/TheFooter/TheFooter.vue'; import TheFooter from '@/presentation/components/TheFooter/TheFooter.vue';
import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeButtons.vue'; import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeButtons.vue';
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue'; import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
import TheSearchBar from '@/presentation/components/TheSearchBar.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: { components: {
TheHeader, TheHeader,
TheCodeButtons, TheCodeButtons,
@@ -26,10 +30,10 @@ import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
TheSearchBar, TheSearchBar,
TheFooter, TheFooter,
}, },
}) setup() {
export default class App extends Vue { provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts
},
} });
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@@ -14,20 +14,36 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { import { defineComponent } from 'vue';
Component, Prop, Emit, Vue,
} from 'vue-property-decorator';
@Component export default defineComponent({
export default class IconButton extends Vue { props: {
@Prop() public text!: number; text: {
type: String,
@Prop() public iconPrefix!: string; required: true,
},
@Prop() public iconName!: string; iconPrefix: {
type: String,
@Emit('click') public onClicked() { /* do nothing except firing event */ } required: true,
},
iconName: {
type: String,
required: true,
},
},
emits: [
'click',
],
setup(_, { emit }) {
function onClicked() {
emit('click');
} }
return {
onClicked,
};
},
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -2,26 +2,41 @@
<span class="code-wrapper"> <span class="code-wrapper">
<span class="dollar">$</span> <span class="dollar">$</span>
<code><slot /></code> <code><slot /></code>
<TooltipWrapper>
<font-awesome-icon <font-awesome-icon
class="copy-button" class="copy-button"
:icon="['fas', 'copy']" :icon="['fas', 'copy']"
@click="copyCode" @click="copyCode"
v-tooltip.top-center="'Copy'"
/> />
<template v-slot:tooltip>
Copy
</template>
</TooltipWrapper>
</span> </span>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-property-decorator'; import { defineComponent, useSlots } from 'vue';
import { Clipboard } from '@/infrastructure/Clipboard'; import { Clipboard } from '@/infrastructure/Clipboard';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
@Component export default defineComponent({
export default class Code extends Vue { components: {
public copyCode(): void { TooltipWrapper,
const code = this.$slots.default[0].text; },
setup() {
const slots = useSlots();
function copyCode() {
const code = slots.default()[0].text;
Clipboard.copyText(code); Clipboard.copyText(code);
} }
}
return {
copyCode,
};
},
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -6,8 +6,8 @@
<hr /> <hr />
<p> <p>
<strong>1. The easy alternative</strong>. Run your script without any manual steps by <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 <a :href="macOsDownloadUrl">downloading desktop version</a> of {{ appName }} on the
{{ this.osName }} system you wish to configure, and then click on the Run button. This is {{ osName }} system you wish to configure, and then click on the Run button. This is
recommended for most users. recommended for most users.
</p> </p>
<hr /> <hr />
@@ -20,27 +20,33 @@
<p> <p>
<ol> <ol>
<li <li
v-for='(step, index) in this.data.steps' v-for='(step, index) in data.steps'
v-bind:key="index" v-bind:key="index"
class="step" class="step"
> >
<div class="step__action"> <div class="step__action">
<span>{{ step.action.instruction }}</span> <span>{{ step.action.instruction }}</span>
<TooltipWrapper v-if="step.action.details">
<font-awesome-icon <font-awesome-icon
v-if="step.action.details"
class="explanation" class="explanation"
:icon="['fas', 'info-circle']" :icon="['fas', 'info-circle']"
v-tooltip.top-center="step.action.details"
/> />
<template v-slot:tooltip>
<div v-html="step.action.details" />
</template>
</TooltipWrapper>
</div> </div>
<div v-if="step.code" class="step__code"> <div v-if="step.code" class="step__code">
<Code>{{ step.code.instruction }}</Code> <CodeInstruction>{{ step.code.instruction }}</CodeInstruction>
<TooltipWrapper v-if="step.code.details">
<font-awesome-icon <font-awesome-icon
v-if="step.code.details"
class="explanation" class="explanation"
:icon="['fas', 'info-circle']" :icon="['fas', 'info-circle']"
v-tooltip.top-center="step.code.details"
/> />
<template v-slot:tooltip>
<div v-html="step.code.details" />
</template>
</TooltipWrapper>
</div> </div>
</li> </li>
</ol> </ol>
@@ -49,36 +55,50 @@
</template> </template>
<script lang="ts"> <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 { OperatingSystem } from '@/domain/OperatingSystem';
import { ApplicationFactory } from '@/application/ApplicationFactory'; import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import Code from './Code.vue'; import CodeInstruction from './CodeInstruction.vue';
import { IInstructionListData } from './InstructionListData'; import { IInstructionListData } from './InstructionListData';
@Component({ export default defineComponent({
components: { components: {
Code, CodeInstruction,
TooltipWrapper,
}, },
}) props: {
export default class InstructionList extends Vue { data: {
public appName = ''; 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) {
public async created() {
if (!this.data) {
throw new Error('missing data'); throw new Error('missing data');
} }
const app = await ApplicationFactory.Current.getApp(); return renderOsName(props.data.operatingSystem);
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 { function renderOsName(os: OperatingSystem): string {
switch (os) { switch (os) {

View File

@@ -1,17 +1,17 @@
<template> <template>
<div class="container" v-if="hasCode"> <div class="container" v-if="hasCode">
<IconButton <IconButton
v-if="this.canRun" v-if="canRun"
text="Run" text="Run"
v-on:click="executeCode" v-on:click="executeCode"
icon-prefix="fas" icon-prefix="fas"
icon-name="play" icon-name="play"
/> />
<IconButton <IconButton
:text="this.isDesktopVersion ? 'Save' : 'Download'" :text="isDesktopVersion ? 'Save' : 'Download'"
v-on:click="saveCode" v-on:click="saveCode"
icon-prefix="fas" icon-prefix="fas"
:icon-name="this.isDesktopVersion ? 'save' : 'file-download'" :icon-name="isDesktopVersion ? 'save' : 'file-download'"
/> />
<IconButton <IconButton
text="Copy" text="Copy"
@@ -19,25 +19,26 @@
icon-prefix="fas" icon-prefix="fas"
icon-name="copy" icon-name="copy"
/> />
<Dialog v-if="this.hasInstructions" ref="instructionsDialog"> <ModalDialog v-if="instructions" v-model="areInstructionsVisible">
<InstructionList :data="this.instructions" /> <InstructionList :data="instructions" />
</Dialog> </ModalDialog>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component } from 'vue-property-decorator'; import {
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue'; defineComponent, ref, computed, inject,
} from 'vue';
import { useCollectionStateKey, useEnvironmentKey } from '@/presentation/injectionSymbols';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog'; import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard'; 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 { Environment } from '@/application/Environment/Environment';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode'; import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { CodeRunner } from '@/infrastructure/CodeRunner'; import { CodeRunner } from '@/infrastructure/CodeRunner';
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext'; import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import InstructionList from './Instructions/InstructionList.vue'; import InstructionList from './Instructions/InstructionList.vue';
@@ -45,79 +46,88 @@ import IconButton from './IconButton.vue';
import { IInstructionListData } from './Instructions/InstructionListData'; import { IInstructionListData } from './Instructions/InstructionListData';
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory'; import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
@Component({ export default defineComponent({
components: { components: {
IconButton, IconButton,
InstructionList, InstructionList,
Dialog, ModalDialog,
}, },
}) setup() {
export default class TheCodeButtons extends StatefulVue { const {
public readonly isDesktopVersion = Environment.CurrentEnvironment.isDesktop; 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; async function copyCode() {
const code = await getCurrentCode();
public instructions: IInstructionListData | undefined;
public hasInstructions = false;
public fileName = '';
public async copyCode() {
const code = await this.getCurrentCode();
Clipboard.copyText(code.current); Clipboard.copyText(code.current);
} }
public async saveCode() { function saveCode() {
const context = await this.getCurrentContext(); saveCodeToDisk(fileName.value, currentState.value);
saveCode(this.fileName, context.state); areInstructionsVisible.value = true;
if (this.hasInstructions) {
(this.$refs.instructionsDialog as Dialog).show();
}
} }
public async executeCode() { async function executeCode() {
const context = await this.getCurrentContext(); await runCode(currentContext);
await executeCode(context);
} }
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void { onStateChange((newState) => {
this.updateRunState(newState.os); subscribeToCodeChanges(newState.code);
this.updateDownloadState(newState.collection); }, { immediate: true });
this.updateCodeState(newState.code);
function subscribeToCodeChanges(code: IApplicationCode) {
hasCode.value = code.current && code.current.length > 0;
events.unsubscribeAll();
events.register(code.changed.on((newCode) => {
hasCode.value = newCode && newCode.code.length > 0;
}));
} }
private async getCurrentCode(): Promise<IApplicationCode> { async function getCurrentCode(): Promise<IApplicationCode> {
const context = await this.getCurrentContext(); const { code } = currentContext.state;
const { code } = context.state;
return code; return code;
} }
private updateRunState(selectedOs: OperatingSystem) { return {
isDesktopVersion: isDesktop,
canRun,
hasCode,
instructions,
fileName,
areInstructionsVisible,
copyCode,
saveCode,
executeCode,
};
},
});
function getDownloadInstructions(
os: OperatingSystem,
fileName: string,
): IInstructionListData | undefined {
if (!hasInstructions(os)) {
return undefined;
}
return getInstructions(os, fileName);
}
function getCanRunState(selectedOs: OperatingSystem, isDesktopVersion: boolean): boolean {
const isRunningOnSelectedOs = selectedOs === Environment.CurrentEnvironment.os; const isRunningOnSelectedOs = selectedOs === Environment.CurrentEnvironment.os;
this.canRun = this.isDesktopVersion && isRunningOnSelectedOs; return isDesktopVersion && isRunningOnSelectedOs;
} }
private updateDownloadState(collection: ICategoryCollection) { function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
this.fileName = buildFileName(collection.scripting);
this.hasInstructions = hasInstructions(collection.os);
if (this.hasInstructions) {
this.instructions = getInstructions(collection.os, this.fileName);
}
}
private updateCodeState(code: IApplicationCode) {
this.hasCode = code.current && code.current.length > 0;
this.events.unsubscribeAll();
this.events.register(code.changed.on((newCode) => {
this.hasCode = newCode && newCode.code.length > 0;
}));
}
}
function saveCode(fileName: string, state: IReadOnlyCategoryCollectionState) {
const content = state.code.current; const content = state.code.current;
const type = getType(state.collection.scripting.language); const type = getType(state.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type); SaveFileDialog.saveFile(content, fileName, type);
@@ -141,7 +151,7 @@ function buildFileName(scripting: IScriptingDefinition) {
return fileName; return fileName;
} }
async function executeCode(context: IReadOnlyApplicationContext) { async function runCode(context: IReadOnlyApplicationContext) {
const runner = new CodeRunner(); const runner = new CodeRunner();
await runner.runCode( await runner.runCode(
/* code: */ context.state.code.current, /* code: */ context.state.code.current,

View File

@@ -1,82 +1,105 @@
<template> <template>
<Responsive <SizeObserver
v-on:sizeChanged="sizeChanged()" v-on:sizeChanged="sizeChanged()"
v-non-collapsing> v-non-collapsing>
<div <div
:id="editorId" :id="editorId"
class="code-area" class="code-area"
/> />
</Responsive> </SizeObserver>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop } from 'vue-property-decorator'; import {
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue'; defineComponent, onUnmounted, onMounted, inject,
} from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent'; import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/CodeBuilderFactory'; 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 { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
import ace from './ace-importer'; import ace from './ace-importer';
@Component({ export default defineComponent({
components: { props: {
Responsive, theme: {
type: String,
default: undefined,
}, },
directives: { NonCollapsing }, },
}) components: {
export default class TheCodeArea extends StatefulVue { SizeObserver,
public readonly editorId = 'codeEditor'; },
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() { function handleNewState(newState: IReadOnlyCategoryCollectionState) {
this.destroyEditor(); destroyEditor();
} editor = initializeEditor(
props.theme,
public sizeChanged() { editorId,
if (this.editor) {
this.editor.resize();
}
}
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
this.destroyEditor();
this.editor = initializeEditor(
this.theme,
this.editorId,
newState.collection.scripting.language, newState.collection.scripting.language,
); );
const appCode = newState.code; const appCode = newState.code;
const innerCode = appCode.current || getDefaultCode(newState.collection.scripting.language); const innerCode = appCode.current || getDefaultCode(newState.collection.scripting.language);
this.editor.setValue(innerCode, 1); editor.setValue(innerCode, 1);
this.events.unsubscribeAll(); events.unsubscribeAll();
this.events.register(appCode.changed.on((code) => this.updateCode(code))); events.register(appCode.changed.on((code) => updateCode(code)));
} }
private async updateCode(event: ICodeChangedEvent) { function updateCode(event: ICodeChangedEvent) {
this.removeCurrentHighlighting(); removeCurrentHighlighting();
if (event.isEmpty()) { if (event.isEmpty()) {
const context = await this.getCurrentContext(); const defaultCode = getDefaultCode(currentState.value.collection.scripting.language);
const defaultCode = getDefaultCode(context.state.collection.scripting.language); editor.setValue(defaultCode, 1);
this.editor.setValue(defaultCode, 1);
return; return;
} }
this.editor.setValue(event.code, 1); editor.setValue(event.code, 1);
if (event.addedScripts && event.addedScripts.length) { if (event.addedScripts?.length > 0) {
this.reactToChanges(event, event.addedScripts); reactToChanges(event, event.addedScripts);
} else if (event.changedScripts && event.changedScripts.length) { } else if (event.changedScripts?.length > 0) {
this.reactToChanges(event, event.changedScripts); reactToChanges(event, event.changedScripts);
} }
} }
private reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) { function sizeChanged() {
editor?.resize();
}
function destroyEditor() {
editor?.destroy();
editor = undefined;
}
function removeCurrentHighlighting() {
if (!currentMarkerId) {
return;
}
editor.session.removeMarker(currentMarkerId);
currentMarkerId = undefined;
}
function reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
const positions = scripts const positions = scripts
.map((script) => event.getScriptPositionInCode(script)); .map((script) => event.getScriptPositionInCode(script));
const start = Math.min( const start = Math.min(
@@ -85,42 +108,33 @@ export default class TheCodeArea extends StatefulVue {
const end = Math.max( const end = Math.max(
...positions.map((position) => position.endLine), ...positions.map((position) => position.endLine),
); );
this.scrollToLine(end + 2); scrollToLine(end + 2);
this.highlight(start, end); highlight(start, end);
} }
private highlight(startRow: number, endRow: number) { function highlight(startRow: number, endRow: number) {
const AceRange = ace.require('ace/range').Range; const AceRange = ace.require('ace/range').Range;
this.currentMarkerId = this.editor.session.addMarker( currentMarkerId = editor.session.addMarker(
new AceRange(startRow, 0, endRow, 0), new AceRange(startRow, 0, endRow, 0),
'code-area__highlight', 'code-area__highlight',
'fullLine', 'fullLine',
); );
} }
private scrollToLine(row: number) { function scrollToLine(row: number) {
const column = this.editor.session.getLine(row).length; const column = editor.session.getLine(row).length;
this.editor.gotoLine(row, column, true); editor.gotoLine(row, column, true);
} }
private removeCurrentHighlighting() { return {
if (!this.currentMarkerId) { editorId,
return; sizeChanged,
} };
this.editor.session.removeMarker(this.currentMarkerId); },
this.currentMarkerId = undefined; });
}
private destroyEditor() {
if (this.editor) {
this.editor.destroy();
this.editor = undefined;
}
}
}
function initializeEditor( function initializeEditor(
theme: string, theme: string | undefined,
editorId: string, editorId: string,
language: ScriptingLanguage, language: ScriptingLanguage,
): ace.Ace.Editor { ): ace.Ace.Editor {

View File

@@ -8,12 +8,16 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'; import { defineComponent } from 'vue';
@Component export default defineComponent({
export default class MenuOptionList extends Vue { props: {
@Prop() public label: string; label: {
} type: String,
default: undefined,
},
},
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -6,26 +6,42 @@
enabled: enabled, enabled: enabled,
}" }"
v-non-collapsing v-non-collapsing
@click="enabled && onClicked()">{{label}}</span> @click="onClicked()">{{label}}</span>
</span> </span>
</template> </template>
<script lang="ts"> <script lang="ts">
import { import { defineComponent } from 'vue';
Component, Prop, Emit, Vue,
} from 'vue-property-decorator';
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective'; import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
@Component({ export default defineComponent({
directives: { NonCollapsing }, directives: { NonCollapsing },
}) props: {
export default class MenuOptionListItem extends Vue { enabled: {
@Prop() public enabled: boolean; type: Boolean,
required: true,
@Prop() public label: string; },
label: {
@Emit('click') public onClicked() { /* do nothing except firing event */ } type: String,
required: true,
},
},
emits: [
'click',
],
setup(props, { emit }) {
const onClicked = () => {
if (!props.enabled) {
return;
} }
emit('click');
};
return {
onClicked,
};
},
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -1,88 +1,125 @@
<template> <template>
<MenuOptionList label="Select"> <MenuOptionList label="Select">
<TooltipWrapper>
<!-- None -->
<MenuOptionListItem <MenuOptionListItem
label="None" label="None"
:enabled="this.currentSelection !== SelectionType.None" :enabled="currentSelection !== SelectionType.None"
@click="selectType(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.'
"
/> />
<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 <MenuOptionListItem
label="Standard" label="Standard"
:enabled="this.currentSelection !== SelectionType.Standard" :enabled="currentSelection !== SelectionType.Standard"
@click="selectType(SelectionType.Standard)" @click="selectType(SelectionType.Standard)"
v-tooltip="
'🛡️ Balanced for privacy and functionality.<br/>'
+ 'OS and applications will function normally.<br/>'
+ '💡 Recommended for everyone'"
/> />
<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 <MenuOptionListItem
label="Strict" label="Strict"
:enabled="this.currentSelection !== SelectionType.Strict" :enabled="currentSelection !== SelectionType.Strict"
@click="selectType(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'
"
/> />
<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 <MenuOptionListItem
label="All" label="All"
:enabled="this.currentSelection !== SelectionType.All" :enabled="currentSelection !== SelectionType.All"
@click="selectType(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'
"
/> />
<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> </MenuOptionList>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component } from 'vue-property-decorator'; import { defineComponent, ref, inject } from 'vue';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue'; import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import MenuOptionList from '../MenuOptionList.vue'; import MenuOptionList from '../MenuOptionList.vue';
import MenuOptionListItem from '../MenuOptionListItem.vue'; import MenuOptionListItem from '../MenuOptionListItem.vue';
import { SelectionType, SelectionTypeHandler } from './SelectionTypeHandler'; import { SelectionType, SelectionTypeHandler } from './SelectionTypeHandler';
@Component({ export default defineComponent({
components: { components: {
MenuOptionList, MenuOptionList,
MenuOptionListItem, MenuOptionListItem,
TooltipWrapper,
}, },
}) setup() {
export default class TheSelector extends StatefulVue { const { modifyCurrentState, onStateChange, events } = inject(useCollectionStateKey)();
public SelectionType = SelectionType;
public currentSelection = SelectionType.None; const currentSelection = ref(SelectionType.None);
private selectionTypeHandler: SelectionTypeHandler; let selectionTypeHandler: SelectionTypeHandler;
public async selectType(type: SelectionType) { onStateChange(() => {
if (this.currentSelection === type) { unregisterMutators();
modifyCurrentState((state) => {
registerStateMutator(state);
});
}, { immediate: true });
function unregisterMutators() {
events.unsubscribeAll();
}
function registerStateMutator(state: ICategoryCollectionState) {
selectionTypeHandler = new SelectionTypeHandler(state);
updateSelections();
events.register(state.selection.changed.on(() => updateSelections()));
}
function selectType(type: SelectionType) {
if (currentSelection.value === type) {
return; return;
} }
this.selectionTypeHandler.selectType(type); selectionTypeHandler.selectType(type);
} }
protected handleCollectionState(newState: ICategoryCollectionState): void { function updateSelections() {
this.events.unsubscribeAll(); currentSelection.value = selectionTypeHandler.getCurrentSelectionType();
this.selectionTypeHandler = new SelectionTypeHandler(newState);
this.updateSelections();
this.events.register(newState.selection.changed.on(() => this.updateSelections()));
}
private updateSelections() {
this.currentSelection = this.selectionTypeHandler.getCurrentSelectionType();
}
} }
return {
SelectionType,
currentSelection,
selectType,
};
},
});
</script> </script>
<style scoped lang="scss">
</style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<MenuOptionList> <MenuOptionList>
<MenuOptionListItem <MenuOptionListItem
v-for="os in this.allOses" v-for="os in allOses"
:key="os.name" :key="os.name"
:enabled="currentOs !== os.os" :enabled="currentOs !== os.os"
@click="changeOs(os.os)" @click="changeOs(os.os)"
@@ -11,41 +11,54 @@
</template> </template>
<script lang="ts"> <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 { 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 MenuOptionList from './MenuOptionList.vue';
import MenuOptionListItem from './MenuOptionListItem.vue'; import MenuOptionListItem from './MenuOptionListItem.vue';
@Component({ interface IOsViewModel {
readonly name: string;
readonly os: OperatingSystem;
}
export default defineComponent({
components: { components: {
MenuOptionList, MenuOptionList,
MenuOptionListItem, MenuOptionListItem,
}, },
}) setup() {
export default class TheOsChanger extends StatefulVue { const { modifyCurrentContext, currentState } = inject(useCollectionStateKey)();
public allOses: Array<{ name: string, os: OperatingSystem }> = []; const { application } = inject(useApplicationKey);
public currentOs?: OperatingSystem = null; const allOses = computed<ReadonlyArray<IOsViewModel>>(() => (
application.getSupportedOsList() ?? [])
public async created() { .map((os) : IOsViewModel => (
const app = await ApplicationFactory.Current.getApp(); {
this.allOses = app.getSupportedOsList() os,
.map((os) => ({ os, name: renderOsName(os) })); name: renderOsName(os),
} }
)));
public async changeOs(newOs: OperatingSystem) { const currentOs = computed<OperatingSystem>(() => {
const context = await this.getCurrentContext(); return currentState.value.os;
});
function changeOs(newOs: OperatingSystem) {
modifyCurrentContext((context) => {
context.changeContext(newOs); context.changeContext(newOs);
});
} }
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void { return {
this.currentOs = newState.os; allOses,
this.$forceUpdate(); // v-bind:class is not updated otherwise currentOs,
} changeOs,
} };
},
});
function renderOsName(os: OperatingSystem): string { function renderOsName(os: OperatingSystem): string {
switch (os) { switch (os) {
@@ -56,7 +69,3 @@ function renderOsName(os: OperatingSystem): string {
} }
} }
</script> </script>
<style scoped lang="scss">
</style>

View File

@@ -5,53 +5,59 @@
<TheViewChanger <TheViewChanger
class="item" class="item"
v-on:viewChanged="$emit('viewChanged', $event)" v-on:viewChanged="$emit('viewChanged', $event)"
v-if="!this.isSearching" /> v-if="!isSearching" />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component } from 'vue-property-decorator'; import {
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue'; defineComponent, ref, onUnmounted, inject,
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; } from 'vue';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource'; import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import TheOsChanger from './TheOsChanger.vue'; import TheOsChanger from './TheOsChanger.vue';
import TheSelector from './Selector/TheSelector.vue'; import TheSelector from './Selector/TheSelector.vue';
import TheViewChanger from './View/TheViewChanger.vue'; import TheViewChanger from './View/TheViewChanger.vue';
@Component({ export default defineComponent({
components: { components: {
TheSelector, TheSelector,
TheOsChanger, TheOsChanger,
TheViewChanger, TheViewChanger,
}, },
}) setup() {
export default class TheScriptsMenu extends StatefulVue { const { onStateChange, events } = inject(useCollectionStateKey)();
public isSearching = false;
private listeners = new Array<IEventSubscription>(); const isSearching = ref(false);
public destroyed() { onStateChange((state) => {
this.unsubscribeAll(); subscribeToFilterChanges(state.filter);
} }, { immediate: true });
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void { onUnmounted(() => {
this.subscribe(newState); unsubscribeAll();
}
private subscribe(state: IReadOnlyCategoryCollectionState) {
this.listeners.push(state.filter.filterRemoved.on(() => {
this.isSearching = false;
}));
state.filter.filtered.on(() => {
this.isSearching = true;
}); });
function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
events.register(
filter.filterChanged.on((event) => {
event.visit({
onApply: () => { isSearching.value = true; },
onClear: () => { isSearching.value = false; },
});
}),
);
} }
private unsubscribeAll() { function unsubscribeAll() {
this.listeners.forEach((listener) => listener.unsubscribe()); events.unsubscribeAll();
this.listeners.splice(0, this.listeners.length);
}
} }
return {
isSearching,
};
},
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -3,7 +3,7 @@
label="View" label="View"
class="part"> class="part">
<MenuOptionListItem <MenuOptionListItem
v-for="view in this.viewOptions" v-for="view in viewOptions"
:key="view.type" :key="view.type"
:label="view.displayName" :label="view.displayName"
:enabled="currentView !== view.type" :enabled="currentView !== view.type"
@@ -13,53 +13,54 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-property-decorator'; import { defineComponent, ref } from 'vue';
import MenuOptionList from '../MenuOptionList.vue'; import MenuOptionList from '../MenuOptionList.vue';
import MenuOptionListItem from '../MenuOptionListItem.vue'; import MenuOptionListItem from '../MenuOptionListItem.vue';
import { ViewType } from './ViewType'; import { ViewType } from './ViewType';
const DefaultView = ViewType.Cards; const DefaultView = ViewType.Cards;
interface IViewOption {
@Component({ readonly type: ViewType;
components: { readonly displayName: string;
MenuOptionList, }
MenuOptionListItem, const viewOptions: readonly IViewOption[] = [
},
})
export default class TheViewChanger extends Vue {
public readonly viewOptions: IViewOption[] = [
{ type: ViewType.Cards, displayName: 'Cards' }, { type: ViewType.Cards, displayName: 'Cards' },
{ type: ViewType.Tree, displayName: 'Tree' }, { type: ViewType.Tree, displayName: 'Tree' },
]; ];
public ViewType = ViewType; export default defineComponent({
components: {
MenuOptionList,
MenuOptionListItem,
},
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 currentView?: ViewType = null; setView(DefaultView);
public mounted() { function setView(view: ViewType) {
this.setView(DefaultView); if (currentView.value === view) {
}
public groupBy(type: ViewType) {
this.setView(type);
}
private setView(view: ViewType) {
if (this.currentView === view) {
throw new Error(`View is already "${ViewType[view]}"`); throw new Error(`View is already "${ViewType[view]}"`);
} }
this.currentView = view; currentView.value = view;
this.$emit('viewChanged', this.currentView); emit('viewChanged', currentView.value);
}
} }
return {
ViewType,
viewOptions,
currentView,
setView,
};
},
});
interface IViewOption { interface IViewOption {
readonly type: ViewType; readonly type: ViewType;
readonly displayName: string; readonly displayName: string;
} }
</script> </script>
<style scoped lang="scss">
</style>

View File

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

View File

@@ -2,16 +2,16 @@
<div <div
class="slider" class="slider"
v-bind:style="{ v-bind:style="{
'--vertical-margin': this.verticalMargin, '--vertical-margin': verticalMargin,
'--first-min-width': this.firstMinWidth, '--first-min-width': firstMinWidth,
'--first-initial-width': this.firstInitialWidth, '--first-initial-width': firstInitialWidth,
'--second-min-width': this.secondMinWidth, '--second-min-width': secondMinWidth,
}" }"
> >
<div class="first" ref="firstElement"> <div class="first" ref="firstElement">
<slot name="first" /> <slot name="first" />
</div> </div>
<Handle class="handle" @resized="onResize($event)" /> <SliderHandle class="handle" @resized="onResize($event)" />
<div class="second"> <div class="second">
<slot name="second" /> <slot name="second" />
</div> </div>
@@ -19,30 +19,45 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'; import { defineComponent, ref } from 'vue';
import Handle from './Handle.vue'; import SliderHandle from './SliderHandle.vue';
@Component({ export default defineComponent({
components: { components: {
Handle, SliderHandle,
}, },
}) props: {
export default class HorizontalResizeSlider extends Vue { verticalMargin: {
@Prop() public verticalMargin: string; 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;
@Prop() public firstInitialWidth: string; firstElement.value.style.width = `${leftWidth}px`;
@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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

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

View File

@@ -19,24 +19,26 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-property-decorator'; import { defineComponent, ref } from 'vue';
import TheCodeArea from '@/presentation/components/Code/TheCodeArea.vue'; import TheCodeArea from '@/presentation/components/Code/TheCodeArea.vue';
import TheScriptsView from '@/presentation/components/Scripts/View/TheScriptsView.vue'; import TheScriptsView from '@/presentation/components/Scripts/View/TheScriptsView.vue';
import TheScriptsMenu from '@/presentation/components/Scripts/Menu/TheScriptsMenu.vue'; import TheScriptsMenu from '@/presentation/components/Scripts/Menu/TheScriptsMenu.vue';
import HorizontalResizeSlider from '@/presentation/components/Scripts/Slider/HorizontalResizeSlider.vue'; import HorizontalResizeSlider from '@/presentation/components/Scripts/Slider/HorizontalResizeSlider.vue';
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType'; import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
@Component({ export default defineComponent({
components: { components: {
TheCodeArea, TheCodeArea,
TheScriptsView, TheScriptsView,
TheScriptsMenu, TheScriptsMenu,
HorizontalResizeSlider, HorizontalResizeSlider,
}, },
}) setup() {
export default class TheScriptArea extends Vue { const currentView = ref(ViewType.Cards);
public currentView = ViewType.Cards;
} return { currentView };
},
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -1,5 +1,5 @@
<template> <template>
<Responsive v-on:widthChanged="width = $event"> <SizeObserver v-on:widthChanged="width = $event">
<!-- <!--
<div id="responsivity-debug"> <div id="responsivity-debug">
Width: {{ width || 'undefined' }} Width: {{ width || 'undefined' }}
@@ -25,86 +25,86 @@
v-bind:key="categoryId" v-bind:key="categoryId"
:categoryId="categoryId" :categoryId="categoryId"
:activeCategoryId="activeCategoryId" :activeCategoryId="activeCategoryId"
v-on:selected="onSelected(categoryId, $event)" v-on:cardExpansionChanged="onSelected(categoryId, $event)"
/> />
</div> </div>
<div v-else class="error">Something went bad 😢</div> <div v-else class="error">Something went bad 😢</div>
</Responsive> </SizeObserver>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component } from 'vue-property-decorator'; import {
import Responsive from '@/presentation/components/Shared/Responsive.vue'; defineComponent, ref, onMounted, onUnmounted, computed,
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue'; inject,
import { ICategory } from '@/domain/ICategory'; } from 'vue';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
import { hasDirective } from './NonCollapsingDirective'; import { hasDirective } from './NonCollapsingDirective';
import CardListItem from './CardListItem.vue'; import CardListItem from './CardListItem.vue';
@Component({ export default defineComponent({
components: { components: {
CardListItem, CardListItem,
Responsive, SizeObserver,
}, },
}) setup() {
export default class CardList extends StatefulVue { const { currentState, onStateChange } = inject(useCollectionStateKey)();
public width = 0;
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; function onSelected(categoryId: number, isExpanded: boolean) {
activeCategoryId.value = isExpanded ? categoryId : undefined;
public created() {
document.addEventListener('click', this.outsideClickListener);
} }
public destroyed() { onStateChange(() => {
document.removeEventListener('click', this.outsideClickListener); collapseAllCards();
} }, { immediate: true });
public onSelected(categoryId: number, isExpanded: boolean) { const outsideClickListener = (event: PointerEvent): void => {
this.activeCategoryId = isExpanded ? categoryId : undefined; if (areAllCardsCollapsed()) {
return;
} }
const element = document.querySelector(`[data-category="${activeCategoryId.value}"]`);
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void { const target = event.target as Element;
this.setCategories(newState.collection.actions); if (element && !element.contains(target)) {
this.activeCategoryId = undefined; onOutsideOfActiveCardClicked(target);
} }
};
private setCategories(categories: ReadonlyArray<ICategory>): void { onMounted(() => {
this.categoryIds = categories.map((category) => category.id); document.addEventListener('click', outsideClickListener);
} });
private onOutsideOfActiveCardClicked(clickedElement: Element): void { onUnmounted(() => {
document.removeEventListener('click', outsideClickListener);
});
function onOutsideOfActiveCardClicked(clickedElement: Element): void {
if (isClickable(clickedElement) || hasDirective(clickedElement)) { if (isClickable(clickedElement) || hasDirective(clickedElement)) {
return; return;
} }
this.collapseAllCards(); collapseAllCards();
if (hasDirective(clickedElement)) {
return;
}
this.activeCategoryId = null;
} }
private outsideClickListener(event: PointerEvent) { function areAllCardsCollapsed(): boolean {
if (this.areAllCardsCollapsed()) { return !activeCategoryId.value;
return;
}
const element = document.querySelector(`[data-category="${this.activeCategoryId}"]`);
const target = event.target as Element;
if (element && !element.contains(target)) {
this.onOutsideOfActiveCardClicked(target);
}
} }
private collapseAllCards(): void { function collapseAllCards(): void {
this.activeCategoryId = undefined; activeCategoryId.value = undefined;
} }
private areAllCardsCollapsed(): boolean { return {
return !this.activeCategoryId; width,
} categoryIds,
} activeCategoryId,
onSelected,
};
},
});
function isClickable(element: Element) { function isClickable(element: Element) {
const cursorName = window.getComputedStyle(element).cursor; const cursorName = window.getComputedStyle(element).cursor;

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
class="card" class="card"
v-on:click="onSelected(!isExpanded)" v-on:click="isExpanded = !isExpanded"
v-bind:class="{ v-bind:class="{
'is-collapsed': !isExpanded, 'is-collapsed': !isExpanded,
'is-inactive': activeCategoryId && activeCategoryId != categoryId, 'is-inactive': activeCategoryId && activeCategoryId != categoryId,
@@ -40,7 +40,7 @@
<div class="card__expander__close-button"> <div class="card__expander__close-button">
<font-awesome-icon <font-awesome-icon
:icon="['fas', 'times']" :icon="['fas', 'times']"
v-on:click="onSelected(false)" v-on:click="collapse()"
/> />
</div> </div>
</div> </div>
@@ -49,74 +49,98 @@
<script lang="ts"> <script lang="ts">
import { import {
Component, Prop, Watch, Emit, defineComponent, ref, watch, computed,
} from 'vue-property-decorator'; inject,
} from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue'; 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: { components: {
ScriptsTree, ScriptsTree,
}, },
}) props: {
export default class CardListItem extends StatefulVue { categoryId: {
@Prop() public categoryId!: number; 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; function collapse() {
isExpanded.value = false;
}
public areAllChildrenSelected = false; onStateChange(async (state) => {
events.unsubscribeAll();
public async mounted() { events.register(state.selection.changed.on(
const context = await this.getCurrentContext(); () => updateSelectionIndicators(props.categoryId),
this.events.register(context.state.selection.changed.on(
() => this.updateSelectionIndicators(this.categoryId),
)); ));
await this.updateState(this.categoryId); await updateSelectionIndicators(props.categoryId);
}, { immediate: true });
watch(
() => props.categoryId,
(categoryId) => updateSelectionIndicators(categoryId),
);
async function scrollToCard() {
await sleep(400); // wait a bit to allow GUI to render the expanded card
cardElement.value.scrollIntoView({ behavior: 'smooth' });
} }
@Emit('selected') async function updateSelectionIndicators(categoryId: number) {
public onSelected(isExpanded: boolean) { const category = currentState.value.collection.findCategory(categoryId);
this.isExpanded = isExpanded; const { selection } = currentState.value;
isAnyChildSelected.value = category ? selection.isAnySelected(category) : false;
areAllChildrenSelected.value = category ? selection.areAllSelected(category) : false;
} }
@Watch('activeCategoryId') return {
public async onActiveCategoryChanged(value?: number) { cardTitle,
this.isExpanded = value === this.categoryId; isExpanded,
} isAnyChildSelected,
areAllChildrenSelected,
@Watch('isExpanded') cardElement,
public async onExpansionChanged(newValue: number, oldValue: number) { collapse,
if (!oldValue && newValue) { };
await new Promise((resolve) => { setTimeout(resolve, 400); }); },
const focusElement = this.$refs.cardElement as HTMLElement; });
focusElement.scrollIntoView({ behavior: 'smooth' });
}
}
@Watch('categoryId')
public async updateState(value?: number) {
const context = await this.getCurrentContext();
const category = !value ? undefined : context.state.collection.findCategory(value);
this.cardTitle = category ? category.name : undefined;
await this.updateSelectionIndicators(value);
}
protected handleCollectionState(): void { /* do nothing */ }
private async updateSelectionIndicators(categoryId: number) {
const context = await this.getCurrentContext();
const { selection } = context.state;
const category = context.state.collection.findCategory(categoryId);
this.isAnyChildSelected = category ? selection.isAnySelected(category) : false;
this.areAllChildrenSelected = category ? selection.areAllSelected(category) : false;
}
}
</script> </script>

View File

@@ -1,4 +1,4 @@
import { DirectiveOptions } from 'vue'; import { ObjectDirective } from 'vue';
const attributeName = 'data-interaction-does-not-collapse'; const attributeName = 'data-interaction-does-not-collapse';
@@ -10,8 +10,8 @@ export function hasDirective(el: Element): boolean {
return !!parent; return !!parent;
} }
export const NonCollapsing: DirectiveOptions = { export const NonCollapsing: ObjectDirective<HTMLElement> = {
inserted(el: HTMLElement) { inserted(el: HTMLElement) { // In Vue 3, use "mounted"
el.setAttribute(attributeName, ''); el.setAttribute(attributeName, '');
}, },
}; };

View File

@@ -1,15 +1,15 @@
import { ICategory, IScript } from '@/domain/ICategory'; import { ICategory, IScript } from '@/domain/ICategory';
import { ICategoryCollection } from '@/domain/ICategoryCollection'; 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); return createCategoryNodes(collection.actions);
} }
export function parseSingleCategory( export function parseSingleCategory(
categoryId: number, categoryId: number,
collection: ICategoryCollection, collection: ICategoryCollection,
): INode[] | undefined { ): INodeContent[] | undefined {
const category = collection.findCategory(categoryId); const category = collection.findCategory(categoryId);
if (!category) { if (!category) {
throw new Error(`Category with id ${categoryId} does not exist`); throw new Error(`Category with id ${categoryId} does not exist`);
@@ -34,7 +34,7 @@ export function getCategoryNodeId(category: ICategory): string {
function parseCategoryRecursively( function parseCategoryRecursively(
parentCategory: ICategory, parentCategory: ICategory,
): INode[] { ): INodeContent[] {
if (!parentCategory) { if (!parentCategory) {
throw new Error('parentCategory is undefined'); 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 || []) return (scripts || [])
.map((script) => convertScriptToNode(script)); .map((script) => convertScriptToNode(script));
} }
function createCategoryNodes(categories: ReadonlyArray<ICategory>): INode[] { function createCategoryNodes(categories: ReadonlyArray<ICategory>): INodeContent[] {
return (categories || []) return (categories || [])
.map((category) => ({ category, children: parseCategoryRecursively(category) })) .map((category) => ({ category, children: parseCategoryRecursively(category) }))
.map((data) => convertCategoryToNode(data.category, data.children)); .map((data) => convertCategoryToNode(data.category, data.children));
@@ -57,8 +57,8 @@ function createCategoryNodes(categories: ReadonlyArray<ICategory>): INode[] {
function convertCategoryToNode( function convertCategoryToNode(
category: ICategory, category: ICategory,
children: readonly INode[], children: readonly INodeContent[],
): INode { ): INodeContent {
return { return {
id: getCategoryNodeId(category), id: getCategoryNodeId(category),
type: NodeType.Category, type: NodeType.Category,
@@ -69,7 +69,7 @@ function convertCategoryToNode(
}; };
} }
function convertScriptToNode(script: IScript): INode { function convertScriptToNode(script: IScript): INodeContent {
return { return {
id: getScriptNodeId(script), id: getScriptNodeId(script),
type: NodeType.Script, type: NodeType.Script,

View File

@@ -14,11 +14,13 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Watch } from 'vue-property-decorator'; import {
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue'; defineComponent, watch, ref, inject,
} from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory'; 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 { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { import {
@@ -26,96 +28,118 @@ import {
getScriptId, getScriptId,
} from './ScriptNodeParser'; } from './ScriptNodeParser';
import SelectableTree from './SelectableTree/SelectableTree.vue'; 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'; import { INodeSelectedEvent } from './SelectableTree/INodeSelectedEvent';
@Component({ export default defineComponent({
props: {
categoryId: {
type: Number,
default: undefined,
},
},
components: { components: {
SelectableTree, SelectableTree,
}, },
}) setup(props) {
export default class ScriptsTree extends StatefulVue { const {
@Prop() public categoryId?: number; 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; watch(
() => props.categoryId,
async (newCategoryId) => { await setNodes(newCategoryId); },
{ immediate: true },
);
private filtered?: IFilterResult; onStateChange((state) => {
setCurrentFilter(state.filter.currentFilter);
if (!props.categoryId) {
nodes.value = parseAllCategories(state.collection);
}
events.unsubscribeAll();
subscribeToState(state);
}, { immediate: true });
public async toggleNodeSelection(event: INodeSelectedEvent) { function toggleNodeSelection(event: INodeSelectedEvent) {
const context = await this.getCurrentContext(); modifyCurrentState((state) => {
switch (event.node.type) { switch (event.node.type) {
case NodeType.Category: case NodeType.Category:
toggleCategoryNodeSelection(event, context.state); toggleCategoryNodeSelection(event, state);
break; break;
case NodeType.Script: case NodeType.Script:
toggleScriptNodeSelection(event, context.state); toggleScriptNodeSelection(event, state);
break; break;
default: default:
throw new Error(`Unknown node type: ${event.node.id}`); throw new Error(`Unknown node type: ${event.node.id}`);
} }
});
} }
@Watch('categoryId', { immediate: true }) function filterPredicate(node: INodeContent): boolean {
public async setNodes(categoryId?: number) { return containsScript(node, filtered.scriptMatches)
const context = await this.getCurrentContext(); || containsCategory(node, filtered.categoryMatches);
if (categoryId) {
this.nodes = parseSingleCategory(categoryId, context.state.collection);
} else {
this.nodes = parseAllCategories(context.state.collection);
} }
this.selectedNodeIds = context.state.selection.selectedScripts
async function setNodes(categoryId?: number) {
if (categoryId) {
nodes.value = parseSingleCategory(categoryId, currentState.value.collection);
} else {
nodes.value = parseAllCategories(currentState.value.collection);
}
selectedNodeIds.value = currentState.value.selection.selectedScripts
.map((selected) => getScriptNodeId(selected.script)); .map((selected) => getScriptNodeId(selected.script));
} }
public filterPredicate(node: INode): boolean { function subscribeToState(state: IReadOnlyCategoryCollectionState) {
return this.filtered.scriptMatches events.register(
.some((script: IScript) => node.id === getScriptNodeId(script)) state.selection.changed.on((scripts) => handleSelectionChanged(scripts)),
|| this.filtered.categoryMatches state.filter.filterChanged.on((event) => {
.some((category: ICategory) => node.id === getCategoryNodeId(category)); event.visit({
} onApply: (filter) => {
filterText.value = filter.query;
protected async handleCollectionState(newState: ICategoryCollectionState) { filtered = filter;
this.setCurrentFilter(newState.filter.currentFilter); },
if (!this.categoryId) { onClear: () => {
this.nodes = parseAllCategories(newState.collection); filterText.value = '';
} },
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),
); );
} }
private setCurrentFilter(currentFilter: IFilterResult | undefined) { function setCurrentFilter(currentFilter: IFilterResult | undefined) {
if (!currentFilter) { filtered = currentFilter;
this.handleFilterRemoved(); filterText.value = currentFilter?.query || '';
} else {
this.handleFiltered(currentFilter);
}
} }
private handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void { function handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
this.selectedNodeIds = selectedScripts selectedNodeIds.value = selectedScripts
.map((node) => node.id); .map((node) => node.id);
} }
private handleFilterRemoved() { return {
this.filterText = ''; nodes,
selectedNodeIds,
filterText,
toggleNodeSelection,
filterPredicate,
};
},
});
function containsScript(expected: INodeContent, scripts: readonly IScript[]) {
return scripts.some((existing: IScript) => expected.id === getScriptNodeId(existing));
} }
private handleFiltered(result: IFilterResult) { function containsCategory(expected: INodeContent, categories: readonly ICategory[]) {
this.filterText = result.query; return categories.some((existing: ICategory) => expected.id === getCategoryNodeId(existing));
this.filtered = result;
}
} }
function toggleCategoryNodeSelection( function toggleCategoryNodeSelection(
@@ -144,7 +168,3 @@ function toggleScriptNodeSelection(
} }
} }
</script> </script>
<style scoped lang="scss">
</style>

View File

@@ -1,6 +1,6 @@
import { INode } from './Node/INode'; import { INodeContent } from './Node/INodeContent';
export interface INodeSelectedEvent { export interface INodeSelectedEvent {
isSelected: boolean; isSelected: boolean;
node: INode; node: INodeContent;
} }

View File

@@ -1,6 +1,5 @@
declare module 'liquor-tree' { declare module 'liquor-tree' {
import { PluginObject } from 'vue'; import { PluginObject } from 'vue';
import { VueClass } from 'vue-class-component/lib/declarations';
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Tree.js // https://github.com/amsik/liquor-tree/blob/master/src/lib/Tree.js
export interface ILiquorTree { export interface ILiquorTree {
@@ -70,6 +69,6 @@ declare module 'liquor-tree' {
matcher(query: string, node: ILiquorTreeExistingNode): boolean; matcher(query: string, node: ILiquorTreeExistingNode): boolean;
} }
const LiquorTree: PluginObject<Vue> & VueClass<Vue>; const LiquorTree: PluginObject<Vue>;
export default LiquorTree; export default LiquorTree;
} }

View File

@@ -1,11 +1,11 @@
import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree'; import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree';
import { INode } from '../../Node/INode'; import { INodeContent } from '../../Node/INodeContent';
import { convertExistingToNode } from './NodeTranslator'; import { convertExistingToNode } from './NodeTranslator';
export type FilterPredicate = (node: INode) => boolean; export type FilterPredicate = (node: INodeContent) => boolean;
export class NodePredicateFilter implements ILiquorTreeFilter { 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) { constructor(private readonly filterPredicate: FilterPredicate) {
if (!filterPredicate) { if (!filterPredicate) {

View File

@@ -1,5 +1,5 @@
import { ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree'; import { ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree';
import { NodeType } from '../../Node/INode'; import { NodeType } from '../../Node/INodeContent';
export function getNewState( export function getNewState(
node: ILiquorTreeNode, node: ILiquorTreeNode,

View File

@@ -1,9 +1,9 @@
import { ILiquorTreeNewNode, ILiquorTreeExistingNode } from 'liquor-tree'; 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 // 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'); } if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); }
return { return {
id: liquorTreeNode.id, 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'); } if (!node) { throw new Error('node is undefined'); }
return { return {
id: node.id, id: node.id,

View File

@@ -27,21 +27,29 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'; import { defineComponent, ref, PropType } from 'vue';
import DocumentationText from './DocumentationText.vue'; import DocumentationText from './DocumentationText.vue';
import ToggleDocumentationButton from './ToggleDocumentationButton.vue'; import ToggleDocumentationButton from './ToggleDocumentationButton.vue';
@Component({ export default defineComponent({
components: { components: {
DocumentationText, DocumentationText,
ToggleDocumentationButton, ToggleDocumentationButton,
}, },
}) props: {
export default class Documentation extends Vue { docs: {
@Prop() public docs!: readonly string[]; type: Array as PropType<readonly string[]>,
required: true,
},
},
setup() {
const isExpanded = ref(false);
public isExpanded = false; return {
} isExpanded,
};
},
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -7,27 +7,38 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'; import { defineComponent, PropType, computed } from 'vue';
import { createRenderer } from './MarkdownRenderer'; import { createRenderer } from './MarkdownRenderer';
@Component export default defineComponent({
export default class DocumentationText extends Vue { props: {
@Prop() public docs: readonly string[]; 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 { const renderer = createRenderer();
if (!this.docs || this.docs.length === 0) {
function renderText(docs: readonly string[] | undefined): string {
if (!docs || docs.length === 0) {
return ''; return '';
} }
if (this.docs.length === 1) { if (docs.length === 1) {
return this.renderer.render(this.docs[0]); return renderer.render(docs[0]);
} }
const bulletpoints = this.docs const bulletpoints = docs
.map((doc) => renderAsMarkdownListItem(doc)) .map((doc) => renderAsMarkdownListItem(doc))
.join('\n'); .join('\n');
return this.renderer.render(bulletpoints); return renderer.render(bulletpoints);
}
} }
function renderAsMarkdownListItem(content: string): string { function renderAsMarkdownListItem(content: string): string {
@@ -39,7 +50,6 @@ function renderAsMarkdownListItem(content: string): string {
.map((line) => `\n ${line}`) .map((line) => `\n ${line}`)
.join()}`; .join()}`;
} }
</script> </script>
<style lang="scss"> /* Not scoped due to element styling such as "a". */ <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; list-style: square;
} }
} }
</style> </style>

View File

@@ -124,33 +124,50 @@ function isGoodPathPart(part: string): boolean {
&& !/^[0-9a-f]{40}$/.test(part); // Git SHA (e.g. GitHub links) && !/^[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) { function openUrlsInNewTab(md: MarkdownIt) {
// https://github.com/markdown-it/markdown-it/blob/12.2.0/docs/architecture.md#renderer // 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) => { md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx]; 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); 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]; const renderer = md.renderer.rules[ruleName];
if (renderer) { return renderer || defaultRenderer;
return renderer; function defaultRenderer(tokens, idx, options, _env, self) {
}
return (tokens, idx, options, _env, self) => {
return self.renderToken(tokens, idx, options); return self.renderToken(tokens, idx, options);
}; }
} }
function getTokenAttributeValue(token: Token, attributeName: string): string | undefined { function getAttribute(token: Token, name: string): string | undefined {
const attributeIndex = token.attrIndex(attributeName); const attributeIndex = token.attrIndex(name);
if (attributeIndex < 0) { if (attributeIndex < 0) {
return undefined; return undefined;
} }
const value = token.attrs[attributeIndex][1]; const value = token.attrs[attributeIndex][1];
return value; 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;
}

View File

@@ -2,7 +2,7 @@
<a <a
class="button" class="button"
target="_blank" target="_blank"
v-bind:class="{ 'button-on': this.isOn }" v-bind:class="{ 'button-on': isOn }"
v-on:click.stop v-on:click.stop
v-on:click="toggle()" v-on:click="toggle()"
> >
@@ -11,22 +11,31 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-property-decorator'; import { defineComponent, ref } from 'vue';
@Component export default defineComponent({
export default class ToggleDocumentationButton extends Vue { emits: [
public isOn = false; 'show',
'hide',
],
setup(_, { emit }) {
const isOn = ref(false);
public toggle() { function toggle() {
this.isOn = !this.isOn; isOn.value = !isOn.value;
if (this.isOn) { if (isOn.value) {
this.$emit('show'); emit('show');
} else { } else {
this.$emit('hide'); emit('hide');
}
} }
} }
return {
isOn,
toggle,
};
},
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -3,11 +3,11 @@ export enum NodeType {
Category, Category,
} }
export interface INode { export interface INodeContent {
readonly id: string; readonly id: string;
readonly text: string; readonly text: string;
readonly isReversible: boolean; readonly isReversible: boolean;
readonly docs: ReadonlyArray<string>; readonly docs: ReadonlyArray<string>;
readonly children?: ReadonlyArray<INode>; readonly children?: ReadonlyArray<INodeContent>;
readonly type: NodeType; readonly type: NodeType;
} }

View File

@@ -1,30 +1,33 @@
<template> <template>
<Documentable :docs="this.data.docs"> <DocumentableNode :docs="data.docs">
<div id="node"> <div id="node">
<div class="item text">{{ this.data.text }}</div> <div class="item text">{{ data.text }}</div>
<RevertToggle <RevertToggle
class="item" class="item"
v-if="data.isReversible" v-if="data.isReversible"
:node="data" /> :node="data" />
</div> </div>
</Documentable> </DocumentableNode>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { defineComponent, PropType } from 'vue';
import { INode } from './INode'; import { INodeContent } from './INodeContent';
import RevertToggle from './RevertToggle.vue'; import RevertToggle from './RevertToggle.vue';
import Documentable from './Documentation/Documentable.vue'; import DocumentableNode from './Documentation/DocumentableNode.vue';
@Component({ export default defineComponent({
components: { components: {
RevertToggle, RevertToggle,
Documentable, DocumentableNode,
}, },
}) props: {
export default class Node extends Vue { data: {
@Prop() public data: INode; type: Object as PropType<INodeContent>,
} required: true,
},
},
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -1,162 +1,86 @@
<template> <template>
<div class="checkbox-switch"> <ToggleSwitch
<input v-model="isChecked"
type="checkbox" :stopClickPropagation="true"
class="input-checkbox" :label="'revert'"
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>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Watch } from 'vue-property-decorator'; import {
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue'; PropType, defineComponent, ref, watch,
computed, inject,
} from 'vue';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; 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 { IReverter } from './Reverter/IReverter';
import { INode } from './INode'; import { INodeContent } from './INodeContent';
import { getReverter } from './Reverter/ReverterFactory'; import { getReverter } from './Reverter/ReverterFactory';
import ToggleSwitch from './ToggleSwitch.vue';
@Component export default defineComponent({
export default class RevertToggle extends StatefulVue { components: {
@Prop() public node: INode; 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) { watch(
const context = await this.getCurrentContext(); () => props.node,
this.handler = getReverter(node, context.state.collection); async (node) => { await onNodeChanged(node); },
{ immediate: true },
);
onStateChange((newState) => {
updateRevertStatusFromState(newState.selection.selectedScripts);
events.unsubscribeAll();
events.register(
newState.selection.changed.on((scripts) => updateRevertStatusFromState(scripts)),
);
}, { immediate: true });
async function onNodeChanged(node: INodeContent) {
handler = getReverter(node, currentState.value.collection);
updateRevertStatusFromState(currentState.value.selection.selectedScripts);
} }
public async onRevertToggled() { async function updateRevertStatusFromState(scripts: ReadonlyArray<SelectedScript>) {
const context = await this.getCurrentContext(); isReverted.value = handler?.getState(scripts) ?? false;
this.handler.selectWithRevertState(this.isReverted, context.state.selection);
} }
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void { function syncReversionStatusWithState(value: boolean) {
this.updateStatus(newState.selection.selectedScripts); if (value === isReverted.value) {
this.events.unsubscribeAll(); return;
this.events.register(newState.selection.changed.on((scripts) => this.updateStatus(scripts))); }
modifyCurrentState((state) => {
handler.selectWithRevertState(value, state.selection);
});
} }
private updateStatus(scripts: ReadonlyArray<SelectedScript>) { const isChecked = computed({
this.isReverted = this.handler.getState(scripts); get() {
} return isReverted.value;
} },
set: (value: boolean) => {
syncReversionStatusWithState(value);
},
});
return {
isChecked,
};
},
});
</script> </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>

View File

@@ -1,10 +1,10 @@
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { INode, NodeType } from '../INode'; import { INodeContent, NodeType } from '../INodeContent';
import { IReverter } from './IReverter'; import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter'; import { ScriptReverter } from './ScriptReverter';
import { CategoryReverter } from './CategoryReverter'; import { CategoryReverter } from './CategoryReverter';
export function getReverter(node: INode, collection: ICategoryCollection): IReverter { export function getReverter(node: INodeContent, collection: ICategoryCollection): IReverter {
switch (node.type) { switch (node.type) {
case NodeType.Category: case NodeType.Category:
return new CategoryReverter(node.id, collection); return new CategoryReverter(node.id, collection);

View File

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

View File

@@ -1,17 +1,17 @@
<template> <template>
<span> <span>
<span v-if="initialLiquorTreeNodes != null && initialLiquorTreeNodes.length > 0"> <span v-if="initialLiquorTreeNodes?.length > 0">
<tree <LiquorTree
:options="liquorTreeOptions" :options="liquorTreeOptions"
:data="initialLiquorTreeNodes" :data="initialLiquorTreeNodes"
v-on:node:checked="nodeSelected($event)" @node:checked="nodeSelected($event)"
v-on:node:unchecked="nodeSelected($event)" @node:unchecked="nodeSelected($event)"
ref="treeElement" ref="liquorTree"
> >
<span class="tree-text" slot-scope="{ node }"> <span class="tree-text" slot-scope="{ node }">
<Node :data="convertExistingToNode(node)" /> <NodeContent :data="convertExistingToNode(node)" />
</span> </span>
</tree> </LiquorTree>
</span> </span>
<span v-else>Nooo 😢</span> <span v-else>Nooo 😢</span>
</span> </span>
@@ -19,76 +19,99 @@
<script lang="ts"> <script lang="ts">
import { import {
Component, Prop, Vue, Watch, PropType, defineComponent, ref, watch,
} from 'vue-property-decorator'; } from 'vue';
import LiquorTree, { import LiquorTree, {
ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree, ILiquorTreeNode, ILiquorTreeNodeState, ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree, ILiquorTreeNode, ILiquorTreeNodeState,
} from 'liquor-tree'; } from 'liquor-tree';
import { sleep } from '@/infrastructure/Threading/AsyncSleep'; import { sleep } from '@/infrastructure/Threading/AsyncSleep';
import Node from './Node/Node.vue'; import NodeContent from './Node/NodeContent.vue';
import { INode } from './Node/INode'; import { INodeContent } from './Node/INodeContent';
import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeWrapper/NodeTranslator'; import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeWrapper/NodeTranslator';
import { INodeSelectedEvent } from './INodeSelectedEvent'; import { INodeSelectedEvent } from './INodeSelectedEvent';
import { getNewState } from './LiquorTree/NodeWrapper/NodeStateUpdater'; import { getNewState } from './LiquorTree/NodeWrapper/NodeStateUpdater';
import { LiquorTreeOptions } from './LiquorTree/LiquorTreeOptions'; import { LiquorTreeOptions } from './LiquorTree/LiquorTreeOptions';
import { FilterPredicate, NodePredicateFilter } from './LiquorTree/NodeWrapper/NodePredicateFilter'; 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: { components: {
LiquorTree, LiquorTree,
Node, NodeContent,
}, },
}) props: {
export default class SelectableTree extends Vue { // Stateless to make it easier to switch out filterPredicate: {
@Prop() public filterPredicate?: FilterPredicate; type: Function as PropType<FilterPredicate>,
default: undefined,
@Prop() public filterText?: string; },
filterText: {
@Prop() public selectedNodeIds?: ReadonlyArray<string>; type: String,
default: undefined,
@Prop() public initialNodes?: ReadonlyArray<INode>; },
selectedNodeIds: {
public initialLiquorTreeNodes?: ILiquorTreeNewNode[] = null; type: Array as PropType<ReadonlyArray<string>>,
default: undefined,
public liquorTreeOptions = new LiquorTreeOptions( },
new NodePredicateFilter((node) => this.filterPredicate(node)), initialNodes: {
type: Array as PropType<ReadonlyArray<INodeContent>>,
default: undefined,
},
},
setup(props, { emit }) {
const liquorTree = ref< { tree: ILiquorTree }>();
const initialLiquorTreeNodes = ref<ReadonlyArray<ILiquorTreeNewNode>>();
const liquorTreeOptions = new LiquorTreeOptions(
new NodePredicateFilter((node) => props.filterPredicate(node)),
); );
public convertExistingToNode = convertExistingToNode; function nodeSelected(node: ILiquorTreeExistingNode) {
public nodeSelected(node: ILiquorTreeExistingNode) {
const event: INodeSelectedEvent = { const event: INodeSelectedEvent = {
node: convertExistingToNode(node), node: convertExistingToNode(node),
isSelected: node.states.checked, isSelected: node.states.checked,
}; };
this.$emit('nodeSelected', event); emit('nodeSelected', event);
} }
@Watch('initialNodes', { immediate: true }) watch(
public async updateNodes(nodes: readonly INode[]) { () => 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) { if (!nodes) {
throw new Error('missing initial nodes'); throw new Error('missing initial nodes');
} }
const initialNodes = nodes.map((node) => toNewLiquorTreeNode(node)); const initialNodes = nodes.map((node) => toNewLiquorTreeNode(node));
if (this.selectedNodeIds) { if (props.selectedNodeIds) {
recurseDown( recurseDown(
initialNodes, initialNodes,
(node) => { (node) => {
node.state = updateState(node.state, node, this.selectedNodeIds); node.state = updateState(node.state, node, props.selectedNodeIds);
}, },
); );
} }
this.initialLiquorTreeNodes = initialNodes; initialLiquorTreeNodes.value = initialNodes;
const api = await this.getLiquorTreeApi(); const api = await getLiquorTreeApi();
// We need to set the model manually on each update because liquor tree is not reactive to data api.setModel(initialLiquorTreeNodes.value);
// changes after its initialization.
api.setModel(this.initialLiquorTreeNodes);
} }
@Watch('filterText', { immediate: true }) async function setFilterText(filterText?: string) {
public async updateFilterText(filterText?: string) { const api = await getLiquorTreeApi();
const api = await this.getLiquorTreeApi();
if (!filterText) { if (!filterText) {
api.clearFilter(); api.clearFilter();
} else { } else {
@@ -96,12 +119,11 @@ export default class SelectableTree extends Vue { // Stateless to make it easier
} }
} }
@Watch('selectedNodeIds') async function setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) {
public async setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) {
if (!selectedNodeIds) { if (!selectedNodeIds) {
throw new Error('Selected recurseDown nodes are undefined'); throw new Error('Selected recurseDown nodes are undefined');
} }
const tree = await this.getLiquorTreeApi(); const tree = await getLiquorTreeApi();
tree.recurseDown( tree.recurseDown(
(node) => { (node) => {
node.states = updateState(node.states, node, selectedNodeIds); node.states = updateState(node.states, node, selectedNodeIds);
@@ -109,20 +131,28 @@ export default class SelectableTree extends Vue { // Stateless to make it easier
); );
} }
private async getLiquorTreeApi(): Promise<ILiquorTree> { async function getLiquorTreeApi(): Promise<ILiquorTree> {
const accessor = (): ILiquorTree => { const tree = await tryUntilDefined(
const uiElement = this.$refs.treeElement; () => liquorTree.value?.tree,
type TreeElement = typeof uiElement & { tree: ILiquorTree }; 5,
return uiElement ? (uiElement as TreeElement).tree : undefined; 20,
}; );
const treeElement = await tryUntilDefined(accessor, 5, 20); // Wait for it to render if (!tree) {
if (!treeElement) {
throw Error('Referenced tree element cannot be found. Perhaps it\'s not yet rendered?'); throw Error('Referenced tree element cannot be found. Perhaps it\'s not yet rendered?');
} }
return treeElement; return tree;
}
} }
return {
liquorTreeOptions,
initialLiquorTreeNodes,
convertExistingToNode,
nodeSelected,
liquorTree,
};
},
});
function updateState( function updateState(
old: ILiquorTreeNodeState, old: ILiquorTreeNodeState,
node: ILiquorTreeNode, node: ILiquorTreeNode,
@@ -162,3 +192,4 @@ async function tryUntilDefined<T>(
return value; return value;
} }
</script> </script>
./Node/INodeContent

View File

@@ -9,7 +9,7 @@
<div v-else> <!-- Searching --> <div v-else> <!-- Searching -->
<div class="search"> <div class="search">
<div class="search__query"> <div class="search__query">
<div>Searching for "{{this.searchQuery | threeDotsTrim }}"</div> <div>Searching for "{{ trimmedSearchQuery }}"</div>
<div class="search__query__close-button"> <div class="search__query__close-button">
<font-awesome-icon <font-awesome-icon
:icon="['fas', 'times']" :icon="['fas', 'times']"
@@ -17,10 +17,10 @@
</div> </div>
</div> </div>
<div v-if="!searchHasMatches" class="search-no-matches"> <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> <div>
Feel free to extend the scripts 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> </div>
</div> </div>
@@ -32,75 +32,83 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop } from 'vue-property-decorator'; import {
import TheGrouper from '@/presentation/components/Scripts/Menu/View/TheViewChanger.vue'; defineComponent, PropType, ref, computed,
inject,
} from 'vue';
import { useApplicationKey, useCollectionStateKey } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue'; import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.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 { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ApplicationFactory } from '@/application/ApplicationFactory';
/** Shows content of single category or many categories */ export default defineComponent({
@Component({
components: { components: {
TheGrouper,
ScriptsTree, ScriptsTree,
CardList, CardList,
}, },
filters: { props: {
threeDotsTrim(query: string) { 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; const threshold = 30;
if (query.length <= threshold - 3) { if (query.length <= threshold - 3) {
return query; 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;
}, },
}) });
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();
}), }),
); );
} }
}
return {
repositoryUrl,
trimmedSearchQuery,
isSearching,
searchHasMatches,
clearSearchQuery,
ViewType,
};
},
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -161,5 +169,4 @@ $margin-inner: 4px;
} }
} }
} }
</style> </style>

View File

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

View 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.

View 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,
};
}

View File

@@ -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,
};
}

View File

@@ -0,0 +1,8 @@
import { IEnvironment } from '@/application/Environment/IEnvironment';
export function useEnvironment(environment: IEnvironment) {
if (!environment) {
throw new Error('missing environment');
}
return environment;
}

View File

@@ -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,
};
}

View File

@@ -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;
}
});
}

View File

@@ -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);
});
}

View File

@@ -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();
});
}

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

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

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

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

View File

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

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

View File

@@ -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);
}
}

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

View File

@@ -18,28 +18,35 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-property-decorator'; import { defineComponent, inject } from 'vue';
import { Environment } from '@/application/Environment/Environment';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { useEnvironmentKey } from '@/presentation/injectionSymbols';
import DownloadUrlListItem from './DownloadUrlListItem.vue'; import DownloadUrlListItem from './DownloadUrlListItem.vue';
@Component({ const supportedOperativeSystems: readonly OperatingSystem[] = [
components: { DownloadUrlListItem }, OperatingSystem.Windows,
}) OperatingSystem.Linux,
export default class DownloadUrlList extends Vue { OperatingSystem.macOS,
public readonly supportedDesktops: ReadonlyArray<OperatingSystem>; ];
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() { const hasCurrentOsDesktopVersion = supportedOperativeSystems.includes(currentOs);
super();
const supportedOperativeSystems = [ return {
OperatingSystem.Windows, OperatingSystem.Linux, OperatingSystem.macOS]; supportedDesktops,
const currentOs = Environment.CurrentEnvironment.os; hasCurrentOsDesktopVersion,
this.supportedDesktops = supportedOperativeSystems.sort((os) => (os === currentOs ? 0 : 1)); };
this.hasCurrentOsDesktopVersion = supportedOperativeSystems.includes(currentOs); },
} });
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -11,42 +11,47 @@
<script lang="ts"> <script lang="ts">
import { import {
Component, Prop, Watch, Vue, defineComponent, PropType, computed,
} from 'vue-property-decorator'; inject,
import { Environment } from '@/application/Environment/Environment'; } from 'vue';
import { useApplicationKey, useEnvironmentKey } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { ApplicationFactory } from '@/application/ApplicationFactory';
@Component export default defineComponent({
export default class DownloadUrlListItem extends Vue { props: {
@Prop() public operatingSystem!: OperatingSystem; 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() { return {
await this.onOperatingSystemChanged(this.operatingSystem); downloadUrl,
} operatingSystemName,
isCurrentOs,
@Watch('operatingSystem') hasCurrentOsDesktopVersion,
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);
}
function hasDesktopVersion(os: OperatingSystem): boolean { function hasDesktopVersion(os: OperatingSystem): boolean {
return os === OperatingSystem.Windows return os === OperatingSystem.Windows

View File

@@ -19,13 +19,13 @@
<div class="line__emoji">🤖</div> <div class="line__emoji">🤖</div>
<div> <div>
All transparent: Deployed automatically from the master branch 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> </div>
<div v-if="!isDesktop" class="line"> <div v-if="!isDesktop" class="line">
<div class="line__emoji">📈</div> <div class="line__emoji">📈</div>
<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. 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. You can download the offline version if you don't want any CDN data collection.
</div> </div>
@@ -35,36 +35,30 @@
<div> <div>
As almost no data is collected, the application gets better As almost no data is collected, the application gets better
only with your active feedback. 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>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-property-decorator'; import { defineComponent, computed, inject } from 'vue';
import { Environment } from '@/application/Environment/Environment'; import { useApplicationKey, useEnvironmentKey } from '@/presentation/injectionSymbols';
import { ApplicationFactory } from '@/application/ApplicationFactory';
import { IApplication } from '@/domain/IApplication';
@Component export default defineComponent({
export default class PrivacyPolicy extends Vue { setup() {
public repositoryUrl = ''; 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; return {
repositoryUrl,
public async created() { feedbackUrl,
const app = await ApplicationFactory.Current.getApp(); isDesktop,
this.initialize(app); };
} },
});
private initialize(app: IApplication) {
const { info } = app;
this.repositoryUrl = info.repositoryWebUrl;
this.feedbackUrl = info.feedbackUrl;
}
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -5,7 +5,7 @@
<span v-if="isDesktop" class="footer__section__item"> <span v-if="isDesktop" class="footer__section__item">
<font-awesome-icon class="icon" :icon="['fas', 'globe']" /> <font-awesome-icon class="icon" :icon="['fas', 'globe']" />
<span> <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> </span>
<span v-else class="footer__section__item"> <span v-else class="footer__section__item">
@@ -14,76 +14,82 @@
</div> </div>
<div class="footer__section"> <div class="footer__section">
<div class="footer__section__item"> <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']" /> <font-awesome-icon class="icon" :icon="['far', 'smile']" />
<span>Feedback</span> <span>Feedback</span>
</a> </a>
</div> </div>
<div class="footer__section__item"> <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']" /> <font-awesome-icon class="icon" :icon="['fab', 'github']" />
<span>Source Code</span> <span>Source Code</span>
</a> </a>
</div> </div>
<div class="footer__section__item"> <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']" /> <font-awesome-icon class="icon" :icon="['fas', 'tag']" />
<span>v{{ version }}</span> <span>v{{ version }}</span>
</a> </a>
</div> </div>
<div class="footer__section__item"> <div class="footer__section__item">
<font-awesome-icon class="icon" :icon="['fas', 'user-secret']" /> <font-awesome-icon class="icon" :icon="['fas', 'user-secret']" />
<a @click="$refs.privacyDialog.show()">Privacy</a> <a @click="showPrivacyDialog()">Privacy</a>
</div> </div>
</div> </div>
</div> </div>
<Dialog ref="privacyDialog"> <ModalDialog v-model="isPrivacyDialogVisible">
<PrivacyPolicy /> <PrivacyPolicy />
</Dialog> </ModalDialog>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-property-decorator'; import {
import { Environment } from '@/application/Environment/Environment'; defineComponent, ref, computed, inject,
import Dialog from '@/presentation/components/Shared/Dialog.vue'; } from 'vue';
import { IApplication } from '@/domain/IApplication'; import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { ApplicationFactory } from '@/application/ApplicationFactory'; import { useApplicationKey, useEnvironmentKey } from '@/presentation/injectionSymbols';
import DownloadUrlList from './DownloadUrlList.vue'; import DownloadUrlList from './DownloadUrlList.vue';
import PrivacyPolicy from './PrivacyPolicy.vue'; import PrivacyPolicy from './PrivacyPolicy.vue';
@Component({ export default defineComponent({
components: { components: {
Dialog, PrivacyPolicy, DownloadUrlList, ModalDialog,
PrivacyPolicy,
DownloadUrlList,
}, },
}) setup() {
export default class TheFooter extends Vue { const { info } = inject(useApplicationKey);
public readonly isDesktop = Environment.CurrentEnvironment.isDesktop; 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 feedbackUrl = computed<string>(() => info.feedbackUrl);
const app = await ApplicationFactory.Current.getApp();
this.initialize(app); function showPrivacyDialog() {
isPrivacyDialogVisible.value = true;
} }
private initialize(app: IApplication) { return {
const { info } = app; isDesktop,
this.version = info.version.toString(); isPrivacyDialogVisible,
this.homepageUrl = info.homepage; showPrivacyDialog,
this.repositoryUrl = info.repositoryWebUrl; version,
this.releaseUrl = info.releaseUrl; homepageUrl,
this.feedbackUrl = info.feedbackUrl; repositoryUrl,
} releaseUrl,
} feedbackUrl,
};
},
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -1,26 +1,27 @@
<template> <template>
<div id="container"> <div id="container">
<h1 class="child title">{{ title }}</h1> <h1 class="child title">{{ title }}</h1>
<h2 class="child subtitle">Now you have the choice</h2> <h2 class="child subtitle">{{ subtitle }}</h2>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-property-decorator'; import { defineComponent, computed, inject } from 'vue';
import { ApplicationFactory } from '@/application/ApplicationFactory'; import { useApplicationKey } from '@/presentation/injectionSymbols';
@Component export default defineComponent({
export default class TheHeader extends Vue { setup() {
public title = ''; const { info } = inject(useApplicationKey);
public subtitle = ''; const title = computed(() => info.name);
const subtitle = computed(() => info.slogan);
public async created() { return {
const app = await ApplicationFactory.Current.getApp(); title,
this.title = app.info.name; subtitle,
this.subtitle = app.info.slogan; };
} },
} });
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -3,7 +3,7 @@
<input <input
type="search" type="search"
class="search-term" class="search-term"
:placeholder="searchPlaceHolder" :placeholder="searchPlaceholder"
v-model="searchQuery" v-model="searchQuery"
> >
<div class="icon-wrapper"> <div class="icon-wrapper">
@@ -13,53 +13,75 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Watch } from 'vue-property-decorator'; import {
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue'; defineComponent, ref, watch, computed,
inject,
} from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective'; import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter'; import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
@Component({ export default defineComponent({
directives: { NonCollapsing }, directives: {
}) NonCollapsing,
export default class TheSearchBar extends StatefulVue { },
public searchPlaceHolder = 'Search'; 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') watch(searchQuery, (newFilter) => updateFilter(newFilter));
public async updateFilter(newFilter?: string) {
const context = await this.getCurrentContext(); function updateFilter(newFilter: string) {
const { filter } = context.state; modifyCurrentState((state) => {
const { filter } = state;
if (!newFilter) { if (!newFilter) {
filter.removeFilter(); filter.clearFilter();
} else { } else {
filter.setFilter(newFilter); filter.applyFilter(newFilter);
} }
});
} }
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState) { onStateChange((newState) => {
const { totalScripts } = newState.collection; events.unsubscribeAll();
this.searchPlaceHolder = `Search in ${totalScripts} scripts`; updateFromInitialFilter(newState.filter.currentFilter);
this.searchQuery = newState.filter.currentFilter ? newState.filter.currentFilter.query : ''; subscribeToFilterChanges(newState.filter);
this.events.unsubscribeAll(); }, { immediate: true });
this.subscribeFilter(newState.filter);
function updateFromInitialFilter(filter?: IFilterResult) {
searchQuery.value = filter?.query || '';
} }
private subscribeFilter(filter: IReadOnlyUserFilter) { function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
this.events.register(filter.filtered.on((result) => this.handleFiltered(result))); events.register(
this.events.register(filter.filterRemoved.on(() => this.handleFilterRemoved())); filter.filterChanged.on((event) => {
event.visit({
onApply: (result) => {
searchQuery.value = result.query;
},
onClear: () => {
searchQuery.value = '';
},
});
}),
);
} }
private handleFiltered(result: IFilterResult) { return {
this.searchQuery = result.query; searchPlaceholder,
} searchQuery,
};
},
});
private handleFilterRemoved() {
this.searchQuery = '';
}
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View 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>;
}

View File

@@ -1,10 +1,16 @@
import Vue from 'vue'; import Vue from 'vue';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import App from './components/App.vue'; import App from './components/App.vue';
import { ApplicationBootstrapper } from './bootstrapping/ApplicationBootstrapper'; import { ApplicationBootstrapper } from './bootstrapping/ApplicationBootstrapper';
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() new ApplicationBootstrapper()
.bootstrap(Vue); .bootstrap(Vue);
new Vue({ new Vue({
render: (h) => h(App), render: (h) => h(App),
}).$mount('#app'); }).$mount('#app');
});

View File

@@ -1,5 +1,7 @@
import Vue from 'vue'; /* eslint-disable */
declare module '*.vue' { declare module '*.vue' {
export default Vue; import { DefineComponent } from 'vue';
const component: DefineComponent;
export default component;
} }

4
tests/bootstrap/setup.ts Normal file
View File

@@ -0,0 +1,4 @@
import 'mocha';
import { enableAutoDestroy } from '@vue/test-utils';
enableAutoDestroy(afterEach);

View File

@@ -9,7 +9,7 @@
// /* eslint-disable import/no-extraneous-dependencies, global-require */ // /* eslint-disable import/no-extraneous-dependencies, global-require */
// const webpack = require('@cypress/webpack-preprocessor') // const webpack = require('@cypress/webpack-preprocessor')
module.exports = (on, config) => { export default (on, config) => {
// on('file:preprocessor', webpack({ // on('file:preprocessor', webpack({
// webpackOptions: require('@vue/cli-service/webpack.config'), // webpackOptions: require('@vue/cli-service/webpack.config'),
// watchOptions: {} // watchOptions: {}

View 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;
}

View 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 { }

View File

@@ -47,7 +47,7 @@ describe('ApplicationContext', () => {
const sut = testContext const sut = testContext
.withInitialOs(OperatingSystem.Windows) .withInitialOs(OperatingSystem.Windows)
.construct(); .construct();
sut.state.filter.setFilter('filtered'); sut.state.filter.applyFilter('filtered');
sut.changeContext(OperatingSystem.macOS); sut.changeContext(OperatingSystem.macOS);
// assert // assert
expectEmptyState(sut.state); expectEmptyState(sut.state);
@@ -65,10 +65,10 @@ describe('ApplicationContext', () => {
.withInitialOs(os) .withInitialOs(os)
.construct(); .construct();
const firstState = sut.state; const firstState = sut.state;
firstState.filter.setFilter(expectedFilter); firstState.filter.applyFilter(expectedFilter);
sut.changeContext(os); sut.changeContext(os);
sut.changeContext(changedOs); sut.changeContext(changedOs);
sut.state.filter.setFilter('second-state'); sut.state.filter.applyFilter('second-state');
sut.changeContext(os); sut.changeContext(os);
// assert // assert
const actualFilter = sut.state.filter.currentFilter.query; const actualFilter = sut.state.filter.currentFilter.query;
@@ -103,7 +103,7 @@ describe('ApplicationContext', () => {
.withInitialOs(os) .withInitialOs(os)
.construct(); .construct();
const initialState = sut.state; const initialState = sut.state;
initialState.filter.setFilter('dirty-state'); initialState.filter.applyFilter('dirty-state');
sut.changeContext(os); sut.changeContext(os);
// assert // assert
expect(testContext.firedEvents.length).to.equal(0); expect(testContext.firedEvents.length).to.equal(0);

View File

@@ -91,11 +91,11 @@ describe('CategoryCollectionState', () => {
.withAction(new CategoryStub(0).withScript(expectedScript)); .withAction(new CategoryStub(0).withScript(expectedScript));
const sut = new CategoryCollectionState(collection); const sut = new CategoryCollectionState(collection);
// act // act
let actualScript: IScript; let actualScript: IScript | undefined;
sut.filter.filtered.on((result) => { sut.filter.filterChanged.on((result) => {
[actualScript] = result.scriptMatches; [actualScript] = result.filter?.scriptMatches ?? [undefined];
}); });
sut.filter.setFilter(scriptNameFilter); sut.filter.applyFilter(scriptNameFilter);
// assert // assert
expect(expectedScript).to.equal(actualScript); expect(expectedScript).to.equal(actualScript);
}); });

View File

@@ -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);
});
}

View File

@@ -5,173 +5,182 @@ import { UserFilter } from '@/application/Context/State/Filter/UserFilter';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; 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('UserFilter', () => {
describe('removeFilter', () => { describe('clearFilter', () => {
it('signals when removing filter', () => { it('signals when removing filter', () => {
// arrange // arrange
let isCalled = false; const expectedChange = FilterChange.forClear();
let actualChange: IFilterChangeDetails;
const sut = new UserFilter(new CategoryCollectionStub()); const sut = new UserFilter(new CategoryCollectionStub());
sut.filterRemoved.on(() => { sut.filterChanged.on((change) => {
isCalled = true; actualChange = change;
}); });
// act // act
sut.removeFilter(); sut.clearFilter();
// assert // assert
expect(isCalled).to.be.equal(true); expect(actualChange).to.deep.equal(expectedChange);
}); });
it('sets currentFilter to undefined', () => { it('sets currentFilter to undefined', () => {
// arrange // arrange
const sut = new UserFilter(new CategoryCollectionStub()); const sut = new UserFilter(new CategoryCollectionStub());
// act // act
sut.setFilter('non-important'); sut.applyFilter('non-important');
sut.removeFilter(); sut.clearFilter();
// assert // assert
expect(sut.currentFilter).to.be.equal(undefined); expect(sut.currentFilter).to.be.equal(undefined);
}); });
}); });
describe('setFilter', () => { describe('applyFilter', () => {
it('signals when no matches', () => { interface IApplyFilterTestCase {
// arrange readonly name: string;
let actual: IFilterResult; readonly filter: string;
readonly collection: ICategoryCollection;
readonly assert: (result: IFilterResult) => void;
}
const testCases: readonly IApplyFilterTestCase[] = [
(() => {
const nonMatchingFilter = 'non matching filter'; const nonMatchingFilter = 'non matching filter';
const sut = new UserFilter(new CategoryCollectionStub()); return {
sut.filtered.on((filterResult) => { name: 'given no matches',
actual = filterResult; filter: nonMatchingFilter,
}); collection: new CategoryCollectionStub(),
// act assert: (filter) => {
sut.setFilter(nonMatchingFilter); expect(filter.hasAnyMatches()).be.equal(false);
// assert expect(filter.query).to.equal(nonMatchingFilter);
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 code = 'HELLO world';
const filter = 'Hello WoRLD'; const matchingFilter = 'Hello WoRLD';
let actual: IFilterResult;
const script = new ScriptStub('id').withCode(code); const script = new ScriptStub('id').withCode(code);
const category = new CategoryStub(33).withScript(script); return {
const sut = new UserFilter(new CategoryCollectionStub() name: 'given script match with case-insensitive code',
.withAction(category)); filter: matchingFilter,
sut.filtered.on((filterResult) => { collection: new CategoryCollectionStub()
actual = filterResult; .withAction(new CategoryStub(33).withScript(script)),
}); assert: (filter) => {
// act expect(filter.hasAnyMatches()).be.equal(true);
sut.setFilter(filter); expect(filter.categoryMatches).to.have.lengthOf(0);
// assert expect(filter.scriptMatches).to.have.lengthOf(1);
expect(actual.hasAnyMatches()).be.equal(true); expect(filter.scriptMatches[0]).to.deep.equal(script);
expect(actual.categoryMatches).to.have.lengthOf(0); expect(filter.query).to.equal(matchingFilter);
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 revertCode = 'HELLO world';
const filter = 'Hello WoRLD'; const matchingFilter = 'Hello WoRLD';
let actual: IFilterResult;
const script = new ScriptStub('id').withRevertCode(revertCode); const script = new ScriptStub('id').withRevertCode(revertCode);
const category = new CategoryStub(33).withScript(script); return {
const sut = new UserFilter(new CategoryCollectionStub() name: 'given script match with case-insensitive revertCode',
.withAction(category)); filter: matchingFilter,
sut.filtered.on((filterResult) => { collection: new CategoryCollectionStub()
actual = filterResult; .withAction(new CategoryStub(33).withScript(script)),
}); assert: (filter) => {
// act expect(filter.hasAnyMatches()).be.equal(true);
sut.setFilter(filter); expect(filter.categoryMatches).to.have.lengthOf(0);
// assert expect(filter.scriptMatches).to.have.lengthOf(1);
expect(actual.hasAnyMatches()).be.equal(true); expect(filter.scriptMatches[0]).to.deep.equal(script);
expect(actual.categoryMatches).to.have.lengthOf(0); expect(filter.query).to.equal(matchingFilter);
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 name = 'HELLO world';
const filter = 'Hello WoRLD'; const matchingFilter = 'Hello WoRLD';
let actual: IFilterResult;
const script = new ScriptStub('id').withName(name); const script = new ScriptStub('id').withName(name);
const category = new CategoryStub(33).withScript(script); return {
const sut = new UserFilter(new CategoryCollectionStub() name: 'given script match with case-insensitive name',
.withAction(category)); filter: matchingFilter,
sut.filtered.on((filterResult) => { collection: new CategoryCollectionStub()
actual = filterResult; .withAction(new CategoryStub(33).withScript(script)),
}); assert: (filter) => {
// act expect(filter.hasAnyMatches()).be.equal(true);
sut.setFilter(filter); expect(filter.categoryMatches).to.have.lengthOf(0);
// assert expect(filter.scriptMatches).to.have.lengthOf(1);
expect(actual.hasAnyMatches()).be.equal(true); expect(filter.scriptMatches[0]).to.deep.equal(script);
expect(actual.categoryMatches).to.have.lengthOf(0); expect(filter.query).to.equal(matchingFilter);
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
const categoryName = 'HELLO world'; const categoryName = 'HELLO world';
const filter = 'Hello WoRLD'; const matchingFilter = 'Hello WoRLD';
let actual: IFilterResult;
const category = new CategoryStub(55).withName(categoryName); const category = new CategoryStub(55).withName(categoryName);
const sut = new UserFilter(new CategoryCollectionStub() return {
.withAction(category)); name: 'given category match with case-insensitive name',
sut.filtered.on((filterResult) => { filter: matchingFilter,
actual = filterResult; collection: new CategoryCollectionStub()
}); .withAction(category),
// act assert: (filter) => {
sut.setFilter(filter); expect(filter.hasAnyMatches()).be.equal(true);
// assert expect(filter.categoryMatches).to.have.lengthOf(1);
expect(actual.hasAnyMatches()).be.equal(true); expect(filter.categoryMatches[0]).to.deep.equal(category);
expect(actual.categoryMatches).to.have.lengthOf(1); expect(filter.scriptMatches).to.have.lengthOf(0);
expect(actual.categoryMatches[0]).to.deep.equal(category); expect(filter.query).to.equal(matchingFilter);
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
const matchingText = 'HELLO world'; const matchingText = 'HELLO world';
const filter = 'Hello WoRLD'; const matchingFilter = 'Hello WoRLD';
let actual: IFilterResult;
const script = new ScriptStub('script') const script = new ScriptStub('script')
.withName(matchingText); .withName(matchingText);
const category = new CategoryStub(55) const category = new CategoryStub(55)
.withName(matchingText) .withName(matchingText)
.withScript(script); .withScript(script);
const collection = new CategoryCollectionStub() return {
.withAction(category); 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); const sut = new UserFilter(collection);
sut.filtered.on((filterResult) => { // act
actual = filterResult; 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 // act
sut.setFilter(filter); sut.applyFilter(filter);
// assert // assert
expect(actual.hasAnyMatches()).be.equal(true); assert(actualFilterResult);
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);
}); });
}); });
}); });

View File

@@ -3,6 +3,7 @@ import { expect } from 'chai';
import { INodeDataErrorContext, NodeDataError } from '@/application/Parser/NodeValidation/NodeDataError'; import { INodeDataErrorContext, NodeDataError } from '@/application/Parser/NodeValidation/NodeDataError';
import { NodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub'; import { NodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub';
import { NodeType } from '@/application/Parser/NodeValidation/NodeType'; import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { CustomError } from '@/application/Common/CustomError';
describe('NodeDataError', () => { describe('NodeDataError', () => {
it('sets message as expected', () => { it('sets message as expected', () => {
@@ -28,20 +29,13 @@ describe('NodeDataError', () => {
// assert // assert
expect(sut.context).to.equal(expected); expect(sut.context).to.equal(expected);
}); });
it('sets stack as expected', () => { it('extends CustomError', () => {
// arrange // arrange
const expected = CustomError;
// act // act
const sut = new NodeDataErrorBuilder() const sut = new NodeDataErrorBuilder()
.build(); .build();
// assert // 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); expect(sut).to.be.an.instanceof(expected);
}); });
}); });

View File

@@ -8,17 +8,18 @@ describe('NonCollapsingDirective', () => {
describe('NonCollapsing', () => { describe('NonCollapsing', () => {
it('adds expected attribute to the element when inserted', () => { it('adds expected attribute to the element when inserted', () => {
// arrange // arrange
const element = getElementMock(); const element = createElementMock();
// act // act
NonCollapsing.inserted(element, undefined, undefined, undefined); NonCollapsing.inserted(element, undefined, undefined, undefined);
// assert // assert
expect(element.hasAttribute(expectedAttributeName)); expect(element.hasAttribute(expectedAttributeName));
}); });
}); });
describe('hasDirective', () => { describe('hasDirective', () => {
it('returns true if the element has expected attribute', () => { it('returns true if the element has expected attribute', () => {
// arrange // arrange
const element = getElementMock(); const element = createElementMock();
element.setAttribute(expectedAttributeName, undefined); element.setAttribute(expectedAttributeName, undefined);
// act // act
const actual = hasDirective(element); const actual = hasDirective(element);
@@ -27,8 +28,8 @@ describe('NonCollapsingDirective', () => {
}); });
it('returns true if the element has a parent with expected attribute', () => { it('returns true if the element has a parent with expected attribute', () => {
// arrange // arrange
const parent = getElementMock(); const parent = createElementMock();
const element = getElementMock(); const element = createElementMock();
parent.appendChild(element); parent.appendChild(element);
element.setAttribute(expectedAttributeName, undefined); element.setAttribute(expectedAttributeName, undefined);
// act // act
@@ -38,16 +39,15 @@ describe('NonCollapsingDirective', () => {
}); });
it('returns false if nor the element or its parent has expected attribute', () => { it('returns false if nor the element or its parent has expected attribute', () => {
// arrange // arrange
const element = getElementMock(); const element = createElementMock();
// act // act
const actual = hasDirective(element); const actual = hasDirective(element);
// assert // assert
expect(actual).to.equal(false); expect(actual).to.equal(false);
}); });
}); });
});
function getElementMock(): HTMLElement { function createElementMock(): HTMLElement {
const element = document.createElement('div'); return document.createElement('div');
return element;
} }
});

View File

@@ -4,7 +4,7 @@ import {
getScriptNodeId, getScriptId, getCategoryNodeId, getCategoryId, parseSingleCategory, getScriptNodeId, getScriptId, getCategoryNodeId, getCategoryId, parseSingleCategory,
parseAllCategories, parseAllCategories,
} from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser'; } 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 { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory'; import { ICategory } from '@/domain/ICategory';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
@@ -87,7 +87,7 @@ function isReversible(category: ICategory): boolean {
return category.subCategories.every((c) => isReversible(c)); 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.type).to.equal(NodeType.Category, getErrorMessage('type'));
expect(node.id).to.equal(getCategoryNodeId(category), getErrorMessage('id')); expect(node.id).to.equal(getCategoryNodeId(category), getErrorMessage('id'));
expect(node.docs).to.equal(category.docs, getErrorMessage('docs')); 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.type).to.equal(NodeType.Script, getErrorMessage('type'));
expect(node.id).to.equal(getScriptNodeId(script), getErrorMessage('id')); expect(node.id).to.equal(getScriptNodeId(script), getErrorMessage('id'));
expect(node.docs).to.equal(script.docs, getErrorMessage('docs')); expect(node.docs).to.equal(script.docs, getErrorMessage('docs'));

View File

@@ -1,7 +1,7 @@
import 'mocha'; import 'mocha';
import { expect } from 'chai'; import { expect } from 'chai';
import { ILiquorTreeExistingNode } from 'liquor-tree'; 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'; import { NodePredicateFilter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter';
describe('NodePredicateFilter', () => { describe('NodePredicateFilter', () => {
@@ -18,7 +18,7 @@ describe('NodePredicateFilter', () => {
states: undefined, states: undefined,
children: [], children: [],
}; };
const expected: INode = { const expected: INodeContent = {
id: 'script', id: 'script',
text: 'script-text', text: 'script-text',
isReversible: false, isReversible: false,
@@ -26,8 +26,8 @@ describe('NodePredicateFilter', () => {
children: [], children: [],
type: NodeType.Script, type: NodeType.Script,
}; };
let actual: INode; let actual: INodeContent;
const predicate = (node: INode) => { actual = node; return true; }; const predicate = (node: INodeContent) => { actual = node; return true; };
const sut = new NodePredicateFilter(predicate); const sut = new NodePredicateFilter(predicate);
// act // act
sut.matcher('nop query', object); sut.matcher('nop query', object);

View File

@@ -1,7 +1,7 @@
import 'mocha'; import 'mocha';
import { expect } from 'chai'; import { expect } from 'chai';
import { ILiquorTreeNode } from 'liquor-tree'; 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'; import { getNewState } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater';
describe('NodeStateUpdater', () => { describe('NodeStateUpdater', () => {

Some files were not shown because too many files have changed in this diff Show More